Rebuild app IA and polish pantry, chips, and food data.

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>
This commit is contained in:
Steffi Müller
2026-08-04 15:52:24 +02:00
parent a482bf5b09
commit a0d415b08f
43 changed files with 3400 additions and 1358 deletions

View File

@@ -4,14 +4,16 @@ import type {
ExtraItem,
FoodCategory,
FoodItem,
HowToRecipe,
LocalizedString,
PlanRecipeType,
MealTime,
NutritionBasis,
Recipe,
TagType,
TimeSlot,
} 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"
@@ -19,8 +21,13 @@ interface RawFoodItem {
id: string
name: LocalizedString
v: LocalizedString
basis?: NutritionBasis
kcal?: number
proteinG?: number
f?: TagType
approx?: boolean
note?: LocalizedString
cautionWhy?: LocalizedString
}
interface RawFoodCategory {
@@ -29,6 +36,10 @@ interface RawFoodCategory {
title: LocalizedString
thumb: LocalizedString
items: RawFoodItem[]
sub?: {
title: LocalizedString
items: RawFoodItem[]
}
}
export interface PlateStep {
@@ -39,25 +50,87 @@ export interface PlateStep {
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: item.v,
valueInfo: formatFoodValueInfo(item),
basis,
kcal: item.kcal,
proteinG: item.proteinG,
tag: item.f,
cautionWhy: item.cautionWhy,
isApprox: item.approx,
}
}
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
(category) => ({
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: category.items.map((item) => mapItem(category.id, item)),
}),
items,
}
}
/**
* Sort: protein high→low within preferred basis first, then other bases,
* then AZ. 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)
@@ -72,21 +145,29 @@ 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 PLAN_TYPE_ORDER: PlanRecipeType[] = [
export const MEAL_TIME_ORDER: MealTime[] = [
"breakfast",
"lunch",
"snack",
"full",
"recipe",
"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) {
@@ -97,6 +178,7 @@ export function resolveCatalogId(id: string): CatalogItem | null {
cat: food.cat,
valueInfo: food.valueInfo,
tag: food.tag,
cautionWhy: food.cautionWhy,
}
}
const extra = extraById[id]
@@ -110,30 +192,49 @@ export function resolveCatalogId(id: string): CatalogItem | null {
return null
}
export function resolveRecipeUses(recipe: Recipe): CatalogItem[] {
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 recipesByType(type: PlanRecipeType): Recipe[] {
return recipes.filter((recipe) => recipe.type === type)
export function recipesForMealTime(time: MealTime): Recipe[] {
return recipes.filter((recipe) => recipe.times.includes(time))
}
/** Map plan recipe types onto suggestion time slots. */
export function timeSlotsForRecipe(recipe: Recipe): TimeSlot[] {
switch (recipe.type) {
case "breakfast":
return ["morning"]
/** Category color token for ingredient chips / plate segments. */
export function categoryDotColor(cat: CategoryId | undefined): string {
switch (cat) {
case "protein":
case "snack":
return ["afternoon"]
case "full":
return ["midday", "evening"]
case "recipe":
return ["morning", "afternoon", "midday", "evening"]
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)"
}
}
export function mealTypeForRecipe(recipe: Recipe): "full" | "half" {
return recipe.type === "full" ? "full" : "half"
/** 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)",
}