Files
pocket-pascal/client/src/lib/catalog.ts
Steffi Müller a0d415b08f 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>
2026-08-04 15:52:24 +02:00

241 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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)
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)",
}