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

Introduce the four-tab hubs (Jetzt/Küche/Planen/Wissen), frosted ingredient flyouts with caution reasons, structured nutrition fields with protein sorting, and disabled coming-soon nav tiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Steffi Müller
2026-08-04 15:52:24 +02:00
parent a482bf5b09
commit a0d415b08f
43 changed files with 3400 additions and 1358 deletions

View File

@@ -20,6 +20,7 @@
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"shadcn": "^4.16.1", "shadcn": "^4.16.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",

View File

@@ -4,10 +4,17 @@ import { HomeScreen } from "@/components/home/HomeScreen"
import { AppShell } from "@/components/layout/AppShell" import { AppShell } from "@/components/layout/AppShell"
import { LanguageProvider } from "@/lib/language" import { LanguageProvider } from "@/lib/language"
import { BuilderScreen } from "@/screens/BuilderScreen" 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 { PantryScreen } from "@/screens/PantryScreen"
import { PlaceholderScreen } from "@/screens/PlaceholderScreen"
import { PlanHubScreen } from "@/screens/PlanHubScreen"
import { PlateScreen } from "@/screens/PlateScreen" import { PlateScreen } from "@/screens/PlateScreen"
import { RulesScreen } from "@/screens/RulesScreen"
import { SettingsScreen } from "@/screens/SettingsScreen" import { SettingsScreen } from "@/screens/SettingsScreen"
import { WeekPlanScreen } from "@/screens/WeekPlanScreen" import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
import { ui } from "@/i18n/ui"
export default function App() { export default function App() {
return ( return (
@@ -16,11 +23,70 @@ export default function App() {
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route index element={<HomeScreen />} /> <Route index element={<HomeScreen />} />
<Route path="pantry" element={<PantryScreen />} />
<Route path="knowledge" element={<PlateScreen />} /> <Route path="kitchen" element={<KitchenHubScreen />} />
<Route path="weekplan" element={<WeekPlanScreen />} /> <Route path="kitchen/pantry" element={<PantryScreen />} />
<Route path="builder" element={<BuilderScreen />} /> <Route path="kitchen/builder" element={<BuilderScreen />} />
<Route path="settings" element={<SettingsScreen />} /> <Route
path="kitchen/stock"
element={
<PlaceholderScreen
backTo="/kitchen"
backLabel={ui.kitchen.back}
title={ui.placeholders.stockTitle}
body={ui.placeholders.stockBody}
/>
}
/>
<Route path="plan" element={<PlanHubScreen />} />
<Route path="plan/week" element={<WeekPlanScreen />} />
<Route
path="plan/meal-planner"
element={
<PlaceholderScreen
backTo="/plan"
backLabel={ui.planHub.back}
title={ui.placeholders.mealPlannerTitle}
body={ui.placeholders.mealPlannerBody}
/>
}
/>
<Route
path="plan/shopping"
element={
<PlaceholderScreen
backTo="/plan"
backLabel={ui.planHub.back}
title={ui.placeholders.shoppingTitle}
body={ui.placeholders.shoppingBody}
/>
}
/>
<Route
path="plan/recipes"
element={
<PlaceholderScreen
backTo="/plan"
backLabel={ui.planHub.back}
title={ui.placeholders.recipesTitle}
body={ui.placeholders.recipesBody}
/>
}
/>
<Route path="knowledge" element={<KnowledgeHubScreen />} />
<Route path="knowledge/plate" element={<PlateScreen />} />
<Route path="knowledge/recipes" element={<HowToLibraryScreen />} />
<Route path="knowledge/rules" element={<RulesScreen />} />
<Route path="knowledge/settings" element={<SettingsScreen />} />
{/* Legacy redirects */}
<Route path="pantry" element={<Navigate to="/kitchen/pantry" replace />} />
<Route path="builder" element={<Navigate to="/kitchen/builder" replace />} />
<Route path="weekplan" element={<Navigate to="/plan/week" replace />} />
<Route path="settings" element={<Navigate to="/knowledge/settings" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -0,0 +1,42 @@
import { categoryDotColor } from "@/lib/catalog"
import { cx } from "@/lib/cx"
import type { CategoryId } from "@/types/domain"
const CAT_ICON: Record<CategoryId, string> = {
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 (
<span
aria-hidden
className={cx(
"flex h-9 w-9 items-center justify-center rounded-[var(--radius-control)]",
"text-lg leading-none ring-1 ring-[var(--border)]",
className,
)}
style={{
background: categoryDotColor(categoryId),
boxShadow:
"inset 0 0 0 999px color-mix(in srgb, white 72%, transparent)",
}}
>
{CAT_ICON[categoryId]}
</span>
)
}

View File

@@ -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 (
<Popover.Root>
<Popover.Trigger
type="button"
aria-label={detailAria(label, lang)}
title={item.tag ? label : undefined}
className={cx(
"inline-flex items-center rounded-[var(--radius-pill)]",
"cursor-pointer transition-[filter] active:brightness-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
"data-[popup-open]:ring-2 data-[popup-open]:ring-[var(--ink)]/25",
chipType,
chipBox,
tagMod.className,
)}
style={{ ...surface, ...tagMod.style }}
>
<span className={cx("truncate", large ? "max-w-[10rem]" : "max-w-[8rem]")}>
{name}
</span>
</Popover.Trigger>
<Popover.Portal>
{/*
Prefer bottom-end (bottom-right of chip). Collision avoidance flips
when the panel would leave the viewport. No scale/transform — keeps
backdrop-filter working.
*/}
<Popover.Positioner
side="bottom"
align="end"
sideOffset={8}
collisionPadding={12}
className="z-50 outline-none"
>
<Popover.Popup
className={cx(
"relative w-[min(18rem,calc(100vw-2rem))] rounded-[var(--radius-card)] p-3",
"shadow-[var(--shadow-float)]",
"data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
"transition-opacity duration-150 ease-out",
)}
style={{
background:
"color-mix(in srgb, var(--glass-fill) 12%, transparent)",
backdropFilter: "blur(28px) saturate(1.6)",
WebkitBackdropFilter: "blur(28px) saturate(1.6)",
}}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-40"
style={{
backgroundImage:
"linear-gradient(145deg, color-mix(in srgb, white 45%, transparent) 0%, transparent 50%)",
}}
/>
<div
aria-hidden
className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]"
/>
{/* Solid ink on glass — ink-soft fails AA over busy blurred chips */}
<div className="relative z-10 [text-shadow:0_0.5px_0_color-mix(in_srgb,white_55%,transparent)]">
<Popover.Title className="sr-only">{name}</Popover.Title>
{valueInfo ? (
<Popover.Description className="type-body-emphasis text-[var(--ink)]">
{valueInfo}
</Popover.Description>
) : null}
{showCaution ? (
<div className={valueInfo ? "mt-2" : undefined}>
<p className="type-caption font-bold text-[var(--signal-warning)]">
{t(ui.pantry.caution, lang)}
</p>
{cautionWhy ? (
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
{cautionWhy}
</p>
) : null}
</div>
) : null}
{thumb ? (
<div
className={cx(
"border-t border-[var(--border)] pt-2.5",
valueInfo || showCaution ? "mt-3" : undefined,
)}
>
<p className="type-label text-[var(--ink-faint)]">
{t(ui.pantry.tip, lang)}
{categoryTitle ? ` · ${categoryTitle}` : null}
</p>
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
{thumb}
</p>
</div>
) : null}
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
)
}

View File

@@ -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 (
<ul className={cx("flex flex-wrap", large ? "gap-2" : "gap-1.5", className)}>
{visible.map((item) => (
<li key={item.id} className="inline-flex">
<IngredientChip item={item} size={size} />
</li>
))}
{overflow > 0 ? (
<li>
<button
type="button"
aria-expanded={expanded}
aria-label={
expanded
? t(ui.ingredients.showLess, lang)
: t(ui.ingredients.showMore, lang)
}
onClick={() => setExpanded((open) => !open)}
className={cx(
"inline-flex items-center rounded-[var(--radius-pill)] ring-1 ring-[var(--border)]",
"text-[var(--ink-faint)] cursor-pointer transition-colors",
"hover:text-[var(--ink)] focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
large ? "type-body-emphasis" : "type-caption font-bold",
chipBox,
)}
style={{ background: "var(--glass-fill)" }}
>
{expanded ? `${overflow}` : `+${overflow}`}
</button>
</li>
) : null}
</ul>
)
}

View File

@@ -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 (
<GlassCard
variant="interactive"
className="p-4"
onClick={() => navigate("/builder")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
navigate("/builder")
}
}}
>
<div className="flex flex-col gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
style={{ backgroundImage: "var(--gradient-primary)" }}
>
<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />
</span>
<div>
<h3 className="type-title-sm text-[var(--ink)]">
{t(ui.builderSlot.title, lang)}
</h3>
<p className="type-body mt-1 text-[var(--ink-soft)]">
{t(ui.builderSlot.body, lang)}
</p>
</div>
</div>
</GlassCard>
)
}

View File

@@ -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 { SuggestionCard } from "@/components/home/SuggestionCard"
import { t, ui } from "@/i18n/ui" import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language" 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 (
<section className="relative">
{editing ? (
<div className="mb-2 flex items-center justify-end gap-1">
<button
type="button"
aria-label={t(ui.home.moveUp, lang)}
disabled={index <= 0}
onClick={() => onMove(id, "up")}
className={cx(
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
"disabled:opacity-30",
)}
>
<ChevronUp className="h-5 w-5" />
</button>
<button
type="button"
aria-label={t(ui.home.moveDown, lang)}
disabled={index >= order.length - 1}
onClick={() => onMove(id, "down")}
className={cx(
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
"disabled:opacity-30",
)}
>
<ChevronDown className="h-5 w-5" />
</button>
</div>
) : null}
{children}
</section>
)
}
/** Focus-First Start: only the hunger suggestion — tools live in Küche / Planen. */
export function HomeScreen() { export function HomeScreen() {
const { lang } = useLanguage() const { lang } = useLanguage()
const [editing, setEditing] = useState(false)
const [order, setOrder] = useState<WidgetOrder>(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<HomeWidgetId, ReactNode> = {
suggestion: <SuggestionCard />,
knowledge: <KnowledgeHub />,
builder: <BuilderSlot />,
}
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<header className="flex items-start justify-between gap-3"> <header>
<div>
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p> <p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
<h1 className="type-display mt-1 text-[var(--ink)]"> <h1 className="type-display mt-1 text-[var(--ink)]">
{t(ui.home.title, lang)} {t(ui.home.title, lang)}
</h1> </h1>
</div>
<button
type="button"
onClick={() => setEditing((value) => !value)}
className={cx(
"elevation-transition inline-flex min-h-[48px] items-center rounded-[var(--radius-pill)] px-4",
"type-button text-[var(--ink)]",
"ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
)}
style={{ background: "var(--glass-fill)" }}
>
{editing ? t(ui.home.doneEdit, lang) : t(ui.home.editLayout, lang)}
</button>
</header> </header>
{order.map((id) => ( <section aria-label={t(ui.home.title, lang)}>
<WidgetShell <SuggestionCard />
key={id} </section>
id={id}
editing={editing}
order={order}
onMove={handleMove}
>
{widgets[id]}
</WidgetShell>
))}
</div> </div>
) )
} }

View File

@@ -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 (
<GlassCard
variant="interactive"
className="p-4"
onClick={onNavigate}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onNavigate()
}
}}
>
<div className="flex flex-col gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
style={{ backgroundImage: gradient }}
>
{icon}
</span>
<div>
<h3 className="type-title-sm text-[var(--ink)]">{title}</h3>
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
</div>
</div>
</GlassCard>
)
}
export function KnowledgeHub() {
const { lang } = useLanguage()
const navigate = useNavigate()
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<HubTile
title={t(ui.knowledgeHub.plateTitle, lang)}
body={t(ui.knowledgeHub.plateBody, lang)}
gradient="var(--gradient-primary)"
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge")}
/>
<HubTile
title={t(ui.knowledgeHub.pantryTitle, lang)}
body={t(ui.knowledgeHub.pantryBody, lang)}
gradient="var(--gradient-accent)"
icon={<Refrigerator className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/pantry")}
/>
<HubTile
title={t(ui.knowledgeHub.weekplanTitle, lang)}
body={t(ui.knowledgeHub.weekplanBody, lang)}
gradient="var(--gradient-primary)"
icon={<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/weekplan")}
/>
</div>
)
}

View File

@@ -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 (
<GlassCard
variant={interactive ? "interactive" : "default"}
aria-disabled={disabled || undefined}
className={cx(
featured ? "p-5" : "p-4",
"min-h-[44px]",
disabled && "opacity-55",
className,
)}
onClick={interactive ? onNavigate : undefined}
onKeyDown={
interactive
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onNavigate?.()
}
}
: undefined
}
>
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<span
className={cx(
"flex items-center justify-center rounded-[var(--radius-control)] text-[var(--ink)]",
"[&_svg]:text-[var(--ink)]",
featured ? "h-12 w-12" : "h-10 w-10",
)}
style={{ backgroundImage: gradient }}
>
{icon}
</span>
{badge ? (
<span className="type-caption rounded-[var(--radius-pill)] px-2 py-1 font-bold text-[var(--ink-faint)] ring-1 ring-[var(--border)]">
{badge}
</span>
) : null}
</div>
<div>
<h3
className={cx(
"text-[var(--ink)]",
featured ? "type-title" : "type-title-sm",
)}
>
{title}
</h3>
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
</div>
</div>
</GlassCard>
)
}
export function HubBackLink({
to,
label,
}: {
to: string
label: LocalizedString
}) {
const { lang } = useLanguage()
const navigate = useNavigate()
return (
<button
type="button"
onClick={() => navigate(to)}
className={cx(
"type-caption mb-1 inline-flex min-h-[44px] items-center gap-1 font-bold text-[var(--ink-soft)]",
"active:brightness-95",
)}
>
<span aria-hidden></span>
{t(label, lang)}
</button>
)
}
export function KnowledgeBackLink() {
return <HubBackLink to="/knowledge" label={ui.knowledge.back} />
}
export function KitchenBackLink() {
return <HubBackLink to="/kitchen" label={ui.kitchen.back} />
}
export function PlanBackLink() {
return <HubBackLink to="/plan" label={ui.planHub.back} />
}

View File

@@ -1,127 +1,135 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { RefreshCw } from "lucide-react"
import { Pin, RefreshCw } from "lucide-react"
import { IngredientChips } from "@/components/food/IngredientChips"
import { Button } from "@/components/ui-pp/Button" import { Button } from "@/components/ui-pp/Button"
import { GlassCard } from "@/components/ui-pp/GlassCard" import { GlassCard } from "@/components/ui-pp/GlassCard"
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
import { t, tr, ui } from "@/i18n/ui" import { t, tr, ui } from "@/i18n/ui"
import { resolveRecipeUses } from "@/lib/catalog"
import { useLanguage } from "@/lib/language" import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx" import { cx } from "@/lib/cx"
import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion" import { pickSuggestion } from "@/lib/suggestion"
import { formatClock, getTimeContext } from "@/lib/timeContext" 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<string, string> = { const EFFORT_OPTIONS: Effort[] = ["none", "quick", "cook"]
protein: "var(--color-protein)",
obst: "var(--color-obstgem)", function effortLabel(effort: Effort, lang: "de" | "en"): string {
gemuese: "var(--color-obstgem)", if (effort === "none") return t(ui.suggestion.effortNone, lang)
fett: "var(--color-fett)", if (effort === "quick") return t(ui.suggestion.effortQuick, lang)
kh: "var(--color-kh)", return t(ui.suggestion.effortCook, lang)
snack: "var(--color-protein)",
} }
function slotLabel(slot: TimeSlot, lang: "de" | "en"): string { function mealTimeLabel(mealTime: MealTime, lang: "de" | "en"): string {
return t(ui.suggestion[slot], lang) 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() { export function SuggestionCard() {
const { lang } = useLanguage() const { lang } = useLanguage()
const navigate = useNavigate()
const context = useMemo(() => getTimeContext(), []) const context = useMemo(() => getTimeContext(), [])
const [effort, setEffort] = useState<Effort | null>(null)
const [recipe, setRecipe] = useState<Recipe | null>(() =>
pickSuggestion(context.mealTime, null),
)
const [suggestion, setSuggestion] = useState(() => buildSuggestion(context.slot)) const ingredients = recipe ? resolveRecipeUses(recipe) : []
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set()) 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 function applyEffort(next: Effort | null) {
const items: FoodItem[] = suggestion.foods setEffort(next)
setRecipe(pickSuggestion(context.mealTime, next, recipe?.id))
}
const clock = formatClock(context.hour, context.minute, lang) function toggleEffort(value: Effort) {
const header = applyEffort(effort === value ? null : value)
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 reroll() { function reroll() {
if (pinnedIds.size === 0) { setRecipe(pickSuggestion(context.mealTime, effort, recipe?.id ?? undefined))
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,
},
})
} }
return ( return (
<GlassCard className="p-4"> <GlassCard className="p-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex gap-3">
<p className="type-label text-[var(--ink-faint)]">{header}</p> <TimeIconBadge
<h2 className="type-title text-[var(--ink)]">{title}</h2> hour={context.hour}
mealTime={context.mealTime}
aria-label={eyebrow}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<p className="type-label uppercase tracking-wide text-[var(--ink-soft)]">
{eyebrow}
</p>
<p className="type-caption text-[var(--ink-faint)]" aria-hidden>
{clock}
{lang === "de" ? " Uhr" : ""}
</p>
</div> </div>
<div className="flex flex-wrap gap-2"> {recipe ? (
{items.map((item) => { <h2 className="type-title text-[var(--ink)]">
const pinned = pinnedIds.has(item.id) {tr(recipe.name, lang)}
</h2>
) : (
<p className="type-body text-[var(--ink-soft)]">
{t(ui.suggestion.emptyFilter, lang)}
</p>
)}
</div>
</div>
{ingredients.length > 0 ? (
<IngredientChips items={ingredients} size="large" />
) : null}
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.suggestion.effortLabel, lang)}
</p>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label={t(ui.suggestion.effortLabel, lang)}
>
{EFFORT_OPTIONS.map((option) => {
const active = effort === option
return ( return (
<button <button
key={item.id} key={option}
type="button" type="button"
onClick={() => togglePin(item.id)} aria-pressed={active}
aria-pressed={pinned} onClick={() => toggleEffort(option)}
aria-label={`${tr(item.name, lang)}${pinned ? t(ui.suggestion.unpin, lang) : t(ui.suggestion.pin, lang)}`}
className={cx( className={cx(
"elevation-transition inline-flex min-h-[48px] items-center gap-2 rounded-[var(--radius-pill)] px-3", "elevation-transition inline-flex min-h-[44px] items-center rounded-[var(--radius-pill)] px-3",
"type-body-emphasis text-[var(--ink)]", "type-caption font-bold text-[var(--ink)]",
"ring-1 ring-[var(--border)]", "ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95", "active:translate-y-px active:brightness-95",
pinned && "ring-2 ring-[var(--color-pick)]/50", active && "ring-2 ring-[var(--ui-accent)]/40",
)} )}
style={{ background: "var(--glass-fill)" }} style={{ background: "var(--glass-fill)" }}
> >
<span {effortLabel(option, lang)}
aria-hidden
className="h-2.5 w-2.5 rounded-full"
style={{ background: CAT_DOT[item.cat] ?? "var(--ink-faint)" }}
/>
<span>{tr(item.name, lang)}</span>
{pinned ? (
<Pin className="h-3.5 w-3.5 text-[var(--color-pick)]" aria-hidden />
) : null}
</button> </button>
) )
})} })}
</div> </div>
</div>
<div className="flex flex-col gap-2 sm:flex-row"> <Button variant="secondary" onClick={reroll}>
<Button variant="secondary" className="flex-1" onClick={reroll}>
<RefreshCw className="h-4 w-4" aria-hidden /> <RefreshCw className="h-4 w-4" aria-hidden />
{t(ui.suggestion.reroll, lang)} {t(ui.suggestion.reroll, lang)}
</Button> </Button>
<Button className="flex-1" onClick={sendToBuilder}>
{t(ui.suggestion.toBuilder, lang)}
</Button>
</div>
</div> </div>
</GlassCard> </GlassCard>
) )

View File

@@ -1,15 +1,20 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom" 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 { BottomNav } from "@/components/ui-pp/BottomNav"
import { t, ui } from "@/i18n/ui" import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language" import { useLanguage } from "@/lib/language"
const ROUTES = [ const ROUTES = [
{ key: "home", path: "/", icon: Home, labelKey: "home" as const }, { key: "now", path: "/", icon: Sparkles, labelKey: "now" as const },
{ key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const }, { key: "kitchen", path: "/kitchen", icon: CookingPot, labelKey: "kitchen" as const },
{ key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const }, { key: "plan", path: "/plan", icon: CalendarDays, labelKey: "plan" as const },
{ key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const }, {
key: "knowledge",
path: "/knowledge",
icon: BookOpen,
labelKey: "knowledge" as const,
},
] ]
export function AppShell() { export function AppShell() {

View File

@@ -1,4 +1,5 @@
import { useId } from "react" import { useMemo } from "react"
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"
import { cx } from "@/lib/cx" import { cx } from "@/lib/cx"
@@ -12,7 +13,9 @@ export interface PlateSegment {
export interface DiningPlateProps { export interface DiningPlateProps {
segments: PlateSegment[] segments: PlateSegment[]
/** Mobile-first default. */
size?: number size?: number
selectedId?: string | null
onSelect?: (id: string) => void onSelect?: (id: string) => void
className?: string className?: string
"aria-label"?: 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) } 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. * Ceramic dining plate with detached, rounded pie wedges (Recharts).
* Rim depth via CSS inset shadows; wedges are SVG (clickable). * padAngle ≈ ceramic gaps; cornerRadius softens each Baustein tile.
*/ */
export function DiningPlate({ export function DiningPlate({
segments, segments,
size = 280, size = 240,
selectedId = null,
onSelect, onSelect,
className, className,
"aria-label": ariaLabel, "aria-label": ariaLabel,
}: DiningPlateProps) { }: DiningPlateProps) {
const uid = useId().replace(/:/g, "") const rimPad = size * 0.14
const centerX = size / 2
const centerY = size / 2
const rimPad = size * 0.09
const foodR = size / 2 - rimPad const foodR = size / 2 - rimPad
const gapDeg = 2.5 const center = size / 2
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 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 let angle = 0
const wedges = segments.map((segment) => { return segments.map((segment) => {
const sweep = (segment.weight / total) * 360 const sweep = (segment.weight / total) * usable
const start = angle + gapDeg / 2 const start = angle + padAngle / 2
const end = angle + sweep - gapDeg / 2 const end = angle + padAngle / 2 + sweep
angle += sweep const mid = (start + end) / 2
angle += sweep + padAngle
return { return {
...segment, id: segment.id,
start, label: segment.label,
end, color: segment.color,
d: end > start ? wedgePath(centerX, centerY, foodR, start, end) : null, marker: polar(center, center, foodR + rimPad * 0.48, mid),
pill: polar(center, center, foodR + rimPad * 1.45, mid),
} }
}) })
}, [segments, center, foodR, rimPad])
return ( return (
<div <div
className={cx("relative select-none", className)} className={cx("relative mx-auto select-none overflow-visible", className)}
style={{ width: size, height: size }} style={{ width: size, height: size, maxWidth: "100%" }}
role="img"
aria-label={ariaLabel}
> >
{/* Ceramic body + raised rim */} {/* Ceramic body + raised rim */}
<div <div
className="absolute inset-0 rounded-full" className="absolute inset-0 rounded-full"
style={{ style={{
background: background:
"radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 45%, var(--plate-well-edge) 100%)", "radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 48%, var(--plate-well-edge) 100%)",
boxShadow: ` boxShadow: `
0 8px 24px rgba(25, 24, 0, 0.12), 0 6px 18px rgba(25, 24, 0, 0.1),
inset 4px 5px 10px rgba(255, 255, 255, 0.85), inset 5px 6px 12px rgba(255, 255, 255, 0.9),
inset -5px -6px 12px rgba(25, 24, 0, 0.12), inset -6px -7px 14px rgba(25, 24, 0, 0.11),
inset 0 0 0 1px rgba(25, 24, 0, 0.06) inset 0 0 0 1px rgba(25, 24, 0, 0.05)
`, `,
}} }}
/> />
{/* Soft recessed well under the food */} {/* Recessed well — ceramic shows through wedge gaps */}
<div <div
className="absolute rounded-full" className="absolute overflow-hidden rounded-full"
style={{ style={{
inset: rimPad * 0.55, inset: rimPad,
background: background:
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)", "radial-gradient(circle at 50% 42%, #fbfbf7 0%, #ecece4 100%)",
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)", boxShadow: "inset 0 2px 10px rgba(25, 24, 0, 0.07)",
}} }}
/>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={ariaLabel}
className="relative z-10"
> >
<defs> <ResponsiveContainer width="100%" height="100%">
<filter id={`${uid}-food-soft`} x="-20%" y="-20%" width="140%" height="140%"> <PieChart>
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2" result="blur" /> <Pie
<feOffset dy="1" result="off" /> data={chartData}
<feComponentTransfer> dataKey="value"
<feFuncA type="linear" slope="0.18" /> nameKey="label"
</feComponentTransfer> cx="50%"
<feMerge> cy="50%"
<feMergeNode /> innerRadius={0}
<feMergeNode in="SourceGraphic" /> outerRadius="98%"
</feMerge> startAngle={90}
</filter> endAngle={-270}
</defs> paddingAngle={5}
cornerRadius={10}
<g filter={`url(#${uid}-food-soft)`}> stroke="none"
{wedges.map( isAnimationActive={false}
(wedge) => onClick={(_, index) => {
wedge.d && ( const segment = chartData[index]
<path if (segment) onSelect?.(segment.id)
key={wedge.id} }}
d={wedge.d} >
fill={wedge.color} {chartData.map((entry) => (
opacity={0.92} <Cell
className={cx( key={entry.id}
"outline-none transition-[opacity,filter] duration-150", fill={entry.color}
onSelect && fillOpacity={
"cursor-pointer hover:opacity-100 focus-visible:opacity-100", selectedId && selectedId !== entry.id ? 0.72 : 0.96
)}
tabIndex={onSelect ? 0 : undefined}
role={onSelect ? "button" : undefined}
aria-label={wedge.label}
onClick={() => onSelect?.(wedge.id)}
onKeyDown={(event) => {
if (!onSelect) return
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onSelect(wedge.id)
} }
className={cx(
"outline-none",
onSelect && "cursor-pointer focus:outline-none",
)}
style={{ outline: "none" }}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
{/* Rim markers */}
{markers.map((marker) => {
const selected = selectedId === marker.id
return (
<button
key={`marker-${marker.id}`}
type="button"
aria-label={marker.label}
aria-pressed={selected}
onClick={() => onSelect?.(marker.id)}
className={cx(
"absolute z-20 -translate-x-1/2 -translate-y-1/2",
"flex min-h-[44px] min-w-[44px] items-center justify-center",
"no-select [-webkit-tap-highlight-color:transparent]",
)}
style={{ left: marker.marker.x, top: marker.marker.y }}
>
<span
className={cx(
"elevation-transition block rounded-full",
selected ? "h-3.5 w-3.5" : "h-2.5 w-2.5",
)}
style={{
background: marker.color,
boxShadow: selected
? "0 0 0 3px rgba(255,255,255,0.95), 0 0 0 5px rgba(25,24,0,0.2), 0 2px 6px rgba(25,24,0,0.18)"
: "0 0 0 2px rgba(255,255,255,0.95), 0 0 0 3px rgba(25,24,0,0.18)",
}} }}
/> />
), </button>
)} )
</g> })}
{/* Subtle inner rim line separating food from ceramic lip */} {/* Floating label pill for selection */}
<circle {markers.map((marker) => {
cx={centerX} if (selectedId !== marker.id) return null
cy={centerY} return (
r={foodR + 1} <div
fill="none" key={`pill-${marker.id}`}
stroke="rgba(255,255,255,0.55)" className={cx(
strokeWidth={2} "pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2",
pointerEvents="none" "type-caption whitespace-nowrap rounded-[var(--radius-pill)] px-3 py-1.5 font-bold text-[var(--ink)]",
"shadow-[var(--shadow-float)] ring-1 ring-[var(--border)]",
)}
style={{
left: marker.pill.x,
top: marker.pill.y,
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
>
<span
className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
style={{ background: marker.color }}
aria-hidden
/> />
</svg> {marker.label}
</div>
)
})}
</div> </div>
) )
} }

View File

@@ -70,9 +70,7 @@ export function Accordion({
"active:brightness-[0.98]", "active:brightness-[0.98]",
)} )}
> >
{icon ? ( {icon ? <span className="shrink-0 leading-none">{icon}</span> : null}
<span className="shrink-0 text-xl leading-none">{icon}</span>
) : null}
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]"> <span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
{title} {title}
</span> </span>

View File

@@ -17,6 +17,7 @@ export interface BottomNavProps {
/** /**
* Floating pill bottom navigation — float elevation above content cards. * 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) { export function BottomNav({ items, className }: BottomNavProps) {
return ( return (
@@ -49,7 +50,7 @@ export function BottomNav({ items, className }: BottomNavProps) {
className={cx( className={cx(
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5", "text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
item.active item.active
? "text-[var(--color-obstgem-dk)]" ? "text-[var(--ui-accent)]"
: "text-[var(--ink-soft)]", : "text-[var(--ink-soft)]",
)} )}
> >
@@ -59,14 +60,9 @@ export function BottomNav({ items, className }: BottomNavProps) {
className={cx( className={cx(
"type-label normal-case tracking-wide", "type-label normal-case tracking-wide",
item.active item.active
? "bg-clip-text text-transparent" ? "text-[var(--ui-accent)]"
: "text-[var(--ink-soft)]", : "text-[var(--ink-soft)]",
)} )}
style={
item.active
? { backgroundImage: "var(--gradient-accent)" }
: undefined
}
> >
{item.label} {item.label}
</span> </span>

View File

@@ -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 (
<span
aria-hidden={ariaHidden}
aria-label={ariaLabel}
className={cx(
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-control)]",
"shadow-[var(--shadow-resting)]",
className,
)}
style={{ backgroundImage: DAYLIGHT_GRADIENT[period] }}
>
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px]"
style={{ background: plate }}
>
<Icon
className="h-4 w-4"
strokeWidth={2.4}
color={iconColor}
aria-hidden
/>
</span>
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
[
{
"id": "rec_protein_shake",
"name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" },
"uses": ["protein_shake"],
"text": {
"de": "40 g Proteinpulver + 250300 ml Wasser oder Milch shaken. ≈30 g Protein.",
"en": "Shake 40 g protein powder with 250300 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 (57 Min.).",
"en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (57 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 58 Min. anbraten. Protein dazugeben, kurz mitbraten, würzen, fertig.",
"en": "Cut vegetables into bite-size pieces. Pan-fry in hot oil 58 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"
}
]
}
]

View File

@@ -1,7 +1,9 @@
[ [
{ {
"id": "bf_skyr_oats_blueberry", "id": "skyr_oats_blueberry",
"type": "breakfast", "type": "half",
"times": ["breakfast"],
"effort": "none",
"name": { "name": {
"de": "Skyr + Haferflocken + Blaubeeren", "de": "Skyr + Haferflocken + Blaubeeren",
"en": "Skyr + oats + blueberries" "en": "Skyr + oats + blueberries"
@@ -9,14 +11,18 @@
"uses": ["skyr", "oats", "blueberry"] "uses": ["skyr", "oats", "blueberry"]
}, },
{ {
"id": "bf_eggs_tomato", "id": "eggs_tomato",
"type": "breakfast", "type": "half",
"times": ["breakfast"],
"effort": "quick",
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" }, "name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
"uses": ["egg", "tomato"] "uses": ["egg", "tomato"]
}, },
{ {
"id": "bf_quark_pomegranate", "id": "quark_pomegranate",
"type": "breakfast", "type": "half",
"times": ["breakfast", "snack"],
"effort": "none",
"name": { "name": {
"de": "Magerquark + Granatapfel", "de": "Magerquark + Granatapfel",
"en": "Low-fat quark + pomegranate" "en": "Low-fat quark + pomegranate"
@@ -24,14 +30,29 @@
"uses": ["low_fat_quark", "pomegranate"] "uses": ["low_fat_quark", "pomegranate"]
}, },
{ {
"id": "sn_skyr_blueberry", "id": "shake_banana",
"type": "snack", "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" }, "name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" },
"uses": ["skyr", "blueberry"] "uses": ["skyr", "blueberry"]
}, },
{ {
"id": "sn_cottage_cucumber", "id": "cottage_cucumber",
"type": "snack", "type": "half",
"times": ["snack"],
"effort": "none",
"name": { "name": {
"de": "Hüttenkäse + Gurke", "de": "Hüttenkäse + Gurke",
"en": "Cottage cheese + cucumber" "en": "Cottage cheese + cucumber"
@@ -39,122 +60,68 @@
"uses": ["cottage_cheese", "cucumber"] "uses": ["cottage_cheese", "cucumber"]
}, },
{ {
"id": "sn_quark_pomegranate", "id": "protein_pudding_snack",
"type": "snack", "type": "half",
"name": { "times": ["snack"],
"de": "Magerquark + Granatapfel", "effort": "none",
"en": "Low-fat quark + pomegranate"
},
"uses": ["low_fat_quark", "pomegranate"]
},
{
"id": "sn_protein_pudding",
"type": "snack",
"name": { "de": "Proteinpudding", "en": "Protein pudding" }, "name": { "de": "Proteinpudding", "en": "Protein pudding" },
"uses": ["protein_pudding"] "uses": ["protein_pudding"]
}, },
{ {
"id": "full_chicken_cooked", "id": "chicken_warm_plate",
"type": "full", "type": "full",
"cooked": true, "times": ["lunch", "dinner"],
"effort": "cook",
"name": { "name": {
"de": "Hühnerbrust warm + Gemüse + Leinöl + Rote Linsen", "de": "Hühnerbrust warm mit Gemüse",
"en": "Warm chicken breast + veg + linseed oil + red lentils" "en": "Warm chicken breast with veg"
}, },
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"] "uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
}, },
{ {
"id": "full_cold_platter", "id": "cold_platter",
"type": "full", "type": "full",
"cooked": false, "times": ["lunch", "dinner"],
"name": { "effort": "quick",
"de": "Kalte Brotzeit: Feta + Feldsalat + Walnüsse + Nudelsalat", "name": { "de": "Kalte Brotzeit", "en": "Cold platter" },
"en": "Cold platter: feta + lamb's lettuce + walnuts + pasta salad"
},
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"] "uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
}, },
{ {
"id": "full_bowl_nocook", "id": "tofu_bowl",
"type": "full", "type": "full",
"cooked": false, "times": ["lunch", "dinner"],
"name": { "effort": "none",
"de": "Bowl: Räuchertofu + Spinat + Avocado + Tellerlinsen (Dose)", "name": { "de": "Räuchertofu-Bowl", "en": "Smoked tofu bowl" },
"en": "Bowl: smoked tofu + spinach + avocado + tinned French lentils"
},
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"] "uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
}, },
{ {
"id": "full_leftovers_cold", "id": "leftovers_plate",
"type": "full", "type": "full",
"cooked": false, "times": ["lunch", "dinner"],
"name": { "effort": "none",
"de": "Resteteller: Hühnerbrust + Karotte + Walnüsse + Rote Linsen (kalt)", "name": { "de": "Resteteller kalt", "en": "Cold leftovers plate" },
"en": "Leftovers plate: chicken + carrot + walnuts + red lentils (cold)"
},
"uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"] "uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"]
}, },
{ {
"id": "rec_protein_shake", "id": "tempeh_stirfry_plate",
"type": "recipe", "type": "full",
"name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" }, "times": ["lunch", "dinner"],
"uses": ["protein_shake"], "effort": "cook",
"text": {
"de": "40 g Proteinpulver + 250300 ml Wasser oder Milch shaken. ≈30 g Protein.",
"en": "Shake 40 g protein powder with 250300 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 (57 Min.).",
"en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (57 min)."
}
},
{
"id": "rec_silken_tofu_pudding",
"type": "recipe",
"name": { "name": {
"de": "Schoko-Seidentofu-Pudding", "de": "Tempeh-Gemüsepfanne",
"en": "Chocolate silken-tofu pudding" "en": "Tempeh vegetable stir-fry"
}, },
"uses": ["silken_tofu", "cocoa_powder", "honey"], "uses": ["tempeh", "bell_pepper", "zucchini", "sesame_oil", "quinoa"]
"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", "id": "chicken_salad_plate",
"type": "recipe", "type": "full",
"times": ["lunch", "dinner"],
"effort": "quick",
"name": { "name": {
"de": "Grundrezept: Gemüsepfanne", "de": "Hühnersalat mit Kichererbsen",
"en": "Base recipe: vegetable stir-fry" "en": "Chicken salad with chickpeas"
}, },
"uses": ["carrot", "bell_pepper", "zucchini", "olive_oil"], "uses": ["chicken_breast", "lambs_lettuce", "avocado", "chickpeas"]
"text": {
"de": "Gemüse in mundgerechte Stücke schneiden. In heißem Öl 58 Min. anbraten. Protein dazugeben, kurz mitbraten, würzen, fertig.",
"en": "Cut vegetables into bite-size pieces. Pan-fry in hot oil 58 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"
}
]
} }
] ]

View File

@@ -19,8 +19,8 @@
"n": 2, "n": 2,
"cls": "t", "cls": "t",
"title": { "title": {
"de": "Obst ODER Gemüse", "de": "Obst & Gemüse",
"en": "Fruit OR Vegetables" "en": "Fruit & Veggie"
}, },
"qty": { "qty": {
"de": "eine Portion", "de": "eine Portion",

View File

@@ -18,76 +18,192 @@ export function tr(value: LocalizedString, lang: Language): string {
export const ui = { export const ui = {
appName: { de: "Pocket Pascal", en: "Pocket Pascal" }, appName: { de: "Pocket Pascal", en: "Pocket Pascal" },
nav: { nav: {
home: { de: "Start", en: "Home" }, now: { de: "Jetzt", en: "Now" },
pantry: { de: "Vorrat", en: "Pantry" }, kitchen: { de: "Küche", en: "Kitchen" },
knowledge: { de: "Teller", en: "Plate" }, plan: { de: "Planen", en: "Plan" },
settings: { de: "Einstellungen", en: "Settings" }, knowledge: { de: "Wissen", en: "Knowledge" },
}, },
home: { home: {
title: { de: "Was esse ich jetzt?", en: "What do I eat now?" }, title: { de: "Was esse ich jetzt?", en: "What do I eat now?" },
editLayout: { de: "Layout", en: "Layout" }, },
doneEdit: { de: "Fertig", en: "Done" }, kitchen: {
moveUp: { de: "Nach oben", en: "Move up" }, title: { de: "Küche", en: "Kitchen" },
moveDown: { de: "Nach unten", en: "Move down" }, 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: { suggestion: {
morning: { de: "Morgen · Protein + Obst", en: "Morning · Protein + fruit" }, breakfast: { de: "Frühstück", en: "Breakfast" },
midday: { de: "Mittag · Ganzer Teller", en: "Midday · Full plate" }, lunch: { de: "Mittag", en: "Lunch" },
afternoon: { de: "Nachmittag · Snack-Zeit", en: "Afternoon · Snack time" }, snack: { de: "Snack", en: "Snack" },
evening: { de: "Abend · Ganzer Teller", en: "Evening · Full plate" }, dinner: { de: "Abend", en: "Dinner" },
timeSuffix: { de: "Uhr", en: "" }, plateFull: { de: "Ganzer Teller", en: "Full plate" },
pinned: { de: "Fixiert", en: "Pinned" }, plateHalf: { de: "Halber Teller", en: "Half plate" },
pin: { de: "Fixieren", en: "Pin" }, effortLabel: { de: "Wie viel Zeit?", en: "How much time?" },
unpin: { de: "Lösen", en: "Unpin" }, effortNone: { de: "Keine Zeit", en: "No time" },
reroll: { de: "Was anderes", en: "Something else" }, effortQuick: { de: "15 Min", en: "15 min" },
toBuilder: { de: "In den Builder übernehmen", en: "Send to builder" }, effortCook: { de: "Kann kochen", en: "Can cook" },
fallbackName: { de: "Dein Teller jetzt", en: "Your plate now" }, 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?",
},
},
knowledge: {
title: { de: "Wissen", en: "Knowledge" },
intro: {
de: "Regeln, Rezepte und Etikett-Checks",
en: "Rules, recipes and label checks",
}, },
knowledgeHub: {
plateTitle: { de: "Mein Teller", en: "My Plate" }, plateTitle: { de: "Mein Teller", en: "My Plate" },
plateBody: { plateBody: {
de: "⅓ Protein · ⅓ Gemüse/Obst · ⅓ Fett + Kohlenhydrate", de: "Ganze und halbe Mahlzeit",
en: "⅓ protein · ⅓ veg/fruit · ⅓ fat + carbs", en: "Full and half meals",
}, },
pantryTitle: { de: "Vorratskammer", en: "Pantry" }, recipesTitle: { de: "Rezept-Bibliothek", en: "Recipe library" },
pantryBody: { recipesBody: {
de: "Kategorien, Suche, Pick- & Caution-Markierungen", de: "Zubereitung und Grundrezepte",
en: "Categories, search, pick & caution markers", en: "Prep steps and base recipes",
}, },
weekplanTitle: { de: "Wochenplan", en: "Weekly plan" }, rulesTitle: { de: "Daumenregeln", en: "Rules of thumb" },
weekplanBody: { rulesBody: {
de: "Frühstück, Snacks, volle Teller & Rezepte", de: "Etikett-Check pro Kategorie",
en: "Breakfast, snacks, full plates & recipes", 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: { weekplan: {
title: { de: "Wochenplan", en: "Weekly plan" }, title: { de: "Wochenplan", en: "Weekly plan" },
intro: { intro: {
de: "Kuratierte Kombinationen aus dem Baukasten — gruppiert nach Mahlzeitentyp.", de: "Browsebare Inspiration für die Woche",
en: "Curated building-block combos — grouped by meal type.", en: "Browsable inspiration for the week",
}, },
breakfast: { de: "Frühstück", en: "Breakfast" }, breakfast: { de: "Frühstück", en: "Breakfast" },
lunch: { de: "Mittag", en: "Lunch" },
snack: { de: "Snack", en: "Snack" }, snack: { de: "Snack", en: "Snack" },
full: { de: "Ganzer Teller", en: "Full plate" }, dinner: { de: "Abend", en: "Dinner" },
recipe: { de: "Rezepte", en: "Recipes" }, effortNone: { de: "ohne Kochen", en: "no cook" },
cooked: { de: "Warm / gekocht", en: "Warm / cooked" }, effortQuick: { de: "15 Min", en: "15 min" },
cold: { de: "Kalt / ohne Kochen", en: "Cold / no cook" }, 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" }, uses: { de: "Zutaten", en: "Ingredients" },
variations: { de: "Variationen", en: "Variations" }, variations: { de: "Variationen", en: "Variations" },
}, },
pantry: { pantry: {
title: { de: "Vorratskammer", en: "Pantry" }, title: { de: "Küche", en: "Kitchen" },
search: { de: "Lebensmittel suchen…", en: "Search foods…" }, search: { de: "Lebensmittel suchen…", en: "Search foods…" },
pick: { de: "empfohlen", en: "recommended" }, pick: { de: "empfohlen", en: "recommended" },
caution: { de: "bewusst genießen", en: "enjoy mindfully" }, 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" }, 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: { plate: {
title: { de: "Mein Teller", en: "My Plate" }, title: { de: "Mein Teller", en: "My Plate" },
subtitle: { subtitle: {
de: "Baukasten-Reihenfolge: zuerst Protein, dann Obst oder Gemüse, danach Fett und Kohlenhydrate.", de: "Protein, Obst & Gemüse, Fett, Kohlenhydrate",
en: "Building-block order: protein first, then fruit or vegetables, then fat and carbohydrates.", en: "Protein, fruit & veggie, fat, carbohydrates",
}, },
mealToggle: { de: "Mahlzeitentyp", en: "Meal type" }, mealToggle: { de: "Mahlzeitentyp", en: "Meal type" },
segFull: { de: "Ganze Mahlzeit", en: "Full meal" }, segFull: { de: "Ganze Mahlzeit", en: "Full meal" },
@@ -114,33 +230,17 @@ export const ui = {
en: "2 L of water daily.", 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: { 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" }, builderTitle: { de: "Teller-Builder", en: "Plate builder" },
builderBody: { builderBody: {
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen.", de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen",
en: "Phase 1 stub — chip selection & scoring coming next.", en: "Phase 1 stub — chip selection and scoring next",
}, },
builderHandoff: { builderHandoff: {
de: "Übernommen aus dem Vorschlag:", de: "Übernommen aus dem Vorschlag",
en: "Handed off from suggestion:", en: "Handed off from suggestion",
}, },
builderMissing: { de: "Fehlt noch", en: "Still missing" },
settingsTitle: { de: "Einstellungen", en: "Settings" }, settingsTitle: { de: "Einstellungen", en: "Settings" },
language: { de: "Sprache", en: "Language" }, language: { de: "Sprache", en: "Language" },
comingSoon: { de: "Demnächst", en: "Coming soon" }, comingSoon: { de: "Demnächst", en: "Coming soon" },

View File

@@ -4,14 +4,16 @@ import type {
ExtraItem, ExtraItem,
FoodCategory, FoodCategory,
FoodItem, FoodItem,
HowToRecipe,
LocalizedString, LocalizedString,
PlanRecipeType, MealTime,
NutritionBasis,
Recipe, Recipe,
TagType, TagType,
TimeSlot,
} from "@/types/domain" } from "@/types/domain"
import extrasRaw from "@/data/extras.json" import extrasRaw from "@/data/extras.json"
import foodsRaw from "@/data/foods.json" import foodsRaw from "@/data/foods.json"
import howtosRaw from "@/data/howtos.json"
import recipesRaw from "@/data/recipes.json" import recipesRaw from "@/data/recipes.json"
import stepsRaw from "@/data/steps.json" import stepsRaw from "@/data/steps.json"
@@ -19,8 +21,13 @@ interface RawFoodItem {
id: string id: string
name: LocalizedString name: LocalizedString
v: LocalizedString v: LocalizedString
basis?: NutritionBasis
kcal?: number
proteinG?: number
f?: TagType f?: TagType
approx?: boolean approx?: boolean
note?: LocalizedString
cautionWhy?: LocalizedString
} }
interface RawFoodCategory { interface RawFoodCategory {
@@ -29,6 +36,10 @@ interface RawFoodCategory {
title: LocalizedString title: LocalizedString
thumb: LocalizedString thumb: LocalizedString
items: RawFoodItem[] items: RawFoodItem[]
sub?: {
title: LocalizedString
items: RawFoodItem[]
}
} }
export interface PlateStep { export interface PlateStep {
@@ -39,25 +50,87 @@ export interface PlateStep {
text: LocalizedString text: LocalizedString
} }
function formatProteinG(n: number, lang: "de" | "en"): string {
if (Number.isInteger(n)) return String(n)
return lang === "de" ? String(n).replace(".", ",") : String(n)
}
/** Build display valueInfo from structured macros when present. */
export function formatFoodValueInfo(
item: Pick<RawFoodItem, "kcal" | "proteinG" | "approx" | "note" | "v">,
): LocalizedString {
if (item.kcal != null && item.proteinG != null) {
const prefix = item.approx ? "≈" : ""
const noteDe = item.note?.de ? ` ${item.note.de}` : ""
const noteEn = item.note?.en ? ` ${item.note.en}` : ""
return {
de: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "de")} g${noteDe}`,
en: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "en")} g${noteEn}`,
}
}
return item.v
}
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem { function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
const basis: NutritionBasis =
item.basis ??
(cat === "snack" ? "perServing" : item.id === "egg" ? "perUnit" : "per100g")
return { return {
id: item.id, id: item.id,
cat, cat,
name: item.name, name: item.name,
valueInfo: item.v, valueInfo: formatFoodValueInfo(item),
basis,
kcal: item.kcal,
proteinG: item.proteinG,
tag: item.f, tag: item.f,
cautionWhy: item.cautionWhy,
isApprox: item.approx, isApprox: item.approx,
} }
} }
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map( function mapCategory(category: RawFoodCategory): FoodCategory {
(category) => ({ const items = [
...category.items.map((item) => mapItem(category.id, item)),
...(category.sub?.items.map((item) => mapItem(category.id, item)) ?? []),
]
return {
id: category.id, id: category.id,
cls: category.cls, cls: category.cls,
title: category.title, title: category.title,
thumb: category.thumb, thumb: category.thumb,
items: category.items.map((item) => mapItem(category.id, item)), items,
}), }
}
/**
* Sort: protein high→low within preferred basis first, then other bases,
* then AZ. Preferred basis is switchable later (perServing / perUnit views).
*/
export function sortFoodItems(
items: FoodItem[],
lang: "de" | "en",
preferredBasis: NutritionBasis = "per100g",
): FoodItem[] {
function rank(item: FoodItem): number {
if (item.proteinG != null && item.basis === preferredBasis) return 0
if (item.proteinG != null) return 1
return 2
}
return [...items].sort((a, b) => {
const rankDiff = rank(a) - rank(b)
if (rankDiff !== 0) return rankDiff
if (a.proteinG != null && b.proteinG != null && a.proteinG !== b.proteinG) {
return b.proteinG - a.proteinG
}
return a.name[lang].localeCompare(b.name[lang], lang)
})
}
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
mapCategory,
) )
export const foods: FoodItem[] = foodCategories.flatMap((category) => category.items) export const foods: FoodItem[] = foodCategories.flatMap((category) => category.items)
@@ -72,21 +145,29 @@ export const extraById: Record<string, ExtraItem> = Object.fromEntries(
extras.map((item) => [item.id, item]), extras.map((item) => [item.id, item]),
) )
/** Plate suggestions (RECIPES). */
export const recipes: Recipe[] = recipesRaw as Recipe[] 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 plateSteps: PlateStep[] = stepsRaw as PlateStep[]
export const PLAN_TYPE_ORDER: PlanRecipeType[] = [ export const MEAL_TIME_ORDER: MealTime[] = [
"breakfast", "breakfast",
"lunch",
"snack", "snack",
"full", "dinner",
"recipe",
] ]
export function foodsInCategory(cat: CategoryId): FoodItem[] { export function foodsInCategory(cat: CategoryId): FoodItem[] {
return foodCategories.find((category) => category.id === cat)?.items ?? [] 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 { export function resolveCatalogId(id: string): CatalogItem | null {
const food = foodById[id] const food = foodById[id]
if (food) { if (food) {
@@ -97,6 +178,7 @@ export function resolveCatalogId(id: string): CatalogItem | null {
cat: food.cat, cat: food.cat,
valueInfo: food.valueInfo, valueInfo: food.valueInfo,
tag: food.tag, tag: food.tag,
cautionWhy: food.cautionWhy,
} }
} }
const extra = extraById[id] const extra = extraById[id]
@@ -110,30 +192,49 @@ export function resolveCatalogId(id: string): CatalogItem | null {
return null return null
} }
export function resolveRecipeUses(recipe: Recipe): CatalogItem[] { export function resolveRecipeUses(
recipe: Pick<Recipe, "uses"> | Pick<HowToRecipe, "uses">,
): CatalogItem[] {
return recipe.uses return recipe.uses
.map((id) => resolveCatalogId(id)) .map((id) => resolveCatalogId(id))
.filter((item): item is CatalogItem => Boolean(item)) .filter((item): item is CatalogItem => Boolean(item))
} }
export function recipesByType(type: PlanRecipeType): Recipe[] { export function recipesForMealTime(time: MealTime): Recipe[] {
return recipes.filter((recipe) => recipe.type === type) return recipes.filter((recipe) => recipe.times.includes(time))
} }
/** Map plan recipe types onto suggestion time slots. */ /** Category color token for ingredient chips / plate segments. */
export function timeSlotsForRecipe(recipe: Recipe): TimeSlot[] { export function categoryDotColor(cat: CategoryId | undefined): string {
switch (recipe.type) { switch (cat) {
case "breakfast": case "protein":
return ["morning"]
case "snack": case "snack":
return ["afternoon"] return "var(--cat-protein)"
case "full": case "obst":
return ["midday", "evening"] case "gemuese":
case "recipe": return "var(--cat-produce)"
return ["morning", "afternoon", "midday", "evening"] case "fett":
return "var(--cat-fat)"
case "kh":
return "var(--cat-berry)"
default:
return "var(--ink-faint)"
} }
} }
export function mealTypeForRecipe(recipe: Recipe): "full" | "half" { /** Four plate slots for completeness scan (snack → protein). */
return recipe.type === "full" ? "full" : "half" export type PlateSlot = "protein" | "produce" | "fat" | "berry"
export function plateSlotForCategory(cat: CategoryId): PlateSlot {
if (cat === "protein" || cat === "snack") return "protein"
if (cat === "obst" || cat === "gemuese") return "produce"
if (cat === "fett") return "fat"
return "berry"
}
export const PLATE_SLOT_COLOR: Record<PlateSlot, string> = {
protein: "var(--cat-protein)",
produce: "var(--cat-produce)",
fat: "var(--cat-fat)",
berry: "var(--cat-berry)",
} }

View File

@@ -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
}

View File

@@ -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<DaylightPeriod, string> = {
/* 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
}

10
client/src/lib/signals.ts Normal file
View File

@@ -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)
}

View File

@@ -1,103 +1,32 @@
import { foodById, foods, recipes, timeSlotsForRecipe } from "@/lib/catalog" import { recipesForMealTime } from "@/lib/catalog"
import type { CategoryId, FoodItem, Recipe, TimeSlot } from "@/types/domain" import type { Effort, MealTime, Recipe } from "@/types/domain"
const SLOT_CATEGORIES: Record<TimeSlot, CategoryId[]> = { function pickRandom<T>(items: T[]): T | null {
morning: ["protein", "obst", "fett"], if (items.length === 0) return null
midday: ["protein", "gemuese", "fett", "kh"], return items[Math.floor(Math.random() * items.length)] ?? null
afternoon: ["protein", "gemuese"],
evening: ["protein", "gemuese", "fett", "kh"],
} }
function pickRandom<T>(items: T[], excludeIds: Set<string>, getId: (item: T) => string): T | null { export function filterRecipes(
const pool = items.filter((item) => !excludeIds.has(getId(item))) mealTime: MealTime,
if (pool.length === 0) return null effort: Effort | null,
return pool[Math.floor(Math.random() * pool.length)] ?? null ): Recipe[] {
} const byTime = recipesForMealTime(mealTime)
if (!effort) return byTime
export function recipesForSlot(slot: TimeSlot): Recipe[] { return byTime.filter((recipe) => recipe.effort === effort)
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 }
} }
/** /**
* Replace unpinned ingredients with alternatives from the same category. * Pick a plate suggestion for the current meal time.
* Pinned IDs stay in place; missing categories are filled when possible. * When `avoidId` is set, prefer a different recipe (falls back if only one match).
*/ */
export function reshuffleSuggestion( export function pickSuggestion(
current: FoodItem[], mealTime: MealTime,
pinnedIds: Set<string>, effort: Effort | null,
slot: TimeSlot, avoidId?: string,
): FoodItem[] { ): Recipe | null {
const pinned = current.filter((item) => pinnedIds.has(item.id)) const pool = filterRecipes(mealTime, effort)
const pinnedCats = new Set(pinned.map((item) => item.cat)) if (pool.length === 0) return null
const exclude = new Set(current.map((item) => item.id))
const next: FoodItem[] = [...pinned] const others = avoidId ? pool.filter((recipe) => recipe.id !== avoidId) : pool
return pickRandom(others.length > 0 ? others : pool)
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
} }

View File

@@ -1,36 +1,35 @@
import type { MealType, TimeSlot } from "@/types/domain" import type { MealTime, MealType } from "@/types/domain"
export interface TimeContext { export interface TimeContext {
slot: TimeSlot mealTime: MealTime
mealType: MealType mealType: MealType
hour: number hour: number
minute: 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 { export function getTimeContext(date = new Date()): TimeContext {
const hour = date.getHours() const hour = date.getHours()
const minute = date.getMinutes() const minute = date.getMinutes()
const minutes = hour * 60 + minute
if (hour >= 6 && hour < 11) { let mealTime: MealTime
return { slot: "morning", mealType: "half", hour, minute } if (minutes < 10 * 60 + 30) mealTime = "breakfast"
} else if (minutes < 14 * 60 + 30) mealTime = "lunch"
if (hour >= 11 && hour < 15) { else if (minutes < 17 * 60 + 30) mealTime = "snack"
return { slot: "midday", mealType: "full", hour, minute } else mealTime = "dinner"
}
if (hour >= 15 && hour < 18) {
return { slot: "afternoon", mealType: "half", hour, minute }
}
if (hour >= 18 && hour < 22) {
return { slot: "evening", mealType: "full", hour, minute }
}
// Late night / early morning: lean snack context const mealType: MealType =
return { slot: "afternoon", mealType: "half", hour, minute } 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 hh = String(hour).padStart(2, "0")
const mm = String(minute).padStart(2, "0") const mm = String(minute).padStart(2, "0")
return lang === "de" ? `${hh}:${mm}` : `${hh}:${mm}` return `${hh}:${mm}`
} }

View File

@@ -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
}

View File

@@ -1,19 +1,63 @@
import { useMemo } from "react"
import { useLocation } from "react-router-dom" 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 { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, tr, ui } from "@/i18n/ui" import { t, ui } from "@/i18n/ui"
import { foodById } from "@/lib/catalog" import {
PLATE_SLOT_COLOR,
plateSlotForCategory,
resolveCatalogId,
type PlateSlot,
} from "@/lib/catalog"
import { useLanguage } from "@/lib/language" 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() { export function BuilderScreen() {
const { lang } = useLanguage() const { lang } = useLanguage()
const location = useLocation() const location = useLocation()
const handoff = location.state as BuilderHandoffState | null 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<PlateSlot, CatalogItem[]> = {
protein: [],
produce: [],
fat: [],
berry: [],
}
for (const item of items) {
if (!item.cat) continue
map[plateSlotForCategory(item.cat)].push(item)
}
return map
}, [items])
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<KitchenBackLink />
<GlassCard className="p-4"> <GlassCard className="p-4">
<p className="type-label text-[var(--ink-faint)]"> <p className="type-label text-[var(--ink-faint)]">
{t(ui.stubs.comingSoon, lang)} {t(ui.stubs.comingSoon, lang)}
@@ -26,27 +70,48 @@ export function BuilderScreen() {
</p> </p>
</GlassCard> </GlassCard>
{ingredientIds.length > 0 ? ( <div className="flex flex-col gap-3">
<GlassCard className="p-4"> {SLOT_ORDER.map((slot) => {
<p className="type-body-emphasis text-[var(--ink)]"> const slotItems = bySlot[slot]
{t(ui.stubs.builderHandoff, lang)} const filled = slotItems.length > 0
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{ingredientIds.map((id) => {
const item = foodById[id]
if (!item) return null
return ( return (
<li <div
key={id} key={slot}
className="type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)] ring-1 ring-[var(--border)]" className={cx(
style={{ background: "var(--glass-fill)" }} "rounded-[var(--radius-card)] p-3",
filled
? "ring-1 ring-[var(--border)]"
: "border border-dashed",
)}
style={{
background: filled ? "var(--glass-fill)" : "transparent",
borderColor: filled ? undefined : PLATE_SLOT_COLOR[slot],
}}
> >
{tr(item.name, lang)} <p className="type-label mb-2 flex items-center gap-2 text-[var(--ink-faint)]">
</li> <span
aria-hidden
className="h-2.5 w-2.5 rounded-full ring-1 ring-[var(--ink)]/20"
style={{ background: PLATE_SLOT_COLOR[slot] }}
/>
{t(SLOT_LABEL[slot], lang)}
</p>
{filled ? (
<IngredientChips items={slotItems} />
) : (
<p className="type-caption text-[var(--ink-faint)]">
{t(ui.stubs.builderMissing, lang)}
</p>
)}
</div>
) )
})} })}
</ul> </div>
</GlassCard>
{items.length > 0 ? (
<p className="type-caption text-[var(--ink-faint)]">
{t(ui.stubs.builderHandoff, lang)}
</p>
) : null} ) : null}
</div> </div>
) )

View File

@@ -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 (
<div className="flex flex-col gap-3">
{recipe.text ? (
<p className="type-body text-[var(--ink-soft)]">{tr(recipe.text, lang)}</p>
) : null}
{recipe.variations && recipe.variations.length > 0 ? (
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.howto.variations, lang)}
</p>
<ul className="flex flex-col gap-2">
{recipe.variations.map((variation, index) => (
<li
key={`${recipe.id}-var-${index}`}
className="type-body flex gap-2 text-[var(--ink-soft)]"
>
<span className="text-[var(--ink-faint)]" aria-hidden>
·
</span>
<span>{tr(variation, lang)}</span>
</li>
))}
</ul>
</div>
) : null}
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.howto.uses, lang)}
</p>
<IngredientChips items={ingredients} />
</div>
</div>
)
}
export function HowToLibraryScreen() {
const { lang } = useLanguage()
return (
<div className="flex flex-col gap-4">
<header>
<KnowledgeBackLink />
<h1 className="type-display text-[var(--ink)]">
{t(ui.howto.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.howto.intro, lang)}
</p>
</header>
<div className="flex flex-col gap-2">
{howtos.map((recipe) => (
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
<HowToBody recipe={recipe} lang={lang} />
</NestedDisclosure>
))}
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.kitchen.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.kitchen.intro, lang)}
</p>
</header>
<div className="grid grid-cols-1 gap-3">
<NavCard
title={t(ui.kitchen.pantryTitle, lang)}
body={t(ui.kitchen.pantryBody, lang)}
gradient="var(--gradient-primary)"
icon={
<ShoppingBasket className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
onNavigate={() => navigate("/kitchen/pantry")}
/>
<NavCard
title={t(ui.kitchen.stockTitle, lang)}
body={t(ui.kitchen.stockBody, lang)}
gradient="var(--gradient-accent)"
badge={soon}
disabled
icon={<Package className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
/>
<NavCard
title={t(ui.kitchen.builderTitle, lang)}
body={t(ui.kitchen.builderBody, lang)}
gradient="var(--gradient-primary)"
badge={soon}
disabled
icon={<Blocks className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
/>
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.knowledge.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.knowledge.intro, lang)}
</p>
</header>
<div className="grid grid-cols-1 gap-3">
<NavCard
title={t(ui.knowledge.plateTitle, lang)}
body={t(ui.knowledge.plateBody, lang)}
gradient="var(--gradient-primary)"
icon={<Utensils className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/plate")}
/>
<NavCard
title={t(ui.knowledge.recipesTitle, lang)}
body={t(ui.knowledge.recipesBody, lang)}
gradient="var(--gradient-accent)"
icon={<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/recipes")}
/>
<NavCard
title={t(ui.knowledge.rulesTitle, lang)}
body={t(ui.knowledge.rulesBody, lang)}
gradient="var(--gradient-primary)"
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/rules")}
/>
<NavCard
title={t(ui.knowledge.settingsTitle, lang)}
body={t(ui.knowledge.settingsBody, lang)}
gradient="var(--gradient-accent)"
icon={<Settings2 className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/settings")}
/>
</div>
</div>
)
}

View File

@@ -1,10 +1,14 @@
import { useMemo, useState } from "react" 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 { Accordion } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui" import { t, tr, ui } from "@/i18n/ui"
import { foodCategories } from "@/lib/catalog" import { foodCategories, sortFoodItems } from "@/lib/catalog"
import { useLanguage } from "@/lib/language" import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx" import { cx } from "@/lib/cx"
import { signalGlyphChar, signalGlyphLabel } from "@/lib/signals"
import { DEFAULT_NUTRITION_BASIS } from "@/types/domain"
import type { FoodItem } from "@/types/domain" import type { FoodItem } from "@/types/domain"
function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean { function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean {
@@ -16,29 +20,49 @@ export function PantryScreen() {
const { lang } = useLanguage() const { lang } = useLanguage()
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const normalized = query.trim().toLowerCase() const normalized = query.trim().toLowerCase()
/** Switch later when UI offers perServing / perUnit views. */
const displayBasis = DEFAULT_NUTRITION_BASIS
const sections = useMemo( const sections = useMemo(
() => () =>
foodCategories foodCategories
.map((category) => { .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 } return { category, items }
}) })
.filter((section) => section.items.length > 0), .filter((section) => section.items.length > 0),
[lang, normalized], [lang, normalized, displayBasis],
) )
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<header> <header>
<KitchenBackLink />
<h1 className="type-display text-[var(--ink)]"> <h1 className="type-display text-[var(--ink)]">
{t(ui.pantry.title, lang)} {t(ui.pantry.title, lang)}
</h1> </h1>
<p className="type-caption mt-2 text-[var(--ink-soft)]"> <p className="type-caption mt-3 text-[var(--ink-soft)]">
<span className="text-[var(--color-pick)]"></span> {t(ui.pantry.pick, lang)} <span className="font-bold text-[var(--signal-success)]" aria-hidden>
</span>{" "}
{t(ui.pantry.pick, lang)}
{" · "} {" · "}
<span className="text-[var(--color-caution)]"></span> {t(ui.pantry.caution, lang)} <span className="font-bold text-[var(--signal-warning)]" aria-hidden>
!
</span>{" "}
{t(ui.pantry.caution, lang)}
</p> </p>
{displayBasis === "per100g" ? (
<p className="type-caption mt-1 text-[var(--ink-faint)]">
{t(ui.pantry.per100gHint, lang)}
</p>
) : null}
</header> </header>
<label className="sr-only" htmlFor="pantry-search"> <label className="sr-only" htmlFor="pantry-search">
@@ -53,13 +77,15 @@ export function PantryScreen() {
className={cx( className={cx(
"type-body min-h-[48px] w-full rounded-[var(--radius-control)] px-4 text-[var(--ink)]", "type-body min-h-[48px] w-full rounded-[var(--radius-control)] px-4 text-[var(--ink)]",
"ring-1 ring-[var(--border)] outline-none placeholder:text-[var(--ink-faint)]", "ring-1 ring-[var(--border)] outline-none placeholder:text-[var(--ink-faint)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-kh)]/40", "focus-visible:ring-2 focus-visible:ring-[var(--ui-accent)]/30",
)} )}
style={{ background: "var(--glass-fill)" }} style={{ background: "var(--glass-fill)" }}
/> />
{sections.length === 0 ? ( {sections.length === 0 ? (
<p className="type-body text-[var(--ink-soft)]">{t(ui.pantry.noResults, lang)}</p> <p className="type-body text-[var(--ink-soft)]">
{t(ui.pantry.noResults, lang)}
</p>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{sections.map(({ category, items }) => ( {sections.map(({ category, items }) => (
@@ -68,21 +94,7 @@ export function PantryScreen() {
defaultOpen={Boolean(normalized)} defaultOpen={Boolean(normalized)}
title={tr(category.title, lang)} title={tr(category.title, lang)}
meta={items.length} meta={items.length}
icon={ icon={<CategorySoftChip categoryId={category.id} />}
<span aria-hidden>
{category.id === "protein"
? "🥚"
: category.id === "snack"
? "🥤"
: category.id === "obst"
? "🍎"
: category.id === "gemuese"
? "🥦"
: category.id === "fett"
? "🥑"
: "🌾"}
</span>
}
> >
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{items.map((item) => ( {items.map((item) => (
@@ -90,31 +102,51 @@ export function PantryScreen() {
key={item.id} key={item.id}
className="flex items-baseline justify-between gap-3 border-b border-[var(--border)] pb-2 last:border-0 last:pb-0" className="flex items-baseline justify-between gap-3 border-b border-[var(--border)] pb-2 last:border-0 last:pb-0"
> >
<span className="type-body-emphasis text-[var(--ink)]"> <span className="type-body-emphasis min-w-0 truncate text-[var(--ink)]">
{tr(item.name, lang)} {tr(item.name, lang)}
</span> </span>
<span <span
className={cx( className={cx(
"type-caption shrink-0 text-right text-[var(--ink-soft)]", "type-caption inline-flex shrink-0 items-center gap-1.5 text-right text-[var(--ink-soft)]",
item.isApprox && "italic", item.isApprox && "italic",
)} )}
> >
{item.tag === "pick" ? ( <span>{tr(item.valueInfo, lang)}</span>
<span className="mr-1 text-[var(--color-pick)]"></span> {item.tag ? (
<span
className={cx(
"font-bold",
item.tag === "pick"
? "text-[var(--signal-success)]"
: "text-[var(--signal-warning)]",
)}
aria-label={
item.tag === "caution" && item.cautionWhy
? `${signalGlyphLabel(item.tag, lang)}: ${tr(item.cautionWhy, lang)}`
: signalGlyphLabel(item.tag, lang)
}
title={
item.tag === "caution" && item.cautionWhy
? tr(item.cautionWhy, lang)
: undefined
}
>
{signalGlyphChar(item.tag)}
</span>
) : null} ) : null}
{item.tag === "caution" ? (
<span className="mr-1 text-[var(--color-caution)]"></span>
) : null}
{tr(item.valueInfo, lang)}
</span> </span>
</li> </li>
))} ))}
</ul> </ul>
{category.id !== "obst" ? (
<p className="type-caption mt-3 text-[var(--ink-soft)]"> <p className="type-caption mt-3 text-[var(--ink-soft)]">
<b className="font-bold text-[var(--ink)]">{t(ui.pantry.tip, lang)}</b> <b className="font-bold text-[var(--ink)]">
{t(ui.pantry.tip, lang)}
</b>
<br /> <br />
{tr(category.thumb, lang)} {tr(category.thumb, lang)}
</p> </p>
) : null}
</Accordion> </Accordion>
))} ))}
</div> </div>

View File

@@ -0,0 +1,41 @@
import { HubBackLink } from "@/components/home/NavCard"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
import type { LocalizedString } from "@/types/domain"
export function PlaceholderScreen({
backTo,
backLabel,
title,
body,
}: {
backTo: string
backLabel: LocalizedString
title: LocalizedString
body: LocalizedString
}) {
const { lang } = useLanguage()
return (
<div className="flex flex-col gap-4">
<header>
<HubBackLink to={backTo} label={backLabel} />
<p className="type-label text-[var(--ink-faint)]">
{t(ui.placeholders.badge, lang)}
</p>
<h1 className="type-display mt-1 text-[var(--ink)]">{t(title, lang)}</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">{t(body, lang)}</p>
</header>
<GlassCard className="p-5">
<p className="type-body text-[var(--ink-soft)]">
{t(ui.stubs.comingSoon, lang)}
</p>
<p className="type-caption mt-3 text-[var(--ink-faint)]">
Pocket Pascal · A+B Hub
</p>
</GlassCard>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useNavigate } from "react-router-dom"
import { BookMarked, CalendarDays, CalendarRange, ShoppingCart } from "lucide-react"
import { NavCard } from "@/components/home/NavCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
export function PlanHubScreen() {
const { lang } = useLanguage()
const navigate = useNavigate()
const soon = t(ui.placeholders.badge, lang)
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.planHub.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.planHub.intro, lang)}
</p>
</header>
<div className="grid grid-cols-1 gap-3">
<NavCard
title={t(ui.planHub.weekTitle, lang)}
body={t(ui.planHub.weekBody, lang)}
gradient="var(--gradient-primary)"
icon={
<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
onNavigate={() => navigate("/plan/week")}
/>
<NavCard
title={t(ui.planHub.mealPlannerTitle, lang)}
body={t(ui.planHub.mealPlannerBody, lang)}
gradient="var(--gradient-accent)"
badge={soon}
disabled
icon={
<CalendarRange className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
/>
<NavCard
title={t(ui.planHub.shoppingTitle, lang)}
body={t(ui.planHub.shoppingBody, lang)}
gradient="var(--gradient-primary)"
badge={soon}
disabled
icon={
<ShoppingCart className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
/>
<NavCard
title={t(ui.planHub.recipesTitle, lang)}
body={t(ui.planHub.recipesBody, lang)}
gradient="var(--gradient-accent)"
badge={soon}
disabled
icon={
<BookMarked className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
/>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { ChevronRight } from "lucide-react" import { ChevronRight } from "lucide-react"
import { KnowledgeBackLink } from "@/components/home/NavCard"
import { DiningPlate } from "@/components/plate/DiningPlate" import { DiningPlate } from "@/components/plate/DiningPlate"
import { SegControl } from "@/components/ui-pp/SegControl" import { SegControl } from "@/components/ui-pp/SegControl"
import { t, tr, ui } from "@/i18n/ui" import { t, tr, ui } from "@/i18n/ui"
@@ -11,10 +12,10 @@ import { cx } from "@/lib/cx"
type MealMode = "full" | "half" type MealMode = "full" | "half"
const CLS_COLOR: Record<string, string> = { const CLS_COLOR: Record<string, string> = {
g: "var(--green)", g: "var(--cat-protein)",
t: "var(--terracotta)", t: "var(--cat-produce)",
o: "var(--gold)", o: "var(--cat-fat)",
s: "var(--slate)", s: "var(--cat-berry)",
} }
const FULL_WEIGHT: Record<string, number> = { const FULL_WEIGHT: Record<string, number> = {
@@ -81,10 +82,11 @@ function StepDetailCard({
)} )}
> >
<span <span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full type-button text-white" className="type-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ style={{
background: color, background: color,
color: step.cls === "o" ? "var(--ink)" : "#fff", color:
step.cls === "o" ? "var(--cat-fat-fg)" : "var(--cat-on-dark)",
}} }}
> >
{step.n} {step.n}
@@ -123,6 +125,7 @@ export function PlateScreen() {
const { lang } = useLanguage() const { lang } = useLanguage()
const [mode, setMode] = useState<MealMode>("full") const [mode, setMode] = useState<MealMode>("full")
const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1])) const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1]))
const [selectedId, setSelectedId] = useState<number>(1)
const [highlightId, setHighlightId] = useState<number | null>(null) const [highlightId, setHighlightId] = useState<number | null>(null)
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -152,7 +155,6 @@ export function PlateScreen() {
}, []) }, [])
useEffect(() => { useEffect(() => {
// Drop open state for cards that are hidden in half-meal mode
setOpenIds((current) => { setOpenIds((current) => {
const next = new Set<number>() const next = new Set<number>()
for (const id of current) { for (const id of current) {
@@ -161,9 +163,12 @@ export function PlateScreen() {
if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n) if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n)
return next return next
}) })
}, [visibleSteps]) const stillVisible = visibleSteps.some((step) => step.n === selectedId)
if (!stillVisible && visibleSteps[0]) setSelectedId(visibleSteps[0].n)
}, [visibleSteps, selectedId])
function focusStep(n: number) { function focusStep(n: number) {
setSelectedId(n)
setOpenIds((current) => new Set(current).add(n)) setOpenIds((current) => new Set(current).add(n))
setHighlightId(n) setHighlightId(n)
if (highlightTimer.current) clearTimeout(highlightTimer.current) if (highlightTimer.current) clearTimeout(highlightTimer.current)
@@ -177,6 +182,7 @@ export function PlateScreen() {
} }
function toggleStep(n: number) { function toggleStep(n: number) {
setSelectedId(n)
setOpenIds((current) => { setOpenIds((current) => {
const next = new Set(current) const next = new Set(current)
if (next.has(n)) next.delete(n) if (next.has(n)) next.delete(n)
@@ -190,6 +196,7 @@ export function PlateScreen() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<header> <header>
<KnowledgeBackLink />
<h1 className="type-display text-[var(--ink)]">{t(ui.plate.title, lang)}</h1> <h1 className="type-display text-[var(--ink)]">{t(ui.plate.title, lang)}</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]"> <p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.plate.subtitle, lang)} {t(ui.plate.subtitle, lang)}
@@ -206,47 +213,20 @@ export function PlateScreen() {
]} ]}
/> />
<div className="flex flex-col items-center gap-4 py-2"> <div className="flex flex-col items-center gap-6 overflow-visible px-2 py-4">
<DiningPlate <DiningPlate
key={mode} key={mode}
size={280} size={240}
segments={segments} segments={segments}
selectedId={String(selectedId)}
aria-label={t(ui.plate.plateAria, lang)} aria-label={t(ui.plate.plateAria, lang)}
onSelect={(id) => focusStep(Number(id))} onSelect={(id) => focusStep(Number(id))}
/> />
<ul className="flex w-full flex-col gap-2">
{visibleSteps.map((step) => (
<li key={step.n}>
<button
type="button"
onClick={() => focusStep(step.n)}
className={cx(
"type-caption flex min-h-[44px] w-full items-center justify-center gap-2 font-bold text-[var(--ink-soft)]",
"rounded-[var(--radius-control)] px-2 active:brightness-95",
)}
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: stepColor(step.cls) }}
aria-hidden
/>
<span>{tr(step.title, lang)}</span>
</button>
</li>
))}
</ul>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{visibleSteps.map((step) => ( {visibleSteps.map((step) => (
<div <div key={step.n}>
key={step.n}
className={cx(
"transition-[opacity,transform] duration-300 ease-out",
"motion-reduce:transition-none",
)}
>
<StepDetailCard <StepDetailCard
step={step} step={step}
open={openIds.has(step.n)} open={openIds.has(step.n)}
@@ -258,7 +238,8 @@ export function PlateScreen() {
))} ))}
</div> </div>
<footer className="mt-2 rounded-[var(--radius-card)] p-4 ring-1 ring-[var(--border)]" <footer
className="mt-2 rounded-[var(--radius-card)] p-4 ring-1 ring-[var(--border)]"
style={{ background: "var(--glass-fill)" }} style={{ background: "var(--glass-fill)" }}
> >
<p className="type-label mb-3 text-[var(--ink-faint)]"> <p className="type-label mb-3 text-[var(--ink-faint)]">

View File

@@ -0,0 +1,45 @@
import { useState } from "react"
import { KnowledgeBackLink } from "@/components/home/NavCard"
import { Accordion } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui"
import { foodCategories } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
export function RulesScreen() {
const { lang } = useLanguage()
const [openIds, setOpenIds] = useState<Record<string, boolean>>(() =>
Object.fromEntries(foodCategories.map((c) => [c.id, c.id === "protein"])),
)
return (
<div className="flex flex-col gap-4">
<header>
<KnowledgeBackLink />
<h1 className="type-display text-[var(--ink)]">
{t(ui.rules.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.rules.intro, lang)}
</p>
</header>
<div className="flex flex-col gap-3">
{foodCategories.map((category) => (
<Accordion
key={category.id}
open={Boolean(openIds[category.id])}
onOpenChange={(open) =>
setOpenIds((current) => ({ ...current, [category.id]: open }))
}
title={tr(category.title, lang)}
>
<p className="type-body text-[var(--ink-soft)]">
{tr(category.thumb, lang)}
</p>
</Accordion>
))}
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { KnowledgeBackLink } from "@/components/home/NavCard"
import { Button } from "@/components/ui-pp/Button" import { Button } from "@/components/ui-pp/Button"
import { GlassCard } from "@/components/ui-pp/GlassCard" import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui" import { t, ui } from "@/i18n/ui"
@@ -14,6 +15,8 @@ export function SettingsScreen() {
const { lang, setLang } = useLanguage() const { lang, setLang } = useLanguage()
return ( return (
<div className="flex flex-col gap-4">
<KnowledgeBackLink />
<GlassCard className="p-4"> <GlassCard className="p-4">
<h1 className="type-display text-[var(--ink)]"> <h1 className="type-display text-[var(--ink)]">
{t(ui.stubs.settingsTitle, lang)} {t(ui.stubs.settingsTitle, lang)}
@@ -54,5 +57,6 @@ export function SettingsScreen() {
</Button> </Button>
</div> </div>
</GlassCard> </GlassCard>
</div>
) )
} }

View File

@@ -1,99 +1,86 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Accordion, NestedDisclosure } from "@/components/ui-pp/Accordion" import { IngredientChips } from "@/components/food/IngredientChips"
import { PlanBackLink } from "@/components/home/NavCard"
import { Accordion } from "@/components/ui-pp/Accordion"
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
import { t, tr, ui } from "@/i18n/ui" import { t, tr, ui } from "@/i18n/ui"
import { PLAN_TYPE_ORDER, recipesByType, resolveRecipeUses } from "@/lib/catalog" import { MEAL_TIME_ORDER, recipesForMealTime, resolveRecipeUses } from "@/lib/catalog"
import { useLanguage } from "@/lib/language" import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx" import { cx } from "@/lib/cx"
import type { PlanRecipeType, Recipe } from "@/types/domain" import type { Effort, MealTime, Recipe } from "@/types/domain"
const TYPE_ICON: Record<PlanRecipeType, string> = { function effortLabel(effort: Effort, lang: "de" | "en"): string {
breakfast: "🌅", if (effort === "none") return t(ui.weekplan.effortNone, lang)
snack: "🥤", if (effort === "quick") return t(ui.weekplan.effortQuick, lang)
full: "🍽️", return t(ui.weekplan.effortCook, lang)
recipe: "📖",
} }
function typeLabel(type: PlanRecipeType, lang: "de" | "en"): string { /** Show when cooking, or when a concrete duration is 15+ minutes. */
return t(ui.weekplan[type], lang) function shouldShowEffortCaption(
effort: Effort,
durationMinutes?: number,
): boolean {
if (effort === "cook") return true
if (durationMinutes != null && durationMinutes >= 15) return true
return false
} }
function RecipeBody({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) { function EffortCaption({
effort,
lang,
}: {
effort: Effort
lang: "de" | "en"
}) {
return (
<p
className={cx(
"type-caption shrink-0 text-right",
effort === "none" && "text-[var(--ink-faint)]",
effort === "quick" && "text-[var(--ink-soft)]",
effort === "cook" && "font-bold text-[var(--signal-warning)]",
)}
>
{effortLabel(effort, lang)}
</p>
)
}
function RecipeRow({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) {
const ingredients = resolveRecipeUses(recipe) const ingredients = resolveRecipeUses(recipe)
const showEffort = shouldShowEffortCaption(recipe.effort)
return ( return (
<div className="flex flex-col gap-3"> <article className="border-b border-[var(--border)] py-3 last:border-0 last:pb-0 first:pt-0">
{recipe.cooked !== undefined ? ( <div className="flex items-baseline justify-between gap-3">
<p className="type-label text-[var(--ink-faint)]"> <h3 className="type-title-sm min-w-0 text-[var(--ink)]">
{recipe.cooked ? t(ui.weekplan.cooked, lang) : t(ui.weekplan.cold, lang)} {tr(recipe.name, lang)}
</p> </h3>
) : null} {showEffort ? <EffortCaption effort={recipe.effort} lang={lang} /> : null}
{recipe.text ? (
<p className="type-body text-[var(--ink-soft)]">{tr(recipe.text, lang)}</p>
) : null}
{recipe.variations && recipe.variations.length > 0 ? (
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.weekplan.variations, lang)}
</p>
<ul className="flex flex-col gap-2">
{recipe.variations.map((variation, index) => (
<li
key={`${recipe.id}-var-${index}`}
className="type-body flex gap-2 text-[var(--ink-soft)]"
>
<span className="text-[var(--ink-faint)]" aria-hidden>
·
</span>
<span>{tr(variation, lang)}</span>
</li>
))}
</ul>
</div>
) : null}
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.weekplan.uses, lang)}
</p>
<ul className="flex flex-wrap gap-2">
{ingredients.map((item) => (
<li
key={item.id}
className={cx(
"type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)]",
"ring-1 ring-[var(--border)]",
)}
style={{ background: "var(--glass-fill)" }}
>
{tr(item.name, lang)}
{item.source === "extra" ? (
<span className="ml-1 text-[var(--ink-faint)]">+</span>
) : null}
</li>
))}
</ul>
</div>
</div> </div>
<IngredientChips
className="mt-2.5"
items={ingredients}
maxVisible={4}
/>
</article>
) )
} }
export function WeekPlanScreen() { export function WeekPlanScreen() {
const { lang } = useLanguage() const { lang } = useLanguage()
const [openTypes, setOpenTypes] = useState<Record<PlanRecipeType, boolean>>({ const [openTimes, setOpenTimes] = useState<Record<MealTime, boolean>>(() =>
breakfast: true, Object.fromEntries(
snack: false, MEAL_TIME_ORDER.map((time) => [time, time === "breakfast"]),
full: false, ) as Record<MealTime, boolean>,
recipe: false, )
})
const groups = useMemo( const groups = useMemo(
() => () =>
PLAN_TYPE_ORDER.map((type) => ({ MEAL_TIME_ORDER.map((time) => ({
type, time,
items: recipesByType(type), items: recipesForMealTime(time),
})), })),
[], [],
) )
@@ -101,6 +88,7 @@ export function WeekPlanScreen() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<header> <header>
<PlanBackLink />
<h1 className="type-display text-[var(--ink)]"> <h1 className="type-display text-[var(--ink)]">
{t(ui.weekplan.title, lang)} {t(ui.weekplan.title, lang)}
</h1> </h1>
@@ -110,22 +98,25 @@ export function WeekPlanScreen() {
</header> </header>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{groups.map(({ type, items }) => ( {groups.map(({ time, items }) => (
<Accordion <Accordion
key={type} key={time}
open={openTypes[type]} open={openTimes[time]}
onOpenChange={(open) => onOpenChange={(open) =>
setOpenTypes((current) => ({ ...current, [type]: open })) setOpenTimes((current) => ({ ...current, [time]: open }))
} }
title={typeLabel(type, lang)} title={t(ui.weekplan[time], lang)}
meta={items.length} meta={items.length}
icon={<span aria-hidden>{TYPE_ICON[type]}</span>} icon={
<TimeIconBadge
mealTime={time}
aria-hidden
/>
}
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col">
{items.map((recipe) => ( {items.map((recipe) => (
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}> <RecipeRow key={recipe.id} recipe={recipe} lang={lang} />
<RecipeBody recipe={recipe} lang={lang} />
</NestedDisclosure>
))} ))}
</div> </div>
</Accordion> </Accordion>

View File

@@ -1,42 +1,36 @@
/** /**
* Pocket Pascal — Elevated Surface Design Tokens * Pocket Pascal — Elevated Surface Design Tokens
* *
* Pivoted from heavy glassmorphism to a low-transparency, tactile system: * Category palette (locked): Emerald Bridge + Neon Punch fat
* cards read as solid raised surfaces (elevation shadow + faint top/bottom * --cat-protein #2563EB Blau
* bevel), buttons visibly depress on press (inset shadow + 1px downward * --cat-produce #059669 Emerald (Obst + Gemüse)
* shift) instead of just scaling. `--glass-*` names kept for minimal diff * --cat-fat #EAB308 Gold (from Neon Punch)
* across components — they now mean "surface fill / surface blur", not * --cat-berry #C0267A Berry (Kohlenhydrate)
* "see-through glass". Blur is low (8px) and opacity high (~88%): it softens
* whatever sits behind a card without reading as transparent.
* *
* Toggle dark mode by adding/removing the `dark` class on <html> * Signals (glyph-primary for pick/caution; color redundant):
* (matches Tailwind `darkMode: 'class'` — see README). * --signal-success / --signal-error / --signal-warning
* *
* Brand hues (protein/obstgem/fett/kh + ink) come from the WCAG-audited * --ui-accent is non-semantic (chrome only — tabs, focus), not a plate category.
* Pocket Pascal palette, not generic placeholders — see /docs/CONCEPT.html
* section 05/08 for the full rationale and contrast numbers.
*/ */
:root { :root {
/* ---------- Elevated surface (formerly "glass") ---------- */ /* ---------- Elevated surface (formerly "glass") ---------- */
--glass-fill: rgba(255, 255, 255, 0.9); --glass-fill: rgba(255, 255, 255, 0.9);
--glass-blur: 8px; --glass-blur: 8px;
/* top-light / bottom-shadow bevel instead of an even glass edge — reads as raised, not see-through */
--glass-border-from: rgba(255, 255, 255, 0.9); --glass-border-from: rgba(255, 255, 255, 0.9);
--glass-border-to: rgba(0, 0, 0, 0.06); --glass-border-to: rgba(0, 0, 0, 0.06);
/* ---------- Radius scale (nested: outer ≈ inner + padding) ---------- */ /* ---------- Radius scale ---------- */
--radius-card: 24px; --radius-card: 24px;
--radius-nested: 12px; --radius-nested: 12px;
--radius-control: 16px; --radius-control: 16px;
--radius-pill: 9999px; --radius-pill: 9999px;
/* ---------- Shadows (resting → elevated → float; warm ink-tinted ambient) ---------- */ /* ---------- Shadows ---------- */
--shadow-resting: 0 1px 2px rgba(25, 24, 0, 0.04), 0 1px 3px rgba(25, 24, 0, 0.06); --shadow-resting: 0 1px 2px rgba(25, 24, 0, 0.04), 0 1px 3px rgba(25, 24, 0, 0.06);
--shadow-elevated: 0 2px 4px rgba(25, 24, 0, 0.04), 0 8px 20px rgba(25, 24, 0, 0.08); --shadow-elevated: 0 2px 4px rgba(25, 24, 0, 0.04), 0 8px 20px rgba(25, 24, 0, 0.08);
--shadow-float: 0 4px 8px rgba(25, 24, 0, 0.05), 0 16px 32px rgba(25, 24, 0, 0.1); --shadow-float: 0 4px 8px rgba(25, 24, 0, 0.05), 0 16px 32px rgba(25, 24, 0, 0.1);
--shadow-pressed: inset 0 2px 5px rgba(25, 24, 0, 0.22), inset 0 1px 1px rgba(25, 24, 0, 0.12); --shadow-pressed: inset 0 2px 5px rgba(25, 24, 0, 0.22), inset 0 1px 1px rgba(25, 24, 0, 0.12);
/* BottomNav / overlays sit one step above content cards */
--shadow-ambient: var(--shadow-float); --shadow-ambient: var(--shadow-float);
/* ---------- Brand ink / neutrals ---------- */ /* ---------- Brand ink / neutrals ---------- */
@@ -45,43 +39,65 @@
--ink-faint: #6b6a56; --ink-faint: #6b6a56;
--border: #e4e2d8; --border: #e4e2d8;
/* ---------- Brand category colors (bright primary + dark accent pairs) ---------- */ /* ---------- Canonical category palette ---------- */
--color-protein: #d9f227; --cat-protein: #2563eb;
--color-protein-dk: #2e3200; --cat-produce: #059669;
--color-obstgem: #ff6a3d; --cat-fat: #eab308;
--color-obstgem-dk: #3d1508; --cat-berry: #c0267a;
--color-fett: #ffb300; /* Text on category fills: gold needs dark ink; others use white */
--color-fett-dk: #3d2900; --cat-fat-fg: #191800;
--color-kh: #1fd9c4; --cat-on-dark: #ffffff;
--color-kh-dk: #04302b;
--color-pick: #0f7a37;
--color-caution: #c81e1e;
/* ---------- Plate / Baukasten category hues (from prototype) ---------- */ /* ---------- Signals (not categories) ---------- */
--green: #2f5233; --signal-success: #0f7a37;
--green-soft: #e3ece4; --signal-error: #c81e1e;
--terracotta: #b33d22; --signal-warning: #ea580c;
--terracotta-soft: #f7e0d8;
--gold: #e8b93a;
--gold-soft: #fdf1d9;
--slate: #6b4c9a;
--slate-soft: #efe9f5;
/* ---------- Surface gradients (subtle, sit UNDER glass layers) ---------- */ /* ---------- Non-semantic UI chrome ---------- */
--ui-accent: #191800;
--ui-accent-soft: #5c5b48;
/* ---------- Legacy aliases → canonical (safe during migration) ---------- */
--color-protein: var(--cat-protein);
--color-protein-dk: #1e3a8a;
--color-obstgem: var(--cat-produce);
--color-obstgem-dk: #065f46;
--color-fett: var(--cat-fat);
--color-fett-dk: #a16207;
--color-kh: var(--cat-berry);
--color-kh-dk: #9d174d;
--color-pick: var(--signal-success);
--color-caution: var(--signal-error);
--plate-protein: var(--cat-protein);
--plate-veg: var(--cat-produce);
--plate-fat: var(--cat-fat);
--plate-carb: var(--cat-berry);
--plate-protein-soft: #dbeafe;
--plate-veg-soft: #d1fae5;
--plate-fat-soft: #fef9c3;
--plate-carb-soft: #fce7f3;
--green: var(--cat-produce);
--green-soft: var(--plate-veg-soft);
--terracotta: var(--cat-berry);
--terracotta-soft: var(--plate-carb-soft);
--gold: var(--cat-fat);
--gold-soft: var(--plate-fat-soft);
--slate: var(--cat-protein);
--slate-soft: var(--plate-protein-soft);
/* ---------- Surfaces ---------- */
--surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%); --surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%);
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%), --page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%); radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
/* ---------- Action gradients (vibrant, multi-stop) ---------- */ /* Decorative UI gradients — lime-vibrant brand chrome (NOT category encoding).
/* NOTE: brand hues are light/high-luminance by design (see contrast audit), Paired with --ink icons/text for WCAG AA on these light stops. */
so action gradients pair with --ink text, not white — this keeps every
button at AA contrast without text-shadow hacks. Do not swap to white
text on these without re-checking contrast. */
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%); --gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%); --gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%); --gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
/* ---------- Dining Plate chart tokens ---------- */
--plate-rim-light: #ffffff; --plate-rim-light: #ffffff;
--plate-rim-shadow: #d8d8d0; --plate-rim-shadow: #d8d8d0;
--plate-well-center: #f7f7f2; --plate-well-center: #f7f7f2;
@@ -105,17 +121,22 @@
--ink-faint: #8f8e7a; --ink-faint: #8f8e7a;
--border: #33342a; --border: #33342a;
/* category primaries stay identical (they're the brand, not a theme) */ --cat-protein: #2563eb;
--color-protein: #d9f227; --cat-produce: #059669;
--color-protein-dk: #2e3200; --cat-fat: #eab308;
--color-obstgem: #ff6a3d; --cat-berry: #c0267a;
--color-obstgem-dk: #3d1508; --cat-fat-fg: #191800;
--color-fett: #ffb300; --cat-on-dark: #ffffff;
--color-fett-dk: #3d2900;
--color-kh: #1fd9c4; --signal-success: #4ade80;
--color-kh-dk: #04302b; --signal-error: #ff6b60;
--color-pick: #4ade80; --signal-warning: #fb923c;
--color-caution: #ff6b60;
--ui-accent: #f3f2e6;
--ui-accent-soft: #c2c1ac;
--color-pick: var(--signal-success);
--color-caution: var(--signal-error);
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%); --surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%), --page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
@@ -132,9 +153,6 @@
} }
/* ---------- Fonts ---------- */ /* ---------- Fonts ---------- */
/* Download the two static woff2 files listed in README into /public/fonts,
then this block just works. Using local @font-face (not next/font) keeps
this file framework-agnostic for Cursor. */
@font-face { @font-face {
font-family: "Unbounded"; font-family: "Unbounded";
font-weight: 700; font-weight: 700;
@@ -179,31 +197,30 @@
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif; font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
} }
/* ---------- Type ramp (single hierarchy) ---------- */
.type-display { .type-display {
font-family: "Unbounded", ui-sans-serif, sans-serif; font-family: "Unbounded", ui-sans-serif, sans-serif;
font-weight: 900; font-weight: 900;
font-size: 1.5rem; /* 24px */ font-size: 1.5rem;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.type-title { .type-title {
font-family: "Unbounded", ui-sans-serif, sans-serif; font-family: "Unbounded", ui-sans-serif, sans-serif;
font-weight: 700; font-weight: 700;
font-size: 1.25rem; /* 20px */ font-size: 1.25rem;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.type-title-sm { .type-title-sm {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif; font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 700; font-weight: 700;
font-size: 1rem; /* 16px */ font-size: 1rem;
line-height: 1.3; line-height: 1.3;
} }
.type-body { .type-body {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif; font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 400; font-weight: 400;
font-size: 0.875rem; /* 14px */ font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
} }
.type-body-emphasis { .type-body-emphasis {
@@ -215,7 +232,7 @@
.type-label { .type-label {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif; font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 700; font-weight: 700;
font-size: 0.6875rem; /* 11px */ font-size: 0.6875rem;
line-height: 1.3; line-height: 1.3;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
@@ -223,7 +240,7 @@
.type-caption { .type-caption {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif; font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 400; font-weight: 400;
font-size: 0.75rem; /* 12px */ font-size: 0.75rem;
line-height: 1.45; line-height: 1.45;
} }
.type-button { .type-button {
@@ -234,31 +251,24 @@
} }
.type-button-secondary { .type-button-secondary {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif; font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 500; /* closest to semibold until 600 is loaded */ font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.2; line-height: 1.2;
} }
/* ---------- Glass border (gradient 1px border that respects border-radius) ---------- */
/* border-image ignores border-radius, so we use a masked pseudo-element instead.
Apply `.glass-border` to a `relative` container with `overflow-hidden` off
(the pseudo-element is inset:0 and radius:inherit). */
.glass-border { .glass-border {
border-radius: inherit; border-radius: inherit;
padding: 1px; padding: 1px;
/* top-light, bottom-shadow — reads as a raised bevel, not a glass sheen */
background: linear-gradient(180deg, var(--glass-border-from), var(--glass-border-to)); background: linear-gradient(180deg, var(--glass-border-from), var(--glass-border-to));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; -webkit-mask-composite: xor;
mask-composite: exclude; mask-composite: exclude;
} }
/* Applied on top of --shadow-elevated/--shadow-resting to animate between them. */
.elevation-transition { .elevation-transition {
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out; transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
} }
/* ---------- PWA polish ---------- */
html, html,
body { body {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;

View File

@@ -15,22 +15,36 @@ export type CategoryId =
export type TagType = "pick" | "caution" export type TagType = "pick" | "caution"
/** Plate size per building-block rule. */
export type MealType = "full" | "half" export type MealType = "full" | "half"
export type TimeSlot = "morning" | "midday" | "afternoon" | "evening" /** Day-part for plate suggestions (RECIPES.times). */
export type MealTime = "breakfast" | "lunch" | "snack" | "dinner"
export type PlanRecipeType = "breakfast" | "snack" | "full" | "recipe" export type Effort = "none" | "quick" | "cook"
export type HomeWidgetId = "suggestion" | "knowledge" | "builder" /** How kcal / protein on a food record are measured. */
export type NutritionBasis = "per100g" | "perServing" | "perUnit"
export type WidgetOrder = HomeWidgetId[] /**
* Default display / sort basis for pantry lists.
* Switch later when the UI offers perServing / perUnit views.
*/
export const DEFAULT_NUTRITION_BASIS: NutritionBasis = "per100g"
export interface FoodItem { export interface FoodItem {
id: string id: string
cat: CategoryId cat: CategoryId
name: LocalizedString name: LocalizedString
/** Display string (macros or qualitative e.g. vitamins). */
valueInfo: LocalizedString valueInfo: LocalizedString
/** Measurement basis for kcal / proteinG. */
basis: NutritionBasis
kcal?: number
proteinG?: number
tag?: TagType tag?: TagType
/** Required when tag is caution — why enjoy mindfully. */
cautionWhy?: LocalizedString
isApprox?: boolean isApprox?: boolean
} }
@@ -56,20 +70,31 @@ export interface CatalogItem {
cat?: CategoryId cat?: CategoryId
valueInfo?: LocalizedString valueInfo?: LocalizedString
tag?: TagType tag?: TagType
cautionWhy?: LocalizedString
} }
/** /**
* Curated meal / snack / how-to recipes. * Curated plate suggestion (half / full meal).
* `uses` references ids from CATS (foods) or EXTRAS. * `uses` references ids from CATS (foods) or EXTRAS.
*/ */
export interface Recipe { export interface Recipe {
id: string id: string
type: PlanRecipeType type: MealType
times: MealTime[]
effort: Effort
name: LocalizedString
uses: string[]
}
/**
* Prep / how-to instruction — not a plate suggestion.
* Kept separate from RECIPES.
*/
export interface HowToRecipe {
id: string
name: LocalizedString name: LocalizedString
uses: string[] uses: string[]
cooked?: boolean
text?: LocalizedString text?: LocalizedString
/** Optional flavor / protein twists shown under the base recipe text. */
variations?: LocalizedString[] variations?: LocalizedString[]
} }

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,8 @@
"n": 2, "n": 2,
"cls": "t", "cls": "t",
"title": { "title": {
"de": "Obst ODER Gemüse", "de": "Obst & Gemüse",
"en": "Fruit OR Vegetables" "en": "Fruit & Veggie"
}, },
"qty": { "qty": {
"de": "eine Portion", "de": "eine Portion",

288
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.18.2 specifier: ^7.18.2
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
recharts:
specifier: ^3.10.1
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
shadcn: shadcn:
specifier: ^4.16.1 specifier: ^4.16.1
version: 4.16.1(typescript@6.0.3) version: 4.16.1(typescript@6.0.3)
@@ -1022,6 +1025,17 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.2.2': '@rolldown/binding-android-arm64@1.2.2':
resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1290,6 +1304,12 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.3.3': '@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@@ -1396,6 +1416,33 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1446,6 +1493,9 @@ packages:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validate-npm-package-name@4.0.2': '@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -1773,6 +1823,50 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1806,6 +1900,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1945,6 +2042,9 @@ packages:
resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.50.0:
resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==}
esbuild@0.28.1: esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1977,6 +2077,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
eventsource-parser@3.1.0: eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -2239,6 +2342,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immer@11.1.15:
resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2253,6 +2359,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.4.0: ip-address@10.4.0:
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -3038,6 +3148,21 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.8 react: ^19.2.8
react-is@19.2.8:
resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.18.2: react-router-dom@7.18.2:
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -3067,6 +3192,22 @@ packages:
resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
recharts@3.10.1:
resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3555,6 +3696,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-plugin-pwa@1.3.0: vite-plugin-pwa@1.3.0:
resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==} resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -4697,6 +4841,18 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.77.0': '@oxlint/binding-win32-x64-msvc@1.77.0':
optional: true optional: true
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.15
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.2.0
optionalDependencies:
react: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.2.2': '@rolldown/binding-android-arm64@1.2.2':
optional: true optional: true
@@ -4863,6 +5019,10 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.3.3': '@tailwindcss/node@4.3.3':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@@ -4957,6 +5117,30 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.13.3 '@types/node': 24.13.3
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/express-serve-static-core@5.1.3': '@types/express-serve-static-core@5.1.3':
@@ -5016,6 +5200,8 @@ snapshots:
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/validate-npm-package-name@4.0.2': {} '@types/validate-npm-package-name@4.0.2': {}
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))': '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))':
@@ -5342,6 +5528,44 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-view-buffer@1.0.2: data-view-buffer@1.0.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -5372,6 +5596,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
decompress-response@6.0.0: decompress-response@6.0.0:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
@@ -5547,6 +5773,8 @@ snapshots:
is-date-object: 1.1.0 is-date-object: 1.1.0
is-symbol: 1.1.1 is-symbol: 1.1.1
es-toolkit@1.50.0: {}
esbuild@0.28.1: esbuild@0.28.1:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1 '@esbuild/aix-ppc64': 0.28.1
@@ -5590,6 +5818,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
eventemitter3@5.0.4: {}
eventsource-parser@3.1.0: {} eventsource-parser@3.1.0: {}
eventsource@3.0.7: eventsource@3.0.7:
@@ -5933,6 +6163,8 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
immer@11.1.15: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -5948,6 +6180,8 @@ snapshots:
hasown: 2.0.4 hasown: 2.0.4
side-channel: 1.1.1 side-channel: 1.1.1
internmap@2.0.3: {}
ip-address@10.4.0: {} ip-address@10.4.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
@@ -6611,6 +6845,17 @@ snapshots:
react: 19.2.8 react: 19.2.8
scheduler: 0.27.0 scheduler: 0.27.0
react-is@19.2.8: {}
react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
redux: 5.0.1
react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8
@@ -6641,6 +6886,32 @@ snapshots:
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
tslib: 2.8.1 tslib: 2.8.1
recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.50.0
eventemitter3: 5.0.4
immer: 11.1.15
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-is: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
reselect: 5.2.0
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.8)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -7274,6 +7545,23 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-pwa@1.3.0(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))(workbox-build@7.4.1)(workbox-window@7.4.1): vite-plugin-pwa@1.3.0(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))(workbox-build@7.4.1)(workbox-window@7.4.1):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3