From a0d415b08ff553af1cd1aad0a68e86ea39d4e2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steffi=20M=C3=BCller?= Date: Tue, 4 Aug 2026 15:52:24 +0200 Subject: [PATCH] Rebuild app IA and polish pantry, chips, and food data. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/package.json | 1 + client/src/App.tsx | 76 ++- .../src/components/food/CategorySoftChip.tsx | 42 ++ client/src/components/food/IngredientChip.tsx | 159 +++++ .../src/components/food/IngredientChips.tsx | 73 +++ client/src/components/home/BuilderSlot.tsx | 43 -- client/src/components/home/HomeScreen.tsx | 128 +--- client/src/components/home/KnowledgeHub.tsx | 79 --- client/src/components/home/NavCard.tsx | 131 ++++ client/src/components/home/SuggestionCard.tsx | 196 +++--- client/src/components/layout/AppShell.tsx | 15 +- client/src/components/plate/DiningPlate.tsx | 276 +++++---- client/src/components/ui-pp/Accordion.tsx | 4 +- client/src/components/ui-pp/BottomNav.tsx | 10 +- client/src/components/ui-pp/TimeIconBadge.tsx | 84 +++ client/src/data/foods.json | 575 ++++++++++++++---- client/src/data/howtos.json | 62 ++ client/src/data/recipes.json | 165 ++--- client/src/data/steps.json | 4 +- client/src/i18n/ui.ts | 222 +++++-- client/src/lib/catalog.ts | 151 ++++- client/src/lib/chipStyle.ts | 54 ++ client/src/lib/daylight.ts | 76 +++ client/src/lib/signals.ts | 10 + client/src/lib/suggestion.ts | 117 +--- client/src/lib/timeContext.ts | 37 +- client/src/lib/widgetOrder.ts | 55 -- client/src/screens/BuilderScreen.tsx | 115 +++- client/src/screens/HowToLibraryScreen.tsx | 73 +++ client/src/screens/KitchenHubScreen.tsx | 53 ++ client/src/screens/KnowledgeHubScreen.tsx | 55 ++ client/src/screens/PantryScreen.tsx | 104 ++-- client/src/screens/PlaceholderScreen.tsx | 41 ++ client/src/screens/PlanHubScreen.tsx | 67 ++ client/src/screens/PlateScreen.tsx | 61 +- client/src/screens/RulesScreen.tsx | 45 ++ client/src/screens/SettingsScreen.tsx | 78 +-- client/src/screens/WeekPlanScreen.tsx | 159 +++-- client/src/styles/theme.css | 154 ++--- client/src/types/domain.ts | 41 +- handover/data/foods.json | 575 ++++++++++++++---- handover/data/steps.json | 4 +- pnpm-lock.yaml | 288 +++++++++ 43 files changed, 3400 insertions(+), 1358 deletions(-) create mode 100644 client/src/components/food/CategorySoftChip.tsx create mode 100644 client/src/components/food/IngredientChip.tsx create mode 100644 client/src/components/food/IngredientChips.tsx delete mode 100644 client/src/components/home/BuilderSlot.tsx delete mode 100644 client/src/components/home/KnowledgeHub.tsx create mode 100644 client/src/components/home/NavCard.tsx create mode 100644 client/src/components/ui-pp/TimeIconBadge.tsx create mode 100644 client/src/data/howtos.json create mode 100644 client/src/lib/chipStyle.ts create mode 100644 client/src/lib/daylight.ts create mode 100644 client/src/lib/signals.ts delete mode 100644 client/src/lib/widgetOrder.ts create mode 100644 client/src/screens/HowToLibraryScreen.tsx create mode 100644 client/src/screens/KitchenHubScreen.tsx create mode 100644 client/src/screens/KnowledgeHubScreen.tsx create mode 100644 client/src/screens/PlaceholderScreen.tsx create mode 100644 client/src/screens/PlanHubScreen.tsx create mode 100644 client/src/screens/RulesScreen.tsx diff --git a/client/package.json b/client/package.json index e514968..80aedb7 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", "shadcn": "^4.16.1", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 3e6f7fc..bf6efdb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,10 +4,17 @@ import { HomeScreen } from "@/components/home/HomeScreen" import { AppShell } from "@/components/layout/AppShell" import { LanguageProvider } from "@/lib/language" import { BuilderScreen } from "@/screens/BuilderScreen" +import { HowToLibraryScreen } from "@/screens/HowToLibraryScreen" +import { KitchenHubScreen } from "@/screens/KitchenHubScreen" +import { KnowledgeHubScreen } from "@/screens/KnowledgeHubScreen" import { PantryScreen } from "@/screens/PantryScreen" +import { PlaceholderScreen } from "@/screens/PlaceholderScreen" +import { PlanHubScreen } from "@/screens/PlanHubScreen" import { PlateScreen } from "@/screens/PlateScreen" +import { RulesScreen } from "@/screens/RulesScreen" import { SettingsScreen } from "@/screens/SettingsScreen" import { WeekPlanScreen } from "@/screens/WeekPlanScreen" +import { ui } from "@/i18n/ui" export default function App() { return ( @@ -16,11 +23,70 @@ export default function App() { }> } /> - } /> - } /> - } /> - } /> - } /> + + } /> + } /> + } /> + + } + /> + + } /> + } /> + + } + /> + + } + /> + + } + /> + + } /> + } /> + } /> + } /> + } /> + + {/* Legacy redirects */} + } /> + } /> + } /> + } /> + } /> diff --git a/client/src/components/food/CategorySoftChip.tsx b/client/src/components/food/CategorySoftChip.tsx new file mode 100644 index 0000000..7d759d0 --- /dev/null +++ b/client/src/components/food/CategorySoftChip.tsx @@ -0,0 +1,42 @@ +import { categoryDotColor } from "@/lib/catalog" +import { cx } from "@/lib/cx" +import type { CategoryId } from "@/types/domain" + +const CAT_ICON: Record = { + protein: "🥚", + snack: "🥤", + obst: "🍎", + gemuese: "🥦", + fett: "🥑", + kh: "🌾", +} + +/** + * Soft-Chip category marker (Vorrat accordion headers). + * Category hue washed with white — readable emoji, no side stripe on cards. + */ +export function CategorySoftChip({ + categoryId, + className, +}: { + categoryId: CategoryId + className?: string +}) { + return ( + + {CAT_ICON[categoryId]} + + ) +} diff --git a/client/src/components/food/IngredientChip.tsx b/client/src/components/food/IngredientChip.tsx new file mode 100644 index 0000000..d63dbae --- /dev/null +++ b/client/src/components/food/IngredientChip.tsx @@ -0,0 +1,159 @@ +import { Popover } from "@base-ui/react/popover" + +import { t, tr, ui } from "@/i18n/ui" +import { foodCategoryById } from "@/lib/catalog" +import { categoryChipSurface, chipAriaLabel, chipTagModifiers } from "@/lib/chipStyle" +import { useLanguage } from "@/lib/language" +import { cx } from "@/lib/cx" +import type { CatalogItem, Language } from "@/types/domain" + +function detailAria(name: string, lang: Language): string { + return t(ui.ingredients.detailAria, lang).replace("{name}", name) +} + +/** + * Chip + flyout (Base UI Popover). Tap opens; tap same chip / outside closes. + * Content: valueInfo · caution (if tagged) · category Daumenregel. + * Name is not repeated — the chip already shows it. + */ +export function IngredientChip({ + item, + size, +}: { + item: CatalogItem + size: "regular" | "large" +}) { + const { lang } = useLanguage() + const large = size === "large" + const surface = categoryChipSurface(item.cat) + const tagMod = chipTagModifiers(item.tag) + const name = tr(item.name, lang) + const label = chipAriaLabel( + name, + item.tag, + t(ui.pantry.pick, lang), + t(ui.pantry.caution, lang), + ) + const category = item.cat ? foodCategoryById(item.cat) : undefined + const valueInfo = item.valueInfo ? tr(item.valueInfo, lang) : null + /** Obst: valueInfo is enough — no category tip in the flyout. */ + const showThumb = Boolean(category && item.cat !== "obst") + const thumb = showThumb && category ? tr(category.thumb, lang) : null + const categoryTitle = showThumb && category ? tr(category.title, lang) : null + const showCaution = item.tag === "caution" + const cautionWhy = + showCaution && item.cautionWhy ? tr(item.cautionWhy, lang) : null + + const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5" + const chipType = large + ? "type-body-emphasis text-[var(--ink)]" + : "type-caption font-bold text-[var(--ink)]" + + return ( + + + + {name} + + + + + {/* + Prefer bottom-end (bottom-right of chip). Collision avoidance flips + when the panel would leave the viewport. No scale/transform — keeps + backdrop-filter working. + */} + + +
+
+ + {/* Solid ink on glass — ink-soft fails AA over busy blurred chips */} +
+ {name} + + {valueInfo ? ( + + {valueInfo} + + ) : null} + + {showCaution ? ( +
+

+ {t(ui.pantry.caution, lang)} +

+ {cautionWhy ? ( +

+ {cautionWhy} +

+ ) : null} +
+ ) : null} + + {thumb ? ( +
+

+ {t(ui.pantry.tip, lang)} + {categoryTitle ? ` · ${categoryTitle}` : null} +

+

+ {thumb} +

+
+ ) : null} +
+ + + + + ) +} diff --git a/client/src/components/food/IngredientChips.tsx b/client/src/components/food/IngredientChips.tsx new file mode 100644 index 0000000..6a383b4 --- /dev/null +++ b/client/src/components/food/IngredientChips.tsx @@ -0,0 +1,73 @@ +import { useState } from "react" + +import { IngredientChip } from "@/components/food/IngredientChip" +import { t, ui } from "@/i18n/ui" +import { useLanguage } from "@/lib/language" +import { cx } from "@/lib/cx" +import type { CatalogItem } from "@/types/domain" + +/** + * Ingredient chips — two sizes only: + * - `regular` (default): browse lists (Wochenplan, How-to, Builder, …) + * - `large`: Jetzt suggestion only + * + * Each chip opens a flyout (tap again / outside to close). + */ +export function IngredientChips({ + items, + maxVisible, + className, + size = "regular", +}: { + items: CatalogItem[] + maxVisible?: number + className?: string + size?: "regular" | "large" +}) { + const { lang } = useLanguage() + const [expanded, setExpanded] = useState(false) + const large = size === "large" + const overflow = + maxVisible !== undefined ? Math.max(0, items.length - maxVisible) : 0 + const visible = + maxVisible !== undefined && !expanded + ? items.slice(0, maxVisible) + : items + + const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5" + + return ( +
    + {visible.map((item) => ( +
  • + +
  • + ))} + {overflow > 0 ? ( +
  • + +
  • + ) : null} +
+ ) +} diff --git a/client/src/components/home/BuilderSlot.tsx b/client/src/components/home/BuilderSlot.tsx deleted file mode 100644 index bf1299e..0000000 --- a/client/src/components/home/BuilderSlot.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useNavigate } from "react-router-dom" -import { ChefHat } from "lucide-react" - -import { GlassCard } from "@/components/ui-pp/GlassCard" -import { t, ui } from "@/i18n/ui" -import { useLanguage } from "@/lib/language" - -/** Navigation tile — whole card is the hit target (same pattern as KnowledgeHub). */ -export function BuilderSlot() { - const { lang } = useLanguage() - const navigate = useNavigate() - - return ( - navigate("/builder")} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - navigate("/builder") - } - }} - > -
- - - -
-

- {t(ui.builderSlot.title, lang)} -

-

- {t(ui.builderSlot.body, lang)} -

-
-
-
- ) -} diff --git a/client/src/components/home/HomeScreen.tsx b/client/src/components/home/HomeScreen.tsx index 2cdd3b8..80b437b 100644 --- a/client/src/components/home/HomeScreen.tsx +++ b/client/src/components/home/HomeScreen.tsx @@ -1,133 +1,23 @@ -import { useEffect, useState, type ReactNode } from "react" -import { ChevronDown, ChevronUp } from "lucide-react" - -import { BuilderSlot } from "@/components/home/BuilderSlot" -import { KnowledgeHub } from "@/components/home/KnowledgeHub" import { SuggestionCard } from "@/components/home/SuggestionCard" import { t, ui } from "@/i18n/ui" import { useLanguage } from "@/lib/language" -import { cx } from "@/lib/cx" -import { - DEFAULT_WIDGET_ORDER, - moveWidget, - readWidgetOrder, - writeWidgetOrder, -} from "@/lib/widgetOrder" -import type { HomeWidgetId, WidgetOrder } from "@/types/domain" - -function WidgetShell({ - id, - editing, - order, - onMove, - children, -}: { - id: HomeWidgetId - editing: boolean - order: WidgetOrder - onMove: (id: HomeWidgetId, direction: "up" | "down") => void - children: ReactNode -}) { - const { lang } = useLanguage() - const index = order.indexOf(id) - - return ( -
- {editing ? ( -
- - -
- ) : null} - {children} -
- ) -} +/** Focus-First Start: only the hunger suggestion — tools live in Küche / Planen. */ export function HomeScreen() { const { lang } = useLanguage() - const [editing, setEditing] = useState(false) - const [order, setOrder] = useState(DEFAULT_WIDGET_ORDER) - - useEffect(() => { - setOrder(readWidgetOrder()) - }, []) - - function handleMove(id: HomeWidgetId, direction: "up" | "down") { - setOrder((current) => { - const next = moveWidget(current, id, direction) - writeWidgetOrder(next) - return next - }) - } - - const widgets: Record = { - suggestion: , - knowledge: , - builder: , - } return (
-
-
-

Pocket Pascal

-

- {t(ui.home.title, lang)} -

-
- +
+

Pocket Pascal

+

+ {t(ui.home.title, lang)} +

- {order.map((id) => ( - - {widgets[id]} - - ))} +
+ +
) } diff --git a/client/src/components/home/KnowledgeHub.tsx b/client/src/components/home/KnowledgeHub.tsx deleted file mode 100644 index 5ec902e..0000000 --- a/client/src/components/home/KnowledgeHub.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useNavigate } from "react-router-dom" -import { BookOpen, CalendarDays, Refrigerator } from "lucide-react" -import type { ReactNode } from "react" - -import { GlassCard } from "@/components/ui-pp/GlassCard" -import { t, ui } from "@/i18n/ui" -import { useLanguage } from "@/lib/language" - -function HubTile({ - title, - body, - gradient, - icon, - onNavigate, -}: { - title: string - body: string - gradient: string - icon: ReactNode - onNavigate: () => void -}) { - return ( - { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - onNavigate() - } - }} - > -
- - {icon} - -
-

{title}

-

{body}

-
-
-
- ) -} - -export function KnowledgeHub() { - const { lang } = useLanguage() - const navigate = useNavigate() - - return ( -
- } - onNavigate={() => navigate("/knowledge")} - /> - } - onNavigate={() => navigate("/pantry")} - /> - } - onNavigate={() => navigate("/weekplan")} - /> -
- ) -} diff --git a/client/src/components/home/NavCard.tsx b/client/src/components/home/NavCard.tsx new file mode 100644 index 0000000..f9c694a --- /dev/null +++ b/client/src/components/home/NavCard.tsx @@ -0,0 +1,131 @@ +import type { ReactNode } from "react" +import { useNavigate } from "react-router-dom" + +import { GlassCard } from "@/components/ui-pp/GlassCard" +import { t, ui } from "@/i18n/ui" +import { useLanguage } from "@/lib/language" +import { cx } from "@/lib/cx" +import type { LocalizedString } from "@/types/domain" + +export interface NavCardProps { + title: string + body: string + icon: ReactNode + gradient?: string + /** Required when interactive; ignored when `disabled`. */ + onNavigate?: () => void + className?: string + size?: "default" | "featured" + /** Status chip, e.g. Demnächst — typically paired with `disabled`. */ + badge?: string + /** Coming-soon / unavailable: not clickable, no navigation. */ + disabled?: boolean +} + +/** Navigation card — noun-phrase body, ≥44px touch target when interactive. */ +export function NavCard({ + title, + body, + icon, + gradient = "var(--gradient-primary)", + onNavigate, + className, + size = "default", + badge, + disabled = false, +}: NavCardProps) { + const featured = size === "featured" + const interactive = !disabled && Boolean(onNavigate) + + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + onNavigate?.() + } + } + : undefined + } + > +
+
+ + {icon} + + {badge ? ( + + {badge} + + ) : null} +
+
+

+ {title} +

+

{body}

+
+
+
+ ) +} + +export function HubBackLink({ + to, + label, +}: { + to: string + label: LocalizedString +}) { + const { lang } = useLanguage() + const navigate = useNavigate() + + return ( + + ) +} + +export function KnowledgeBackLink() { + return +} + +export function KitchenBackLink() { + return +} + +export function PlanBackLink() { + return +} diff --git a/client/src/components/home/SuggestionCard.tsx b/client/src/components/home/SuggestionCard.tsx index 0ec7904..5acf860 100644 --- a/client/src/components/home/SuggestionCard.tsx +++ b/client/src/components/home/SuggestionCard.tsx @@ -1,127 +1,135 @@ import { useMemo, useState } from "react" -import { useNavigate } from "react-router-dom" -import { Pin, RefreshCw } from "lucide-react" +import { RefreshCw } from "lucide-react" +import { IngredientChips } from "@/components/food/IngredientChips" import { Button } from "@/components/ui-pp/Button" import { GlassCard } from "@/components/ui-pp/GlassCard" +import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge" import { t, tr, ui } from "@/i18n/ui" +import { resolveRecipeUses } from "@/lib/catalog" import { useLanguage } from "@/lib/language" import { cx } from "@/lib/cx" -import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion" +import { pickSuggestion } from "@/lib/suggestion" import { formatClock, getTimeContext } from "@/lib/timeContext" -import type { FoodItem, Recipe, TimeSlot } from "@/types/domain" +import type { Effort, MealTime, MealType, Recipe } from "@/types/domain" -const CAT_DOT: Record = { - protein: "var(--color-protein)", - obst: "var(--color-obstgem)", - gemuese: "var(--color-obstgem)", - fett: "var(--color-fett)", - kh: "var(--color-kh)", - snack: "var(--color-protein)", +const EFFORT_OPTIONS: Effort[] = ["none", "quick", "cook"] + +function effortLabel(effort: Effort, lang: "de" | "en"): string { + if (effort === "none") return t(ui.suggestion.effortNone, lang) + if (effort === "quick") return t(ui.suggestion.effortQuick, lang) + return t(ui.suggestion.effortCook, lang) } -function slotLabel(slot: TimeSlot, lang: "de" | "en"): string { - return t(ui.suggestion[slot], lang) +function mealTimeLabel(mealTime: MealTime, lang: "de" | "en"): string { + return t(ui.suggestion[mealTime], lang) +} + +function plateLabel(mealType: MealType, lang: "de" | "en"): string { + return mealType === "full" + ? t(ui.suggestion.plateFull, lang) + : t(ui.suggestion.plateHalf, lang) } export function SuggestionCard() { const { lang } = useLanguage() - const navigate = useNavigate() const context = useMemo(() => getTimeContext(), []) + const [effort, setEffort] = useState(null) + const [recipe, setRecipe] = useState(() => + pickSuggestion(context.mealTime, null), + ) - const [suggestion, setSuggestion] = useState(() => buildSuggestion(context.slot)) - const [pinnedIds, setPinnedIds] = useState>(() => new Set()) + const ingredients = recipe ? resolveRecipeUses(recipe) : [] + const clock = formatClock(context.hour, context.minute) + const plateType = recipe?.type ?? context.mealType + const eyebrow = `${mealTimeLabel(context.mealTime, lang)} · ${plateLabel(plateType, lang)}` - const recipe: Recipe | null = suggestion.recipe - const items: FoodItem[] = suggestion.foods + function applyEffort(next: Effort | null) { + setEffort(next) + setRecipe(pickSuggestion(context.mealTime, next, recipe?.id)) + } - const clock = formatClock(context.hour, context.minute, lang) - const header = - lang === "de" - ? `${clock} ${t(ui.suggestion.timeSuffix, lang)} · ${slotLabel(context.slot, lang)}` - : `${clock} · ${slotLabel(context.slot, lang)}` - - const title = recipe ? tr(recipe.name, lang) : t(ui.suggestion.fallbackName, lang) - - function togglePin(id: string) { - setPinnedIds((current) => { - const next = new Set(current) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) + function toggleEffort(value: Effort) { + applyEffort(effort === value ? null : value) } function reroll() { - if (pinnedIds.size === 0) { - setSuggestion(buildSuggestion(context.slot, recipe?.id)) - return - } - setSuggestion({ - recipe: null, - foods: reshuffleSuggestion(items, pinnedIds, context.slot), - }) - } - - function sendToBuilder() { - navigate("/builder", { - state: { - ingredientIds: items.map((item) => item.id), - source: "suggestion" as const, - }, - }) + setRecipe(pickSuggestion(context.mealTime, effort, recipe?.id ?? undefined)) } return ( - +
-
-

{header}

-

{title}

+
+ +
+
+

+ {eyebrow} +

+

+ {clock} + {lang === "de" ? " Uhr" : ""} +

+
+ + {recipe ? ( +

+ {tr(recipe.name, lang)} +

+ ) : ( +

+ {t(ui.suggestion.emptyFilter, lang)} +

+ )} +
-
- {items.map((item) => { - const pinned = pinnedIds.has(item.id) - return ( - - ) - })} + {ingredients.length > 0 ? ( + + ) : null} + +
+

+ {t(ui.suggestion.effortLabel, lang)} +

+
+ {EFFORT_OPTIONS.map((option) => { + const active = effort === option + return ( + + ) + })} +
-
- - -
+
) diff --git a/client/src/components/layout/AppShell.tsx b/client/src/components/layout/AppShell.tsx index 20ba571..1fc3407 100644 --- a/client/src/components/layout/AppShell.tsx +++ b/client/src/components/layout/AppShell.tsx @@ -1,15 +1,20 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom" -import { BookOpen, Home, Settings2, ShoppingBasket } from "lucide-react" +import { BookOpen, CalendarDays, CookingPot, Sparkles } from "lucide-react" import { BottomNav } from "@/components/ui-pp/BottomNav" import { t, ui } from "@/i18n/ui" import { useLanguage } from "@/lib/language" const ROUTES = [ - { key: "home", path: "/", icon: Home, labelKey: "home" as const }, - { key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const }, - { key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const }, - { key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const }, + { key: "now", path: "/", icon: Sparkles, labelKey: "now" as const }, + { key: "kitchen", path: "/kitchen", icon: CookingPot, labelKey: "kitchen" as const }, + { key: "plan", path: "/plan", icon: CalendarDays, labelKey: "plan" as const }, + { + key: "knowledge", + path: "/knowledge", + icon: BookOpen, + labelKey: "knowledge" as const, + }, ] export function AppShell() { diff --git a/client/src/components/plate/DiningPlate.tsx b/client/src/components/plate/DiningPlate.tsx index df6047c..c863611 100644 --- a/client/src/components/plate/DiningPlate.tsx +++ b/client/src/components/plate/DiningPlate.tsx @@ -1,4 +1,5 @@ -import { useId } from "react" +import { useMemo } from "react" +import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts" import { cx } from "@/lib/cx" @@ -12,7 +13,9 @@ export interface PlateSegment { export interface DiningPlateProps { segments: PlateSegment[] + /** Mobile-first default. */ size?: number + selectedId?: string | null onSelect?: (id: string) => void className?: string "aria-label"?: string @@ -23,160 +26,189 @@ function polar(centerX: number, centerY: number, r: number, angleDeg: number) { return { x: centerX + r * Math.cos(rad), y: centerY + r * Math.sin(rad) } } -/** Filled pie wedge from center (food on the plate — not a thin outer ring). */ -function wedgePath( - centerX: number, - centerY: number, - r: number, - startAngle: number, - endAngle: number, -): string { - if (endAngle - startAngle >= 359.9) { - return [ - `M ${centerX} ${centerY - r}`, - `A ${r} ${r} 0 1 1 ${centerX} ${centerY + r}`, - `A ${r} ${r} 0 1 1 ${centerX} ${centerY - r}`, - "Z", - ].join(" ") - } - const start = polar(centerX, centerY, r, startAngle) - const end = polar(centerX, centerY, r, endAngle) - const large = endAngle - startAngle > 180 ? 1 : 0 - return [ - `M ${centerX} ${centerY}`, - `L ${start.x} ${start.y}`, - `A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`, - "Z", - ].join(" ") -} - /** - * Ceramic dining plate with food-like category wedges inside the rim. - * Rim depth via CSS inset shadows; wedges are SVG (clickable). + * Ceramic dining plate with detached, rounded pie wedges (Recharts). + * padAngle ≈ ceramic gaps; cornerRadius softens each Baustein tile. */ export function DiningPlate({ segments, - size = 280, + size = 240, + selectedId = null, onSelect, className, "aria-label": ariaLabel, }: DiningPlateProps) { - const uid = useId().replace(/:/g, "") - const centerX = size / 2 - const centerY = size / 2 - const rimPad = size * 0.09 + const rimPad = size * 0.14 const foodR = size / 2 - rimPad - const gapDeg = 2.5 + const center = size / 2 - const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1 - let angle = 0 - const wedges = segments.map((segment) => { - const sweep = (segment.weight / total) * 360 - const start = angle + gapDeg / 2 - const end = angle + sweep - gapDeg / 2 - angle += sweep - return { - ...segment, - start, - end, - d: end > start ? wedgePath(centerX, centerY, foodR, start, end) : null, - } - }) + const chartData = useMemo( + () => + segments.map((segment) => ({ + id: segment.id, + value: segment.weight, + color: segment.color, + label: segment.label, + })), + [segments], + ) + + /** Mid-angles for rim dots / floating pill (Recharts starts at +90° / 12 o'clock). */ + const markers = useMemo(() => { + const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1 + const padAngle = 5 + const padTotal = padAngle * segments.length + const usable = 360 - padTotal + let angle = 0 + return segments.map((segment) => { + const sweep = (segment.weight / total) * usable + const start = angle + padAngle / 2 + const end = angle + padAngle / 2 + sweep + const mid = (start + end) / 2 + angle += sweep + padAngle + return { + id: segment.id, + label: segment.label, + color: segment.color, + marker: polar(center, center, foodR + rimPad * 0.48, mid), + pill: polar(center, center, foodR + rimPad * 1.45, mid), + } + }) + }, [segments, center, foodR, rimPad]) return (
{/* Ceramic body + raised rim */}
- {/* Soft recessed well under the food */} + {/* Recessed well — ceramic shows through wedge gaps */}
- - - - - - - - - - - - - - - - - - {wedges.map( - (wedge) => - wedge.d && ( - + + { + const segment = chartData[index] + if (segment) onSelect?.(segment.id) + }} + > + {chartData.map((entry) => ( + onSelect?.(wedge.id)} - onKeyDown={(event) => { - if (!onSelect) return - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - onSelect(wedge.id) - } - }} + style={{ outline: "none" }} /> - ), - )} - + ))} + + + +
- {/* Subtle inner rim line separating food from ceramic lip */} - - + {/* Rim markers */} + {markers.map((marker) => { + const selected = selectedId === marker.id + return ( + + ) + })} + + {/* Floating label pill for selection */} + {markers.map((marker) => { + if (selectedId !== marker.id) return null + return ( +
+ + {marker.label} +
+ ) + })}
) } diff --git a/client/src/components/ui-pp/Accordion.tsx b/client/src/components/ui-pp/Accordion.tsx index affb3a9..f00af97 100644 --- a/client/src/components/ui-pp/Accordion.tsx +++ b/client/src/components/ui-pp/Accordion.tsx @@ -70,9 +70,7 @@ export function Accordion({ "active:brightness-[0.98]", )} > - {icon ? ( - {icon} - ) : null} + {icon ? {icon} : null} {title} diff --git a/client/src/components/ui-pp/BottomNav.tsx b/client/src/components/ui-pp/BottomNav.tsx index 9d50c6f..39ef728 100644 --- a/client/src/components/ui-pp/BottomNav.tsx +++ b/client/src/components/ui-pp/BottomNav.tsx @@ -17,6 +17,7 @@ export interface BottomNavProps { /** * Floating pill bottom navigation — float elevation above content cards. + * Active state uses --ui-accent (non-semantic chrome), not category colors. */ export function BottomNav({ items, className }: BottomNavProps) { return ( @@ -49,7 +50,7 @@ export function BottomNav({ items, className }: BottomNavProps) { className={cx( "text-xl leading-none [&_svg]:h-5 [&_svg]:w-5", item.active - ? "text-[var(--color-obstgem-dk)]" + ? "text-[var(--ui-accent)]" : "text-[var(--ink-soft)]", )} > @@ -59,14 +60,9 @@ export function BottomNav({ items, className }: BottomNavProps) { className={cx( "type-label normal-case tracking-wide", item.active - ? "bg-clip-text text-transparent" + ? "text-[var(--ui-accent)]" : "text-[var(--ink-soft)]", )} - style={ - item.active - ? { backgroundImage: "var(--gradient-accent)" } - : undefined - } > {item.label} diff --git a/client/src/components/ui-pp/TimeIconBadge.tsx b/client/src/components/ui-pp/TimeIconBadge.tsx new file mode 100644 index 0000000..3f96c76 --- /dev/null +++ b/client/src/components/ui-pp/TimeIconBadge.tsx @@ -0,0 +1,84 @@ +import type { LucideIcon } from "lucide-react" + +import { cx } from "@/lib/cx" +import { + DAYLIGHT_GRADIENT, + daylightIconPlate, + daylightPeriodFromHour, + daylightPeriodFromMealTime, + mealTimeIcon, + periodFallbackIcon, + type DaylightPeriod, +} from "@/lib/daylight" +import type { MealTime } from "@/types/domain" + +export type { DaylightPeriod } + +export interface TimeIconBadgeProps { + icon?: LucideIcon + /** Explicit period wins over hour / mealTime. */ + period?: DaylightPeriod + /** + * Wall-clock hour for live “now” gradient. + * When set (without `period`), drives the sky gradient — even if `mealTime` is set for the icon. + */ + hour?: number + /** Section meal slot — drives icon; drives gradient only when `hour` is omitted. */ + mealTime?: MealTime + className?: string + "aria-hidden"?: boolean + "aria-label"?: string +} + +/** + * Rounded gradient badge for time-of-day / meal-section headers. + * - Suggestion (now): pass `hour` + `mealTime` (sky from hour, icon from meal). + * - Wochenplan sections: pass `mealTime` only (stable per section). + */ +export function TimeIconBadge({ + icon, + period: periodProp, + hour, + mealTime, + className, + "aria-hidden": ariaHidden, + "aria-label": ariaLabel, +}: TimeIconBadgeProps) { + const period: DaylightPeriod = + periodProp ?? + (hour !== undefined + ? daylightPeriodFromHour(hour) + : mealTime + ? daylightPeriodFromMealTime(mealTime) + : daylightPeriodFromHour(new Date().getHours())) + + const Icon = + icon ?? (mealTime ? mealTimeIcon(mealTime) : periodFallbackIcon(period)) + + const { plate, icon: iconColor } = daylightIconPlate(period) + + return ( + + + + + + ) +} diff --git a/client/src/data/foods.json b/client/src/data/foods.json index 6d750f9..496e7cb 100644 --- a/client/src/data/foods.json +++ b/client/src/data/foods.json @@ -8,8 +8,8 @@ "en": "Protein" }, "thumb": { - "de": "Mindestens 15–20 g Protein pro 100 g, wenig Zucker & wenig Fett, kurze Zutatenliste.", - "en": "At least 15–20 g protein per 100 g, low sugar & low fat, short ingredient list." + "de": "Mindestens 15–20 g Protein pro 100 g.", + "en": "At least 15–20 g protein per 100 g." }, "items": [ { @@ -18,6 +18,9 @@ "de": "Edamame", "en": "Edamame" }, + "basis": "per100g", + "kcal": 125, + "proteinG": 14, "v": { "de": "125 kcal · 14 g", "en": "125 kcal · 14 g" @@ -29,6 +32,9 @@ "de": "Ei (Größe M)", "en": "Egg (size M)" }, + "basis": "perUnit", + "kcal": 137, + "proteinG": 13, "v": { "de": "137 kcal · 13 g", "en": "137 kcal · 13 g" @@ -40,6 +46,9 @@ "de": "Erbsenproteinschnetzel", "en": "Pea-protein strips" }, + "basis": "per100g", + "kcal": 367, + "proteinG": 61, "v": { "de": "367 kcal · 61 g", "en": "367 kcal · 61 g" @@ -51,11 +60,18 @@ "de": "Feta (leicht)", "en": "Feta (light)" }, + "basis": "per100g", + "kcal": 161, + "proteinG": 19, + "f": "caution", "v": { "de": "161 kcal · 19 g", "en": "161 kcal · 19 g" }, - "f": "caution" + "cautionWhy": { + "de": "Relativ viel Fett und Salz — Portionsgröße im Blick behalten.", + "en": "Relatively high in fat and salt — watch the portion." + } }, { "id": "chicken_breast", @@ -63,11 +79,14 @@ "de": "Hühnerbrust", "en": "Chicken breast" }, + "basis": "per100g", + "kcal": 111, + "proteinG": 24, + "f": "pick", "v": { "de": "111 kcal · 24 g", "en": "111 kcal · 24 g" - }, - "f": "pick" + } }, { "id": "cottage_cheese", @@ -75,6 +94,9 @@ "de": "Hüttenkäse", "en": "Cottage cheese" }, + "basis": "per100g", + "kcal": 87, + "proteinG": 12.3, "v": { "de": "87 kcal · 12,3 g", "en": "87 kcal · 12.3 g" @@ -86,11 +108,14 @@ "de": "„Like Chicken\" (pflanzlich)", "en": "\"Like Chicken\" (plant-based)" }, + "basis": "per100g", + "kcal": 140, + "proteinG": 18, + "approx": true, "v": { "de": "≈140 kcal · 18 g", "en": "≈140 kcal · 18 g" - }, - "approx": true + } }, { "id": "low_fat_quark", @@ -98,6 +123,9 @@ "de": "Magerquark", "en": "Low-fat quark" }, + "basis": "per100g", + "kcal": 67, + "proteinG": 12, "v": { "de": "67 kcal · 12 g", "en": "67 kcal · 12 g" @@ -109,11 +137,14 @@ "de": "Rinderfilet / Tatar", "en": "Beef fillet / tartare" }, + "basis": "per100g", + "kcal": 112, + "proteinG": 22, + "f": "pick", "v": { "de": "112 kcal · 22 g", "en": "112 kcal · 22 g" - }, - "f": "pick" + } }, { "id": "silken_tofu", @@ -121,11 +152,14 @@ "de": "Seidentofu", "en": "Silken tofu" }, + "basis": "per100g", + "kcal": 55, + "proteinG": 5, + "approx": true, "v": { "de": "≈55 kcal · 5 g", "en": "≈55 kcal · 5 g" - }, - "approx": true + } }, { "id": "skyr", @@ -133,11 +167,14 @@ "de": "Skyr", "en": "Skyr" }, + "basis": "per100g", + "kcal": 54, + "proteinG": 10.3, + "f": "pick", "v": { "de": "54 kcal · 10,3 g", "en": "54 kcal · 10.3 g" - }, - "f": "pick" + } }, { "id": "tempeh", @@ -145,6 +182,9 @@ "de": "Tempeh", "en": "Tempeh" }, + "basis": "per100g", + "kcal": 184, + "proteinG": 19, "v": { "de": "184 kcal · 19 g", "en": "184 kcal · 19 g" @@ -156,10 +196,139 @@ "de": "Tofu", "en": "Tofu" }, + "basis": "per100g", + "kcal": 129, + "proteinG": 13, "v": { "de": "129 kcal · 13 g", "en": "129 kcal · 13 g" } + }, + { + "id": "turkey_breast", + "name": { + "de": "Putenbrust", + "en": "Turkey breast" + }, + "basis": "per100g", + "kcal": 102, + "proteinG": 23, + "v": { + "de": "102 kcal · 23 g", + "en": "102 kcal · 23 g" + } + }, + { + "id": "pork_loin", + "name": { + "de": "Schweinelende", + "en": "Pork loin" + }, + "basis": "per100g", + "kcal": 143, + "proteinG": 22, + "v": { + "de": "143 kcal · 22 g", + "en": "143 kcal · 22 g" + } + }, + { + "id": "veal_medallions", + "name": { + "de": "Kalbsmedaillons", + "en": "Veal medallions" + }, + "basis": "per100g", + "kcal": 101, + "proteinG": 22, + "v": { + "de": "101 kcal · 22 g", + "en": "101 kcal · 22 g" + } + }, + { + "id": "pork_fillet", + "name": { + "de": "Schweinefilet", + "en": "Pork fillet" + }, + "basis": "per100g", + "kcal": 115, + "proteinG": 20, + "v": { + "de": "115 kcal · 20 g", + "en": "115 kcal · 20 g" + } + }, + { + "id": "lamb_saddle", + "name": { + "de": "Lammlachs", + "en": "Lamb saddle" + }, + "basis": "per100g", + "kcal": 117, + "proteinG": 20, + "v": { + "de": "117 kcal · 20 g", + "en": "117 kcal · 20 g" + } + }, + { + "id": "chicken_liver", + "name": { + "de": "Hühnerleber", + "en": "Chicken liver" + }, + "basis": "per100g", + "kcal": 136, + "proteinG": 22, + "v": { + "de": "136 kcal · 22 g", + "en": "136 kcal · 22 g" + } + }, + { + "id": "cooked_ham", + "name": { + "de": "Kochschinken", + "en": "Cooked ham" + }, + "basis": "per100g", + "kcal": 126, + "proteinG": 20, + "v": { + "de": "126 kcal · 20 g", + "en": "126 kcal · 20 g" + } + }, + { + "id": "raw_ham", + "name": { + "de": "Rohschinken", + "en": "Cured ham" + }, + "basis": "per100g", + "kcal": 190, + "proteinG": 24, + "v": { + "de": "190 kcal · 24 g", + "en": "190 kcal · 24 g" + } + }, + { + "id": "turkey", + "name": { + "de": "Truthahn", + "en": "Turkey" + }, + "basis": "per100g", + "kcal": 110, + "proteinG": 23, + "v": { + "de": "110 kcal · 23 g", + "en": "110 kcal · 23 g" + } } ], "sub": { @@ -174,11 +343,14 @@ "de": "Griech. Joghurt 10 %", "en": "Greek yoghurt 10%" }, + "basis": "per100g", + "kcal": 115, + "proteinG": 5, + "approx": true, "v": { "de": "≈115 kcal · 5 g", "en": "≈115 kcal · 5 g" - }, - "approx": true + } }, { "id": "natural_yogurt", @@ -186,11 +358,14 @@ "de": "Naturjoghurt", "en": "Natural yoghurt" }, + "basis": "per100g", + "kcal": 61, + "proteinG": 3.5, + "approx": true, "v": { "de": "≈61 kcal · 3,5 g", "en": "≈61 kcal · 3.5 g" - }, - "approx": true + } }, { "id": "oatly_barista", @@ -198,12 +373,23 @@ "de": "Oatly Barista", "en": "Oatly Barista" }, + "basis": "per100g", + "kcal": 68, + "proteinG": 1, + "approx": true, + "f": "caution", "v": { "de": "≈68 kcal · 1 g (kaum Protein)", "en": "≈68 kcal · 1 g (barely protein)" }, - "approx": true, - "f": "caution" + "note": { + "de": "(kaum Protein)", + "en": "(barely protein)" + }, + "cautionWhy": { + "de": "Kaum Protein — als Milchersatz kein Baustein für den Proteinanteil.", + "en": "Barely any protein — not a building block for the protein share." + } }, { "id": "whole_milk", @@ -211,12 +397,19 @@ "de": "Vollmilch 3,5 %", "en": "Whole milk 3.5%" }, + "basis": "per100g", + "kcal": 64, + "proteinG": 3.4, + "approx": true, + "f": "caution", "v": { "de": "≈64 kcal · 3,4 g", "en": "≈64 kcal · 3.4 g" }, - "approx": true, - "f": "caution" + "cautionWhy": { + "de": "Viel Fett bei wenig Protein — lieber magerere Milchprodukte wählen.", + "en": "High fat for little protein — prefer leaner dairy." + } } ] } @@ -230,8 +423,8 @@ "en": "Protein snacks for Steffi" }, "thumb": { - "de": "Mindestens 15 g Protein pro Portion, unter 5 g Zucker, Fett unter 8 g — Zutatenliste beginnt nicht mit Zucker/Sirup/Schokolade.", - "en": "At least 15 g protein per serving, under 5 g sugar, fat under 8 g — ingredient list shouldn’t open with sugar/syrup/chocolate." + "de": "Mindestens 15 g Protein, maximal 5 g Zucker und 8 g Fett. Check: Zucker oder Sirup sollten nicht an 1. Stelle der Zutaten stehen.", + "en": "At least 15 g protein, max 5 g sugar and 8 g fat. Check: sugar or syrup should not be the first ingredient." }, "items": [ { @@ -240,11 +433,14 @@ "de": "3 Eier", "en": "3 eggs" }, + "basis": "perServing", + "kcal": 205, + "proteinG": 19, + "approx": true, "v": { "de": "≈205 kcal · 19 g", "en": "≈205 kcal · 19 g" - }, - "approx": true + } }, { "id": "cottage_berries", @@ -252,11 +448,14 @@ "de": "Hüttenkäse + Beeren", "en": "Cottage cheese + berries" }, + "basis": "perServing", + "kcal": 210, + "proteinG": 25, + "approx": true, "v": { "de": "≈210 kcal · 25 g", "en": "≈210 kcal · 25 g" - }, - "approx": true + } }, { "id": "chickpea_waffles", @@ -264,12 +463,19 @@ "de": "Kichererbsenwaffeln 90g", "en": "Chickpea waffles 90g" }, + "basis": "perServing", + "kcal": 300, + "proteinG": 17, + "approx": true, + "f": "caution", "v": { "de": "≈300 kcal · 17 g", "en": "≈300 kcal · 17 g" }, - "approx": true, - "f": "caution" + "cautionWhy": { + "de": "Kaloriendicht für die Proteinmenge — eher bewusste Snack-Portion.", + "en": "Calorie-dense for the protein yield — keep snack portions mindful." + } }, { "id": "pumpkin_seeds_30", @@ -277,12 +483,19 @@ "de": "Kürbiskerne 30g", "en": "Pumpkin seeds 30g" }, + "basis": "perServing", + "kcal": 171, + "proteinG": 9, + "approx": true, + "f": "caution", "v": { "de": "≈171 kcal · 9 g", "en": "≈171 kcal · 9 g" }, - "approx": true, - "f": "caution" + "cautionWhy": { + "de": "Kaloriendicht (Fett) — kleine Handvoll reicht.", + "en": "Calorie-dense (fat) — a small handful is enough." + } }, { "id": "lentil_waffles", @@ -290,12 +503,19 @@ "de": "Linsenwaffeln 90g", "en": "Lentil waffles 90g" }, + "basis": "perServing", + "kcal": 300, + "proteinG": 18, + "approx": true, + "f": "caution", "v": { "de": "≈300 kcal · 18 g", "en": "≈300 kcal · 18 g" }, - "approx": true, - "f": "caution" + "cautionWhy": { + "de": "Kaloriendicht für die Proteinmenge — eher bewusste Snack-Portion.", + "en": "Calorie-dense for the protein yield — keep snack portions mindful." + } }, { "id": "protein_bar", @@ -303,11 +523,14 @@ "de": "Magerer Proteinriegel", "en": "Lean protein bar" }, + "basis": "perServing", + "kcal": 180, + "proteinG": 20, + "approx": true, "v": { "de": "≈180 kcal · 20 g", "en": "≈180 kcal · 20 g" - }, - "approx": true + } }, { "id": "protein_rice_pudding", @@ -315,11 +538,14 @@ "de": "Proteinmilchreis (2x)", "en": "Protein rice pudding (2x)" }, + "basis": "perServing", + "kcal": 340, + "proteinG": 40, + "approx": true, "v": { "de": "≈340 kcal · 40 g", "en": "≈340 kcal · 40 g" - }, - "approx": true + } }, { "id": "protein_pudding", @@ -327,12 +553,15 @@ "de": "Proteinpudding", "en": "Protein pudding" }, + "basis": "perServing", + "kcal": 90, + "proteinG": 20, + "approx": true, + "f": "pick", "v": { "de": "≈90 kcal · 20 g", "en": "≈90 kcal · 20 g" - }, - "approx": true, - "f": "pick" + } }, { "id": "protein_shake", @@ -340,12 +569,15 @@ "de": "Proteinshake 40g", "en": "Protein shake 40g" }, + "basis": "perServing", + "kcal": 150, + "proteinG": 30, + "approx": true, + "f": "pick", "v": { "de": "≈150 kcal · 30 g", "en": "≈150 kcal · 30 g" - }, - "approx": true, - "f": "pick" + } }, { "id": "turkey_breast_pack", @@ -353,12 +585,15 @@ "de": "Putenbrust, 1 Packung", "en": "Turkey breast, 1 pack" }, + "basis": "perServing", + "kcal": 130, + "proteinG": 28, + "approx": true, + "f": "pick", "v": { "de": "≈130 kcal · 28 g", "en": "≈130 kcal · 28 g" - }, - "approx": true, - "f": "pick" + } }, { "id": "smoked_tofu_cubes", @@ -366,11 +601,14 @@ "de": "Räuchertofu-Würfel", "en": "Smoked tofu cubes" }, + "basis": "perServing", + "kcal": 225, + "proteinG": 25, + "approx": true, "v": { "de": "≈225 kcal · 25 g", "en": "≈225 kcal · 25 g" - }, - "approx": true + } }, { "id": "skyr_honey", @@ -378,11 +616,14 @@ "de": "Skyr/Griech. Joghurt 10% + Honig, 250g+", "en": "Skyr/Greek yoghurt 10% + honey, 250g+" }, + "basis": "perServing", + "kcal": 180, + "proteinG": 25, + "approx": true, "v": { "de": "≈180 kcal · 25 g", "en": "≈180 kcal · 25 g" - }, - "approx": true + } } ], "recipe": { @@ -405,8 +646,8 @@ "en": "Fruit" }, "thumb": { - "de": "Frisch oder tiefgekühlt ohne Zuckerzusatz, Saison bevorzugen.", - "en": "Fresh or frozen without added sugar, prefer what’s in season." + "de": "Genauso gut wie frisch: Tiefkühlgemüse spart Zeit und hält Nährstoffe. Achte einfach auf reine Sorten ohne Butter, Sahne oder Salz.", + "en": "Just as good as fresh: frozen veg saves time and keeps nutrients. Simply choose plain varieties without butter, cream, or salt." }, "items": [ { @@ -415,6 +656,7 @@ "de": "Apfel", "en": "Apple" }, + "basis": "per100g", "v": { "de": "Vit. C, Kalium, Kupfer", "en": "Vit. C, potassium, copper" @@ -426,11 +668,16 @@ "de": "Banane", "en": "Banana" }, + "basis": "per100g", + "f": "caution", "v": { "de": "Kalium, B6, C, Magnesium", "en": "Potassium, B6, C, magnesium" }, - "f": "caution" + "cautionWhy": { + "de": "Relativ zuckerreich unter dem Obst — Portionsgröße beachten.", + "en": "Relatively high in sugar among fruit — mind the portion." + } }, { "id": "blueberry", @@ -438,11 +685,12 @@ "de": "Blaubeere", "en": "Blueberry" }, + "basis": "per100g", + "f": "pick", "v": { "de": "Vit. K, C, Mangan", "en": "Vit. K, C, manganese" - }, - "f": "pick" + } }, { "id": "strawberry", @@ -450,6 +698,7 @@ "de": "Erdbeere", "en": "Strawberry" }, + "basis": "per100g", "v": { "de": "Vit. C, Mangan, Folsäure, Kalium", "en": "Vit. C, manganese, folate, potassium" @@ -461,11 +710,12 @@ "de": "Granadilla (≈ Passionsfrucht)", "en": "Granadilla (≈ passion fruit)" }, + "basis": "per100g", + "approx": true, "v": { "de": "Vit. C, A, Kalium, B2", "en": "Vit. C, A, potassium, B2" - }, - "approx": true + } }, { "id": "pomegranate", @@ -473,11 +723,12 @@ "de": "Granatapfel", "en": "Pomegranate" }, + "basis": "per100g", + "f": "pick", "v": { "de": "Vit. C, K, Folsäure, Kalium, B6", "en": "Vit. C, K, folate, potassium, B6" - }, - "f": "pick" + } }, { "id": "mango", @@ -485,11 +736,12 @@ "de": "Mango", "en": "Mango" }, + "basis": "per100g", + "f": "pick", "v": { "de": "Vit. C, A, B6, Folsäure, Kupfer", "en": "Vit. C, A, B6, folate, copper" - }, - "f": "pick" + } }, { "id": "nectarine", @@ -497,6 +749,7 @@ "de": "Nektarine", "en": "Nectarine" }, + "basis": "per100g", "v": { "de": "Vit. C, A, Kalium, E, Niacin", "en": "Vit. C, A, potassium, E, niacin" @@ -508,6 +761,7 @@ "de": "Orange", "en": "Orange" }, + "basis": "per100g", "v": { "de": "Vit. C, Folsäure, Kalium, Calcium", "en": "Vit. C, folate, potassium, calcium" @@ -519,11 +773,12 @@ "de": "Passionsfrucht", "en": "Passion fruit" }, + "basis": "per100g", + "approx": true, "v": { "de": "Vit. C, A, Kalium, Eisen", "en": "Vit. C, A, potassium, iron" - }, - "approx": true + } }, { "id": "plum", @@ -531,6 +786,7 @@ "de": "Pflaume", "en": "Plum" }, + "basis": "per100g", "v": { "de": "Vit. C, K, Kalium, A, Kupfer", "en": "Vit. C, K, potassium, A, copper" @@ -542,11 +798,16 @@ "de": "Trauben", "en": "Grapes" }, + "basis": "per100g", + "f": "caution", "v": { "de": "Vit. K, C, Kalium, Kupfer, Mangan", "en": "Vit. K, C, potassium, copper, manganese" }, - "f": "caution" + "cautionWhy": { + "de": "Hoher Zuckergehalt — bewusst genießen, nicht naschen ohne Maß.", + "en": "High in sugar — enjoy mindfully, don’t snack freely." + } } ] }, @@ -563,8 +824,8 @@ "en": "Tip" }, "thumb": { - "de": "TK-Gemüse ist völlig okay, wenn’s schnell gehen muss — genauso nährstoffreich wie frisch, einfach ohne Salz-/Fettzusatz wählen.", - "en": "Frozen veg is perfectly fine when time is short — just as nutrient-rich as fresh, just pick it without added salt/fat." + "de": "Genauso gut wie frisch: Tiefkühlgemüse spart Zeit und hält Nährstoffe. Achte einfach auf reine Sorten ohne Butter, Sahne oder Salz.", + "en": "Just as good as fresh: frozen veg saves time and keeps nutrients. Simply choose plain varieties without butter, cream, or salt." }, "items": [ { @@ -573,6 +834,7 @@ "de": "Brokkoli", "en": "Broccoli" }, + "basis": "per100g", "v": { "de": "Vit. C, K, Folsäure, Eisen", "en": "Vit. C, K, folate, iron" @@ -584,6 +846,7 @@ "de": "Champignons", "en": "Mushrooms" }, + "basis": "per100g", "v": { "de": "Kalium, Selen, Kupfer, B2", "en": "Potassium, selenium, copper, B2" @@ -595,11 +858,16 @@ "de": "Eisbergsalat", "en": "Iceberg lettuce" }, + "basis": "per100g", + "f": "caution", "v": { "de": "Vit. K, A, Folsäure, Kalium — nährstoffarm", "en": "Vit. K, A, folate, potassium — low nutrient density" }, - "f": "caution" + "cautionWhy": { + "de": "Nährstoffarm — ok als Volumen, besser mit dichterem Gemüse kombinieren.", + "en": "Low nutrient density — fine for volume; pair with denser veg." + } }, { "id": "lambs_lettuce", @@ -607,11 +875,12 @@ "de": "Feldsalat", "en": "Lamb’s lettuce" }, + "basis": "per100g", + "f": "pick", "v": { "de": "Vit. A, C, Folsäure, Eisen", "en": "Vit. A, C, folate, iron" - }, - "f": "pick" + } }, { "id": "cucumber", @@ -619,6 +888,7 @@ "de": "Gurke", "en": "Cucumber" }, + "basis": "per100g", "v": { "de": "Vit. K, Kalium, Mangan", "en": "Vit. K, potassium, manganese" @@ -630,11 +900,12 @@ "de": "Karotte", "en": "Carrot" }, + "basis": "per100g", + "f": "pick", "v": { "de": "Vit. A, K, Kalium, B6", "en": "Vit. A, K, potassium, B6" - }, - "f": "pick" + } }, { "id": "bell_pepper", @@ -642,6 +913,7 @@ "de": "Paprika", "en": "Bell pepper" }, + "basis": "per100g", "v": { "de": "Vit. C, A, B6, E", "en": "Vit. C, A, B6, E" @@ -653,11 +925,12 @@ "de": "Spinat", "en": "Spinach" }, + "basis": "per100g", + "f": "pick", "v": { "de": "Vit. K, A, Folsäure, Eisen", "en": "Vit. K, A, folate, iron" - }, - "f": "pick" + } }, { "id": "tomato", @@ -665,6 +938,7 @@ "de": "Tomate", "en": "Tomato" }, + "basis": "per100g", "v": { "de": "Vit. C, K, Kalium, Folsäure", "en": "Vit. C, K, potassium, folate" @@ -676,6 +950,7 @@ "de": "Zucchini", "en": "Zucchini" }, + "basis": "per100g", "v": { "de": "Vit. C, Kalium, B6, Mangan", "en": "Vit. C, potassium, B6, manganese" @@ -696,8 +971,8 @@ "en": "Fat" }, "thumb": { - "de": "Native/kaltgepresste Öle, ungesalzene & ungeröstete Nüsse/Kerne, Zutatenliste = nur die Zutat selbst.", - "en": "Native/cold-pressed oils, unsalted & unroasted nuts/seeds, ingredient list = just the ingredient itself." + "de": "Unverarbeitet schlägt raffiniert: Wähle native/kaltgepresste Öle sowie ungesalzene Nüsse. Zutaten-Check: Es sollte nur die Zutat selbst auf der Packung stehen.", + "en": "Unprocessed beats refined: choose native/cold-pressed oils and unsalted nuts. Ingredient check: the pack should list only the ingredient itself." }, "items": [ { @@ -706,6 +981,9 @@ "de": "Avocado", "en": "Avocado" }, + "basis": "per100g", + "kcal": 217, + "proteinG": 12.5, "v": { "de": "217 kcal · 12,5 g", "en": "217 kcal · 12.5 g" @@ -717,11 +995,14 @@ "de": "Chiasamen", "en": "Chia seeds" }, + "basis": "per100g", + "kcal": 486, + "proteinG": 31, + "f": "pick", "v": { "de": "486 kcal · 31 g", "en": "486 kcal · 31 g" - }, - "f": "pick" + } }, { "id": "pumpkin_seeds", @@ -729,6 +1010,9 @@ "de": "Kürbiskerne", "en": "Pumpkin seeds" }, + "basis": "per100g", + "kcal": 570, + "proteinG": 44, "v": { "de": "570 kcal · 44 g", "en": "570 kcal · 44 g" @@ -740,11 +1024,14 @@ "de": "Leinöl", "en": "Linseed oil" }, + "basis": "per100g", + "kcal": 837, + "proteinG": 93, + "f": "pick", "v": { "de": "837 kcal · 93 g", "en": "837 kcal · 93 g" - }, - "f": "pick" + } }, { "id": "almonds", @@ -752,6 +1039,9 @@ "de": "Mandeln", "en": "Almonds" }, + "basis": "per100g", + "kcal": 612, + "proteinG": 55, "v": { "de": "612 kcal · 55 g", "en": "612 kcal · 55 g" @@ -763,6 +1053,9 @@ "de": "Olivenöl", "en": "Olive oil" }, + "basis": "per100g", + "kcal": 857, + "proteinG": 91.5, "v": { "de": "857 kcal · 91,5 g", "en": "857 kcal · 91.5 g" @@ -774,11 +1067,14 @@ "de": "Walnüsse", "en": "Walnuts" }, + "basis": "per100g", + "kcal": 674, + "proteinG": 62.5, + "f": "pick", "v": { "de": "674 kcal · 62,5 g", "en": "674 kcal · 62.5 g" - }, - "f": "pick" + } } ] }, @@ -791,8 +1087,8 @@ "en": "Carbohydrates" }, "thumb": { - "de": "Vollkorn als erste Zutat, wenig zugesetzter Zucker. Faustregel 10:1 — pro 10 g Kohlenhydrate mind. 1 g Ballaststoffe (z. B. 30 g KH → mind. 3 g Ballaststoffe).", - "en": "Whole grain as the first ingredient, low added sugar. 10:1 rule of thumb — at least 1 g fibre per 10 g carbs (e.g. 30 g carbs → at least 3 g fibre)." + "de": "Vollkorn zuerst: Achte auf Vollkorn als Hauptzutat. Nutze die 10:1-Faustregel: Auf 10 g Kohlenhydrate sollte mind. 1 g Ballaststoff kommen.", + "en": "Whole grain first: look for whole grain as the main ingredient. Use the 10:1 rule of thumb: for every 10 g of carbs, there should be at least 1 g of fibre." }, "items": [ { @@ -801,12 +1097,15 @@ "de": "Berglinsen", "en": "Brown lentils" }, - "v": { - "de": "319 kcal · ≈46 g", - "en": "319 kcal · ≈46 g" - }, + "basis": "per100g", + "kcal": 319, + "proteinG": 46, "approx": true, - "f": "pick" + "f": "pick", + "v": { + "de": "≈319 kcal · 46 g", + "en": "≈319 kcal · 46 g" + } }, { "id": "buckwheat", @@ -814,6 +1113,9 @@ "de": "Buchweizen", "en": "Buckwheat" }, + "basis": "per100g", + "kcal": 343, + "proteinG": 71.5, "v": { "de": "343 kcal · 71,5 g", "en": "343 kcal · 71.5 g" @@ -825,11 +1127,14 @@ "de": "Dinkel gepufft", "en": "Puffed spelt" }, + "basis": "per100g", + "kcal": 338, + "proteinG": 70, + "approx": true, "v": { "de": "≈338 kcal · 70 g", "en": "≈338 kcal · 70 g" - }, - "approx": true + } }, { "id": "spelt_pasta", @@ -837,11 +1142,14 @@ "de": "Dinkel-Vollkorn-Nudeln", "en": "Whole-grain spelt pasta" }, + "basis": "per100g", + "kcal": 338, + "proteinG": 70, + "approx": true, "v": { "de": "≈338 kcal · 70 g", "en": "≈338 kcal · 70 g" - }, - "approx": true + } }, { "id": "peas", @@ -849,6 +1157,9 @@ "de": "Erbsen", "en": "Peas" }, + "basis": "per100g", + "kcal": 93, + "proteinG": 10.4, "v": { "de": "93 kcal · 10,4 g", "en": "93 kcal · 10.4 g" @@ -860,6 +1171,9 @@ "de": "Haferflocken", "en": "Oats" }, + "basis": "per100g", + "kcal": 372, + "proteinG": 66.3, "v": { "de": "372 kcal · 66,3 g", "en": "372 kcal · 66.3 g" @@ -871,11 +1185,18 @@ "de": "Hirse", "en": "Millet" }, + "basis": "per100g", + "kcal": 363, + "proteinG": 69, + "f": "caution", "v": { "de": "363 kcal · 69 g", "en": "363 kcal · 69 g" }, - "f": "caution" + "cautionWhy": { + "de": "Kohlenhydrat-lastig — als KH-Baustein zählen und Portion begrenzen.", + "en": "Carb-heavy — count it as the carb block and limit the portion." + } }, { "id": "chickpeas", @@ -883,6 +1204,9 @@ "de": "Kichererbsen", "en": "Chickpeas" }, + "basis": "per100g", + "kcal": 108, + "proteinG": 14.2, "v": { "de": "108 kcal · 14,2 g", "en": "108 kcal · 14.2 g" @@ -894,11 +1218,14 @@ "de": "Kichererbsennudeln / -couscous", "en": "Chickpea pasta / couscous" }, + "basis": "per100g", + "kcal": 335, + "proteinG": 50, + "approx": true, "v": { "de": "≈335 kcal · 50 g", "en": "≈335 kcal · 50 g" - }, - "approx": true + } }, { "id": "plantain", @@ -906,12 +1233,19 @@ "de": "Kochbanane", "en": "Plantain" }, + "basis": "per100g", + "kcal": 122, + "proteinG": 32, + "approx": true, + "f": "caution", "v": { "de": "≈122 kcal · 32 g", "en": "≈122 kcal · 32 g" }, - "approx": true, - "f": "caution" + "cautionWhy": { + "de": "Kohlenhydrat-lastig (stärkereich) — als KH-Baustein, nicht als Obst.", + "en": "Carb-heavy (starchy) — treat as the carb block, not as fruit." + } }, { "id": "lentil_pasta", @@ -919,11 +1253,14 @@ "de": "Linsennudeln", "en": "Lentil pasta" }, + "basis": "per100g", + "kcal": 335, + "proteinG": 52, + "approx": true, "v": { "de": "≈335 kcal · 52 g", "en": "≈335 kcal · 52 g" - }, - "approx": true + } }, { "id": "quinoa", @@ -931,6 +1268,9 @@ "de": "Quinoa", "en": "Quinoa" }, + "basis": "per100g", + "kcal": 368, + "proteinG": 64.2, "v": { "de": "368 kcal · 64,2 g", "en": "368 kcal · 64.2 g" @@ -942,12 +1282,15 @@ "de": "Rote Linsen", "en": "Red lentils" }, - "v": { - "de": "326 kcal · ≈52 g", - "en": "326 kcal · ≈52 g" - }, + "basis": "per100g", + "kcal": 326, + "proteinG": 52, "approx": true, - "f": "pick" + "f": "pick", + "v": { + "de": "≈326 kcal · 52 g", + "en": "≈326 kcal · 52 g" + } }, { "id": "sweet_potato", @@ -955,11 +1298,18 @@ "de": "Süßkartoffel", "en": "Sweet potato" }, + "basis": "per100g", + "kcal": 86, + "proteinG": 20.1, + "f": "caution", "v": { "de": "86 kcal · 20,1 g", "en": "86 kcal · 20.1 g" }, - "f": "caution" + "cautionWhy": { + "de": "Kohlenhydrat-lastig — als KH-Baustein zählen und Portion begrenzen.", + "en": "Carb-heavy — count it as the carb block and limit the portion." + } }, { "id": "french_lentils", @@ -967,13 +1317,16 @@ "de": "Tellerlinsen", "en": "French lentils" }, - "v": { - "de": "234 kcal · ≈34 g", - "en": "234 kcal · ≈34 g" - }, + "basis": "per100g", + "kcal": 234, + "proteinG": 34, "approx": true, - "f": "pick" + "f": "pick", + "v": { + "de": "≈234 kcal · 34 g", + "en": "≈234 kcal · 34 g" + } } ] } -] \ No newline at end of file +] diff --git a/client/src/data/howtos.json b/client/src/data/howtos.json new file mode 100644 index 0000000..ad16ecd --- /dev/null +++ b/client/src/data/howtos.json @@ -0,0 +1,62 @@ +[ + { + "id": "rec_protein_shake", + "name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" }, + "uses": ["protein_shake"], + "text": { + "de": "40 g Proteinpulver + 250–300 ml Wasser oder Milch shaken. ≈30 g Protein.", + "en": "Shake 40 g protein powder with 250–300 ml water or milk. ≈30 g protein." + } + }, + { + "id": "rec_tempeh_pan", + "name": { "de": "Tempeh-Pfanne", "en": "Pan-fried tempeh" }, + "uses": ["tempeh", "soy_sauce", "lime", "garlic"], + "text": { + "de": "Tempeh würfeln, 10 Min. in Sojasauce + Limette + Knoblauch marinieren, knusprig braten (5–7 Min.).", + "en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (5–7 min)." + } + }, + { + "id": "rec_silken_tofu_pudding", + "name": { + "de": "Schoko-Seidentofu-Pudding", + "en": "Chocolate silken-tofu pudding" + }, + "uses": ["silken_tofu", "cocoa_powder", "honey"], + "text": { + "de": "150 g Seidentofu + 1 EL Kakaopulver + 1 TL Honig pürieren, 30 Min. kalt stellen.", + "en": "Blend 150 g silken tofu + 1 tbsp cocoa powder + 1 tsp honey, chill 30 min." + } + }, + { + "id": "rec_veggie_stirfry_base", + "name": { + "de": "Grundrezept: Gemüsepfanne", + "en": "Base recipe: vegetable stir-fry" + }, + "uses": ["carrot", "bell_pepper", "zucchini", "olive_oil"], + "text": { + "de": "Gemüse in mundgerechte Stücke schneiden. In heißem Öl 5–8 Min. anbraten. Protein dazugeben, kurz mitbraten, würzen, fertig.", + "en": "Cut vegetables into bite-size pieces. Pan-fry in hot oil 5–8 min. Add protein, fry briefly, season, done." + }, + "variations": [ + { + "de": "Asiatisch: Ingwer + Sojasauce + Sesamöl", + "en": "Asian: ginger + soy sauce + sesame oil" + }, + { + "de": "Mediterran: Olivenöl + Zitrone + Oregano", + "en": "Mediterranean: olive oil + lemon + oregano" + }, + { + "de": "Mexikanisch: Kreuzkümmel + Paprikapulver + Limette", + "en": "Mexican: cumin + paprika powder + lime" + }, + { + "de": "Protein nach Wahl: Hühnerbrust, Tempeh, Räuchertofu oder Kichererbsen", + "en": "Protein of choice: chicken breast, tempeh, smoked tofu, or chickpeas" + } + ] + } +] diff --git a/client/src/data/recipes.json b/client/src/data/recipes.json index f22842b..0a99fb8 100644 --- a/client/src/data/recipes.json +++ b/client/src/data/recipes.json @@ -1,7 +1,9 @@ [ { - "id": "bf_skyr_oats_blueberry", - "type": "breakfast", + "id": "skyr_oats_blueberry", + "type": "half", + "times": ["breakfast"], + "effort": "none", "name": { "de": "Skyr + Haferflocken + Blaubeeren", "en": "Skyr + oats + blueberries" @@ -9,14 +11,18 @@ "uses": ["skyr", "oats", "blueberry"] }, { - "id": "bf_eggs_tomato", - "type": "breakfast", + "id": "eggs_tomato", + "type": "half", + "times": ["breakfast"], + "effort": "quick", "name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" }, "uses": ["egg", "tomato"] }, { - "id": "bf_quark_pomegranate", - "type": "breakfast", + "id": "quark_pomegranate", + "type": "half", + "times": ["breakfast", "snack"], + "effort": "none", "name": { "de": "Magerquark + Granatapfel", "en": "Low-fat quark + pomegranate" @@ -24,14 +30,29 @@ "uses": ["low_fat_quark", "pomegranate"] }, { - "id": "sn_skyr_blueberry", - "type": "snack", + "id": "shake_banana", + "type": "half", + "times": ["breakfast", "snack"], + "effort": "none", + "name": { + "de": "Proteinshake + Banane", + "en": "Protein shake + banana" + }, + "uses": ["protein_shake", "banana"] + }, + { + "id": "skyr_blueberry", + "type": "half", + "times": ["snack"], + "effort": "none", "name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" }, "uses": ["skyr", "blueberry"] }, { - "id": "sn_cottage_cucumber", - "type": "snack", + "id": "cottage_cucumber", + "type": "half", + "times": ["snack"], + "effort": "none", "name": { "de": "Hüttenkäse + Gurke", "en": "Cottage cheese + cucumber" @@ -39,122 +60,68 @@ "uses": ["cottage_cheese", "cucumber"] }, { - "id": "sn_quark_pomegranate", - "type": "snack", - "name": { - "de": "Magerquark + Granatapfel", - "en": "Low-fat quark + pomegranate" - }, - "uses": ["low_fat_quark", "pomegranate"] - }, - { - "id": "sn_protein_pudding", - "type": "snack", + "id": "protein_pudding_snack", + "type": "half", + "times": ["snack"], + "effort": "none", "name": { "de": "Proteinpudding", "en": "Protein pudding" }, "uses": ["protein_pudding"] }, { - "id": "full_chicken_cooked", + "id": "chicken_warm_plate", "type": "full", - "cooked": true, + "times": ["lunch", "dinner"], + "effort": "cook", "name": { - "de": "Hühnerbrust warm + Gemüse + Leinöl + Rote Linsen", - "en": "Warm chicken breast + veg + linseed oil + red lentils" + "de": "Hühnerbrust warm mit Gemüse", + "en": "Warm chicken breast with veg" }, "uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"] }, { - "id": "full_cold_platter", + "id": "cold_platter", "type": "full", - "cooked": false, - "name": { - "de": "Kalte Brotzeit: Feta + Feldsalat + Walnüsse + Nudelsalat", - "en": "Cold platter: feta + lamb's lettuce + walnuts + pasta salad" - }, + "times": ["lunch", "dinner"], + "effort": "quick", + "name": { "de": "Kalte Brotzeit", "en": "Cold platter" }, "uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"] }, { - "id": "full_bowl_nocook", + "id": "tofu_bowl", "type": "full", - "cooked": false, - "name": { - "de": "Bowl: Räuchertofu + Spinat + Avocado + Tellerlinsen (Dose)", - "en": "Bowl: smoked tofu + spinach + avocado + tinned French lentils" - }, + "times": ["lunch", "dinner"], + "effort": "none", + "name": { "de": "Räuchertofu-Bowl", "en": "Smoked tofu bowl" }, "uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"] }, { - "id": "full_leftovers_cold", + "id": "leftovers_plate", "type": "full", - "cooked": false, - "name": { - "de": "Resteteller: Hühnerbrust + Karotte + Walnüsse + Rote Linsen (kalt)", - "en": "Leftovers plate: chicken + carrot + walnuts + red lentils (cold)" - }, + "times": ["lunch", "dinner"], + "effort": "none", + "name": { "de": "Resteteller kalt", "en": "Cold leftovers plate" }, "uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"] }, { - "id": "rec_protein_shake", - "type": "recipe", - "name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" }, - "uses": ["protein_shake"], - "text": { - "de": "40 g Proteinpulver + 250–300 ml Wasser oder Milch shaken. ≈30 g Protein.", - "en": "Shake 40 g protein powder with 250–300 ml water or milk. ≈30 g protein." - } - }, - { - "id": "rec_tempeh_pan", - "type": "recipe", - "name": { "de": "Tempeh-Pfanne", "en": "Pan-fried tempeh" }, - "uses": ["tempeh", "soy_sauce", "lime", "garlic"], - "text": { - "de": "Tempeh würfeln, 10 Min. in Sojasauce + Limette + Knoblauch marinieren, knusprig braten (5–7 Min.).", - "en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (5–7 min)." - } - }, - { - "id": "rec_silken_tofu_pudding", - "type": "recipe", + "id": "tempeh_stirfry_plate", + "type": "full", + "times": ["lunch", "dinner"], + "effort": "cook", "name": { - "de": "Schoko-Seidentofu-Pudding", - "en": "Chocolate silken-tofu pudding" + "de": "Tempeh-Gemüsepfanne", + "en": "Tempeh vegetable stir-fry" }, - "uses": ["silken_tofu", "cocoa_powder", "honey"], - "text": { - "de": "150 g Seidentofu + 1 EL Kakaopulver + 1 TL Honig pürieren, 30 Min. kalt stellen.", - "en": "Blend 150 g silken tofu + 1 tbsp cocoa powder + 1 tsp honey, chill 30 min." - } + "uses": ["tempeh", "bell_pepper", "zucchini", "sesame_oil", "quinoa"] }, { - "id": "rec_veggie_stirfry_base", - "type": "recipe", + "id": "chicken_salad_plate", + "type": "full", + "times": ["lunch", "dinner"], + "effort": "quick", "name": { - "de": "Grundrezept: Gemüsepfanne", - "en": "Base recipe: vegetable stir-fry" + "de": "Hühnersalat mit Kichererbsen", + "en": "Chicken salad with chickpeas" }, - "uses": ["carrot", "bell_pepper", "zucchini", "olive_oil"], - "text": { - "de": "Gemüse in mundgerechte Stücke schneiden. In heißem Öl 5–8 Min. anbraten. Protein dazugeben, kurz mitbraten, würzen, fertig.", - "en": "Cut vegetables into bite-size pieces. Pan-fry in hot oil 5–8 min. Add protein, fry briefly, season, done." - }, - "variations": [ - { - "de": "Asiatisch: Ingwer + Sojasauce + Sesamöl", - "en": "Asian: ginger + soy sauce + sesame oil" - }, - { - "de": "Mediterran: Olivenöl + Zitrone + Oregano", - "en": "Mediterranean: olive oil + lemon + oregano" - }, - { - "de": "Mexikanisch: Kreuzkümmel + Paprikapulver + Limette", - "en": "Mexican: cumin + paprika powder + lime" - }, - { - "de": "Protein nach Wahl: Hühnerbrust, Tempeh, Räuchertofu oder Kichererbsen", - "en": "Protein of choice: chicken breast, tempeh, smoked tofu, or chickpeas" - } - ] + "uses": ["chicken_breast", "lambs_lettuce", "avocado", "chickpeas"] } ] diff --git a/client/src/data/steps.json b/client/src/data/steps.json index 4342d7a..0b3e04a 100644 --- a/client/src/data/steps.json +++ b/client/src/data/steps.json @@ -19,8 +19,8 @@ "n": 2, "cls": "t", "title": { - "de": "Obst ODER Gemüse", - "en": "Fruit OR Vegetables" + "de": "Obst & Gemüse", + "en": "Fruit & Veggie" }, "qty": { "de": "eine Portion", diff --git a/client/src/i18n/ui.ts b/client/src/i18n/ui.ts index a377d7f..1dbd7ed 100644 --- a/client/src/i18n/ui.ts +++ b/client/src/i18n/ui.ts @@ -18,76 +18,192 @@ export function tr(value: LocalizedString, lang: Language): string { export const ui = { appName: { de: "Pocket Pascal", en: "Pocket Pascal" }, nav: { - home: { de: "Start", en: "Home" }, - pantry: { de: "Vorrat", en: "Pantry" }, - knowledge: { de: "Teller", en: "Plate" }, - settings: { de: "Einstellungen", en: "Settings" }, + now: { de: "Jetzt", en: "Now" }, + kitchen: { de: "Küche", en: "Kitchen" }, + plan: { de: "Planen", en: "Plan" }, + knowledge: { de: "Wissen", en: "Knowledge" }, }, home: { title: { de: "Was esse ich jetzt?", en: "What do I eat now?" }, - editLayout: { de: "Layout", en: "Layout" }, - doneEdit: { de: "Fertig", en: "Done" }, - moveUp: { de: "Nach oben", en: "Move up" }, - moveDown: { de: "Nach unten", en: "Move down" }, + }, + kitchen: { + title: { de: "Küche", en: "Kitchen" }, + intro: { + de: "Vorrat, Bestand und Teller bauen", + en: "Pantry, stock and plate building", + }, + back: { de: "Küche", en: "Kitchen" }, + pantryTitle: { de: "Vorrat", en: "Pantry" }, + pantryBody: { + de: "Lebensmittel und Markierungen", + en: "Foods and markers", + }, + stockTitle: { de: "Bestand", en: "In stock" }, + stockBody: { + de: "Was zu Hause ist", + en: "What's at home", + }, + builderTitle: { de: "Teller-Builder", en: "Plate builder" }, + builderBody: { + de: "Kombination und Regel-Check", + en: "Combination and rule check", + }, + }, + planHub: { + title: { de: "Planen", en: "Plan" }, + intro: { + de: "Woche, Einkauf und eigene Rezepte", + en: "Week, shopping and your recipes", + }, + back: { de: "Planen", en: "Plan" }, + weekTitle: { de: "Wochenplan", en: "Weekly plan" }, + weekBody: { + de: "Frühstück, Mittag, Snack, Abend", + en: "Breakfast, lunch, snack, dinner", + }, + mealPlannerTitle: { de: "Meal Planner", en: "Meal planner" }, + mealPlannerBody: { + de: "Tage und Mahlzeiten zuordnen", + en: "Assign meals to days", + }, + shoppingTitle: { de: "Einkaufsliste", en: "Shopping list" }, + shoppingBody: { + de: "Abhakbar im Laden", + en: "Checkable in the shop", + }, + recipesTitle: { de: "Rezepte", en: "Recipes" }, + recipesBody: { + de: "Eigene Rezepte und Import", + en: "Your recipes and import", + }, + }, + placeholders: { + badge: { de: "Demnächst", en: "Coming soon" }, + stockTitle: { de: "Bestand", en: "In stock" }, + stockBody: { + de: "Erfasst, was zu Hause ist — Grundlage für Vorschläge aus dem Vorrat.", + en: "Tracks what's at home — basis for stock-aware suggestions.", + }, + shoppingTitle: { de: "Einkaufsliste", en: "Shopping list" }, + shoppingBody: { + de: "Ausgewogene Liste nach Kategorien, abhakbar im Laden.", + en: "Balanced list by category, checkable while shopping.", + }, + mealPlannerTitle: { de: "Meal Planner", en: "Meal planner" }, + mealPlannerBody: { + de: "Mahlzeiten auf Wochentage legen — bewusst später als der Wochenplan.", + en: "Place meals on weekdays — intentionally later than the weekly browse plan.", + }, + recipesTitle: { de: "Rezepte", en: "Recipes" }, + recipesBody: { + de: "Eigene Rezepte speichern. Später: Link aus Social Media teilen — Zutaten, Schritte und Bild extrahieren.", + en: "Save your recipes. Later: share a social link — extract ingredients, steps and thumbnail.", + }, }, suggestion: { - morning: { de: "Morgen · Protein + Obst", en: "Morning · Protein + fruit" }, - midday: { de: "Mittag · Ganzer Teller", en: "Midday · Full plate" }, - afternoon: { de: "Nachmittag · Snack-Zeit", en: "Afternoon · Snack time" }, - evening: { de: "Abend · Ganzer Teller", en: "Evening · Full plate" }, - timeSuffix: { de: "Uhr", en: "" }, - pinned: { de: "Fixiert", en: "Pinned" }, - pin: { de: "Fixieren", en: "Pin" }, - unpin: { de: "Lösen", en: "Unpin" }, - reroll: { de: "Was anderes", en: "Something else" }, - toBuilder: { de: "In den Builder übernehmen", en: "Send to builder" }, - fallbackName: { de: "Dein Teller jetzt", en: "Your plate now" }, + breakfast: { de: "Frühstück", en: "Breakfast" }, + lunch: { de: "Mittag", en: "Lunch" }, + snack: { de: "Snack", en: "Snack" }, + dinner: { de: "Abend", en: "Dinner" }, + plateFull: { de: "Ganzer Teller", en: "Full plate" }, + plateHalf: { de: "Halber Teller", en: "Half plate" }, + effortLabel: { de: "Wie viel Zeit?", en: "How much time?" }, + effortNone: { de: "Keine Zeit", en: "No time" }, + effortQuick: { de: "15 Min", en: "15 min" }, + effortCook: { de: "Kann kochen", en: "Can cook" }, + reroll: { de: "Neuer Vorschlag", en: "New suggestion" }, + emptyFilter: { + de: "Mit dieser Zeit passt gerade nichts — Filter lockern?", + en: "Nothing fits this time filter — loosen it?", + }, }, - knowledgeHub: { + knowledge: { + title: { de: "Wissen", en: "Knowledge" }, + intro: { + de: "Regeln, Rezepte und Etikett-Checks", + en: "Rules, recipes and label checks", + }, plateTitle: { de: "Mein Teller", en: "My Plate" }, plateBody: { - de: "⅓ Protein · ⅓ Gemüse/Obst · ⅓ Fett + Kohlenhydrate", - en: "⅓ protein · ⅓ veg/fruit · ⅓ fat + carbs", + de: "Ganze und halbe Mahlzeit", + en: "Full and half meals", }, - pantryTitle: { de: "Vorratskammer", en: "Pantry" }, - pantryBody: { - de: "Kategorien, Suche, Pick- & Caution-Markierungen", - en: "Categories, search, pick & caution markers", + recipesTitle: { de: "Rezept-Bibliothek", en: "Recipe library" }, + recipesBody: { + de: "Zubereitung und Grundrezepte", + en: "Prep steps and base recipes", }, - weekplanTitle: { de: "Wochenplan", en: "Weekly plan" }, - weekplanBody: { - de: "Frühstück, Snacks, volle Teller & Rezepte", - en: "Breakfast, snacks, full plates & recipes", + rulesTitle: { de: "Daumenregeln", en: "Rules of thumb" }, + rulesBody: { + de: "Etikett-Check pro Kategorie", + en: "Label check per category", }, + settingsTitle: { de: "Einstellungen", en: "Settings" }, + settingsBody: { + de: "Sprache und App-Optionen", + en: "Language and app options", + }, + back: { de: "Wissen", en: "Knowledge" }, }, weekplan: { title: { de: "Wochenplan", en: "Weekly plan" }, intro: { - de: "Kuratierte Kombinationen aus dem Baukasten — gruppiert nach Mahlzeitentyp.", - en: "Curated building-block combos — grouped by meal type.", + de: "Browsebare Inspiration für die Woche", + en: "Browsable inspiration for the week", }, breakfast: { de: "Frühstück", en: "Breakfast" }, + lunch: { de: "Mittag", en: "Lunch" }, snack: { de: "Snack", en: "Snack" }, - full: { de: "Ganzer Teller", en: "Full plate" }, - recipe: { de: "Rezepte", en: "Recipes" }, - cooked: { de: "Warm / gekocht", en: "Warm / cooked" }, - cold: { de: "Kalt / ohne Kochen", en: "Cold / no cook" }, + dinner: { de: "Abend", en: "Dinner" }, + effortNone: { de: "ohne Kochen", en: "no cook" }, + effortQuick: { de: "15 Min", en: "15 min" }, + effortCook: { de: "kochen", en: "cook" }, + }, + ingredients: { + showMore: { de: "Weitere Zutaten anzeigen", en: "Show more ingredients" }, + showLess: { de: "Weniger anzeigen", en: "Show less" }, + detailAria: { + de: "Details zu {name}", + en: "Details for {name}", + }, + }, + howto: { + title: { de: "Rezept-Bibliothek", en: "Recipe library" }, + intro: { + de: "Zubereitungsanleitungen und Grundrezepte", + en: "Prep instructions and base recipes", + }, uses: { de: "Zutaten", en: "Ingredients" }, variations: { de: "Variationen", en: "Variations" }, }, pantry: { - title: { de: "Vorratskammer", en: "Pantry" }, + title: { de: "Küche", en: "Kitchen" }, search: { de: "Lebensmittel suchen…", en: "Search foods…" }, pick: { de: "empfohlen", en: "recommended" }, caution: { de: "bewusst genießen", en: "enjoy mindfully" }, - noResults: { de: "Keine Treffer.", en: "No results." }, + noResults: { de: "Keine Treffer", en: "No results" }, tip: { de: "Tipp", en: "Tip" }, + per100gHint: { + de: "Angaben pro 100 g", + en: "Values per 100 g", + }, + legendProtein: { de: "Protein", en: "Protein" }, + legendProduce: { de: "Obst & Gemüse", en: "Fruit & veg" }, + legendFat: { de: "Fett", en: "Fat" }, + legendBerry: { de: "Kohlenhydrate", en: "Carbs" }, + }, + rules: { + title: { de: "Daumenregeln", en: "Rules of thumb" }, + intro: { + de: "Etikett-Check pro Baustein-Kategorie", + en: "Label check per building-block category", + }, }, plate: { title: { de: "Mein Teller", en: "My Plate" }, subtitle: { - de: "Baukasten-Reihenfolge: zuerst Protein, dann Obst oder Gemüse, danach Fett und Kohlenhydrate.", - en: "Building-block order: protein first, then fruit or vegetables, then fat and carbohydrates.", + de: "Protein, Obst & Gemüse, Fett, Kohlenhydrate", + en: "Protein, fruit & veggie, fat, carbohydrates", }, mealToggle: { de: "Mahlzeitentyp", en: "Meal type" }, segFull: { de: "Ganze Mahlzeit", en: "Full meal" }, @@ -114,33 +230,17 @@ export const ui = { en: "2 L of water daily.", }, }, - builderSlot: { - title: { de: "Teller-Builder", en: "Plate builder" }, - body: { - de: "Chips antippen, Kombination sofort gegen die Baukasten-Regeln prüfen.", - en: "Tap chips and check the combo against the building-block rules instantly.", - }, - }, stubs: { - pantryTitle: { de: "Vorratskammer", en: "Pantry" }, - pantryBody: { - de: "Phase-1-Platzhalter — Suche & Kategorien folgen.", - en: "Phase 1 stub — search & categories coming next.", - }, - knowledgeTitle: { de: "Knowledge Base", en: "Knowledge Base" }, - knowledgeBody: { - de: "Phase-1-Platzhalter — Teller-Regel & Daumenregeln folgen.", - en: "Phase 1 stub — plate rule & thumb rules coming next.", - }, builderTitle: { de: "Teller-Builder", en: "Plate builder" }, builderBody: { - de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen.", - en: "Phase 1 stub — chip selection & scoring coming next.", + de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen", + en: "Phase 1 stub — chip selection and scoring next", }, builderHandoff: { - de: "Übernommen aus dem Vorschlag:", - en: "Handed off from suggestion:", + de: "Übernommen aus dem Vorschlag", + en: "Handed off from suggestion", }, + builderMissing: { de: "Fehlt noch", en: "Still missing" }, settingsTitle: { de: "Einstellungen", en: "Settings" }, language: { de: "Sprache", en: "Language" }, comingSoon: { de: "Demnächst", en: "Coming soon" }, diff --git a/client/src/lib/catalog.ts b/client/src/lib/catalog.ts index d280462..78fb13e 100644 --- a/client/src/lib/catalog.ts +++ b/client/src/lib/catalog.ts @@ -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, +): 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 = 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 | Pick, +): 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 = { + protein: "var(--cat-protein)", + produce: "var(--cat-produce)", + fat: "var(--cat-fat)", + berry: "var(--cat-berry)", } diff --git a/client/src/lib/chipStyle.ts b/client/src/lib/chipStyle.ts new file mode 100644 index 0000000..403563c --- /dev/null +++ b/client/src/lib/chipStyle.ts @@ -0,0 +1,54 @@ +import type { CSSProperties } from "react" + +import { categoryDotColor } from "@/lib/catalog" +import type { CategoryId, TagType } from "@/types/domain" + +/** + * C1: Category = soft tint only (no solid border). + * C2: caution = dashed warning border. No pick ring on chips. + */ +export function categoryChipSurface(cat: CategoryId | undefined): CSSProperties { + if (!cat) { + return { + background: "var(--glass-fill)", + borderWidth: 0, + borderStyle: "none", + } + } + + const token = categoryDotColor(cat) + return { + background: `color-mix(in srgb, ${token} 15%, white)`, + borderWidth: 0, + borderStyle: "none", + } +} + +export function chipTagModifiers(tag: TagType | undefined): { + className: string + style: CSSProperties +} { + if (tag === "caution") { + return { + className: "", + style: { + borderWidth: 1.5, + borderStyle: "dashed", + borderColor: "var(--signal-warning)", + }, + } + } + return { className: "", style: {} } +} + +/** Screen-reader label when pick/caution is ring/border-only. */ +export function chipAriaLabel( + name: string, + tag: TagType | undefined, + pickLabel: string, + cautionLabel: string, +): string { + if (tag === "pick") return `${name} — ${pickLabel}` + if (tag === "caution") return `${name} — ${cautionLabel}` + return name +} diff --git a/client/src/lib/daylight.ts b/client/src/lib/daylight.ts new file mode 100644 index 0000000..6a1838e --- /dev/null +++ b/client/src/lib/daylight.ts @@ -0,0 +1,76 @@ +import type { LucideIcon } from "lucide-react" +import { Coffee, Moon, Sparkles, Sun, Utensils } from "lucide-react" + +import type { MealTime } from "@/types/domain" + +export type DaylightPeriod = "morning" | "midday" | "evening" | "night" + +export const DAYLIGHT_GRADIENT: Record = { + /* Stops tuned so ink (morning/midday) or white (evening/night) clear AA on UI icons */ + morning: "linear-gradient(135deg, #fbbf24 0%, #fb923c 100%)", + midday: "linear-gradient(135deg, #7dd3fc 0%, #93c5fd 50%, #fde68a 100%)", + evening: "linear-gradient(135deg, #be123c 0%, #6b21a8 55%, #b45309 100%)", + night: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 55%, #4c1d95 100%)", +} + +/** Icon sits on a frosted plate so thin strokes never land on mid-tone gradient bands. */ +export function daylightIconPlate(period: DaylightPeriod): { + plate: string + icon: string +} { + if (period === "evening" || period === "night") { + return { + plate: "color-mix(in srgb, #000000 40%, transparent)", + icon: "#ffffff", + } + } + return { + plate: "color-mix(in srgb, #ffffff 88%, transparent)", + icon: "var(--ink)", + } +} + + +/** Wall-clock daylight band (Suggestion “now”). */ +export function daylightPeriodFromHour(hour: number): DaylightPeriod { + if (hour >= 6 && hour < 11) return "morning" + if (hour >= 11 && hour < 16) return "midday" + if (hour >= 16 && hour < 22) return "evening" + return "night" +} + +/** + * Section identity for Wochenplan — NOT wall clock. + * Clock-based fill would paint all four sections identically at any moment. + */ +export function daylightPeriodFromMealTime(mealTime: MealTime): DaylightPeriod { + switch (mealTime) { + case "breakfast": + return "morning" + case "lunch": + return "midday" + case "snack": + return "evening" + case "dinner": + return "night" + } +} + +export function mealTimeIcon(mealTime: MealTime): LucideIcon { + switch (mealTime) { + case "breakfast": + return Coffee + case "lunch": + return Utensils + case "snack": + return Sparkles + case "dinner": + return Moon + } +} + +export function periodFallbackIcon(period: DaylightPeriod): LucideIcon { + if (period === "night" || period === "evening") return Moon + if (period === "morning") return Coffee + return Sun +} diff --git a/client/src/lib/signals.ts b/client/src/lib/signals.ts new file mode 100644 index 0000000..32083b3 --- /dev/null +++ b/client/src/lib/signals.ts @@ -0,0 +1,10 @@ +import { t, ui } from "@/i18n/ui" +import type { Language, TagType } from "@/types/domain" + +export function signalGlyphChar(tag: TagType): string { + return tag === "pick" ? "★" : "!" +} + +export function signalGlyphLabel(tag: TagType, lang: Language): string { + return tag === "pick" ? t(ui.pantry.pick, lang) : t(ui.pantry.caution, lang) +} diff --git a/client/src/lib/suggestion.ts b/client/src/lib/suggestion.ts index f71fd15..e7dacb1 100644 --- a/client/src/lib/suggestion.ts +++ b/client/src/lib/suggestion.ts @@ -1,103 +1,32 @@ -import { foodById, foods, recipes, timeSlotsForRecipe } from "@/lib/catalog" -import type { CategoryId, FoodItem, Recipe, TimeSlot } from "@/types/domain" +import { recipesForMealTime } from "@/lib/catalog" +import type { Effort, MealTime, Recipe } from "@/types/domain" -const SLOT_CATEGORIES: Record = { - morning: ["protein", "obst", "fett"], - midday: ["protein", "gemuese", "fett", "kh"], - afternoon: ["protein", "gemuese"], - evening: ["protein", "gemuese", "fett", "kh"], +function pickRandom(items: T[]): T | null { + if (items.length === 0) return null + return items[Math.floor(Math.random() * items.length)] ?? null } -function pickRandom(items: T[], excludeIds: Set, getId: (item: T) => string): T | null { - const pool = items.filter((item) => !excludeIds.has(getId(item))) - if (pool.length === 0) return null - return pool[Math.floor(Math.random() * pool.length)] ?? null -} - -export function recipesForSlot(slot: TimeSlot): Recipe[] { - return recipes.filter( - (recipe) => - recipe.type !== "recipe" && timeSlotsForRecipe(recipe).includes(slot), - ) -} - -/** Plate foods only (skip EXTRAS) for suggestion chips / builder handoff. */ -export function resolveRecipeFoods(recipe: Recipe): FoodItem[] { - return recipe.uses - .map((id) => foodById[id]) - .filter((item): item is FoodItem => Boolean(item)) -} - -/** Prefer curated recipes; fall back to one random food per expected category. */ -export function buildSuggestion(slot: TimeSlot, avoidRecipeId?: string): { - recipe: Recipe | null - foods: FoodItem[] -} { - const candidates = recipesForSlot(slot).filter((recipe) => recipe.id !== avoidRecipeId) - const recipe = - candidates.length > 0 - ? (pickRandom(candidates, new Set(), (item) => item.id) as Recipe) - : null - - if (recipe) { - return { recipe, foods: resolveRecipeFoods(recipe) } - } - - const generated = SLOT_CATEGORIES[slot] - .map((cat) => - pickRandom( - foods.filter((item) => item.cat === cat), - new Set(), - (item) => item.id, - ), - ) - .filter((item): item is FoodItem => Boolean(item)) - - return { recipe: null, foods: generated } +export function filterRecipes( + mealTime: MealTime, + effort: Effort | null, +): Recipe[] { + const byTime = recipesForMealTime(mealTime) + if (!effort) return byTime + return byTime.filter((recipe) => recipe.effort === effort) } /** - * Replace unpinned ingredients with alternatives from the same category. - * Pinned IDs stay in place; missing categories are filled when possible. + * Pick a plate suggestion for the current meal time. + * When `avoidId` is set, prefer a different recipe (falls back if only one match). */ -export function reshuffleSuggestion( - current: FoodItem[], - pinnedIds: Set, - slot: TimeSlot, -): FoodItem[] { - const pinned = current.filter((item) => pinnedIds.has(item.id)) - const pinnedCats = new Set(pinned.map((item) => item.cat)) - const exclude = new Set(current.map((item) => item.id)) +export function pickSuggestion( + mealTime: MealTime, + effort: Effort | null, + avoidId?: string, +): Recipe | null { + const pool = filterRecipes(mealTime, effort) + if (pool.length === 0) return null - const next: FoodItem[] = [...pinned] - - for (const item of current) { - if (pinnedIds.has(item.id)) continue - const replacement = pickRandom( - foods.filter((candidate) => candidate.cat === item.cat), - exclude, - (candidate) => candidate.id, - ) - if (replacement) { - exclude.add(replacement.id) - next.push(replacement) - } else { - next.push(item) - } - } - - for (const cat of SLOT_CATEGORIES[slot]) { - if (pinnedCats.has(cat) || next.some((item) => item.cat === cat)) continue - const fill = pickRandom( - foods.filter((candidate) => candidate.cat === cat), - exclude, - (candidate) => candidate.id, - ) - if (fill) { - exclude.add(fill.id) - next.push(fill) - } - } - - return next + const others = avoidId ? pool.filter((recipe) => recipe.id !== avoidId) : pool + return pickRandom(others.length > 0 ? others : pool) } diff --git a/client/src/lib/timeContext.ts b/client/src/lib/timeContext.ts index 8392956..f31802c 100644 --- a/client/src/lib/timeContext.ts +++ b/client/src/lib/timeContext.ts @@ -1,36 +1,35 @@ -import type { MealType, TimeSlot } from "@/types/domain" +import type { MealTime, MealType } from "@/types/domain" export interface TimeContext { - slot: TimeSlot + mealTime: MealTime mealType: MealType hour: number minute: number } -/** Local-time meal context for the suggestion engine. */ +/** + * Local-time meal context for the suggestion engine. + * until 10:30 → breakfast · until 14:30 → lunch · until 17:30 → snack · else dinner + */ export function getTimeContext(date = new Date()): TimeContext { const hour = date.getHours() const minute = date.getMinutes() + const minutes = hour * 60 + minute - if (hour >= 6 && hour < 11) { - return { slot: "morning", mealType: "half", hour, minute } - } - if (hour >= 11 && hour < 15) { - return { slot: "midday", mealType: "full", hour, minute } - } - if (hour >= 15 && hour < 18) { - return { slot: "afternoon", mealType: "half", hour, minute } - } - if (hour >= 18 && hour < 22) { - return { slot: "evening", mealType: "full", hour, minute } - } + let mealTime: MealTime + if (minutes < 10 * 60 + 30) mealTime = "breakfast" + else if (minutes < 14 * 60 + 30) mealTime = "lunch" + else if (minutes < 17 * 60 + 30) mealTime = "snack" + else mealTime = "dinner" - // Late night / early morning: lean snack context - return { slot: "afternoon", mealType: "half", hour, minute } + const mealType: MealType = + mealTime === "breakfast" || mealTime === "snack" ? "half" : "full" + + return { mealTime, mealType, hour, minute } } -export function formatClock(hour: number, minute: number, lang: "de" | "en"): string { +export function formatClock(hour: number, minute: number): string { const hh = String(hour).padStart(2, "0") const mm = String(minute).padStart(2, "0") - return lang === "de" ? `${hh}:${mm}` : `${hh}:${mm}` + return `${hh}:${mm}` } diff --git a/client/src/lib/widgetOrder.ts b/client/src/lib/widgetOrder.ts deleted file mode 100644 index 4c44b1f..0000000 --- a/client/src/lib/widgetOrder.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { HomeWidgetId, WidgetOrder } from "@/types/domain" - -const STORAGE_KEY = "pp.home.widgetOrder" - -export const DEFAULT_WIDGET_ORDER: WidgetOrder = [ - "suggestion", - "knowledge", - "builder", -] - -function isWidgetId(value: unknown): value is HomeWidgetId { - return value === "suggestion" || value === "knowledge" || value === "builder" -} - -export function readWidgetOrder(): WidgetOrder { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return DEFAULT_WIDGET_ORDER - const parsed: unknown = JSON.parse(raw) - if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every(isWidgetId)) { - return DEFAULT_WIDGET_ORDER - } - const unique = new Set(parsed) - if (unique.size !== 3) return DEFAULT_WIDGET_ORDER - return parsed - } catch { - return DEFAULT_WIDGET_ORDER - } -} - -export function writeWidgetOrder(order: WidgetOrder): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(order)) - } catch { - // ignore - } -} - -export function moveWidget( - order: WidgetOrder, - id: HomeWidgetId, - direction: "up" | "down", -): WidgetOrder { - const index = order.indexOf(id) - if (index < 0) return order - const target = direction === "up" ? index - 1 : index + 1 - if (target < 0 || target >= order.length) return order - const next = [...order] - const current = next[index] - const swap = next[target] - if (current === undefined || swap === undefined) return order - next[index] = swap - next[target] = current - return next -} diff --git a/client/src/screens/BuilderScreen.tsx b/client/src/screens/BuilderScreen.tsx index 88d5cd6..14d82e2 100644 --- a/client/src/screens/BuilderScreen.tsx +++ b/client/src/screens/BuilderScreen.tsx @@ -1,19 +1,63 @@ +import { useMemo } from "react" import { useLocation } from "react-router-dom" +import { IngredientChips } from "@/components/food/IngredientChips" +import { KitchenBackLink } from "@/components/home/NavCard" import { GlassCard } from "@/components/ui-pp/GlassCard" -import { t, tr, ui } from "@/i18n/ui" -import { foodById } from "@/lib/catalog" +import { t, ui } from "@/i18n/ui" +import { + PLATE_SLOT_COLOR, + plateSlotForCategory, + resolveCatalogId, + type PlateSlot, +} from "@/lib/catalog" import { useLanguage } from "@/lib/language" -import type { BuilderHandoffState } from "@/types/domain" +import { cx } from "@/lib/cx" +import type { BuilderHandoffState, CatalogItem } from "@/types/domain" + +const SLOT_ORDER: PlateSlot[] = ["protein", "produce", "fat", "berry"] + +const SLOT_LABEL = { + protein: ui.pantry.legendProtein, + produce: ui.pantry.legendProduce, + fat: ui.pantry.legendFat, + berry: ui.pantry.legendBerry, +} as const export function BuilderScreen() { const { lang } = useLanguage() const location = useLocation() const handoff = location.state as BuilderHandoffState | null - const ingredientIds = handoff?.ingredientIds ?? [] + const ingredientIds = useMemo( + () => handoff?.ingredientIds ?? [], + [handoff], + ) + + const items: CatalogItem[] = useMemo( + () => + ingredientIds + .map((id) => resolveCatalogId(id)) + .filter((item): item is CatalogItem => Boolean(item)), + [ingredientIds], + ) + + const bySlot = useMemo(() => { + const map: Record = { + protein: [], + produce: [], + fat: [], + berry: [], + } + for (const item of items) { + if (!item.cat) continue + map[plateSlotForCategory(item.cat)].push(item) + } + return map + }, [items]) return (
+

{t(ui.stubs.comingSoon, lang)} @@ -26,27 +70,48 @@ export function BuilderScreen() {

- {ingredientIds.length > 0 ? ( - -

- {t(ui.stubs.builderHandoff, lang)} -

-
    - {ingredientIds.map((id) => { - const item = foodById[id] - if (!item) return null - return ( -
  • - {tr(item.name, lang)} -
  • - ) - })} -
-
+
+ {SLOT_ORDER.map((slot) => { + const slotItems = bySlot[slot] + const filled = slotItems.length > 0 + return ( +
+

+ + {t(SLOT_LABEL[slot], lang)} +

+ {filled ? ( + + ) : ( +

+ {t(ui.stubs.builderMissing, lang)} +

+ )} +
+ ) + })} +
+ + {items.length > 0 ? ( +

+ {t(ui.stubs.builderHandoff, lang)} +

) : null}
) diff --git a/client/src/screens/HowToLibraryScreen.tsx b/client/src/screens/HowToLibraryScreen.tsx new file mode 100644 index 0000000..a8764be --- /dev/null +++ b/client/src/screens/HowToLibraryScreen.tsx @@ -0,0 +1,73 @@ +import { KnowledgeBackLink } from "@/components/home/NavCard" +import { IngredientChips } from "@/components/food/IngredientChips" +import { NestedDisclosure } from "@/components/ui-pp/Accordion" +import { t, tr, ui } from "@/i18n/ui" +import { howtos, resolveRecipeUses } from "@/lib/catalog" +import { useLanguage } from "@/lib/language" +import type { HowToRecipe } from "@/types/domain" + +function HowToBody({ recipe, lang }: { recipe: HowToRecipe; lang: "de" | "en" }) { + const ingredients = resolveRecipeUses(recipe) + + return ( +
+ {recipe.text ? ( +

{tr(recipe.text, lang)}

+ ) : null} + + {recipe.variations && recipe.variations.length > 0 ? ( +
+

+ {t(ui.howto.variations, lang)} +

+
    + {recipe.variations.map((variation, index) => ( +
  • + + · + + {tr(variation, lang)} +
  • + ))} +
+
+ ) : null} + +
+

+ {t(ui.howto.uses, lang)} +

+ +
+
+ ) +} + +export function HowToLibraryScreen() { + const { lang } = useLanguage() + + return ( +
+
+ +

+ {t(ui.howto.title, lang)} +

+

+ {t(ui.howto.intro, lang)} +

+
+ +
+ {howtos.map((recipe) => ( + + + + ))} +
+
+ ) +} diff --git a/client/src/screens/KitchenHubScreen.tsx b/client/src/screens/KitchenHubScreen.tsx new file mode 100644 index 0000000..0ee12e2 --- /dev/null +++ b/client/src/screens/KitchenHubScreen.tsx @@ -0,0 +1,53 @@ +import { useNavigate } from "react-router-dom" +import { Blocks, Package, ShoppingBasket } from "lucide-react" + +import { NavCard } from "@/components/home/NavCard" +import { t, ui } from "@/i18n/ui" +import { useLanguage } from "@/lib/language" + +export function KitchenHubScreen() { + const { lang } = useLanguage() + const navigate = useNavigate() + const soon = t(ui.placeholders.badge, lang) + + return ( +
+
+

+ {t(ui.kitchen.title, lang)} +

+

+ {t(ui.kitchen.intro, lang)} +

+
+ +
+ + } + onNavigate={() => navigate("/kitchen/pantry")} + /> + } + /> + } + /> +
+
+ ) +} diff --git a/client/src/screens/KnowledgeHubScreen.tsx b/client/src/screens/KnowledgeHubScreen.tsx new file mode 100644 index 0000000..908c4d0 --- /dev/null +++ b/client/src/screens/KnowledgeHubScreen.tsx @@ -0,0 +1,55 @@ +import { useNavigate } from "react-router-dom" +import { BookOpen, ChefHat, Settings2, Utensils } from "lucide-react" + +import { NavCard } from "@/components/home/NavCard" +import { t, ui } from "@/i18n/ui" +import { useLanguage } from "@/lib/language" + +export function KnowledgeHubScreen() { + const { lang } = useLanguage() + const navigate = useNavigate() + + return ( +
+
+

+ {t(ui.knowledge.title, lang)} +

+

+ {t(ui.knowledge.intro, lang)} +

+
+ +
+ } + onNavigate={() => navigate("/knowledge/plate")} + /> + } + onNavigate={() => navigate("/knowledge/recipes")} + /> + } + onNavigate={() => navigate("/knowledge/rules")} + /> + } + onNavigate={() => navigate("/knowledge/settings")} + /> +
+
+ ) +} diff --git a/client/src/screens/PantryScreen.tsx b/client/src/screens/PantryScreen.tsx index d437bf9..a1f5be1 100644 --- a/client/src/screens/PantryScreen.tsx +++ b/client/src/screens/PantryScreen.tsx @@ -1,10 +1,14 @@ import { useMemo, useState } from "react" +import { CategorySoftChip } from "@/components/food/CategorySoftChip" +import { KitchenBackLink } from "@/components/home/NavCard" import { Accordion } from "@/components/ui-pp/Accordion" import { t, tr, ui } from "@/i18n/ui" -import { foodCategories } from "@/lib/catalog" +import { foodCategories, sortFoodItems } from "@/lib/catalog" import { useLanguage } from "@/lib/language" import { cx } from "@/lib/cx" +import { signalGlyphChar, signalGlyphLabel } from "@/lib/signals" +import { DEFAULT_NUTRITION_BASIS } from "@/types/domain" import type { FoodItem } from "@/types/domain" function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean { @@ -16,29 +20,49 @@ export function PantryScreen() { const { lang } = useLanguage() const [query, setQuery] = useState("") const normalized = query.trim().toLowerCase() + /** Switch later when UI offers perServing / perUnit views. */ + const displayBasis = DEFAULT_NUTRITION_BASIS const sections = useMemo( () => foodCategories .map((category) => { - const items = category.items.filter((item) => matchesQuery(item, normalized, lang)) + const items = sortFoodItems( + category.items.filter((item) => + matchesQuery(item, normalized, lang), + ), + lang, + displayBasis, + ) return { category, items } }) .filter((section) => section.items.length > 0), - [lang, normalized], + [lang, normalized, displayBasis], ) return (
+

{t(ui.pantry.title, lang)}

-

- {t(ui.pantry.pick, lang)} +

+ + ★ + {" "} + {t(ui.pantry.pick, lang)} {" · "} - {t(ui.pantry.caution, lang)} + + ! + {" "} + {t(ui.pantry.caution, lang)}

+ {displayBasis === "per100g" ? ( +

+ {t(ui.pantry.per100gHint, lang)} +

+ ) : null}