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:
@@ -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 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)
|
||||
@@ -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)",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user