-
-
{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 */}
-
-
- {/* 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 (
+
+
+
+
+ {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 (
+
+
+
+
+
+ }
+ 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 (
+
+
+
+
+ }
+ 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}