Introduce the four-tab hubs (Jetzt/Küche/Planen/Wissen), frosted ingredient flyouts with caution reasons, structured nutrition fields with protein sorting, and disabled coming-soon nav tiles. Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
6.2 KiB
TypeScript
241 lines
6.2 KiB
TypeScript
import type {
|
||
CategoryId,
|
||
CatalogItem,
|
||
ExtraItem,
|
||
FoodCategory,
|
||
FoodItem,
|
||
HowToRecipe,
|
||
LocalizedString,
|
||
MealTime,
|
||
NutritionBasis,
|
||
Recipe,
|
||
TagType,
|
||
} from "@/types/domain"
|
||
import extrasRaw from "@/data/extras.json"
|
||
import foodsRaw from "@/data/foods.json"
|
||
import howtosRaw from "@/data/howtos.json"
|
||
import recipesRaw from "@/data/recipes.json"
|
||
import stepsRaw from "@/data/steps.json"
|
||
|
||
interface RawFoodItem {
|
||
id: string
|
||
name: LocalizedString
|
||
v: LocalizedString
|
||
basis?: NutritionBasis
|
||
kcal?: number
|
||
proteinG?: number
|
||
f?: TagType
|
||
approx?: boolean
|
||
note?: LocalizedString
|
||
cautionWhy?: LocalizedString
|
||
}
|
||
|
||
interface RawFoodCategory {
|
||
id: CategoryId
|
||
cls: string
|
||
title: LocalizedString
|
||
thumb: LocalizedString
|
||
items: RawFoodItem[]
|
||
sub?: {
|
||
title: LocalizedString
|
||
items: RawFoodItem[]
|
||
}
|
||
}
|
||
|
||
export interface PlateStep {
|
||
n: number
|
||
cls: string
|
||
title: LocalizedString
|
||
qty: LocalizedString
|
||
text: LocalizedString
|
||
}
|
||
|
||
function formatProteinG(n: number, lang: "de" | "en"): string {
|
||
if (Number.isInteger(n)) return String(n)
|
||
return lang === "de" ? String(n).replace(".", ",") : String(n)
|
||
}
|
||
|
||
/** Build display valueInfo from structured macros when present. */
|
||
export function formatFoodValueInfo(
|
||
item: Pick<RawFoodItem, "kcal" | "proteinG" | "approx" | "note" | "v">,
|
||
): LocalizedString {
|
||
if (item.kcal != null && item.proteinG != null) {
|
||
const prefix = item.approx ? "≈" : ""
|
||
const noteDe = item.note?.de ? ` ${item.note.de}` : ""
|
||
const noteEn = item.note?.en ? ` ${item.note.en}` : ""
|
||
return {
|
||
de: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "de")} g${noteDe}`,
|
||
en: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "en")} g${noteEn}`,
|
||
}
|
||
}
|
||
return item.v
|
||
}
|
||
|
||
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
|
||
const basis: NutritionBasis =
|
||
item.basis ??
|
||
(cat === "snack" ? "perServing" : item.id === "egg" ? "perUnit" : "per100g")
|
||
|
||
return {
|
||
id: item.id,
|
||
cat,
|
||
name: item.name,
|
||
valueInfo: formatFoodValueInfo(item),
|
||
basis,
|
||
kcal: item.kcal,
|
||
proteinG: item.proteinG,
|
||
tag: item.f,
|
||
cautionWhy: item.cautionWhy,
|
||
isApprox: item.approx,
|
||
}
|
||
}
|
||
|
||
function mapCategory(category: RawFoodCategory): FoodCategory {
|
||
const items = [
|
||
...category.items.map((item) => mapItem(category.id, item)),
|
||
...(category.sub?.items.map((item) => mapItem(category.id, item)) ?? []),
|
||
]
|
||
return {
|
||
id: category.id,
|
||
cls: category.cls,
|
||
title: category.title,
|
||
thumb: category.thumb,
|
||
items,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sort: protein high→low within preferred basis first, then other bases,
|
||
* then A–Z. Preferred basis is switchable later (perServing / perUnit views).
|
||
*/
|
||
export function sortFoodItems(
|
||
items: FoodItem[],
|
||
lang: "de" | "en",
|
||
preferredBasis: NutritionBasis = "per100g",
|
||
): FoodItem[] {
|
||
function rank(item: FoodItem): number {
|
||
if (item.proteinG != null && item.basis === preferredBasis) return 0
|
||
if (item.proteinG != null) return 1
|
||
return 2
|
||
}
|
||
|
||
return [...items].sort((a, b) => {
|
||
const rankDiff = rank(a) - rank(b)
|
||
if (rankDiff !== 0) return rankDiff
|
||
if (a.proteinG != null && b.proteinG != null && a.proteinG !== b.proteinG) {
|
||
return b.proteinG - a.proteinG
|
||
}
|
||
return a.name[lang].localeCompare(b.name[lang], lang)
|
||
})
|
||
}
|
||
|
||
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
|
||
mapCategory,
|
||
)
|
||
|
||
export const foods: FoodItem[] = foodCategories.flatMap((category) => category.items)
|
||
|
||
export const foodById: Record<string, FoodItem> = Object.fromEntries(
|
||
foods.map((item) => [item.id, item]),
|
||
)
|
||
|
||
export const extras: ExtraItem[] = extrasRaw as ExtraItem[]
|
||
|
||
export const extraById: Record<string, ExtraItem> = Object.fromEntries(
|
||
extras.map((item) => [item.id, item]),
|
||
)
|
||
|
||
/** Plate suggestions (RECIPES). */
|
||
export const recipes: Recipe[] = recipesRaw as Recipe[]
|
||
|
||
/** Prep / how-to instructions — not plate suggestions. */
|
||
export const howtos: HowToRecipe[] = howtosRaw as HowToRecipe[]
|
||
|
||
export const plateSteps: PlateStep[] = stepsRaw as PlateStep[]
|
||
|
||
export const MEAL_TIME_ORDER: MealTime[] = [
|
||
"breakfast",
|
||
"lunch",
|
||
"snack",
|
||
"dinner",
|
||
]
|
||
|
||
export function foodsInCategory(cat: CategoryId): FoodItem[] {
|
||
return foodCategories.find((category) => category.id === cat)?.items ?? []
|
||
}
|
||
|
||
export function foodCategoryById(cat: CategoryId): FoodCategory | undefined {
|
||
return foodCategories.find((category) => category.id === cat)
|
||
}
|
||
|
||
export function resolveCatalogId(id: string): CatalogItem | null {
|
||
const food = foodById[id]
|
||
if (food) {
|
||
return {
|
||
id: food.id,
|
||
name: food.name,
|
||
source: "cat",
|
||
cat: food.cat,
|
||
valueInfo: food.valueInfo,
|
||
tag: food.tag,
|
||
cautionWhy: food.cautionWhy,
|
||
}
|
||
}
|
||
const extra = extraById[id]
|
||
if (extra) {
|
||
return {
|
||
id: extra.id,
|
||
name: extra.name,
|
||
source: "extra",
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
export function resolveRecipeUses(
|
||
recipe: Pick<Recipe, "uses"> | Pick<HowToRecipe, "uses">,
|
||
): CatalogItem[] {
|
||
return recipe.uses
|
||
.map((id) => resolveCatalogId(id))
|
||
.filter((item): item is CatalogItem => Boolean(item))
|
||
}
|
||
|
||
export function recipesForMealTime(time: MealTime): Recipe[] {
|
||
return recipes.filter((recipe) => recipe.times.includes(time))
|
||
}
|
||
|
||
/** Category color token for ingredient chips / plate segments. */
|
||
export function categoryDotColor(cat: CategoryId | undefined): string {
|
||
switch (cat) {
|
||
case "protein":
|
||
case "snack":
|
||
return "var(--cat-protein)"
|
||
case "obst":
|
||
case "gemuese":
|
||
return "var(--cat-produce)"
|
||
case "fett":
|
||
return "var(--cat-fat)"
|
||
case "kh":
|
||
return "var(--cat-berry)"
|
||
default:
|
||
return "var(--ink-faint)"
|
||
}
|
||
}
|
||
|
||
/** Four plate slots for completeness scan (snack → protein). */
|
||
export type PlateSlot = "protein" | "produce" | "fat" | "berry"
|
||
|
||
export function plateSlotForCategory(cat: CategoryId): PlateSlot {
|
||
if (cat === "protein" || cat === "snack") return "protein"
|
||
if (cat === "obst" || cat === "gemuese") return "produce"
|
||
if (cat === "fett") return "fat"
|
||
return "berry"
|
||
}
|
||
|
||
export const PLATE_SLOT_COLOR: Record<PlateSlot, string> = {
|
||
protein: "var(--cat-protein)",
|
||
produce: "var(--cat-produce)",
|
||
fat: "var(--cat-fat)",
|
||
berry: "var(--cat-berry)",
|
||
}
|