From 6dd0b7ac9391a551f1960704f7c2b23e03c4d1e8 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 15:06:11 +0200 Subject: [PATCH 01/36] docs(specs): add final frontend specs for members and settings Kachel views Finalised implementation specs for /members (E2) and /settings (E1) pages using the chosen Kachel (card grid) variation. Members spec covers 6 states including role-change inline control and remove confirmation dialog; notes backend gaps (DELETE/PATCH member endpoints). Settings spec covers hub layout, D3 staples sub-page, hover and empty states. Co-Authored-By: Claude Sonnet 4.6 --- specs/frontend/e1-settings-kachel.html | 700 +++++++++++++++++++ specs/frontend/e2-members-kachel.html | 905 +++++++++++++++++++++++++ 2 files changed, 1605 insertions(+) create mode 100644 specs/frontend/e1-settings-kachel.html create mode 100644 specs/frontend/e2-members-kachel.html diff --git a/specs/frontend/e1-settings-kachel.html b/specs/frontend/e1-settings-kachel.html new file mode 100644 index 0000000..e7aea6a --- /dev/null +++ b/specs/frontend/e1-settings-kachel.html @@ -0,0 +1,700 @@ + + + + + + E1 — Einstellungen · Kachel-Ansicht · Finale Spezifikation + + + + + +
+ + +
+
+

E1 — Einstellungen

+

Kachel-Ansicht · Finale Spezifikation · Route: /settings/household/staples?ctx=settings

+
+
+ screens: E1, D3
+ journey: J8
+ variation: Kachel (V2)
+ version: 1.0
+ date: 2026-04-09 +
+
+ +

+ Die Einstellungsseite dient als Hub mit drei Kacheln: Vorräte (primäre Aktion, navigiert zu D3), + Mitglieder (navigiert zu E2) und Profil. Die Vorräte-Kachel zeigt die aktive Zutatenanzahl als + Display-Font-Zahl. D3 verwendet die bestehende StaplesManager-Komponente mit context="settings". +

+ + + + +
+
+
S1
+
+
Einstellungs-Hub — drei Kacheln
+
Vorräte-Kachel (2fr, primär mit grünem Akzentstreifen), Mitglieder-Kachel (1fr), Profil-Kachel (1fr). Desktop 2-spaltig oben, dann 2-spaltig unten.
+
+
+ +
+ + +
+
Mobile
+
+
+
+ +
+
+
14
+
von 32 Vorräten aktiv
+
Vorräte
+
Welche Zutaten hast du immer zu Hause?
+
Vorräte bearbeiten →
+
+
+
👥 Mitglieder
+
3 Mitglieder · Einladen & Rollen
+
Verwalten →
+
+
+
👤 Profil
+
Marcel R.
+
Bearbeiten →
+
+
+
+
📅
Planer
+
🍽
Rezepte
+
🛒
Einkauf
+
⚙️
Einstellungen
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Vorräte-Kachel: grid-column: span 1 aber 2fr Spaltenbreite im 2-Spalten-Grid. Grüner Linksstreifen (border-left: 3px solid --green-dark).
  • +
  • Stat-Zahl: Anzahl Zutaten mit isStaple === true, aus dem gleichen Load-Call der D3-Seite
  • +
  • Mitglieder-Karte: Anzahl aus locals.haushalt oder separatem API-Call; navigiert zu /members
  • +
  • Profil-Karte: Name aus locals.benutzer.name; Zielseite /profile (noch nicht implementiert — Link disabled oder Placeholder)
  • +
  • Hover: box-shadow: --shadow-raised, leicht dunklerer Border
  • +
  • Alle Kacheln sind <a>-Tags für korrekte Navigation und Accessibility
  • +
  • Mobile: Kacheln stapeln sich vertikal in voller Breite, kein Grid
  • +
+
+
+ + + + +
+
+
S2
+
+
D3 — Vorräte bearbeiten (StaplesManager, context="settings")
+
Navigiert man von der Vorräte-Kachel aus, erscheint die bestehende StaplesManager-Komponente mit Breadcrumb zurück zu Einstellungen.
+
+
+ +
+
+
Desktop
+
+
+
+ +
+ + +
Vorräte
+
Markierte Zutaten werden beim Einkaufen herausgefiltert.
+ + +
+
Gewürze & Öle
+
+ Salz + Pfeffer + Olivenöl + Paprika + Kreuzkümmel + Knoblauch + Chili +
+
+
+
Grundnahrung
+
+ Reis + Nudeln + Mehl + Zucker + Linsen + Hülsenfrüchte +
+
+
+
Kühlschrank
+
+ Butter + Eier + Milch + Käse + Joghurt +
+
+
Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.
+
+
+
+
+
+ +
+
Mobile
+
+
+
+ +
+
+
Gewürze & Öle
+
+ Salz + Pfeffer + Olivenöl + Paprika + Knoblauch +
+
+
+
Grundnahrung
+
+ Reis + Nudeln + Mehl + Zucker +
+
+
+
+
📅
Planer
+
🍽
Rezepte
+
🛒
Einkauf
+
⚙️
Einstellungen
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Breadcrumb "← Einstellungen" navigiert zurück zu /settings
  • +
  • "Einstellungen" bleibt in der Sidebar aktiv (kein eigener Nav-Eintrag für Vorräte)
  • +
  • StaplesManager-Komponente unverändert mit context="settings" (3-spaltig auf md+)
  • +
  • Kein Speichern-Button. Hinweistext "Änderungen werden automatisch gespeichert." unter den Chips
  • +
  • Mobile: Chips statt 3-spaltig 1-spaltig (volle Breite), Flex-Wrap bleibt bestehen
  • +
  • D3 hat eigene +page.server.ts die +page.svelte bei /household/staples gibt es bereits
  • +
+
+
+ + + + +
+
+
S3
+
+
Kachel-Hover — visuelles Feedback
+
Alle Kacheln sind anklickbare Links. Hover hebt die Kachel visuell an.
+
+
+ + + +
+
Notizen
+
    +
  • Hover: box-shadow: --shadow-raised + border-color: #C0BFB8
  • +
  • Vorräte-Kachel behält den grünen Linksstreifen auch im Hover
  • +
  • Transition: box-shadow 150ms ease, border-color 150ms ease
  • +
  • Cursor: pointer auf allen Kacheln
  • +
  • Focus-visible: outline: 2px solid --green-dark; outline-offset: 2px
  • +
+
+
+ + + + +
+
+
S4
+
+
Vorräte-Kachel bei 0 aktiven Vorräten
+
Wenn noch kein Vorrat gesetzt wurde, zeigt die Kachel eine Einladung zur Aktion statt der Zahl.
+
+
+ + + +
+
Notizen
+
    +
  • Wenn stapleCount === 0: Stat-Zahl weglassen, stattdessen "Noch keine Vorräte eingerichtet" in muted
  • +
  • CTA-Text ändert sich: "Jetzt einrichten →" statt "Vorräte bearbeiten →"
  • +
  • Kachel navigiert weiterhin zu D3 — StaplesManager lädt immer, unabhängig vom Count
  • +
+
+
+ + +
+

Maschinen-lesbare Spezifikation

+

Diese Sektion enthält verbindliche Implementierungsregeln für den Coding-Agenten.

+ +
+/* spec:rules — E1 Einstellungen Kachel
+ *
+ * ROUTE: /settings (E1 hub)
+ * DATA LOAD (page.server.ts):
+ *   - GET /v1/ingredient-categories to count stapleCount
+ *     stapleCount = sum of ingredients where isStaple === true
+ *   - member count available from layout data (locals.haushalt)
+ *     or fetch GET /v1/households/mine/members and count length
+ *   - profile name from locals.benutzer.name
+ *
+ * LAYOUT: E1 HUB
+ *   grid: 2 columns (2fr 1fr) top row + 2 columns (1fr 1fr) bottom row; gap 16px
+ *   mobile: single column, full-width cards, gap 12px
+ *
+ * CARD: all cards are  tags (href to target route)
+ *   border-radius: --radius-xl
+ *   border: 1px solid --color-border
+ *   bg: white
+ *   padding: 24px desktop / 16px mobile
+ *   hover: box-shadow --shadow-raised, border-color #C0BFB8
+ *   transition: box-shadow 150ms ease, border-color 150ms ease
+ *   cursor: pointer
+ *   focus-visible: outline 2px solid --green-dark, offset 2px
+ *
+ * VORRÄTE CARD (primary)
+ *   border-left: 3px solid --green-dark
+ *   stat number: font-family --font-display, font-size 36px, color --green-dark
+ *   stat label: "von {total} Zutaten als Vorrat markiert", 11px, --color-text-muted
+ *   empty state (stapleCount === 0): hide stat, show "Noch keine Vorräte eingerichtet"
+ *   cta: "Vorräte bearbeiten →" (empty: "Jetzt einrichten →")
+ *   href: /household/staples?ctx=settings
+ *
+ * MITGLIEDER CARD
+ *   meta: "{memberCount} Mitglieder"
+ *   href: /members
+ *
+ * PROFIL CARD
+ *   meta: locals.benutzer.name
+ *   href: /profile (not yet implemented — render as disabled or placeholder)
+ *
+ * ROUTE: /household/staples?ctx=settings (D3)
+ *   component: StaplesManager with context="settings" (already exists)
+ *   breadcrumb: "← Einstellungen" linking back to /settings
+ *   sidebar: "Einstellungen" stays active (no separate nav item for staples)
+ *   no save button — StaplesManager auto-saves via debounced PATCH 300ms
+ *   hint text below grid: "Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste."
+ *   grid: 3-col on md+ (context="settings" already sets this in StaplesManager)
+ *
+ * CHIP STYLES (for reference — rendered by StapleChip, do NOT reimplement)
+ *   selected:   bg --green-dark, color white, border-color --green-dark
+ *   unselected: bg transparent, color --color-text-muted, border 1px solid --color-border
+ *   hover unselected: border-color --green-light, color --green-dark
+ *
+ * CATEGORY LABEL TYPOGRAPHY
+ *   font-size: 10px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase
+ *   color: --color-text-muted; margin-bottom: 10px
+ */
+    
+ + + + + + + + + + + + + + + + + + + + + + + +
PropertyValueNotes
E1 Hub Layout
grid-desktop2fr 1fr / 1fr 1frtop row / bottom row
grid-mobile1frfull-width stack
gap16px desktop / 12px mobile
Vorräte Card
stat-font--font-display, 36px, --green-darkFraunces
accent-borderborder-left: 3px solid --green-darkprimary indicator
stat-sourcecount isStaple=true from /v1/ingredient-categoriesload in page.server.ts
empty-statehide stat; show muted textwhen stapleCount === 0
href/household/staples?ctx=settingsD3 route
D3 Staples Page
componentStaplesManager context="settings"existing, do not modify
breadcrumb← Einstellungen → /settingsabove page title
active-navEinstellungen in sidebarnot a separate nav entry
save-hint"Änderungen werden automatisch gespeichert."below chip grid
debounce300ms (in StaplesManager)do not add extra debounce
+
+ +
+ + diff --git a/specs/frontend/e2-members-kachel.html b/specs/frontend/e2-members-kachel.html new file mode 100644 index 0000000..3d78901 --- /dev/null +++ b/specs/frontend/e2-members-kachel.html @@ -0,0 +1,905 @@ + + + + + + E2 — Mitglieder · Kachel-Ansicht · Finale Spezifikation + + + + + +
+ + +
+
+

E2 — Mitglieder

+

Kachel-Ansicht · Finale Spezifikation · Route: /members

+
+
+ screen: E2
+ journey: J7
+ variation: Kachel (V2)
+ version: 1.0
+ date: 2026-04-09 +
+
+ +

+ Die Mitgliederseite zeigt alle Haushaltsmitglieder als Kacheln. Der Planer kann Rollen ändern und Mitglieder + entfernen über ein Kebab-Menü auf jeder Kachel. Eine Einladekachel ermöglicht das Generieren und Kopieren des + Einlade-Links. Mitglieder sehen alle Kacheln nur lesend. +

+ +
+

Backend-Lücken — vor Implementierung schließen

+
    +
  • DELETE /v1/households/mine/members/{userId} — Mitglied entfernen
  • +
  • PATCH /v1/households/mine/members/{userId} — Rolle ändern (body: { role })
  • +
  • GET /v1/households/mine/invites — aktive Einladungen auflisten (inkl. expiresAt)
  • +
+
+ + + + +
+
+
S1
+
+
Standardansicht — Planer sieht vollständige Kacheln
+
Alle Mitglieder als Kacheln, dahinter die Einladekachel. Kebab-Button erscheint on hover.
+
+
+ +
+
+
Desktop
+
+
+
+ +
+
Mitglieder
+
3 Mitglieder · Familie Raddatz
+
+ +
+
MR
+
Marcel R.
+ Planer +
seit 02.04.2026
+
Du
+
+ +
+ +
SR
+
Sandra R.
+ Mitglied +
seit 03.04.2026
+
+ +
+ +
LR
+
Lena R.
+ Mitglied +
seit 05.04.2026
+
+ +
+
+
+
Mitglied einladen
+
+
+
+
+
+
+
+
+
Mobile
+
+
+
+ +
+
+
+
MR
+
Marcel R.
+ Planer +
Du
+
+
+ +
SR
+
Sandra R.
+ Mitglied +
+
+ +
LR
+
Lena R.
+ Mitglied +
+
+
+
+
Einladen
+
+
+
+
+
📅
Planer
+
🍽
Rezepte
+
🛒
Einkauf
+
⚙️
Einstellungen
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Eigene Kachel (Du): grüner Kartenrahmen (border: var(--green-light)), "Du"-Badge statt Kebab
  • +
  • Kebab-Button (): immer im DOM, opacity:0 bis hover/focus, dann opacity:1. Auf Touch-Geräten immer sichtbar.
  • +
  • Avatar-Initialen: erste zwei Buchstaben des displayName. Planer = green-dark, Mitglied = blue
  • +
  • Kachel-Reihenfolge: eigene Kachel immer zuerst, dann joinedAt aufsteigend, Einladekachel immer zuletzt
  • +
  • Mobile: "+" Button in der Header-Zeile öffnet Einlade-Panel. Einladekachel bleibt zusätzlich im Grid.
  • +
+
+
+ + + + +
+
+
S2
+
+
Kebab-Menü geöffnet
+
Klick auf ⋯ öffnet Dropdown mit zwei Aktionen. Klick außerhalb schließt das Menü.
+
+
+ +
+
+
Desktop — Menü offen auf "Sandra R."
+
+
+
+ +
+
Mitglieder
+
3 Mitglieder · Familie Raddatz
+
+
+
MR
+
Marcel R.
+ Planer +
seit 02.04.2026
+
Du
+
+ +
+ + +
SR
+
Sandra R.
+ Mitglied +
seit 03.04.2026
+
+
+ +
LR
+
Lena R.
+ Mitglied +
seit 05.04.2026
+
+
+
+
+
Mitglied einladen
+
+
+
+
+
+
+
+ +
+ +
+
Notizen
+
    +
  • Dropdown: position: absolute; top: 44px; right: 12px relativ zur Kachel
  • +
  • Zwei Einträge: "Rolle ändern" (neutrales Icon 🔄) und "Entfernen" (rot, Icon ✕)
  • +
  • Klick außerhalb des Dropdowns schließt diesen (click-away listener)
  • +
  • Nur ein Menü gleichzeitig offen. ESC schließt ebenfalls.
  • +
  • Mobile: Tap auf ⋯ öffnet Bottom Sheet mit denselben zwei Einträgen (44px min-height pro Eintrag)
  • +
+
+
+ + + + +
+
+
S3
+
+
Rolle ändern — Segmented Control erscheint
+
Wahl von "Rolle ändern" ersetzt das Rolle-Badge durch einen 2-Button-Schalter [Planer | Mitglied]. Aktive Rolle vorausgewählt. Bestätigung sofort mit PATCH-Request.
+
+
+ +
+
+
Desktop — Rolle-Control auf "Sandra R." aktiv
+
+
+
+ +
+
Mitglieder
+
3 Mitglieder · Familie Raddatz
+
+
+
MR
+
Marcel R.
+ Planer +
seit 02.04.2026
+
Du
+
+ +
+
SR
+
Sandra R.
+ +
+ + +
+
seit 03.04.2026
+ +
+
+ +
LR
+
Lena R.
+ Mitglied +
seit 05.04.2026
+
+
+
+
+
Mitglied einladen
+
+
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Role-Control ersetzt das Badge in-place auf der Kachel. Kein Dialog, kein Page-Change.
  • +
  • Klick auf die inaktive Rolle → optimistisches Update → PATCH /v1/households/mine/members/{userId} { role }
  • +
  • Bei Erfolg: Role-Control durch neues Badge ersetzen
  • +
  • Bei Fehler: Rollback + Toast "Rolle konnte nicht geändert werden."
  • +
  • "Abbrechen" bringt ohne PATCH-Call das Badge zurück
  • +
  • Der Planer kann seinen eigenen Planer-Status nicht abgeben, solange er der einzige Planer ist
  • +
  • Kachel bekommt blauen Rahmen (border-color: #B5D4F4) als Editier-Indikator
  • +
+
+
+ + + + +
+
+
S4
+
+
Bestätigungsdialog "Mitglied entfernen"
+
Klick auf "Entfernen" im Dropdown öffnet einen modalen Dialog. Kein direktes Löschen ohne Bestätigung.
+
+
+ +
+
+
Desktop — Dialog über der Seite
+
+
+
+ +
+
Mitglieder
+
+
MR
Marcel R.
Planer
+
SR
Sandra R.
Mitglied
+
LR
Lena R.
Mitglied
+
+
Mitglied einladen
+
+
+ +
+
+
Mitglied entfernen?
+
Sandra R. wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
+
+ + +
+
+
+
+
+
+
+
+
Mobile
+
+
+
+ +
+
+
MR
Marcel R.
Planer
+
SR
Sandra R.
Mitglied
+
+
+ +
+
+
Mitglied entfernen?
+
Sandra R. wird aus dem Haushalt entfernt.
+
+ + +
+
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Dialog zeigt den displayName des Mitglieds explizit
  • +
  • Bestätigung → DELETE /v1/households/mine/members/{userId} → Kachel aus Grid entfernen
  • +
  • Planer kann sich nicht selbst entfernen (eigene Kachel hat kein Kebab-Menü)
  • +
  • Letzter verbleibender Planer kann nicht entfernt werden → Fehlermeldung im Dialog
  • +
  • Mobile: Dialog als Bottom Sheet (border-radius nur oben, kein max-width)
  • +
  • Hintergrund leicht gedimmt: rgba(28,28,24,.45), Klick außerhalb schließt nicht (explizite Bestätigung erforderlich)
  • +
+
+
+ + + + +
+
+
S5
+
+
Einlade-Panel — nach Klick auf die Einladekachel
+
Kachel expandiert zum Panel unterhalb der Grid-Reihe. Zeigt generierten Link + Ablaufdatum + Regenerieren-Option.
+
+
+ +
+
+
Desktop
+
+
+
+ +
+
Mitglieder
+
3 Mitglieder · Familie Raddatz
+
+
MR
Marcel R.
Planer
seit 02.04.2026
Du
+
SR
Sandra R.
Mitglied
seit 03.04.2026
+
LR
Lena R.
Mitglied
seit 05.04.2026
+
+
+
+
Mitglied einladen
+
+
+ +
+
Einladelink teilen
+
Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.
+ +
Läuft ab: 12.04.2026
+ +
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Klick auf Einladekachel → POST /v1/households/mine/invites (falls kein aktiver Code vorhanden) oder GET /v1/households/mine/invites
  • +
  • Invite-Panel erscheint unterhalb der Grid-Reihe (kein Modal, kein Page-Change)
  • +
  • "Kopieren" → navigator.clipboard.writeText(shareUrl) → Button zeigt kurz "Kopiert ✓"
  • +
  • "Neuen Link generieren" → POST /v1/households/mine/invites → alten Code invalidieren → neuen Code anzeigen
  • +
  • Ablaufdatum expiresAt in gelbem Badge wenn ≤ 24h verbleibend
  • +
  • Nur Planer sehen den Einlade-CTA. Mitglied sieht keine Einladekachel.
  • +
+
+
+ + + + +
+
+
S6
+
+
Ansicht als Haushaltsmitglied (rolle = mitglied)
+
Mitglieder sehen die Kacheln ohne Kebab-Menü und ohne Einladekachel.
+
+
+ +
+
+
Desktop — Mitglied-Perspektive
+
+
+
+ +
+
Mitglieder
+
3 Mitglieder · Familie Raddatz
+
+
+
SR
+
Sandra R.
+ Mitglied +
seit 03.04.2026
+
Du
+
+
+
MR
+
Marcel R.
+ Planer +
seit 02.04.2026
+
+
+
LR
+
Lena R.
+ Mitglied +
seit 05.04.2026
+
+ +
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Mitglied sieht keine Einladekachel und keine Kebab-Buttons auf anderen Kacheln
  • +
  • Eigene Kachel zeigt "Du"-Badge (grüner Rahmen), aber kein Kebab
  • +
  • Grid passt sich an: bei 3 Kacheln → grid-template-columns: repeat(3, 1fr) (kein leerer Slot für Einladen)
  • +
  • Server-seitige Prüfung: Aktionen (DELETE, PATCH) geben 403 für nicht-Planer zurück
  • +
+
+
+ + + + +
+

Maschinen-lesbare Spezifikation

+

Diese Sektion enthält verbindliche Implementierungsregeln für den Coding-Agenten.

+ +
+/* spec:rules — E2 Mitglieder Kachel
+ *
+ * LAYOUT
+ *   grid: repeat(4, 1fr) gap 16px desktop; repeat(2, 1fr) gap 12px mobile
+ *   card: bg white, border 1px solid --color-border, border-radius --radius-xl
+ *   card padding: 24px 20px 20px desktop; 16px mobile
+ *
+ * AVATAR
+ *   size: 56px desktop / 44px mobile; border-radius 50%
+ *   initials: first two chars of displayName, uppercase
+ *   planer color: --green-dark (#2E6E39)
+ *   mitglied color: --blue (#185FA5)
+ *
+ * ROLE BADGE
+ *   planer:  bg --green-tint, color --green-dark
+ *   mitglied: bg --blue-tint,  color --blue-dark
+ *   font-size 10px, font-weight 500, padding 2px 8px, border-radius --radius-full
+ *
+ * OWN CARD (benutzer.id === member.userId)
+ *   border-color: --green-light
+ *   show "Du" badge below join-date
+ *   hide kebab button entirely
+ *
+ * KEBAB BUTTON
+ *   position absolute, top 12px, right 12px
+ *   opacity 0 by default; 1 on card:hover, card:focus-within, touch devices always 1
+ *   opens dropdown: [Rolle ändern, divider, Entfernen(danger)]
+ *   click-away closes; ESC closes
+ *
+ * ROLE CHANGE (S3)
+ *   replaces badge in-place with segmented control [Planer | Mitglied]
+ *   active button: bg --green-dark, color white
+ *   inactive button: bg white, color --color-text-muted
+ *   on select: PATCH /v1/households/mine/members/{userId} body { role }
+ *   optimistic update; on error: rollback + toast
+ *   Abbrechen link below control: reverts to badge without API call
+ *   guard: planer cannot demote self if sole planer
+ *
+ * REMOVE CONFIRM (S4)
+ *   modal dialog, backdrop rgba(28,28,24,.45), backdrop does NOT close on click
+ *   shows member displayName in body text
+ *   confirm → DELETE /v1/households/mine/members/{userId}
+ *   on success: remove card from grid with fade-out
+ *   mobile: bottom-sheet (border-radius top only)
+ *
+ * INVITE (S5)
+ *   invite card always last in grid, only visible to planer
+ *   click → POST /v1/households/mine/invites OR GET /v1/households/mine/invites
+ *   panel below grid (not modal)
+ *   copy: navigator.clipboard.writeText(shareUrl) → button text "Kopiert ✓" for 2s
+ *   regenerate: POST new invite → invalidate old
+ *   expiresAt badge yellow if ≤ 24h remaining
+ *
+ * MEMBER VIEW (S6)
+ *   rolle === 'mitglied': hide all kebab buttons, hide invite card
+ *   grid auto-adjusts columns (no empty slot)
+ *
+ * CARD ORDER
+ *   1. own card (benutzer.id === userId)
+ *   2. other members sorted by joinedAt ASC
+ *   3. invite card (planer only)
+ *
+ * BACKEND GAPS (must exist before page ships)
+ *   DELETE /v1/households/mine/members/{userId}
+ *   PATCH  /v1/households/mine/members/{userId}  body: { role: "planer"|"mitglied" }
+ *   GET    /v1/households/mine/invites
+ */
+    
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyValueNotes
Component: MemberCard
card-width1fr (grid)4-col desktop, 2-col mobile
card-min-height180pxdesktop; auto mobile
avatar-size56px / 44pxdesktop / mobile
avatar-radius50%full circle
kebab-target44×44pxWCAG 2.2 minimum touch target
dropdown-min-width160pxright-aligned to kebab
Role Control
control-height32pxsegmented, full card width
active-bg--green-darkselected role button
api-endpointPATCH /v1/households/mine/members/{userId}body: { role }
Remove Dialog
confirm-btn-bg--color-error (#DC4C3E)danger action
api-endpointDELETE /v1/households/mine/members/{userId}
backdroprgba(28,28,24,.45)click-outside does NOT close
Invite
api-createPOST /v1/households/mine/invitesreturns InviteResponse
api-listGET /v1/households/mine/invitesbackend gap
copy-feedback"Kopiert ✓" for 2000msthen revert to "Kopieren"
+
+ +
+ + From fa4a4c9ef73f3c67f69a078a0f6d43ba2d88fa3f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 15:51:26 +0200 Subject: [PATCH 02/36] docs(specs): add J9 variety score config user journey and variety page rework spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds J9 (Configure variety score) to userjourneys.html — new journey for tuning the algorithm per household dietary context (e.g. disabling protein penalties for vegetarian households); introduces screen E4 (Variety settings) - Adds specs/frontend/variety-page-rework.html with 3 design variations for the /planner/variety page rework: recipe-name pills, action rows (recommended), and week-grid with side panel Co-Authored-By: Claude Sonnet 4.6 --- specs/frontend/variety-page-rework.html | 841 ++++++++++++ specs/userjourneys.html | 1606 +++++++++++++++++++++++ 2 files changed, 2447 insertions(+) create mode 100644 specs/frontend/variety-page-rework.html create mode 100644 specs/userjourneys.html diff --git a/specs/frontend/variety-page-rework.html b/specs/frontend/variety-page-rework.html new file mode 100644 index 0000000..d128314 --- /dev/null +++ b/specs/frontend/variety-page-rework.html @@ -0,0 +1,841 @@ + + + + + + Variety Page Rework · 3 Variationen + + + + + +
+ +
+
+

Variety Page — Rework

+

3 Design-Variationen · Route: /planner/variety

+
+
+ screen: C2
+ journey: J4
+ version: 1.0
+ date: 2026-04-09 +
+
+ +

+ Zwei Kernprobleme werden adressiert: (1) Warnungen zeigen aktuell Wochentag-Kürzel ("MON, WED") + statt Rezeptnamen — rein frontend-seitig lösbar über weekPlan.slots-Mapping. + (2) Es gibt keine Swap-Aktion direkt aus den Warnungen heraus. Das Protein-Score-Problem + für vegetarische Haushalte ist ein Backend-Thema und separat zu behandeln. +

+ +
+

Protein-Score: Vegetarische Haushalte — Backend TBD

+

+ Die aktuelle Formel proteinDiversity = 10 − repeats × 2 bestraft vegetarische + Proteinquellen (Tofu, Linsen, Ei) stärker als in omnivoren Haushalten üblich. + Frontend-seitig ändert sich das Label "Protein-Vielfalt" ggf. zu "Quellen-Vielfalt" sobald + das Backend die Score-Gewichtung anpasst. Bis dahin: keine Änderung an ScoreBreakdownList. +

+
+ + + + +
+
+
1
+
+
Rezept-Pills in Warnkarten
+
Minimale Änderung an der bestehenden Seitenstruktur. Warnkarten zeigen statt "MON, WED" konkrete Rezept-Pills mit Tauschen-Button. Seitenaufbau und Score-Hero bleiben identisch.
+ Vertraut · Geringer Aufwand +
+
+ +
+
+
Desktop
+
+
+
+ +
+
+ Planer + / + Abwechslungs-Analyse +
+
+ +
+
+
6.5/10
+
+
Verbesserbar
+
+
Bewertung im Detail
+
+
Quellen-Vielfalt6/10
+
Zutaten-Überlappung8/10
+
Aufwandsbalance9/10
+
+ +
Hinweise
+ +
+
Tofu mehrfach diese Woche
+
+ MoTofu-Curry + MiTofu-Bowl +
+
+ +
+
Linsen in mehreren Gerichten
+
+ DiLinsen-Suppe + FrLinsen-Dal +
+
+
+ +
+
Quellen-Verteilung
+
+
Mo
TOF
+
Di
LIN
+
Mi
TOF
+
Do
GEM
+
Fr
LIN
+
Sa
+
So
+
+
Aufwandsverteilung
+
+
+
+
+
+ Einfach ×3Mittel ×2 +
+
+
+
+
+
+
+
+ +
+
Mobile
+
+
+
+
Abwechslungs-Analyse
+
+
6.5/10
+
+
Verbesserbar
+
Hinweise
+
+
Tofu mehrfach diese Woche
+
+ MoTofu-Curry + MiTofu-Bowl +
+
+
+
Linsen in mehreren Gerichten
+
+ DiLinsen-Suppe + FrLinsen-Dal +
+
+
+
+
📅
Planer
+
🍽
Rezepte
+
🛒
Einkauf
+
⚙️
Einstellungen
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Kein Backend-Change nötig. Frontend mappt tagRepeat.days[]weekPlan.slots.find(s => s.dayOfWeek === day)recipe.name
  • +
  • Pill-Swap-Button (↔): navigiert zu /planner?week={weekStart}&swap={slotId} — öffnet RecipePicker für den betreffenden Slot
  • +
  • Pill-Label links: Wochentag-Kürzel (Mo, Di, …) aus dayOfWeek-Mapping
  • +
  • Wenn ein Slot leer ist (Rezept wurde bereits entfernt): Pill zeigt nur den Wochentag, kein Swap-Button
  • +
  • Geringe Änderung: nur VarietyWarningCards.svelte + variety.ts anpassen; Rest der Seite bleibt
  • +
+
+
+ + + + +
+
+
2
+
+
Aktions-Zeilen
+
Warnungen stehen oben, Score-Hero wird kompakt. Pro Warnung gibt es eine vollständige Rezept-Zeile mit Wochentag und dediziertem "Tauschen"-Button. Fokus auf sofortige Handlung statt auf Metrik-Verständnis.
+ Empfohlen · Aktionsfokus +
+
+ +
+
+
Desktop
+
+
+
+ +
+
+ Planer + / + Abwechslungs-Analyse +
+
+ +
+
6.5/10
+
+
Verbesserbar — 2 Hinweise
+
+
+
+ +
+
+
Empfehlenswerte Tausche
+ + +
+
🔄
+
+
Tofu mehrfach diese Woche
+
+ Tofu-Curry· Montag + +
+
+ Tofu-Bowl· Mittwoch + +
+
+
+ + +
+
🔄
+
+
Linsen in mehreren Gerichten
+
+ Linsen-Suppe· Dienstag + +
+
+ Linsen-Dal· Freitag + +
+
+
+ + +
+ Bewertung im Detail ▾ +
+
Quellen-Vielfalt6/10
+
Zutaten-Überlappung8/10
+
Aufwandsbalance9/10
+
+
+
+ +
+
Quellen-Verteilung
+
+
Mo
TOF
+
Di
LIN
+
Mi
TOF
+
Do
GEM
+
Fr
LIN
+
Sa
+
So
+
+
+
+
+
+
+
+
+
+ +
+
Mobile
+
+
+
+
Abwechslungs-Analyse
+
+ +
+
6.5/10
+
Verbesserbar
+
+
Empfehlenswerte Tausche
+ +
+
🔄 Tofu mehrfach diese Woche
+
Tofu-Curry Mo
+
Tofu-Bowl Mi
+
+
+
🔄 Linsen in mehreren Gerichten
+
Linsen-Suppe Di
+
Linsen-Dal Fr
+
+
+
+
📅
Planer
+
🍽
Rezepte
+
🛒
Einkauf
+
⚙️
Einstellungen
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Score-Hero wird kompakt: Zahl + Label + Balken in einer horizontal komprimierten Leiste oben
  • +
  • Sub-Scores in aufklappbarem <details>-Element — zugänglich, kein JavaScript nötig
  • +
  • Jeder "Tauschen"-Button navigiert zum Planer mit dem spezifischen Slot vorselektiert
  • +
  • Wochentag als ausgeschriebenes Wort ("Montag") — nicht Kürzel — für bessere Lesbarkeit
  • +
  • Mobile: Score-Hero bleibt kompakt oben, Action-Rows nehmen den Hauptraum ein
  • +
  • Größerer Aufwand als V1: VarietyWarningCards grundlegend neu strukturieren
  • +
+
+
+ + + + +
+
+
3
+
+
Wochenraster mit Kontext-Panel
+
Das bestehende Protein-Raster wird zum Haupt-Interface. Alle 7 Tage zeigen das vollständige Rezept. Problematische Slots sind gelb markiert — Klick öffnet das rechte Panel mit Erklärung und Swap-CTA.
+ Ambitiös · Meiste Übersicht +
+
+ +
+
+
Desktop
+
+
+
+ +
+
+ Planer + / + Abwechslungs-Analyse + +
+ Abwechslung + 6.5 + /10 +
+
+
+ +
+
Wochenübersicht — gelb markierte Gerichte haben Hinweise
+
+ +
+
Mo
+
Tofu-Curry
+
+ +
+
Di
+
Linsen-Suppe
+
+ +
+
Mi
+
Tofu-Bowl
+
+ +
+
Do
+
Gemüse-Stir-Fry
+
+ +
+
Fr
+
Linsen-Dal
+
+ +
+
Sa
+
+
+ +
+
So
+
+
+
+ +
Aufwandsverteilung
+
+
+
+
+
+ Einfach ×3Mittel ×2 +
+ +
+
Quellen-Vielfalt6/10
+
Zutaten-Überlappung8/10
+
Aufwandsbalance9/10
+
+
+ + +
+
+ 6.5 + /10 +
+
Tofu-Curry — Montag
+
Tofu taucht diese Woche auch am Mittwoch auf (Tofu-Bowl). Ein Tausch würde die Quellen-Vielfalt verbessern.
+
Andere betroffene Gerichte
+
+
Tofu-Bowl
Mittwoch
+
+ +
Öffnet den Rezept-Picker für Montag.
+
+
+
+
+
+
+
+ +
+
Mobile (Tab-Navigation)
+
+
+
+
Abwechslungs-Analyse6.5/10
+
+ +
+ + +
+ +
+
Tofu mehrfach diese Woche
+
Tofu-Curry Mo
+
Tofu-Bowl Mi
+
+
+
Linsen in mehreren Gerichten
+
Linsen-Suppe Di
+
Linsen-Dal Fr
+
+
+
+
📅
Planer
+
🍽
Rezepte
+
🛒
Einkauf
+
⚙️
Einstellungen
+
+
+
+
+
+
+ +
+
Notizen
+
    +
  • Wochenraster ersetzt das bisherige Protein-Grid (7 Spalten, Rezeptname statt Kürzel, größere Zellen)
  • +
  • Gelber Slot = mindestens ein Hinweis vorhanden. Klick selektiert den Slot, Panel rechts aktualisiert sich.
  • +
  • Panel zeigt: betroffenes Rezept + Wochentag + Erklärung + andere betroffene Slots + primären "Tauschen"-Button
  • +
  • Score-Zahl wandert in die Topbar-Leiste (kompakt, immer sichtbar)
  • +
  • Mobile: kein Panel — stattdessen Tab-Switcher "Übersicht | Hinweise (N)" mit aufklappbaren Einträgen
  • +
  • Größter Umbau: +page.svelte Struktur und alle beteiligten Komponenten müssen neu aufgebaut werden
  • +
+
+
+ + +
+

Maschinen-lesbare Spezifikation

+

Gilt für alle drei Variationen. Implementierungs-Details werden nach Variantenwahl konkretisiert.

+ +
+/* spec:rules — Variety Page Rework (alle Variationen)
+ *
+ * RECIPE NAME MAPPING (frontend, no backend change)
+ *   Source: weekPlan.slots[] → { dayOfWeek: "MON"|"TUE"|..., recipe: { id, name } }
+ *   tagRepeats[].days[] contains dayOfWeek keys (e.g. "MON")
+ *   slotsByDay = Object.fromEntries(weekPlan.slots.map(s => [s.dayOfWeek, s]))
+ *   recipeName = slotsByDay[day]?.recipe?.name ?? day
+ *   slotId = slotsByDay[day]?.id
+ *
+ * SWAP NAVIGATION
+ *   "Tauschen" button href: /planner?week={weekStart}&swap={slotId}
+ *   weekStart available in page data
+ *   slotId from weekPlan.slots mapping above
+ *   Opens RecipePicker for that slot (existing functionality in planner page)
+ *
+ * DAY LABEL MAPPING (for display)
+ *   MON → "Montag"  TUE → "Dienstag"  WED → "Mittwoch"  THU → "Donnerstag"
+ *   FRI → "Freitag" SAT → "Samstag"   SUN → "Sonntag"
+ *   Short: Mo, Di, Mi, Do, Fr, Sa, So
+ *
+ * EMPTY SLOT HANDLING
+ *   If slotsByDay[day] is undefined: show day key only, no swap button
+ *   This can happen if slot was deleted since varietyScore was computed
+ *
+ * PROTEIN SCORE — VEGETARIAN NOTE
+ *   Label "Protein-Vielfalt" in ScoreBreakdownList may change to "Quellen-Vielfalt"
+ *   pending backend decision on scoring weight adjustment.
+ *   No frontend change required until backend ships the updated score.
+ *
+ * VARIATION-SPECIFIC
+ *   V1: Modify VarietyWarningCards + Warning type (add slots: { day, recipeName, slotId }[])
+ *       computeWarnings() now returns slots[] instead of string days[]
+ *   V2: Restructure VarietyWarningCards to ActionRows; VarietyScoreHero → compact variant
+ *       
for sub-scores (no JS needed) + * V3: Replace protein grid with full week grid (recipe names); add side panel component + * Mobile: tab switcher (Übersicht | Hinweise) using $state activeTab + */ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyValueNotes
Shared: Recipe Mapping
data-sourceweekPlan.slots[].dayOfWeek + recipealready in page data
swap-url/planner?week={weekStart}&swap={slotId}RecipePicker pre-selects slot
day-longMON→Montag, TUE→Dienstag…for V2 display
day-shortMON→Mo, TUE→Di…for V1 pills + V3 grid
V1 Recipe Pills
pill-padding5px 10px 5px 12pxleft more for text
swap-btn-size22×22px, border-radius 50%within pill
pill-bgwhite, border --yellow-lighton yellow-tint card
V2 Action Rows
score-compact-height~64pxreplaces 180px hero
details-summarynative <details>, no JSsub-scores hidden by default
recipe-row-bg--color-subtlewithin white action card
V3 Week Grid
slot-height52px minenough for 2-line recipe name
warn-slot-ring2px solid --yellow + yellow-tint bgproblem indicator
selected-slot-ring2px solid --green-darkactive selection
panel-width280pxfixed, right side
mobile-tab-active-bg--green-darkselected tab button
+
+ +
+ + diff --git a/specs/userjourneys.html b/specs/userjourneys.html new file mode 100644 index 0000000..be8458d --- /dev/null +++ b/specs/userjourneys.html @@ -0,0 +1,1606 @@ + + + + + + Recipe App — User Journeys v1.0 + + + + + + + +
+ + +
+
+

User journeys

+

Recipe app · Eight core journeys · Planner & household member roles

+
+
+ locked
+ Version: 1.2
+ Last updated: 2026-04
+ Journeys: 9
+ Roles: planner · household member +
+
+ + +
+
All journeys at a glance
+

The app serves two user roles. The planner (you) has full access — adding recipes, building the weekly plan, generating the shopping list, and managing the household. The household member (your partner, other household members) has read-only access to the plan and collaborative access to the shopping list. Journeys J7 and J8 cover post-onboarding household management: adding or removing members and keeping the pantry staples configuration current. J9 covers tuning the variety score algorithm to match the household's dietary context (e.g. disabling meat-centric protein penalties for a vegetarian household).

+ +
+
+
+
J1
+
Add a recipe
+
+
Save a recipe from memory, a cookbook, or improvisation into the app library with ingredients and tags.
+
Planner only
+
+
+
+
J2
+
Plan the week
+
+
Assign meals to days, get suggestions that avoid ingredient repetition, review the variety score, and confirm the plan.
+
Planner only
+
+
+
+
J3
+
Cook tonight
+
+
Check what's for dinner, open the recipe, cook step-by-step, and mark the meal as cooked to update variety tracking.
+
Planner only
+
+
+
+
J4
+
Adapt on the fly
+
+
When plans change mid-week, quickly swap a meal for a low-effort alternative and keep the week on track.
+
Planner only
+
+
+
+
J5
+
Generate shopping list
+
+
Turn the confirmed week plan into a live shared shopping list — filtered for pantry staples. Anyone can add or remove items anytime.
+
Planner generates · Household shops
+
+
+
+
J6
+
Household setup
+
+
One-time setup: create the planner account, configure pantry staples, invite household members, and confirm access levels.
+
Planner creates · Members join
+
+
+
+
J7
+
Manage members
+
+
Invite new household members post-setup or revoke access. Keep the household roster current as people join or leave.
+
Planner manages · Members join
+
+
+
+
J8
+
Edit staples
+
+
Update which pantry ingredients are always on hand. Changes take effect on the next generated shopping list.
+
Planner only
+
+
+
+
J9
+
Configure variety score
+
+
Tune the variety algorithm to the household's dietary context — disable protein-type penalties for vegetarian households or adjust how heavily ingredient overlaps are weighted.
+
Planner only
+
+
+
+ + + +
+
+
J1
+
+
Journey 1
+
Add a recipe
+
The planner saves a recipe into the app — from memory, a physical cookbook, or improvisation. Tags are applied so the planner and suggestion engine can use the recipe intelligently in future planning.
+
+
+
Actor
+
Planner
+
+
+
Frequency
+
Occasional — as new recipes are discovered
+
+
+
Entry point
+
Recipe library (B1) or + button in nav
+
+
+
+
+
+
+
+
+
B1
+
Open library
+
Recipe list view
+
+
+
+
B3
+
Add recipe
+
Name + ingredients
+
+
+
+
B3
+
Add steps
+
Cooking instructions
+
+
+
+
B3
+
Tag it
+
Effort, protein, child-friendly
+
+
+
+
+
Saved
+
In library
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenNotes
Open libraryPlanner taps Recipes in nav or the + button anywhere in the app.B1Entry from nav or from the planner (C1) when a day has no meal assigned.
Add recipe formPlanner enters recipe name, serves count, and adds ingredients one by one with name and quantity.B3B3 is a single form used for both add and edit states. Form is prefilled when editing.
Add cooking stepsPlanner adds numbered steps in free text. Steps are used in cook mode (B4).B3Steps are optional at save time — a recipe without steps can still be planned and cooked from ingredient list only.
Tag the recipePlanner selects tags: effort level (easy / medium / hard), child-friendly (yes/no), primary protein or category (chicken, fish, vegetarian, pasta, etc.).B3Tags are used by the suggestion engine in J2 to avoid repetition. Minimum: effort + one category tag required to save.
SaveRecipe is saved and appears in the library (B1). Planner is returned to B1 or to wherever they came from.B1If entered from a day slot in the planner, the recipe is optionally offered for that day immediately after saving.
+
+
+
Design notes
+
    +
  • B3 and B4 (add and edit recipe) share one form component — the only difference is the initial state (empty vs prefilled).
  • +
  • Tags drive intelligent suggestions. The more recipes are tagged, the better the variety algorithm performs.
  • +
  • Recipes from memory or physical cookbooks are the primary source — the app makes no assumptions about digital import.
  • +
+
+
+
+ + + +
+
+
J2
+
+
Journey 2
+
Plan the week
+
The planner builds the dinner plan for the coming week. The app suggests recipes that avoid repeating ingredients from recent meals, balances effort across days, and scores variety so the planner can confirm or adjust before the week begins.
+
+
+
Actor
+
Planner
+
+
+
Frequency
+
Weekly — typically at the weekend
+
+
+
Entry point
+
Planner (C1) — default home screen
+
+
+
+
+
+
+
+
+
C1
+
Open planner
+
7-day grid view
+
+
+
+
C2
+
Get suggestions
+
Filtered by variety
+
+
+
+
C1
+
Fill day slots
+
Pick or drag meals
+
+
+
+
C3
+
Review variety
+
Ingredient overlap score
+
+
+
+
?
+
Swap needed?
+
Optional adjustment
+
+
+
+
+
Week confirmed
+
Plan locked
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenNotes
Open plannerPlanner opens the app. The weekly planner is the default home screen showing the current week with any already-planned meals.C1Today's slot is always highlighted in yellow. Empty slots show a dashed + prompt.
Get suggestionsPlanner taps an empty day slot or the suggestions button. The app shows recipes filtered to avoid ingredients used in the past 3 days and the same protein as adjacent days.C2Suggestions also balance effort — if the previous two days were hard meals, easy meals are surfaced first.
Fill day slotsPlanner picks a suggestion or manually selects a recipe from the library for each day.C1Saturday is optional — some weeks have no planned meal on Saturday. Empty slots are valid.
Review variety scoreThe variety score (0–10) updates live as meals are added. It reflects ingredient overlap across the week. Warnings surface for specific repeated ingredients.C3 C1Score is visible at all times on C1 (variety banner on mobile/tablet, sidebar widget on desktop). C3 shows the full breakdown.
Swap a meal (optional)If the variety score is low or a specific warning appears, the planner swaps one meal for an alternative suggestion.C2Swap uses the same suggestion engine as the initial fill. Swapping updates the score immediately.
Confirm the weekPlanner confirms the plan. The week is locked and visible (read-only) to household members. This also triggers the option to generate a shopping list (J5).C1Confirming does not prevent future edits — the planner can still swap meals mid-week via J4.
+
+
+
Design notes
+
    +
  • The variety score is the central metric of the app — it must be visible throughout this entire journey without extra navigation.
  • +
  • The suggestion filter considers: ingredients used in the last 3 days, same-protein consecutive days, and effort balance. Recipe tags (from J1) power all three filters.
  • +
  • Household members can see the confirmed plan as read-only immediately after confirmation.
  • +
+
+
+
+ + + +
+
+
J3
+
+
Journey 3
+
Cook tonight
+
The planner checks what's for dinner, opens the recipe, and cooks using the step-by-step cook mode. Marking the meal as cooked logs it to the variety history, which feeds back into next week's suggestions.
+
+
+
Actor
+
Planner
+
+
+
Frequency
+
Daily — used at the stove
+
+
+
Entry point
+
Planner (C1) — today highlight
+
+
+
Context
+
Mobile, hands busy, kitchen environment
+
+
+
+
+
+
+
+
+
C1
+
Check today
+
Tonight's meal highlighted
+
+
+
+
B2
+
Open recipe
+
Ingredients + steps
+
+
+
+
B4
+
Cook mode
+
Step-by-step, big text
+
+
+
+
+
Mark cooked
+
Logged to history
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenNotes
Check todayPlanner opens the app. Today's slot is highlighted in yellow on the planner. The meal name is visible without any tap.C1The today highlight is the most important element on C1. It must be visible on first render with zero interaction.
Open recipePlanner taps the today slot. The recipe detail screen shows the full ingredient list and step count.B2B2 shows ingredients scaled to the saved serving count. No serving adjustment is required in this journey.
Enter cook modePlanner taps "Cook now". The screen switches to full-screen cook mode with one step visible at a time in large, readable text. Screen stays awake.B4B4 is designed for kitchen use: 16px body text, 1.75 line height, single step per screen, tap-anywhere to advance. Screen wake lock is requested.
Mark as cookedOn the final step, the planner taps "Done". The meal is logged to cooking history with today's date.B4C1The cooking log is the data source for the variety algorithm. Meals cooked more recently are weighted more heavily in the repetition filter.
+
+
+
Design notes
+
    +
  • Cook mode (B4) is a high-stakes screen — the user is standing at a stove with wet hands. It must be operable with one tap and no fine motor precision required.
  • +
  • Screen wake lock prevents the phone from sleeping during cooking. This should be requested on entering B4 and released on exit.
  • +
  • The "mark as cooked" action is the feedback loop that makes J2 smarter over time. It should feel lightweight, not like admin work.
  • +
+
+
+
+ + + +
+
+
J4
+
+
Journey 4
+
Adapt on the fly
+
Plans change. A busy evening, a missing ingredient, or a child who won't eat what was planned triggers a mid-week swap. The app suggests low-effort alternatives and updates the plan instantly.
+
+
+
Actor
+
Planner
+
+
+
Frequency
+
1–2× per week typically
+
+
+
Entry point
+
Planner (C1) — any day slot
+
+
+
Urgency
+
High — decision needed quickly
+
+
+
+
+
+
+
+
+
C1
+
Plan changes
+
Tap day slot
+
+
+
+
C2
+
Quick swap
+
Low-effort alternatives
+
+
+
+
C1
+
Pick alternative
+
Confirm new meal
+
+
+
+
+
Plan updated
+
History logged
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenNotes
Trigger swapPlanner taps a meal slot and selects "Swap meal". The original meal is not yet removed — the swap is a preview.C1The "Swap" button is visible inline in the upcoming list on mobile and in the detail panel on desktop — one tap away at all times.
View quick alternativesThe suggestion screen shows 3–5 low-effort alternatives that avoid the ingredients already used that week. Sorted: easiest first.C2When the swap is triggered mid-week (today or a past day), effort filter defaults to Easy. The variety filter still applies.
Confirm new mealPlanner picks an alternative. The day slot updates immediately. Variety score recalculates.C1If no suitable suggestion exists, the planner can manually select any recipe from the library.
History loggedThe swap is logged — both the original meal (not cooked) and the replacement. This is used to ensure the original recipe isn't over-suppressed in future suggestions.The original uncooked meal remains in the library and can be planned for a future week.
+
+
+
Design notes
+
    +
  • Speed is critical in this journey — the user is making a decision under time pressure. The swap flow should never exceed 3 taps from the moment "Swap" is tapped to the plan being updated.
  • +
  • Alternatives are sorted by effort (easiest first) because mid-week swaps typically happen because the original plan was too ambitious for that day.
  • +
  • The variety score updates immediately after confirmation, giving the planner instant feedback on whether the swap improved or worsened the week's balance.
  • +
+
+
+
+ + + +
+
+
J5
+
+
Journey 5
+
Generate shopping list
+
Once the week is confirmed, a shopping list is generated from all meal ingredients. Pantry staples are automatically filtered out. The list is immediately live and shared with all household members. Anyone can add or remove items at any time.
+
+
+
Actors
+
Planner generates · Household shops
+
+
+
Frequency
+
Weekly — after plan confirmation
+
+
+
Entry point
+
Shopping tab (D1) or prompt after J2
+
+
+
+
+
+
+
+
+
C1
+
Week confirmed
+
Plan locked in
+
+
+
+
D1
+
Collect ingredients
+
All 7 meals merged
+
+
+
+
D1
+
Smart filter
+
Remove staples
+
+
+
+
+
List is live
+
Shared with household immediately
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenRole
Collect ingredientsAll ingredients from all planned meals are gathered. Shared ingredients across meals are merged and quantities summed (e.g. 3 + 2 carrots = 5 carrots).D1Planner
Smart filterPantry staples (olive oil, salt, pasta, rice, etc.) defined by the planner in settings are automatically removed from the list.D3Planner
List goes liveThe generated list is immediately live and visible to all household members. No approval step needed.D1Both roles
Shop collaborativelyAny household member can check off items while shopping. Checked items stay checked for all members — no double buying.D1Household member
Add itemsHousehold members can add items not on the generated list (e.g. household supplies, snacks). These appear at the bottom of the list.D1Both roles
+
+
+
Design notes
+
    +
  • Only the planner can generate the list. All household members can view, check off, add, and remove items. The list is always live — no approval or publishing step.
  • +
  • Checked items stay checked for everyone — this prevents double-buying when multiple family members are shopping simultaneously or at different times.
  • +
  • The smart filter (D3) is configured once during onboarding (J6, step A3) and can be updated at any time from settings. The planner's staples list grows over time.
  • +
  • CalDAV export is planned as a future enhancement (E3 Integrations) — not part of v1. The in-app shared list is the primary collaboration mechanism.
  • +
+
+
+
+ + + +
+
+
J6
+
+
Journey 6
+
Household setup
+
A one-time journey completed when the app is first used. The planner creates an account, names the household, defines pantry staples, and invites household members. Members accept the invite and gain access to the meal plan and shopping list.
+
+
+
Actors
+
Planner creates · Members join
+
+
+
Frequency
+
Once — on first use
+
+
+
Entry point
+
A1 Welcome screen (new user)
+
+
+
+
+
+
+
+
+
A1
+
Welcome
+
Sign up
+
+
+
+
A2
+
Household setup
+
Name the household
+
+
+
+
A3
+
Staples setup
+
Define pantry defaults
+
+
+
+
A2
+
Invite members
+
Send link or code
+
+
+
+
A4
+
Member joins
+
Accepts invite
+
+
+
+
+
Access granted
+
Household ready
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenRole
Create accountPlanner signs up with email and password. The account is created with the Planner role automatically assigned.A1Planner
Name the householdPlanner gives the household a name (e.g. "Smith family"). This name appears in the sidebar on desktop and in the app header.A2Planner
Define pantry staplesPlanner selects which ingredients their household always has on hand. These are excluded from generated shopping lists. A default list of common staples is pre-selected and can be adjusted.A3Planner
Invite household membersPlanner sends an invite link or code to household members (e.g. spouse). One invite per member. The invite grants Household Member role on acceptance.A2Planner
Member accepts inviteThe invited person opens the link, creates an account, and is automatically joined to the household with the Member role.A4Household member
Access grantedThe member can now see the meal plan (read-only) and the shopping list (view, check off, add items). The planner retains full access.C1 D1Both roles
+
+
+
Design notes
+
    +
  • A3 (staples setup) and D3 (staples manager in settings) are the same component — set up once in onboarding and editable at any time from settings.
  • +
  • The invite mechanism uses a link or short code — no email-based invite system is required in v1. The planner shares the link via any messaging app.
  • +
  • Role summary: Planner has full access to all 18 screens. Household member has read-only access to C1 (weekly planner) and collaborative access to D1 (shopping list). No other screens are accessible to household members in v1.
  • +
  • Future: child accounts with view-only access to the meal plan are planned but not scoped for v1.
  • +
+
+
+
+ + + +
+
+
J7
+
+
Journey 7
+
Manage household members
+
After the initial setup, the planner may need to invite additional members or revoke access for someone who has left the household. The members page provides a live overview of who is in the household, their role, and invite status.
+
+
+
Actors
+
Planner (invites · removes) · New member (accepts)
+
+
+
Frequency
+
Rare — when household composition changes
+
+
+
Entry point
+
Members page (E2) via settings nav
+
+
+
+
+
+
+
+
+
E2
+
Open members
+
See household roster
+
+
+
+
E2
+
Invite member
+
Generate link or code
+
+
+
+
A4
+
Member accepts
+
Creates account
+
+
+
+
+
Access granted
+
Visible in roster
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenRole
Open members pagePlanner navigates to the Members page from the settings nav. The page lists all current household members with their name, role (Planner / Member), and join date. Pending invites are shown separately with their expiry status.E2Planner
Invite a new memberPlanner taps "Mitglied einladen". An invite link or short code is generated and displayed. The planner copies it and sends it via any messaging app (WhatsApp, SMS, email). No email delivery system is required — the link is the mechanism.E2Planner
Member accepts inviteThe invited person opens the link on their device, creates an account if they don't have one, and is automatically added to the household with the Household Member role. The planner sees the new member appear in the roster without refreshing.A4Household member
Access grantedThe new member can now see the weekly meal plan (read-only) and the shopping list (view, check off, add items). Their name appears in the member list on E2.C1 D1Both roles
Remove a memberPlanner taps a member's row and selects "Zugang entziehen". After an explicit confirmation prompt showing the member's name, the member is removed. They lose all access immediately. Their account is not deleted — they simply leave the household.E2Planner
+
+
+
Design notes
+
    +
  • J7 is the post-onboarding continuation of the J6 invite step — same A4 acceptance mechanism, different entry point (E2 not A2). The invite component is re-used.
  • +
  • Pending invites must show a clear expiry state. Expired invites should be re-generatable with one tap — the planner should not need to go through the full invite flow again.
  • +
  • Removing a member is a destructive, irreversible action. Require an explicit confirmation ("Zugang für [Name] wirklich entziehen?") to prevent accidental removal.
  • +
  • Household members can view E2 in read-only mode — they see who is in the household but cannot invite or remove anyone.
  • +
  • The planner cannot remove themselves — the Planner role must always have at least one person. Household role transfer is out of scope for v1.
  • +
+
+
+
+ + + +
+
+
J8
+
+
Journey 8
+
Edit pantry staples
+
The planner's pantry changes over time — a new dietary preference, a bulk buy, or a noticed pattern on the shopping list triggers a staple update. This journey lets the planner keep their "always on hand" list accurate so generated shopping lists stay useful.
+
+
+
Actor
+
Planner only
+
+
+
Frequency
+
Occasional — after household dietary or pantry changes
+
+
+
Entry point
+
Settings (E1) → Staples section (D3)
+
+
+
+
+
+
+
+
+
E1
+
Open settings
+
Settings hub
+
+
+
+
D3
+
Open staples
+
Browse categories
+
+
+
+
D3
+
Toggle items
+
Add or remove staples
+
+
+
+
+
Staples updated
+
Next list reflects changes
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenNotes
Open settingsPlanner navigates to the Settings page (E1) from the settings nav. The settings hub provides access to profile information and pantry staples configuration.E1E1 is the entry hub for settings-area screens. The Staples section is the most common destination from here and should be prominently placed.
Open staples managerPlanner taps the Staples section. The same StaplesManager component from onboarding (A3) renders in settings context — no sidebar, no "Weiter" navigation, just the ingredient category list.D3D3 = A3. One component, two render contexts (onboarding and settings). Context is passed as a prop — no duplicate component needed.
Browse and togglePlanner browses ingredient categories. Checked items are staples and will be excluded from shopping lists. Unchecked items will appear on the next generated list. Changes are saved automatically on each toggle — no save button required.D3Auto-save on toggle is required. The planner must never lose a change because they forgot to tap a save button mid-browsing.
Changes appliedThe updated staple configuration is persisted immediately. The next time a shopping list is generated (J5), the new staple set is used to filter out always-on-hand ingredients.Changes do not retroactively update an already-generated shopping list. If the current list should reflect the change, the planner must regenerate it via J5.
+
+
+
Design notes
+
    +
  • D3 and A3 are the same component with two render contexts. The onboarding context includes the progress sidebar and navigation footer. The settings context renders the staples list standalone — no onboarding chrome.
  • +
  • Trigger for this journey: the planner notices an item on their shopping list they always have at home, or a missing item they now always keep stocked. This is a fast corrective action — optimise for speed, not discoverability.
  • +
  • No confirmation required for staple toggles — they are low-stakes and instantly reversible. Auto-save on change is the correct pattern.
  • +
  • Consider a direct "Vorräte bearbeiten" shortcut link on the shopping list page (D1) — the most common trigger for this journey is noticing a wrong staple while looking at the list.
  • +
+
+
+
+ + + + +
+
+
J9
+
+
Journey 9
+
Configure variety score
+
The variety algorithm ships with defaults designed for omnivore households — protein-type tags penalise consecutive repeats and ingredient overlaps reduce the score. A vegetarian or vegan household will find these defaults unfair: tofu, eggs, and legumes repeat because there are simply fewer protein sources available. This journey lets the planner tune the algorithm to their household's dietary reality without any score gaming.
+
+
+
Actor
+
Planner only
+
+
+
Frequency
+
Rare — once when dietary context changes
+
+
+
Entry point
+
Settings (E1) → Variety settings card (E4)
+
+
+
Trigger
+
Score feels persistently unfair or misleading
+
+
+
+
+
+
+
+
+
E1
+
Open settings
+
Settings hub
+
+
+
+
E4
+
Open variety settings
+
Current config visible
+
+
+
+
E4
+
Toggle tag types
+
Protein on / off
+
+
+
+
?
+
Adjust weights?
+
Optional fine-tuning
+
+
+
+
C3
+
Review impact
+
Updated score
+
+
+
+
+
Config saved
+
Persisted per household
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepWhat happensScreenNotes
Open settingsPlanner navigates to Settings (E1). The settings hub now shows a third card: "Vielfalt-Einstellungen". Current score is shown as a stat number on the card so the planner knows what they are about to tune.E1Planner role only. Household members cannot see the Vielfalt-Einstellungen card. The card is added to the E1 settings hub grid alongside Vorräte and Haushalt cards.
Open variety settingsPlanner taps the card. Screen E4 shows the full algorithm config: which tag types trigger repeat warnings (default: Protein, Küche) and the penalty weights for each violation type.E4E4 shows the current household config. If no custom config has been saved, defaults are displayed: Protein ✓, Küche ✓; all weights at "Mittel". A "Zurücksetzen auf Standard" link is always visible.
Toggle tag type checksPlanner toggles "Protein" off. For a vegetarian household, removing the protein check means tofu, eggs, and legumes appearing on consecutive days no longer reduces the variety score. The change auto-saves immediately.E4Each tag type in the household's recipe library can be checked or unchecked. Only types that actually appear in tagged recipes are listed — empty types are hidden. At least one type must remain checked; the toggle is disabled if it would leave zero checked types.
Adjust penalty weights (optional)Planner uses segmented controls (Niedrig / Mittel / Hoch) to tune how severely each violation type reduces the score. For example, reducing ingredient overlap weight to "Niedrig" for households that cook mostly single-ingredient dishes.E4Four weights are tunable: Tag-Wiederholung (default Mittel = 1.5 pts), Zutaten-Überschneidung (default Niedrig = 0.3 pts), Letzte Wochen (default Mittel = 1.0 pts), Doppelte Rezepte (default Hoch = 2.0 pts). Changes auto-save on interaction — no save button.
Review updated scorePlanner navigates back to the variety review page (C3). The score and all warning cards now reflect the updated config. Warnings for the disabled tag type no longer appear.C3The variety score shown on C1 (the planner home screen) also updates the next time the plan is loaded or a recipe is swapped. No full page reload is required on C3 — the server recalculates using the persisted config on each request.
Reset to defaults (optional exit)If the planner wants to undo all changes, they tap "Zurücksetzen auf Standard" at the bottom of E4. A confirmation prompt names the specific values that will be reset. On confirm, the household config is deleted and the system defaults are restored.E4Reset is a destructive action — it deletes the household's custom VarietyScoreConfig row. Confirmation dialog is required. Unlike most auto-save interactions, this one needs an explicit confirm because it discards all customisations at once.
+
+
+
Design notes
+
    +
  • The protein check is the primary motivation for this journey. Default it to OFF for households whose recipe library contains only vegetarian/vegan protein tags — consider detecting this automatically on first variety score load and surfacing a one-tap "Protein-Prüfung deaktivieren?" nudge on C3.
  • +
  • Penalty weights use three presets (Niedrig / Mittel / Hoch) rather than numeric sliders. Planners should not need to understand the backend scoring formula. Presets translate to fixed multipliers: Niedrig = ×0.5 of default, Mittel = ×1.0, Hoch = ×1.5.
  • +
  • Auto-save on every toggle/segmented-control change follows the same pattern as D3 staples. No save button. The "Zurücksetzen auf Standard" action is the only one that requires a confirmation step.
  • +
  • E4 is a settings screen, not a planning tool — it lives under E1, not under C3. The planner should access it deliberately, not accidentally while checking their variety score. However, C3 should surface a "Einstellungen anpassen →" link when the score has been consistently low for 3+ consecutive weeks.
  • +
  • Backend: config is stored per household in VarietyScoreConfig. PATCH /v1/households/mine/variety-config updates the config; DELETE resets to defaults. The variety score endpoint already reads from VarietyScoreConfig per request — no further backend work needed to make tuned configs take effect.
  • +
+
+
+
+ +
+ + + + +
+ +

Machine-readable spec — User journeys

+

This section is the authoritative journey reference for all agentic LLM tasks. Use it to understand which pages are involved in each journey, who performs each step, and what the key constraints are before building any screen or component.

+ +
/* Journey rules for agents
+ * 1.  Two roles exist: PLANNER (full access) and HOUSEHOLD_MEMBER (limited access).
+ * 2.  PLANNER can access: all screens (A1–E4).
+ * 3.  HOUSEHOLD_MEMBER can access: C1 (read-only) and D1 (view + check off + add items).
+ * 4.  The variety score must be visible on C1 at all times — it is not a secondary feature.
+ * 5.  J1 and J2 are preconditions for J5 — a shopping list requires planned meals with tagged recipes.
+ * 6.  J6 is a precondition for J5 shared list — household members must be invited before the list is shared.
+ * 7.  J3 "mark as cooked" feeds variety history which filters J2 suggestions. This feedback loop is critical.
+ * 8.  J4 swap must complete in ≤ 3 taps from "Swap" to updated plan.
+ * 9.  A3 and D3 are the same component (staples) — design and build once, reference from two entry points.
+ * 10. B3 and B4 (add + edit recipe) are the same form — build once with two initial states (empty vs prefilled).
+ * 11. The shopping list (D1) is real-time shared — checked items update for ALL household members instantly.
+ * 12. CalDAV export is future scope (E3) — do not build in v1.
+ * 13. Child accounts are future scope — do not design for in v1.
+ * 14. J7 is the post-setup continuation of J6 invite step — same A4 acceptance mechanism, different entry point (E2 not A2). The invite component is shared.
+ * 15. J8 entry is E1 (settings hub) → D3 (staples). D3 = A3: same component, settings context prop removes onboarding chrome (sidebar + nav footer).
+ * 16. Staple changes (J8) do not retroactively update an already-generated shopping list — planner must regenerate via J5 if the current list should reflect the change.
+ * 17. PLANNER cannot remove themselves from the household. Household role transfer is future scope, not v1.
+ * 18. J9 variety config is per-household and persisted in VarietyScoreConfig. Auto-save on toggle/weight change; reset-to-defaults requires a confirmation dialog. HOUSEHOLD_MEMBER cannot access E4.
+ * 19. J9 "Protein" tag-type toggle is the primary use case. Vegetarian households should disable it so tofu/eggs/legumes are not penalised for consecutive-day repetition.
+ * 20. E4 weight presets map to: Niedrig = ×0.5, Mittel = ×1.0 (default), Hoch = ×1.5 of the backend default weight.
+ */
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JourneyTitleActor(s)Screens touched (in order)Key constraint
J1Add a recipePLANNERB1 → B3 → B1Minimum tags required: effort + 1 category. Tags power J2 suggestions.
J2Plan the weekPLANNERC1 → C2 → C1 → C3 → C1 (→ C2 if swap needed)Variety score must be visible throughout. Suggestions filter on: last 3 days ingredients, same-protein consecutive days, effort balance.
J3Cook tonightPLANNERC1 → B2 → B4 → C1Cook mode (B4): 16px body text, 1.75 line-height, one step per screen, screen wake lock, tap-anywhere to advance.
J4Adapt on the flyPLANNERC1 → C2 → C1Max 3 taps from Swap button to plan updated. Suggestions sorted by effort (easiest first) when triggered mid-week.
J5Generate shopping listPLANNER (generate) + HOUSEHOLD_MEMBER (shop)C1 → D1 (always live)Ingredients merged + summed across meals. Staples filtered (D3 config). Planner generates. List is always live — all members can view, check off, add, and remove items. Checked state persists for all users.
J6Household setupPLANNER (creates) + HOUSEHOLD_MEMBER (joins)A1 → A2 → A3 → A2 → [invite] → A4One-time journey. A3 = D3 (same component). Invite via link/code (no email system). Member role grants: C1 read-only + D1 collaborative.
J7Manage household membersPLANNER (manages) + HOUSEHOLD_MEMBER (accepts)E2 → [invite link] → A4 → E2Post-onboarding companion to J6 invite step. One-tap re-generation for expired invite links. Member removal requires explicit confirmation with member name. PLANNER cannot remove themselves.
J8Edit pantry staplesPLANNERE1 → D3Auto-save on toggle — no save button. Changes apply to next J5 shopping list generation only, not retroactively. D3 = A3 (same component, settings context prop).
J9Configure variety scorePLANNERE1 → E4 → C3Auto-save on every toggle/weight change. Reset-to-defaults requires explicit confirmation. Protein tag-type toggle is the primary use case for vegetarian households. Config is stored per household — changes take effect on next variety score request, no plan changes required.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScreenNameJourneysPlanner accessMember access
A — Onboarding & auth
A1Welcome / sign upJ6FullFull (joining only)
A2Household setup + inviteJ6FullNo access
A3Staples setup (= D3)J6, J5FullNo access
A4Join household (accept invite)J6N/AFull
B — Recipe library
B1Recipe libraryJ1, J2FullNo access
B2Recipe detailJ3FullNo access
B3Add / edit recipe (one form, two states)J1FullNo access
B4Cook modeJ3FullNo access
C — Meal planning
C1Weekly plannerJ2, J3, J4, J5FullRead-only
C2Meal suggestionsJ2, J4FullNo access
C3Variety reviewJ2FullNo access
D — Shopping list
D1Shopping list (live shared)J5FullView + check off + add items
D3Staples manager (= A3)J5, J6, J8FullNo access
E — Settings
E1Settings hubJ8, J9FullNo access
E2Household (members + roles)J6, J7FullView-only
E3Integrations (CalDAV — future)FullNo access
E4Variety settings (tag types + weights)J9FullNo access
+ +
+ + + + \ No newline at end of file From c297403506859e630b21a0c2e93180ae718b9a77 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 16:00:02 +0200 Subject: [PATCH 03/36] docs(specs): add 3 mockup variations for E4 variety settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1: Structured sections (toggles + segmented weight controls, low effort) V2: Context preset chips (Omnivor/Vegetarisch/Vegan) with live score preview — recommended V3: Rule cards with inline examples showing exact penalty impact Co-Authored-By: Claude Sonnet 4.6 --- specs/frontend/e4-variety-settings.html | 1138 +++++++++++++++++++++++ 1 file changed, 1138 insertions(+) create mode 100644 specs/frontend/e4-variety-settings.html diff --git a/specs/frontend/e4-variety-settings.html b/specs/frontend/e4-variety-settings.html new file mode 100644 index 0000000..86183e0 --- /dev/null +++ b/specs/frontend/e4-variety-settings.html @@ -0,0 +1,1138 @@ + + + + + + Recipe App — E4 Variety Settings · 3 Mockups + + + + +
+ + +
+
+

E4 — Vielfalt-Einstellungen

+

Recipe App · 3 Mockup-Variationen · Atlas UI-Persona

+
+
+ Entwurf
+ Erstellt: 2026-04
+ Variationen: 3
+ Journey: J9 · Screen E4 +
+
+ + + +
+ +

+ E4 ist die neue Einstellungsseite für den Vielfalt-Algorithmus. Sie wird von E1 (Settings Hub) aus aufgerufen und ist nur für Planer zugänglich. Das Kernproblem: Die Standard-Konfiguration bestraft Protein-Wiederholungen, was für vegetarische Haushalte unfair ist — Tofu, Eier und Hülsenfrüchte wiederholen sich strukturell häufiger, weil die Auswahl geringer ist. Die drei Variationen zeigen unterschiedliche Ansätze zwischen "technisch präzise" und "kontext-geführt". +

+

+ Alle Variationen zeigen sowohl die mobile Ansicht (320px) als auch die Desktop-Ansicht (Sidebar + Hauptbereich). Die Desktop-Ansicht nutzt den gleichen Sidebar-Layout wie E1 und E2. +

+
+ + + +
+
+
V1
+
+
Variation 1
+
Strukturierte Sektionen
+
Klassisches Settings-Layout: zwei klare Sektionen (Typen-Toggles + Strafgewichte) mit beschrifteten Segmented-Controls. Kein Schnickschnack — der erfahrene Planer findet sofort alles. Geringer Entwicklungsaufwand, da alle Komponenten bereits im System vorhanden sind.
+
+
+ +
+ + +
+
Mobile · 320px
+
+
9:41●●●
+
+ +
+
← Einstellungen
+
Vielfalt
+
+ +
+ + +
+
+
Wiederholungs-Typen
+
Welche Tags lösen Warnungen aus?
+
+
+
+
+
Protein
+
Tofu, Ei, Hülsenfrüchte …
+
+
+
+
+
+
Küche
+
Pasta, Asiatisch, Mexikanisch …
+
+
+
+
+
+ + +
+
+
Strafgewichte
+
Wie stark wirkt sich jeder Verstoß aus?
+
+
+
+
+
Tag-Wiederholung
+
Gleicher Typ an aufeinanderfolgenden Tagen
+
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
+
Zutaten-Überschneidung
+
Gleiche Zutaten an aufeinanderfolgenden Tagen
+
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
+
Letzte Wochen
+
Rezepte aus dem Kochverlauf
+
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
+
Doppelte Rezepte
+
Gleiches Rezept mehrfach im Plan
+
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
+ + +
+ + +
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ + +
+
Desktop · 1040px
+
+ +
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+ +
+
+
+
Einstellungen Vielfalt-Einstellungen
+
Vielfalt-Einstellungen
+
+
+
+
+ + +
+
+
Wiederholungs-Typen
+
Welche Tags lösen Warnungen aus?
+
+
+
+
+
Protein
+
Tofu, Ei, Hülsenfrüchte, Fisch …
+
+
+
+
+
+
Küche
+
Pasta, Asiatisch, Mexikanisch …
+
+
+
+
+
+ + +
+
Tipp
+
Vegetarische Haushalte haben weniger Protein-Quellen zur Auswahl. Deaktiviere Protein, damit Tofu oder Eier an aufeinanderfolgenden Tagen die Vielfalt nicht reduzieren.
+
+ +
+ + +
+
+
+
Strafgewichte
+
Wie stark wirkt sich jeder Verstoß auf die Punktzahl aus?
+
+
+
+
+
Tag-Wiederholung
+
Gleicher Typ (z.B. Küche) an aufeinanderfolgenden Tagen
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Zutaten-Überschneidung
+
Gleiche Zutaten an aufeinanderfolgenden Tagen
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Letzte Wochen
+
Rezepte aus den letzten 14 Tagen Kochverlauf
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Doppelte Rezepte
+
Gleiches Rezept mehrfach im Wochenplan
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+ +
+
+
+
+
+ +
+ +
+
Design-Notizen V1
+
    +
  • Alle Komponenten (Toggle, Segmented Control, Grp-Block) sind bereits im Design-System vorhanden — minimaler Entwicklungsaufwand.
  • +
  • Desktop: 2-Spalten-Grid oben (Typen + Tipp-Karte) gibt dem Tipp-Text Raum ohne die Seite zu verlängern.
  • +
  • Protein-Toggle ist standardmäßig auf "Aus" gesetzt im Mockup — zeigt den empfohlenen Zustand für vegetarische Haushalte.
  • +
  • Schwachstelle: Keine direkte Verbindung zwischen den Einstellungen und der Score-Auswirkung sichtbar — der Planer muss selbst zurück zu C3 navigieren, um den Effekt zu sehen.
  • +
  • Auto-Save bei jeder Interaktion; kein Speichern-Button. "Auf Standard zurücksetzen" öffnet einen Bestätigungsdialog.
  • +
+
+
+ + +
+ + + +
+
+
V2
+
+
Variation 2 · Empfohlen
+
Kontext-Preset + Feineinstellungen
+
Der Planer wählt zuerst den Haushaltskontext (Omnivor / Vegetarisch / Vegan). Das setzt alle Einstellungen automatisch auf sinnvolle Werte — kein Nachdenken über Gewichte nötig. Für fortgeschrittene Anpassungen gibt es ein ausklappbares "Erweiterte Einstellungen"-Panel. Zeigt auch live, wie der Score des aktuellen Plans mit den neuen Einstellungen aussehen würde.
+
+
+ +
+ + +
+
Mobile · 320px
+
+
9:41●●●
+
+
+
← Einstellungen
+
Vielfalt
+
+ +
+ + +
+
+
Mit diesen Einstellungen
+
8.2
+
vorher 5.8 / 10
+
+
📈
+
+ + +
Haushaltskontext
+
+
+
🥩
+
Omnivor
+
Alle Tags aktiv
+
+
+
🥦
+
Vegetarisch
+
Protein deaktiviert
+
+
+
🌱
+
Vegan
+
Protein deaktiviert
+
+
+ + +
Aktive Einstellungen
+
+
Protein: Aus
+
Küche: An
+
Zutaten: Mittel
+
Duplikate: Hoch
+
+ + +
+
+
Erweiterte Einstellungen
+
+
+
+
+
+
Küche
+
Küchen-Wiederholungen
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Zutaten
+
Überschneidungen
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Duplikate
+
Gleiches Rezept im Plan
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+ + +
+ +
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ + +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
+
+
Einstellungen Vielfalt-Einstellungen
+
Vielfalt-Einstellungen
+
+
+
+
+ + +
+
Haushaltskontext
+
+
+
🥩
+
Omnivor
+
Alle Tags aktiv
+
+
+
🥦
+
Vegetarisch
+
Protein deaktiviert
+
+
+
🌱
+
Vegan
+
Protein deaktiviert
+
+
+ +
+
+
Erweiterte Einstellungen
+
+
+
+
+
+
Küche-Wiederholungen
+
Gleiche Küche an aufeinanderfolgenden Tagen
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Zutaten-Überschneidung
+
Gleiche Zutaten an aufeinanderfolgenden Tagen
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Letzte Wochen
+
Kochverlauf der letzten 14 Tage
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+
Doppelte Rezepte
+
Gleiches Rezept mehrfach im Plan
+
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+
+ + +
+ + +
+
+
+
Mit diesen Einstellungen
+
8.2 / 10
+
vorher 5.8 — +2.4 Punkte
+
+
📈
+
+ +
Aktive Regeln
+
+
+ Protein-Wiederholung + Deaktiviert +
+
+ Küchen-Vielfalt + Mittel +
+
+ Zutaten-Überschneidung + Niedrig +
+
+ Letzte Wochen + Mittel +
+
+ Doppelte Rezepte + Hoch +
+
+
+ +
+
+
+
+
+ +
+ +
+
Design-Notizen V2
+
    +
  • Der Score-Preview-Banner ist das stärkste Feature von V2: der Planer sieht sofort, welchen Effekt die Einstellungen auf seinen aktuellen Plan haben — ohne zu C3 navigieren zu müssen. Der Server berechnet die Simulation mit den noch nicht gespeicherten Einstellungen.
  • +
  • Die drei Kontext-Chips (Omnivor / Vegetarisch / Vegan) sind der primäre Interaktionspunkt. Die meisten Planer müssen keine "Erweiterten Einstellungen" anfassen.
  • +
  • Technische Umsetzung des Score-Previews: Endpoint-Simulation mit temporären Gewichten (nicht persistent), ähnlich wie die bestehende /v1/week-plans/{id}/variety-score?simulate=true-Logik.
  • +
  • Erweiterungsidee: Ein vierter Chip "Individuell" erscheint automatisch, sobald der Planer die erweiterten Einstellungen manuell verändert hat.
  • +
  • Schwachstelle: Der Score-Preview benötigt einen separaten Backend-Aufruf. Wenn kein aktueller Plan existiert, sollte ein sinnvoller Fallback angezeigt werden ("Kein Plan für diese Woche").
  • +
+
+
+ + +
+ + + +
+
+
V3
+
+
Variation 3
+
Regelkarten mit Beispielen
+
Jede Bewertungsregel bekommt eine eigene Karte mit konkretem Beispiel, das zeigt, was genau bestraft wird. Der Planer versteht sofort, warum eine Regel relevant (oder irrelevant) ist. Deaktivierte Karten werden ausgegraut — der Planer sieht auf einen Blick, welche Regeln aktiv sind.
+
+
+ +
+ + +
+
Mobile · 320px
+
+
9:41●●●
+
+
+
← Einstellungen
+
Vielfalt
+
+ +
+ + +
+
+
+
🥚
+
+
Protein-Wiederholung
+
Gleiche Protein-Quelle an aufeinanderfolgenden Tagen
+
+
+
+
+
+ + +
+
+
+
🍝
+
+
Küchen-Vielfalt
+
Gleiche Küche an aufeinanderfolgenden Tagen
+
+
+
+
+
+
Pasta Mo · Pasta Di → −1.5 Pkt
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+ + +
+
+
+
🫑
+
+
Zutaten-Überschneidung
+
Gleiche Zutat an aufeinanderfolgenden Tagen
+
+
+
+
+
+
Paprika Mo · Paprika Di → −0.3 Pkt
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+ + +
+
+
+
📋
+
+
Doppelte Rezepte
+
Gleiches Rezept mehrfach im Wochenplan
+
+
+
+
+
+
Pasta Bolognese 2× → −2.0 Pkt
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+ + +
+
+
+
📅
+
+
Letzte Wochen
+
Rezepte der letzten 14 Tage im Plan
+
+
+
+
+
+
Chili letzte Woche · Chili diese Woche → −1.0 Pkt
+
+
Niedrig
+
Mittel
+
Hoch
+
+
+
+ + +
+ +
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ + +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
+
+
Einstellungen Vielfalt-Einstellungen
+
Vielfalt-Einstellungen
+
+
+
+ +
+ + +
+
+
+
🥚
+
+
Protein-Wiederholung
+
Gleiche Protein-Quelle an aufeinanderfolgenden Tagen · Deaktiviert
+
+
+
+
+
+ + +
+
+
+
🍝
+
+
Küchen-Vielfalt
+
Gleiche Küche an aufeinanderfolgenden Tagen
+
+
+
+
+
+
Pasta Mo · Pasta Di → −1.5 Pkt
+
Niedrig
Mittel
Hoch
+
+
+ + +
+
+
+
🫑
+
+
Zutaten-Überschneidung
+
Gleiche Zutat an aufeinanderfolgenden Tagen
+
+
+
+
+
+
Paprika Mo · Paprika Di → −0.3 Pkt
+
Niedrig
Mittel
Hoch
+
+
+ + +
+
+
+
📋
+
+
Doppelte Rezepte
+
Gleiches Rezept mehrfach im Wochenplan
+
+
+
+
+
+
Pasta Bolognese 2× → −2.0 Pkt
+
Niedrig
Mittel
Hoch
+
+
+ + +
+
+
+
📅
+
+
Letzte Wochen
+
Rezepte der letzten 14 Tage im aktuellen Plan
+
+
+
+
+
+
Chili letzte Woche · Chili diese Woche → −1.0 Pkt
+
Niedrig
Mittel
Hoch
+
+
+ + +
+ +
+ +
+
+
+
+
+ +
+ +
+
Design-Notizen V3
+
    +
  • Das Mono-Code-Beispiel in jeder Karte ("Pasta Mo · Pasta Di → −1.5 Pkt") macht das Algorithmus-Verhalten greifbar — der Planer muss keine Doku lesen, um zu verstehen, was eine Regel tut.
  • +
  • Deaktivierte Karten werden auf 65% Opacity ausgegraut und zeigen kein Gewicht-Control — weniger visuelles Rauschen, klarer Status auf einen Blick.
  • +
  • Desktop 2-Spalten-Grid funktioniert gut bis 5 Regeln. Wenn künftig weitere Regel-Typen hinzukommen, bleibt die Struktur skalierbar ohne Layout-Änderung.
  • +
  • Schwachstelle: Kein Score-Preview (anders als V2). Die Auswirkungen werden erst klar, wenn der Planer zu C3 navigiert. Könnte mit einem Banner unter dem Grid ergänzt werden.
  • +
  • Schwachstelle für mobile: 5 Karten nacheinander ergeben eine sehr lange Seite. Erwägen, deaktivierte Karten ganz an das Ende zu schieben.
  • +
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumV1 SektionenV2 Preset ★V3 Regelkarten
LernkurveMittel — vertraut für Settings-erfahreneNiedrig — drei Chips, alles erledigtNiedrig — jede Regel erklärt sich selbst
Direkte Score-AuswirkungNicht sichtbarLive-Preview ✓Nicht sichtbar
EntwicklungsaufwandNiedrigMittel (+Simulation-Endpoint)Mittel
ErklärungswertGering (nur Label)Mittel (Kontext-Beschreibung)Hoch (Beispiele) ✓
SkalierbarkeitGut — neue Zeile in TabelleMittel — neue Presets brauchen ÜberlegungGut — neue Karte im Grid ✓
EmpfehlungWenn schnelle Lieferung PrioEmpfohlen für v1 ★Gut als v2-Iteration auf V2
+ +
+
Empfehlung Atlas
+
    +
  • V2 (Kontext-Preset) ist die empfohlene Variation für v1. Der Live-Score-Preview schafft sofortiges Feedback — der Planer muss nicht raten, ob eine Einstellung den erhofften Effekt hat.
  • +
  • V2 + V3 lassen sich gut kombinieren: Presets für den Einstieg (V2 oben), Regelkarten als "Erweiterte Einstellungen" darunter (V3-Stil) statt des abstrakten Segmented-Controls.
  • +
  • V1 ist die richtige Wahl, wenn der Simulation-Endpoint nicht in diesem Sprint umgesetzt werden kann — er lässt sich später ohne Layout-Änderung ergänzen.
  • +
+
+
+ +
+ + From bd1604fc1da9481d062761d19968221c35397978 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 16:13:17 +0200 Subject: [PATCH 04/36] docs(specs): add detailed implementation spec for E4 variety settings (V2 Kontext-Preset) 5 states: S0 E1 hub update, S1 default, S2 preset selection + score simulation, S3 advanced settings + Individuell chip, S4 reset confirmation dialog. Includes API contract, preset mappings, weight multipliers, and LLM agent region. Co-Authored-By: Claude Sonnet 4.6 --- .../frontend/e4-variety-settings-kachel.html | 981 ++++++++++++++++++ 1 file changed, 981 insertions(+) create mode 100644 specs/frontend/e4-variety-settings-kachel.html diff --git a/specs/frontend/e4-variety-settings-kachel.html b/specs/frontend/e4-variety-settings-kachel.html new file mode 100644 index 0000000..48297f7 --- /dev/null +++ b/specs/frontend/e4-variety-settings-kachel.html @@ -0,0 +1,981 @@ + + + + + + Recipe App — E4 Vielfalt-Einstellungen · Implementierungsspezifikation + + + + +
+ + +
+
+

E4 — Vielfalt-Einstellungen

+

Implementierungsspezifikation · V2 Kontext-Preset · Journey J9

+
+
+ v1.0
+ Screens: E1 (Update) + E4
+ States: 5
+ Rolle: Planer only +
+
+ + +
+
J9
+
+

Vielfalt-Algorithmus konfigurieren

+

Planer passt Bewertungsregeln an den Haushaltskontext an — primär das Deaktivieren der Protein-Prüfung für vegetarische Haushalte.

+
E1 → E4 → C3 · Planer only · Auto-Save · Reset benötigt Bestätigung
+
+
+ + + +
+
E1 — Settings-Hub (Update)
+

Der bestehende Settings-Hub (E1) erhält eine dritte Kachel: "Vielfalt-Einstellungen". Die Kachel zeigt den aktuellen Vielfalt-Score als Kennzahl. Das Grid-Layout wird von 2-spaltig zu einem Mix aus Hauptkachel oben und zwei gleichbreiten Kacheln unten angepasst.

+ + +
+

S0 · Settings-Hub mit Vielfalt-Kachel

E1
+
Die neue Vielfalt-Kachel erscheint in der unteren Reihe neben der Haushalt-Kachel. Zeigt den aktuellen Score als lila Kennzahl. Bei Score < 6.0 färbt sich die Kennzahl orange als Aufmerksamkeitshinweis.
+
Änderung gegenüber E1 v1: dritte Kachel + Grid-Anpassung. Vorräte-Kachel bleibt primär (2fr oben).
+ +
+
+
Mobile · 320px
+
+
9:41●●●
+
+
Einstellungen
+
+ +
+
12
+
Vorräte
+
Zutaten immer vorrätig
+
Bearbeiten →
+
+ +
+
+
3
+
Haushalt
+
Mitglieder
+
Verwalten →
+
+
+
7.4
+
Vielfalt
+
Diese Woche
+
Einstellungen →
+
+
+
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
Einstellungen
+
+
+ +
+
+
12
+
Vorräte
+
Zutaten, die immer vorrätig sind und nicht auf die Einkaufsliste kommen
+
+
Bearbeiten →
+
+ +
+
+
+
3
+
Haushalt
+
Mitglieder & Rollen
+
+
Verwalten →
+
+
+
+
7.4
+
Vielfalt-Einstellungen
+
Algorithmus anpassen
+
+
Einstellungen →
+
+
+
+
+
+
+
+
+ +
+

E1 Hub Update · S0

+
/* E1 grid: Vorräte (full width, 2fr, border-left: 3px solid --green-dark) on top row.
+ * Bottom row: 2 equal columns — Haushalt + Vielfalt-Einstellungen.
+ * Vielfalt card: border-left: 3px solid --purple. Stat color: --purple (7.4).
+ * If score < 6.0: stat color switches to --orange (Aufmerksamkeit) with no other change.
+ * Score value: load from GET /v1/week-plans?weekStart=current → GET /v1/week-plans/{id}/variety-score.
+ * If no current plan: show "–" as stat value, sub: "Kein Plan".
+ * Tap/click Vielfalt card → navigate to E4. */
+ + + + + + + + + + +
ElementWertNotizen
Vielfalt-Kachel
KennzahlvarietyScore.score, 1 DezimalstelleFarbe: --purple normal, --orange wenn < 6.0
LabelVielfalt-EinstellungenDesktop; Mobile: "Vielfalt"
Sub-Label"Diese Woche" / "Kein Plan" / "–"Kein Plan = kein weekPlan für aktuelle Woche
Randborder-left: 3px solid --purpleAnalog zu Vorräte → --green-dark
AktionTap → navigate /settings/varietyRoute: +page.svelte unter (app)/settings/variety/
Grid-Layout
MobileVorräte fullwidth + grid-template-columns: 1fr 1fr untenGap: 12px
DesktopVorräte fullwidth + grid-template-columns: 1fr 1fr untenMax-width: 640px, gap: 16px
+
+
+
+ + + +
+
E4 — Vielfalt-Einstellungen · States
+ +
+

S1 · Standard (kein Custom-Config)

E4
+
Erster Aufruf, kein haushaltsindividueller Config-Eintrag. Omnivor-Chip ist ausgewählt (Default-Zustand). Score-Preview zeigt den aktuellen tatsächlichen Score — keine Simulation nötig, da noch nichts geändert wurde. Hinweis-Text erklärt kurz den Zweck der Seite.
+
S1 · Omnivor selected · Score-Preview = aktueller Score · Erweiterte Einstellungen eingeklappt
+ +
+
+
Mobile · 320px
+
+
9:41●●●
+
+
← Einstellungen
Vielfalt
+
+
Passe den Algorithmus an deinen Haushalt an. Änderungen werden sofort übernommen.
+ +
Haushaltskontext
+
+
+
🥩
+
Omnivor
+
Alle Regeln aktiv
+
+
+
🥦
+
Vegetarisch
+
Protein deaktiviert
+
+
+
🌱
+
Vegan
+
Protein deaktiviert
+
+
+ +
Aktive Regeln
+
+
✓ Protein
+
✓ Küche
+
✓ Zutaten · Mittel
+
✓ Letzte Wochen · Mittel
+
⚠ Duplikate · Hoch
+
+ +
+
+
Erweiterte Einstellungen
+
+
+
+ +
+
+
Aktueller Score
+
7.4
+
Keine Änderungen
+
+
📊
+
+ + +
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
Einstellungen Vielfalt-Einstellungen
Vielfalt-Einstellungen
+
+
+
+
Passe den Algorithmus an deinen Haushaltskontext an. Änderungen werden sofort übernommen und wirken sich auf den nächsten Score-Abruf aus.
+
Haushaltskontext
+
+
🥩
Omnivor
Alle Regeln aktiv
+
🥦
Vegetarisch
Protein deaktiviert
+
🌱
Vegan
Protein deaktiviert
+
+
+
Erweiterte Einstellungen
+
+ +
+
+
+
+
Aktueller Score
+
7.4 / 10
+
Keine Änderungen aktiv
+
+
📊
+
+
Aktive Regeln
+
+
ProteinMittel
+
KücheMittel
+
ZutatenNiedrig
+
Letzte WochenMittel
+
DuplikateHoch
+
+
+
+
+
+
+
+
+ +
+

E4 · S1 Default

+
/* Load: GET /v1/households/mine/variety-config → 404 if no custom config.
+ * On 404: use defaults (Omnivor preset), show Omnivor chip as selected.
+ * Score banner: show actual GET /v1/week-plans/{id}/variety-score (no simulation).
+ * "Bereits Standard-Einstellungen" replaces reset link if no custom config exists.
+ * Accordion: closed. */
+ + + + + + + + + + + + +
ElementWertNotizen
Laden
Config-LoadGET /v1/households/mine/variety-config404 → Defaults verwenden, Omnivor selected
Score-LoadGET /v1/week-plans/{id}/variety-scoreNur wenn weekPlan existiert; sonst Score-Banner ausblenden
Kontext-Chips
OmnivorrepeatTagTypes: ["protein","cuisine"], alle Gewichte StandardDefault-Preset = backend defaults
VegetarischrepeatTagTypes: ["cuisine"], wTagRepeat StandardProtein deaktiviert
VeganrepeatTagTypes: ["cuisine"], wTagRepeat StandardIdentisch zu Vegetarisch in v1
IndividuellErscheint automatisch wenn Advanced abweicht vom PresetKein manuell wählbarer Chip — nur automatisch
Score-Banner (S1)
WertAktueller Score (keine Simulation)Label: "Aktueller Score"
Sub-Label"Keine Änderungen"Neutral-Farbe (#6B6A63)
+
+
+ + + +
+

S2 · Vegetarisch ausgewählt — Score-Simulation

E4
+
Planer tippt auf "Vegetarisch". Config wird sofort per PATCH gespeichert. Score-Banner lädt die simulierte Punktzahl: wie würde der aktuelle Plan mit der neuen Config abschneiden. Delta wird grün hervorgehoben. Protein-Pill wechselt zu "off". Erweiterte Einstellungen zeigt Protein-Toggle als deaktiviert.
+
S2 · Vegetarisch selected · Score-Preview = simuliert · Protein-Pill = off
+ +
+
+
Mobile · 320px
+
+
9:41●●●
+
+
← Einstellungen
Vielfalt
+
+
Passe den Algorithmus an deinen Haushalt an. Änderungen werden sofort übernommen.
+ +
Haushaltskontext
+
+
🥩
Omnivor
Alle Regeln aktiv
+
🥦
Vegetarisch
Protein deaktiviert
+
🌱
Vegan
Protein deaktiviert
+
+ +
Aktive Regeln
+
+
– Protein
+
✓ Küche
+
✓ Zutaten · Mittel
+
✓ Letzte Wochen · Mittel
+
⚠ Duplikate · Hoch
+
+ +
+
Erweiterte Einstellungen
+
+ +
+
+
Mit diesen Einstellungen
+
8.9
+
↑ +1.5 gegenüber vorher
+
+
📈
+
+ + +
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
Einstellungen Vielfalt-Einstellungen
Vielfalt-Einstellungen
+
+
+
+
Passe den Algorithmus an deinen Haushaltskontext an. Änderungen werden sofort übernommen und wirken sich auf den nächsten Score-Abruf aus.
+
Haushaltskontext
+
+
🥩
Omnivor
Alle Regeln aktiv
+
🥦
Vegetarisch
Protein deaktiviert
+
🌱
Vegan
Protein deaktiviert
+
+
+
Erweiterte Einstellungen
+
+ +
+
+
+
+
Mit diesen Einstellungen
+
8.9 / 10
+
↑ +1.5 gegenüber vorher
+
+
📈
+
+
Aktive Regeln
+
+
ProteinDeaktiviert
+
KücheMittel
+
ZutatenNiedrig
+
Letzte WochenMittel
+
DuplikateHoch
+
+
+
+
+
+
+
+
+ +
+

E4 · S2 Vegetarisch

+
/* On chip tap (Vegetarisch):
+ * 1. Optimistic UI: swap selected chip, update pills, update sum-rows immediately.
+ * 2. PATCH /v1/households/mine/variety-config { repeatTagTypes: ["cuisine"],
+ *    wTagRepeat: 1.5, wIngredientOverlap: 0.3, wRecentRepeat: 1.0, wPlanDuplicate: 2.0 }
+ * 3. On PATCH success: fire GET /v1/week-plans/{id}/variety-score?simulate=true
+ *    with same config body → update score-banner with simulated score + delta.
+ * 4. On PATCH error: rollback to previous chip selection + show toast "Fehler beim Speichern".
+ * Score-Banner during load: show spinner in place of val. */
+ + + + + + + + + + + + +
ElementWertNotizen
Score-Banner (S2)
Label"Mit diesen Einstellungen"Statt "Aktueller Score"
Delta"↑ +X.X gegenüber vorher"Grün (#6FCF97) wenn positiv; rot wenn negativ; neutral wenn = 0
Simulation-EndpointPOST /v1/week-plans/{id}/variety-score/simulateBody: VarietyScoreConfig-Felder. Neuer Endpoint nötig (Backend-Task).
Kein PlanScore-Banner ausblendenKein simulierter Score ohne Plan möglich
Chip-Preset Vegetarisch
repeatTagTypes["cuisine"]Protein entfernt
wTagRepeat1.5 (Standard)Unverändert
wIngredientOverlap0.3 (Standard)Unverändert
wRecentRepeat1.0 (Standard)Unverändert
wPlanDuplicate2.0 (Standard)Unverändert
+
+
+ + + +
+

S3 · Erweiterte Einstellungen geöffnet

E4
+
Planer öffnet das Accordion "Erweiterte Einstellungen". Er sieht Segmented Controls (Niedrig / Mittel / Hoch) für jeden Gewichts-Parameter. Ändert er einen Wert, der nicht mehr dem aktuellen Preset entspricht, erscheint automatisch ein vierter Chip "Individuell" (lila) und ersetzt den aktiven Preset-Chip. Score-Banner aktualisiert sich nach jeder Änderung.
+
S3 · Erweiterte Einstellungen offen · "Individuell"-Chip erschienen (Planer hat Zutaten-Gewicht angepasst)
+ +
+
+
Mobile · 320px
+
+
9:41●●●
+
+
← Einstellungen
Vielfalt
+
+
Haushaltskontext
+
+
🥩
Omnivor
+
🥦
Vegetarisch
+
🌱
Vegan
+
Individuell
+
+ +
+
– Protein
+
✓ Küche
+
✓ Zutaten · Hoch
+
✓ Letzte Wochen · Mittel
+
⚠ Duplikate · Hoch
+
+ +
+
Erweiterte Einstellungen
+
+
Protein ist über den Kontext deaktiviert. Die übrigen Gewichte kannst du hier anpassen.
+
+
Küche
Tag-Wiederholung
+
Niedrig
Mittel
Hoch
+
+
+
Zutaten
Überschneidung
+
Niedrig
Mittel
Hoch
+
+
+
Letzte Wochen
Kochverlauf
+
Niedrig
Mittel
Hoch
+
+
+
Duplikate
Im Plan
+
Niedrig
Mittel
Hoch
+
+
+
+ +
+
+
Mit diesen Einstellungen
+
8.1
+
↑ +0.7 gegenüber vorher
+
+
📈
+
+ +
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
Einstellungen Vielfalt-Einstellungen
Vielfalt-Einstellungen
+
+
+
+
Haushaltskontext
+
+
🥩
Omnivor
Alle Regeln
+
🥦
Vegetarisch
Protein aus
+
🌱
Vegan
Protein aus
+
Individuell
Benutzerdefiniert
+
+
+
Erweiterte Einstellungen
+
+
Protein ist über den Haushaltskontext deaktiviert. Passe die Stärke der übrigen Regeln an.
+
+
Küchen-Wiederholung
Gleiche Küche an aufeinanderfolgenden Tagen
+
Niedrig
Mittel
Hoch
+
+
+
Zutaten-Überschneidung
Gleiche Zutaten an aufeinanderfolgenden Tagen
+
Niedrig
Mittel
Hoch
+
+
+
Letzte Wochen
Kochverlauf (14 Tage)
+
Niedrig
Mittel
Hoch
+
+
+
Doppelte Rezepte
Gleiches Rezept mehrfach im Plan
+
Niedrig
Mittel
Hoch
+
+
+
+ +
+
+
+
+
Mit diesen Einstellungen
+
8.1 / 10
+
↑ +0.7 gegenüber vorher
+
+
📈
+
+
Aktive Regeln
+
+
ProteinDeaktiviert
+
KücheMittel
+
ZutatenHoch ↑
+
Letzte WochenMittel
+
DuplikateHoch
+
+
+
+
+
+
+
+
+ +
+

E4 · S3 Erweiterte Einstellungen

+
/* Accordion öffnet sich per Click/Tap auf acc-hd. Keine Animation nötig — display toggle reicht.
+ * Erweiterte Einstellungen zeigt NUR aktive Tag-Typen als Gewichts-Rows.
+ * Wenn Protein deaktiviert (über Preset): Protein-Row wird in acc-b NICHT angezeigt.
+ * "Individuell"-Chip: erscheint automatisch wenn die Kombination repeatTagTypes+weights
+ *   nicht exakt einem der drei Presets entspricht. Kein manueller Auslöser.
+ * Gewichts-Änderung → PATCH → Score-Simulation → Banner-Update.
+ * Debounce der Simulation: 300ms nach letzter Interaktion. */
+ + + + + + + + + + + + +
ElementWertNotizen
Gewicht-Mapping
NiedrigFaktor 0.5 × Standard-GewichtwTagRepeat: 0.75, wIngredient: 0.15, wRecent: 0.5, wDuplicate: 1.0
MittelFaktor 1.0 (Standard)wTagRepeat: 1.5, wIngredient: 0.3, wRecent: 1.0, wDuplicate: 2.0
HochFaktor 1.5 × Standard-GewichtwTagRepeat: 2.25, wIngredient: 0.45, wRecent: 1.5, wDuplicate: 3.0
Individuell-Chip
TriggerWenn gespeicherter Config ≠ Omnivor, Vegetarisch, oder Vegan PresetLila Border + Hintergrund
Symbol✦ (U+2726)Statt Emoji
LabelIndividuellNicht anklickbar — nur Status-Indikator
Simulation-Debounce
Delay300msNach letzter Segmented-Control-Interaktion
Während LadenScore-Wert zeigt Spinner (CSS animation)Kein Skeleton — nur val-Bereich
+
+
+ + + +
+

S4 · Reset-Bestätigung

E4
+
Planer tippt "Auf Standard zurücksetzen". Ein Dialog erscheint und benennt explizit, was zurückgesetzt wird. Bestätigung löscht den Custom-Config-Eintrag (DELETE) und stellt die Omnivor-Defaults wieder her. Kein Backdrop-Dismiss — der Planer muss explizit wählen.
+
S4 · Modal über S2-Zustand · Backdrop nicht anklickbar · Mobile: Bottom Sheet
+ +
+
+
Mobile · 320px (Bottom Sheet)
+
+
9:41●●●
+
+ +
← Einstellungen
Vielfalt
+
+
+
🥦
Vegetarisch
+
+
+ +
+
+
Auf Standard zurücksetzen?
+
Alle individuellen Einstellungen werden gelöscht. Der Algorithmus verwendet dann wieder:

• Protein: Aktiv
• Küche: Aktiv
• Alle Gewichte: Mittel
+
Zurücksetzen
+
Abbrechen
+
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
⚙️
Einstellungen
+
+
+
+
+ +
+
Desktop · 1040px (Centered Modal)
+
+
+ +
+
+
Vielfalt-Einstellungen
+
🥦
Vegetarisch
+
+ +
+
+
+ +
+

E4 · S4 Reset-Bestätigung

+
/* Reset-Link Tap → Dialog öffnet (kein Backdrop-Dismiss, kein Escape-Dismiss).
+ * "Zurücksetzen" → DELETE /v1/households/mine/variety-config
+ * On success: optimistic reset von UI zu S1 (Omnivor), Score-Banner zeigt echten Score.
+ * On error: Toast "Fehler beim Zurücksetzen".
+ * Mobile: Bottom Sheet (position:fixed, bottom 0, border-radius 20px 20px 0 0).
+ * Desktop: centered modal, backdrop rgba(28,28,24,0.4), max-width 380px. */
+ + + + + + + + + +
ElementWertNotizen
Dialog-Inhalt
Titel"Auf Standard zurücksetzen?"Fraunces 18px (Mobile), 20px (Desktop)
BodyAuflistung der zurückgesetzten WerteMuss konkret benennen: Protein aktiv, Küche aktiv, alle Gewichte Mittel
Primär-Aktion"Zurücksetzen" → DELETEHintergrund: --red, Text: weiß
Sekundär-Aktion"Abbrechen"Ghost-Button, schließt Dialog
API
EndpointDELETE /v1/households/mine/variety-configLöscht Custom-Config-Row; Backend fällt auf Defaults zurück
On SuccessUI reset zu S1Omnivor chip selected, Score-Banner: echter Score
+
+
+
+ + + +
+

Maschinenlesbare Spezifikation — E4 Vielfalt-Einstellungen

+ +

Screens

+ + + + + + +
ScreenRouteZugriffZweck
E1 (Update)/settingsPlanerSettings-Hub: dritte Kachel "Vielfalt-Einstellungen" mit aktuellem Score
E4/settings/varietyPlaner onlyVielfalt-Algorithmus per Kontext-Preset und Feineinstellungen konfigurieren
+ +

States

+ + + + + + + + + +
StateTriggerBeschreibung
S0E1 loadSettings-Hub zeigt Score-Kachel (lila Kennzahl)
S1E4 load, kein Custom-ConfigOmnivor chip selected, Score = aktueller echter Score, Reset-Link = deaktiviert/neutral
S2Preset-Chip tapChip wechselt, PATCH, Score-Simulation lädt und zeigt Delta
S3Accordion öffnen + Gewicht ändernIndividuell-Chip erscheint, Score-Simulation mit Debounce 300ms
S4Reset-Link tapModal/Bottom Sheet — Bestätigung vor DELETE
+ +

API-Endpoints (neu + bestehend)

+ + + + + + + + + +
MethodEndpointNeu?Zweck
GET/v1/households/mine/variety-configNeuAktuellen Config laden; 404 = Defaults verwenden
PATCH/v1/households/mine/variety-configNeuConfig speichern (auto-save bei jedem Preset/Gewicht-Wechsel)
DELETE/v1/households/mine/variety-configNeuCustom-Config löschen, Backend fällt auf Defaults zurück
POST/v1/week-plans/{id}/variety-score/simulateNeuScore simulieren mit temporärem Config-Body (nicht persistiert)
GET/v1/week-plans/{id}/variety-scoreBestehendAktuellen Score laden (für S1 Banner + E1 Kachel)
+ +

Kontext-Preset Mapping

+ + + + + + + + +
PresetrepeatTagTypeswTagRepeatwIngredientOverlapwRecentRepeatwPlanDuplicate
Omnivor (Default)["protein","cuisine"]1.50.31.02.0
Vegetarisch["cuisine"]1.50.31.02.0
Vegan["cuisine"]1.50.31.02.0
IndividuellBeliebig (≠ obige Presets)BeliebigBeliebigBeliebigBeliebig
+ +

Gewicht-Preset Mapping

+ + + + + + + +
StufeFaktorwTagRepeatwIngredientOverlapwRecentRepeatwPlanDuplicate
Niedrig×0.50.750.150.51.0
Mittel×1.01.50.31.02.0
Hoch×1.52.250.451.53.0
+ +

Implementierungsregeln (für Agenten)

+
    +
  • E4 ist nur für rolle === 'planer' zugänglich. Mitglieder werden auf E1 redirected.
  • +
  • Auto-Save auf jede Preset- oder Gewicht-Änderung. Kein expliziter Speichern-Button.
  • +
  • Optimistic Update: UI wechselt sofort; Rollback mit Toast bei API-Fehler.
  • +
  • Score-Simulation: Debounce 300ms. Während Laden: Spinner im Score-Wert-Bereich (nicht Skeleton).
  • +
  • "Individuell"-Chip ist nicht anklickbar — er ist ein reiner Status-Indikator.
  • +
  • Reset-Bestätigung: Backdrop-Dismiss deaktiviert (nicht schließbar durch Klick/Tap auf Overlay).
  • +
  • Mobile Reset: Bottom Sheet mit Handle-Bar (36×4px, --color-border, border-radius 2px). Kein Backdrop-Dismiss.
  • +
  • Desktop Reset: Zentriertes Modal, max-width 380px. Backdrop rgba(28,28,24,0.4).
  • +
  • E1 Vielfalt-Kachel: Score < 6.0 → Kennzahl in --orange; Score ≥ 6.0 → Kennzahl in --purple.
  • +
  • E4-Route: (app)/settings/variety/+page.svelte. Load-Funktion: +page.server.ts → Promise.all([GET variety-config, GET variety-score]).
  • +
+
+ +
+ + From e3066ec3e5a9b54145c841e7b1f465cbd07bb4a0 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 16:31:14 +0200 Subject: [PATCH 05/36] docs(specs): add C3 variety page rework mockups and V1 implementation spec Three mockup variations (c3-variety-rework.html) for /planner/variety page, plus detailed implementation spec for the chosen V1 "Erweiterte Karten" approach: recipe names + swap links inside warning cards, minimal layout changes. Co-Authored-By: Claude Sonnet 4.6 --- specs/frontend/c3-variety-rework-v1-spec.html | 625 ++++++++++++++ specs/frontend/c3-variety-rework.html | 790 ++++++++++++++++++ 2 files changed, 1415 insertions(+) create mode 100644 specs/frontend/c3-variety-rework-v1-spec.html create mode 100644 specs/frontend/c3-variety-rework.html diff --git a/specs/frontend/c3-variety-rework-v1-spec.html b/specs/frontend/c3-variety-rework-v1-spec.html new file mode 100644 index 0000000..bc80f9e --- /dev/null +++ b/specs/frontend/c3-variety-rework-v1-spec.html @@ -0,0 +1,625 @@ + + + + + + Recipe App — C3 Abwechslungs-Analyse · Implementierungsspezifikation V1 + + + + +
+ + +
+
+

C3 — Abwechslungs-Analyse · Implementierungsspezifikation

+

Recipe App · Variation V1 "Erweiterte Karten" · Rezeptnamen + Tausch-Links in Warnkarten

+
+
+ Final
+ Erstellt: 2026-04
+ Screen: C3
+ Bezug: c3-variety-rework.html +
+
+ + + +
+ +

Die Seite /planner/variety zeigt derzeit Warnkarten mit technischen Tages-Codes (MON, WED — erwäge einen Tausch). Der Planer muss manuell nachschlagen, welches Gericht an diesen Tagen eingeplant ist, und dann zurück zum Planer navigieren um es zu tauschen.

+

V1 "Erweiterte Karten" löst dies mit minimalem Umbauaufwand: Die Warnkarten erhalten eine strukturierte Zeile pro betroffenem Tag — mit Wochentag-Abkürzung, Rezeptname und direktem "Tauschen →" Link. Score-Hero, Bewertungsdetails und das Gesamt-Layout bleiben unverändert.

+ +
+
Scope
+
    +
  • Kein neues Backend-Endpoint — alle nötigen Daten sind bereits im weekPlan-Load vorhanden
  • +
  • Kein Layout-Umbau — nur VarietyWarningCards.svelte und die Datenvorbereitung in +page.svelte ändern sich
  • +
  • Protein-Grid und EffortBar bleiben wie bisher (Desktop)
  • +
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementAktuellSoll (V1)
Warnkarte Inhalttitle + explanation (String)Strukturierte Zeilen: Wochentag · Rezeptname · Tauschen-Link
Tages-AngabeAPI-Code MON, WEDAbkürzung Mo, Mi
RezeptnameFehltAus weekPlan.slots[].recipe.name
Tausch-NavigationFehlt — Nutzer verlässt die Seite manuell/planner?week={weekStart}&swap={slotId}
DatenbasiscomputeWarnings() aus variety.tsInline $derived.by() in +page.svelte, direkt aus API-Daten
+
+ + + +
+ + +

Alle nötigen Daten werden bereits im Server-Load geladen. Kein neuer API-Call erforderlich.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QuelleFeldVerwendung
weekPlan.slots[]{ id, dayOfWeek, recipe: { id, name } }Aufbau der slotsByDay-Map: DayCode → { slotId, recipeName }
varietyScore.tagRepeats[]{ tagType, tagName, days: string[] }Warnkarten für wiederholte Tags (Protein, Cuisine). days[] enthält API-Codes: "MON", "TUE" …
varietyScore.ingredientOverlaps[]{ ingredientName, days: string[] }Warnkarten für Zutaten-Überschneidungen
varietyScore.duplicatesInPlan[]string[] (Rezeptnamen)Warnkarte: "X doppelt geplant". Alle Slots mit diesem Rezeptnamen liefern die Items.
data.weekStartstring (YYYY-MM-DD)Swap-URL-Parameter
+ +

Tag-Code → Abkürzung Mapping (konstant):

+
// Day code → German short label +const DAY_SHORT: Record<string, string> = { + MON: 'Mo', TUE: 'Di', WED: 'Mi', + THU: 'Do', FRI: 'Fr', SAT: 'Sa', SUN: 'So' +};
+
+ + + +
+ + +

Die bestehende VarietyWarningCards.svelte definiert bereits die korrekten Interfaces. Diese bleiben unverändert:

+ +
// In VarietyWarningCards.svelte (bereits vorhanden, nicht ändern) +interface WarningItem { + dayShort: string; // 'Mo', 'Di', … + recipeName: string; // aus weekPlan.slots[].recipe.name + slotId: number; // für Swap-Link +} + +interface ActionWarning { + title: string; // z.B. "Tofu mehrfach diese Woche" + items: WarningItem[]; // eine Zeile pro betroffenem Tag +}
+ +

Die alte Warning-Schnittstelle aus variety.ts ({ title, explanation }) wird nicht mehr verwendet.

+
+ + + +
+ + +

Es gibt drei Änderungen:

+ + +
+
+
5.1
+
+page.svelte — slotsByDay Map aufbauen
+
+
+

Füge direkt nach den bestehenden $derived-Deklarationen hinzu:

+
const DAY_SHORT: Record<string, string> = { + MON: 'Mo', TUE: 'Di', WED: 'Mi', + THU: 'Do', FRI: 'Fr', SAT: 'Sa', SUN: 'So' +}; + +// dayOfWeek (API code) → { slotId, recipeName } +let slotsByDay = $derived.by(() => { + const map: Record<string, { slotId: number; recipeName: string }> = {}; + for (const slot of weekPlan?.slots ?? []) { + if (slot.dayOfWeek && slot.recipe?.name && slot.id) { + map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name }; + } + } + return map; +});
+
+
+ + +
+
+
5.2
+
+page.svelte — actionWarnings ersetzen computeWarnings()
+
+
+

Ersetze den bestehenden let warnings = $derived.by(() => computeWarnings(…))-Block vollständig:

+
interface WarningItem { dayShort: string; recipeName: string; slotId: number; } +interface ActionWarning { title: string; items: WarningItem[]; } + +let actionWarnings = $derived.by((): ActionWarning[] => { + const result: ActionWarning[] = []; + const vs = varietyScore; + if (!vs) return result; + + // Tag repeats (protein, cuisine, …) + for (const repeat of vs.tagRepeats ?? []) { + if ((repeat.days?.length ?? 0) < 2) continue; + const items: WarningItem[] = (repeat.days ?? []) + .map((day) => { + const slot = slotsByDay[day]; + return slot + ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } + : null; + }) + .filter((x): x is WarningItem => x !== null); + if (items.length > 0) { + result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items }); + } + } + + // Ingredient overlaps + for (const overlap of vs.ingredientOverlaps ?? []) { + if ((overlap.days?.length ?? 0) < 2) continue; + const items: WarningItem[] = (overlap.days ?? []) + .map((day) => { + const slot = slotsByDay[day]; + return slot + ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } + : null; + }) + .filter((x): x is WarningItem => x !== null); + if (items.length > 0) { + result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items }); + } + } + + // Duplicate recipes — find all slots with that recipe name + for (const name of vs.duplicatesInPlan ?? []) { + const items: WarningItem[] = Object.entries(slotsByDay) + .filter(([, s]) => s.recipeName === name) + .map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId })); + if (items.length > 0) { + result.push({ title: `${name} doppelt geplant`, items }); + } + } + + return result; +});
+
+
+ + +
+
+
5.3
+
+page.svelte — Template: warnings → actionWarnings, weekStart übergeben
+
+
+

An beiden Stellen im Template (Mobile + Desktop) ersetzen:

+
+
+page.svelte (Mobile, ~Zeile 110 / Desktop, ~Zeile 222)
+
- {#if warnings.length > 0} +- <VarietyWarningCards {warnings} /> ++ {#if actionWarnings.length > 0} ++ <VarietyWarningCards warnings={actionWarnings} {weekStart} />
+
+

Achtung: weekStart ist für die Swap-URL erforderlich und muss explizit übergeben werden.

+
+
+ + +
+
+
5.4
+
+page.svelte — Import aufräumen
+
+
+

Entferne den nicht mehr genutzten Import:

+
+
+page.svelte (Script-Block, oben)
+
- import { computeSubScores, computeWarnings } from '$lib/planner/variety'; ++ import { computeSubScores } from '$lib/planner/variety';
+
+

computeSubScores wird noch für die Score-Breakdown-Anzeige genutzt.

+
+
+
+ + + +
+ + +

Die Komponente wurde bereits auf das neue ActionWarning-Format aktualisiert. Keine Änderung erforderlich. Zur Referenz die erwartete Props-Schnittstelle:

+ +
// Props (bereits implementiert) +let { warnings, weekStart }: { + warnings: ActionWarning[]; + weekStart: string; +} = $props();
+ +

Die Komponente rendert für jede Warnung:

+
    +
  • Gelbe Karte (border: yellow-light, bg: yellow-tint) mit Header-Zeile (Titel)
  • +
  • Pro Item: Zeile mit Wochentag-Abkürzung (W=20px, fixed) · Rezeptname (truncate) · "Tauschen →" Link (rechts)
  • +
  • Swap-URL: /planner?week={weekStart}&swap={item.slotId}
  • +
+ + +
+
+
Warnkarte · Referenz-Darstellung
+
+
+
Tofu mehrfach diese Woche
+
+
MoTofu-Gemüse-Pfanne
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+
+
Paprika in mehreren Gerichten
+
+
DiPaprika-Linsen-Eintopf
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FallVerhalten
Tag im tagRepeat hat keinen SlotFilter-Schritt (.filter(x => x !== null)) entfernt das Item. Warnkarte erscheint nur wenn ≥1 Item vorhanden.
weekPlan hat keine Slots (leere Woche)slotsByDay ist {}, actionWarnings ist []. Keine Warnkarten sichtbar.
varietyScore ist nullBestehende {#if !varietyScore}-Guard greift — actionWarnings wird nie gerendert.
Slot hat kein Rezept (slot.recipe === null)slot.recipe?.name ist undefined → Slot wird nicht in slotsByDay aufgenommen.
duplicatesInPlan: Rezeptname kommt in slotsByDay nicht voritems ist leer → Warnkarte wird nicht gepusht.
Unbekannter Tag-Code (z.B. zukünftige API-Erweiterung)DAY_SHORT[day] ?? day — Fallback auf den rohen Code.
Sehr langer RezeptnameCSS truncate auf .wcard-recipe — kein Überlauf, Swap-Link bleibt sichtbar.
+
+ + + +
+ + +
+
Acceptance Criteria
+
    +
  • AC-1: Warnkarte zeigt pro betroffenem Tag eine eigene Zeile (nicht mehr einen langen Erklärungstext)
  • +
  • AC-2: Jede Zeile enthält die deutsche Wochentag-Abkürzung (Mo, Di, Mi, Do, Fr, Sa, So)
  • +
  • AC-3: Jede Zeile enthält den Namen des eingeplanten Rezepts
  • +
  • AC-4: Jede Zeile enthält einen "Tauschen →" Link, der zu /planner?week={weekStart}&swap={slotId} führt
  • +
  • AC-5: Tags mit nur einem betroffenen Tag (days.length < 2) erzeugen keine Warnkarte
  • +
  • AC-6: Score-Hero, Bewertungsdetails und Protein-Grid (Desktop) bleiben unverändert
  • +
  • AC-7: Wenn varietyScore null ist, werden keine Warnkarten gerendert (leere-Woche-State bleibt)
  • +
  • AC-8: Der Import von computeWarnings ist entfernt, TypeScript kompiliert fehlerfrei
  • +
  • AC-9: Auf Mobilgerät sind Tausch-Links touch-freundlich (mind. 44px Zeilenhöhe)
  • +
+
+ +
+
Nicht in Scope
+
    +
  • Neues Backend-Endpoint — alle Daten kommen aus dem bestehenden Load
  • +
  • Layout-Umbau der Seite — Score bleibt oben, Warnungen unten wie bisher
  • +
  • Protein-Grid oder EffortBar Änderungen
  • +
  • computeSubScores aus variety.ts — bleibt unverändert
  • +
  • Entfernen von computeWarnings aus variety.ts (Funktion bleibt, wird nur nicht mehr aufgerufen)
  • +
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
DateiÄnderung
frontend/src/routes/(app)/planner/variety/+page.svelteDAY_SHORT-Konstante, slotsByDay-Derived, actionWarnings-Derived, Template-Update (2×), Import-Bereinigung
frontend/src/lib/planner/VarietyWarningCards.svelteKeine — bereits auf ActionWarning-Format aktualisiert
frontend/src/lib/planner/variety.tsKeine — computeWarnings bleibt (ungenutzt, aber nicht entfernen um Regressions-Risiko zu vermeiden)
+
+ + + +
+ +

Dieser Abschnitt enthält maschinenlesbare Regeln für einen KI-Agenten der die Implementierung durchführt.

+ +
SCREEN: C3 /planner/variety +VARIATION: V1 "Erweiterte Karten" +STATUS: Final spec — ready for implementation + +FILES TO MODIFY: + frontend/src/routes/(app)/planner/variety/+page.svelte + +FILES NOT TO MODIFY: + frontend/src/lib/planner/VarietyWarningCards.svelte (already correct) + frontend/src/lib/planner/variety.ts (keep computeWarnings, remove only import) + +STEP 1 — Add DAY_SHORT constant (in <script> block, after imports): + const DAY_SHORT: Record<string, string> = { + MON: 'Mo', TUE: 'Di', WED: 'Mi', + THU: 'Do', FRI: 'Fr', SAT: 'Sa', SUN: 'So' + }; + +STEP 2 — Add slotsByDay derived (after $derived declarations for weekPlan, etc.): + let slotsByDay = $derived.by(() => { + const map: Record<string, { slotId: number; recipeName: string }> = {}; + for (const slot of weekPlan?.slots ?? []) { + if (slot.dayOfWeek && slot.recipe?.name && slot.id) { + map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name }; + } + } + return map; + }); + +STEP 3 — Define inline interfaces + actionWarnings derived: + interface WarningItem { dayShort: string; recipeName: string; slotId: number; } + interface ActionWarning { title: string; items: WarningItem[]; } + + let actionWarnings = $derived.by((): ActionWarning[] => { + const result: ActionWarning[] = []; + const vs = varietyScore; + if (!vs) return result; + + for (const repeat of vs.tagRepeats ?? []) { + if ((repeat.days?.length ?? 0) < 2) continue; + const items: WarningItem[] = (repeat.days ?? []) + .map((day) => { + const slot = slotsByDay[day]; + return slot ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } : null; + }) + .filter((x): x is WarningItem => x !== null); + if (items.length > 0) result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items }); + } + + for (const overlap of vs.ingredientOverlaps ?? []) { + if ((overlap.days?.length ?? 0) < 2) continue; + const items: WarningItem[] = (overlap.days ?? []) + .map((day) => { + const slot = slotsByDay[day]; + return slot ? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId } : null; + }) + .filter((x): x is WarningItem => x !== null); + if (items.length > 0) result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items }); + } + + for (const name of vs.duplicatesInPlan ?? []) { + const items: WarningItem[] = Object.entries(slotsByDay) + .filter(([, s]) => s.recipeName === name) + .map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId })); + if (items.length > 0) result.push({ title: `${name} doppelt geplant`, items }); + } + + return result; + }); + +STEP 4 — Replace template occurrences (both mobile and desktop sections): + OLD: {#if warnings.length > 0} / <VarietyWarningCards {warnings} /> + NEW: {#if actionWarnings.length > 0} / <VarietyWarningCards warnings={actionWarnings} {weekStart} /> + +STEP 5 — Fix import: + OLD: import { computeSubScores, computeWarnings } from '$lib/planner/variety'; + NEW: import { computeSubScores } from '$lib/planner/variety'; + +INVARIANTS (do not change): + - VarietyScoreHero, ScoreBreakdownList, EffortBar remain untouched + - Desktop protein grid (proteinByDay) remains untouched + - Layout structure (score top, warnings bottom) stays identical + - No new server load or API calls
+
+ +
+ + diff --git a/specs/frontend/c3-variety-rework.html b/specs/frontend/c3-variety-rework.html new file mode 100644 index 0000000..cc51098 --- /dev/null +++ b/specs/frontend/c3-variety-rework.html @@ -0,0 +1,790 @@ + + + + + + Recipe App — C3 Abwechslungs-Analyse · 3 Mockup-Variationen + + + + +
+ + +
+
+

C3 — Abwechslungs-Analyse · Rework

+

Recipe App · 3 Mockup-Variationen · Aktuell: technische Tages-Codes, keine Rezeptnamen, kein direkter Tausch

+
+
+ Entwurf
+ Erstellt: 2026-04
+ Variationen: 3
+ Screen: C3 +
+
+ + +
+ +

Die aktuelle Seite zeigt Warnungen wie "MON, WED — erwäge einen Tausch". Der Planer muss selbst nachschlagen, welches Gericht an Montag und Mittwoch geplant ist, und dann manuell zum Planer navigieren um zu tauschen. Zwei Probleme:

+

1. Keine Rezeptnamen — Tag-Codes statt echter Gerichte. 2. Kein direkter Tausch — der Planer muss die Seite verlassen, zurück zum Planer, das richtige Gericht suchen und dann tauschen.

+
+ + + +
+
+
V1
+
+
Variation 1
+
Erweiterte Karten
+
Minimale Änderung: bestehende gelbe Karten bleiben, aber der Text wird durch strukturierte Zeilen ersetzt — eine pro betroffenem Gericht, mit Wochentag, Rezeptname und "Tauschen →" Link. Score-Bereich und Layout bleiben unverändert.
+
+
+ +
+ + +
+
Mobile · 320px
+
+
9:41●●●
+ +
+
+
Abwechslungs-Analyse
+
+
+ + +
+
+ 5.8 + / 10 + Verbesserbar +
+
+
+ + +
+
Bewertung im Detail
+
Quellen-Vielfalt4 / 10
+
Zutaten-Überschneidung7 / 10
+
Aufwandsbalance8 / 10
+
+ + +
Hinweise
+ +
+
Tofu mehrfach diese Woche
+
+
MoTofu-Gemüse-Pfanne
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+ +
+
Paprika in mehreren Gerichten
+
+
DiPaprika-Linsen-Eintopf
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+ +
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
📊
Analyse
+
+
+
+ + +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
+ Planer / + Abwechslungs-Analyse +
+
+ +
+ +
+
+ 5.8 + / 10 + Verbesserbar +
+
+
+
Bewertung im Detail
+
Quellen-Vielfalt4 / 10
+
Zutaten-Überschneidung7 / 10
+
Aufwandsbalance8 / 10
+
+
+ +
+
Hinweise
+
+
Tofu mehrfach diese Woche
+
+
MoTofu-Gemüse-Pfanne
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+
+
Paprika in mehreren Gerichten
+
+
DiPaprika-Linsen-Eintopf
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+
+
+
+
+
+
+
+ +
+
Design-Notizen V1
+
    +
  • Geringster Umbauaufwand — nur VarietyWarningCards.svelte ändert sich, keine Layout-Umstrukturierung.
  • +
  • Behält die bekannte Score-Hierarchie bei: Zahl oben, dann Detail, dann Hinweise.
  • +
  • Schwachstelle: Hinweise sind trotzdem am Ende der Seite versteckt — auf kurzen Telefon-Bildschirmen muss gescrollt werden, bevor der Planer die Tausch-Links sieht.
  • +
  • Die Sub-Scores bleiben immer sichtbar, auch wenn der Planer nur die Tausch-Aktionen braucht.
  • +
+
+
+ +
+ + + +
+
+
V2
+
+
Variation 2 · Empfohlen
+
Aktions-Liste
+
Hinweise rücken nach oben — direkt unter dem Score. Der Planer sieht sofort, was zu tun ist. Sub-Scores wandern in ein ausklappbares "Bewertung im Detail" (native <details>, kein JS). Kompakterer Score-Hero gibt Hinweisen mehr Raum.
+
+
+ +
+ + +
+
Mobile · 320px
+
+
9:41●●●
+
+
+
Abwechslungs-Analyse
+
+
+ + +
+
+ 5.8 + / 10 +
+
+
Verbesserbar
+
+
+
+ + +
2 Hinweise
+ +
+
Tofu mehrfach diese Woche
+
+
MoTofu-Gemüse-Pfanne
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+ +
+
Paprika in mehreren Gerichten
+
+
DiPaprika-Linsen-Eintopf
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+ + +
+ Bewertung im Detail +
+
Quellen-Vielfalt4 / 10
+
Zutaten-Überschneidung7 / 10
+
Aufwandsbalance8 / 10
+
+
+ +
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
📊
Analyse
+
+
+
+ + +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
+ Planer / + Abwechslungs-Analyse +
+
+ +
+
+ 5.8 + / 10 +
+
+
Verbesserbar — 2 Hinweise
+
+
+ +
+
+
4
+
Quellen
+
+
+
7
+
Zutaten
+
+
+
8
+
Aufwand
+
+
+
+ +
Hinweise
+
+
+
Tofu mehrfach diese Woche
+
+
MoTofu-Gemüse-Pfanne
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+
+
Paprika in mehreren Gerichten
+
+
DiPaprika-Linsen-Eintopf
+ Tauschen → +
+
+
MiTofu-Curry mit Reis
+ Tauschen → +
+
+
+
+
+
+
+
+ +
+
Design-Notizen V2
+
    +
  • Hinweise erscheinen direkt unter dem Score — kein Scrollen nötig auf typischen Telefon-Bildschirmen.
  • +
  • Kompakter Score-Strip auf Mobile spart ~80px gegenüber dem aktuellen großen Hero — mehr Raum für die eigentlichen Tausch-Aktionen.
  • +
  • Desktop: Sub-Scores werden als kompakte Zahlen-Spalte in die Score-Leiste integriert — kein separater Abschnitt mehr nötig.
  • +
  • Native <details> auf Mobile braucht kein JavaScript; funktioniert auch ohne hydration.
  • +
  • "2 Hinweise" im Score-Strip auf Desktop gibt dem Planer sofort Kontext, ohne zu scrollen.
  • +
+
+
+ +
+ + + +
+
+
V3
+
+
Variation 3
+
Hinweise zuerst
+
Invertiertes Layout: die Seite öffnet mit den konkreten Problem-Karten — groß und klar. Score und Breakdown erscheinen darunter als unterstützende Information. Jede Warnung ist eine eigenständige "Aufgaben-Karte" mit prominentem Tausch-Button statt Link.
+
+
+ +
+ + +
+
Mobile · 320px
+
+
9:41●●●
+
+
+
Abwechslungs-Analyse
+
+
+ + +
Was zu tun ist
+ + +
+
+
Quellen-Wiederholung
+
Tofu an 2 Tagen
+
+ +
+
+
Montag
+
Tofu-Gemüse-Pfanne
+
+ Tauschen +
+ +
+
+
Mittwoch
+
Tofu-Curry mit Reis
+
+ Tauschen +
+
+ + +
+
+
Zutaten-Überschneidung
+
Paprika an 2 aufeinanderfolgenden Tagen
+
+
+
+
Dienstag
+
Paprika-Linsen-Eintopf
+
+ Tauschen +
+
+
+
Mittwoch
+
Tofu-Curry mit Reis
+
+ Tauschen +
+
+ + +
+
Gesamt-Score
+
+ 5.8 + / 10 · Verbesserbar +
+
+
+
+ Aufschlüsselung anzeigen +
+
Quellen-Vielfalt4 / 10
+
Zutaten-Überschneidung7 / 10
+
Aufwandsbalance8 / 10
+
+
+
+
+ +
+
+
📅
Plan
+
🛒
Einkauf
+
🍳
Rezepte
+
📊
Analyse
+
+
+
+ + +
+
Desktop · 1040px
+
+
+ +
+
Planung
+
📅 Wochenplan
+
🛒 Einkaufsliste
+
🍳 Rezepte
+
Haushalt
+
⚙️ Einstellungen
+
+
+
+
+ Planer / + Abwechslungs-Analyse +
+
+
+ +
+
Was zu tun ist
+ +
+
+
Quellen-Wiederholung
+
Tofu an 2 Tagen
+
+
+
+ Montag + Tofu-Gemüse-Pfanne +
+ Tauschen +
+
+
+ Mittwoch + Tofu-Curry mit Reis +
+ Tauschen +
+
+ +
+
+
Zutaten-Überschneidung
+
Paprika an 2 aufeinanderfolgenden Tagen
+
+
+
+ Dienstag + Paprika-Linsen-Eintopf +
+ Tauschen +
+
+
+ Mittwoch + Tofu-Curry mit Reis +
+ Tauschen +
+
+
+ + +
+
+
Score
+
+ 5.8 + / 10 +
+
Verbesserbar
+
+
+
+
Aufschlüsselung
+
Quellen4 / 10
+
Zutaten7 / 10
+
Aufwand8 / 10
+
+
+
+
+
+
+
+
+ +
+
Design-Notizen V3
+
    +
  • Klarer Fokus: Das erste, was der Planer sieht, ist "Was zu tun ist" — keine Score-Hierarchie die von der Aktion ablenkt.
  • +
  • Prominente "Tauschen"-Buttons (gefüllt, dunkelgelb) statt Links — erhöht die Tipp-Fläche auf Mobile und macht die Aktion offensichtlicher.
  • +
  • Voller Wochentag ("Montag" statt "Mo") — lesbarer, besonders auf Desktop.
  • +
  • Schwachstelle: Wenn es keine Hinweise gibt (Score ≥ 9), wirkt die Seite leer — der Score müsste dann nach oben rücken. Erfordert einen separaten Empty-State.
  • +
  • Höherer Umbauaufwand gegenüber V1 und V2 — die Page-Struktur ändert sich grundlegend.
  • +
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumV1 Erweiterte KartenV2 Aktions-Liste ★V3 Hinweise zuerst
Rezeptnamen sichtbar✓ Ja✓ Ja✓ Ja, prominent
Direkter TauschLinkLinkButton (größere Tap-Fläche)
Hinweise sichtbar ohne ScrollenNein (Score + Breakdown zuerst)Ja (direkt unter kompaktem Score)Ja (ganz oben)
UmbauaufwandNiedrigMittelHoch
Layout-ÄnderungKeineScore kompakter, Details kollabierbarGrundlegende Umstrukturierung
EmpfehlungWenn schnelle Lieferung PrioEmpfohlen ★Wenn Aktions-Fokus Prio
+
+ +
+ + From b673a466e91d2653230b34b86e2db9dfdf7b2241 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:33:52 +0200 Subject: [PATCH 06/36] feat(planner): replace simulatedScore with scoreDelta + hasConflict in SuggestionItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and hasConflict (scoreDelta ≤ 0) so the frontend can render badges without needing to pass currentVarietyScore as a separate prop. PlanningService.getSuggestions() computes currentScore once per request and derives scoreDelta + hasConflict per candidate. Sorting is unchanged (scoreDelta desc = simulatedScore desc since currentScore is constant). Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/PlanningService.java | 14 +- .../planning/dto/SuggestionResponse.java | 3 +- .../recipeapp/planning/SuggestionsTest.java | 182 ++++++++++++++---- .../planning/WeekPlanControllerTest.java | 5 +- 4 files changed, 165 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 0e39c54..356c694 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -135,6 +135,12 @@ public class PlanningService { .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); + List currentSlots = plan.getSlots().stream() + .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) + .toList(); + double currentScore = currentSlots.isEmpty() ? 10.0 + : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); Set lowerTagFilters = tagFilters.stream() @@ -145,11 +151,13 @@ public class PlanningService { .filter(r -> !usedRecipeIds.contains(r.getId())) .filter(r -> matchesAllTags(r, lowerTagFilters)) .map(candidate -> { - double score = simulateVarietyScore( + double simulatedScore = simulateVarietyScore( plan, candidate, slotDate, config, recentlyCookedIds); - return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score); + double scoreDelta = simulatedScore - currentScore; + boolean hasConflict = scoreDelta <= 0; + return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict); }) - .sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore())) + .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta())) .limit(limit) .toList(); diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java index 7c0f4ed..1844fcc 100644 --- a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java +++ b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java @@ -6,6 +6,7 @@ public record SuggestionResponse(List suggestions) { public record SuggestionItem( SlotResponse.SlotRecipe recipe, - double simulatedScore + double scoreDelta, + boolean hasConflict ) {} } diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 3e7495f..97eecb2 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -165,7 +165,7 @@ class SuggestionsTest { } @Test - void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() { + void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() { var plan = createPlan(); var r1 = createRecipe("Pasta"); var r2 = createRecipe("Salad"); @@ -179,8 +179,9 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(3); + // Empty plan → currentScore = 10.0; no conflicts → scoreDelta = 0.0 for all assertThat(result.suggestions()).allSatisfy(s -> - assertThat(s.simulatedScore()).isEqualTo(10.0)); + assertThat(s.scoreDelta()).isEqualTo(0.0)); } @Test @@ -221,6 +222,117 @@ class SuggestionsTest { } } + // ═══════════════════════════════════════════════════════════ + // Category 1b: scoreDelta and hasConflict + // ═══════════════════════════════════════════════════════════ + + @Nested + class ScoreDeltaAndHasConflict { + + @Test + void recipeWithNoConflictsOnEmptyPlanShouldHaveZeroDeltaAndHasConflict() { + // Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0. + // scoreDelta = 0.0, hasConflict = (0.0 <= 0) = true + var plan = createPlan(); + var recipe = createRecipe("Clean Recipe"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(recipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isEqualTo(0.0); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void recipeWithTagConflictShouldHaveNegativeDeltaAndHasConflict() { + // Existing slot Mon=Monday Pasta (cuisine tag). Adding Tue=More Pasta → tag repeat penalty (-1.5). + // currentScore = 10.0 (1 slot, no consecutive). simulatedScore = 10.0 - 1.5 = 8.5. + // scoreDelta = -1.5, hasConflict = true. + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("More Pasta"); + addTag(candidate, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isEqualTo(-1.5); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void recipeWithIngredientConflictShouldHaveNegativeDeltaAndHasConflict() { + // Existing slot Mon=Tomato Soup (tomato ingredient). Adding Tue=Tomato Pasta → overlap (-0.3). + // currentScore = 10.0, simulatedScore = 9.7, scoreDelta = -0.3, hasConflict = true. + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var existingRecipe = createRecipe("Tomato Soup"); + addIngredient(existingRecipe, tomato); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("Tomato Pasta"); + addIngredient(candidate, tomato); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isCloseTo(-0.3, within(0.001)); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void scoreDeltaIsSortedDescendingCleanBeforeConflicting() { + // Clean recipe (scoreDelta = 0.0) should rank above conflicting (scoreDelta < 0). + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var cleanRecipe = createRecipe("Plain Rice"); + var conflictingRecipe = createRecipe("More Pasta"); + addTag(conflictingRecipe, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, cleanRecipe, conflictingRecipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); + assertThat(result.suggestions().get(0).scoreDelta()).isEqualTo(0.0); + assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("More Pasta"); + assertThat(result.suggestions().get(1).scoreDelta()).isEqualTo(-1.5); + } + } + // ═══════════════════════════════════════════════════════════ // Category 2: Exclusion of In-Plan Recipes // ═══════════════════════════════════════════════════════════ @@ -402,8 +514,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); // B should rank higher (no tag penalty) assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -428,8 +540,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(2); - assertThat(result.suggestions().get(0).simulatedScore()) - .isEqualTo(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isEqualTo(result.suggestions().get(1).scoreDelta()); } @Test @@ -453,8 +565,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - // No penalty — dietary not tracked - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No penalty — dietary not tracked → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -492,8 +604,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -519,7 +631,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // Staples ignored → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -547,8 +660,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -566,7 +679,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No penalty → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -631,7 +745,7 @@ class SuggestionsTest { } @Test - void rankingOrderShouldBeBySimulatedScoreDescending() { + void rankingOrderShouldBeByScoreDeltaDescending() { var plan = createPlan(); var pastaTag = createTag("Pasta", "cuisine"); var tomato = createIngredient("Tomatoes", false); @@ -666,11 +780,11 @@ class SuggestionsTest { assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta"); assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta"); - // Verify scores are strictly descending - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); - assertThat(result.suggestions().get(1).simulatedScore()) - .isGreaterThan(result.suggestions().get(2).simulatedScore()); + // Verify scoreDelta is strictly descending + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); + assertThat(result.suggestions().get(1).scoreDelta()) + .isGreaterThan(result.suggestions().get(2).scoreDelta()); } @Test @@ -688,8 +802,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(2); - assertThat(result.suggestions().get(0).simulatedScore()) - .isEqualTo(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isEqualTo(result.suggestions().get(1).scoreDelta()); } } @@ -726,7 +840,7 @@ class SuggestionsTest { addTag(c1, pastaTag); addIngredient(c1, tomato); - // Candidate 2: Chicken only → protein repeat with Mon + // Candidate 2: Chicken only → protein repeat with Mon (Mon→Wed not consecutive) var c2 = createRecipe("Chicken Salad"); addTag(c2, chickenTag); @@ -745,7 +859,7 @@ class SuggestionsTest { stubPlan(plan); stubDefaultConfig(); stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5); - // c1 was cooked recently + // c1 was cooked recently (within 14-day window) stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3))); // Slot date = Wednesday (adjacent to Tuesday) @@ -754,19 +868,20 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(5); - // c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive) + // currentScore = 10.0 (Mon+Tue plan: no consecutive conflicts between just those 2 slots) + // c2, c4, c5: no additional conflicts → scoreDelta = 0.0 var topThree = result.suggestions().subList(0, 3); assertThat(topThree).extracting(s -> s.recipe().name()) .containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup"); - assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0)); + assertThat(topThree).allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0)); - // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3 + // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: scoreDelta = -0.3 assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette"); - assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001)); + assertThat(result.suggestions().get(3).scoreDelta()).isCloseTo(-0.3, within(0.001)); - // c1 (Tomato Spaghetti) has recent repeat: -1.0 + // c1 (Tomato Spaghetti) has recent repeat: scoreDelta = -1.0 assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti"); - assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0); + assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0); } @Test @@ -800,7 +915,7 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of("Quick meal"), 5); - // Only quick recipes, ranked by variety + // Only quick recipes, ranked by scoreDelta desc assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad"); assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta"); @@ -815,7 +930,7 @@ class SuggestionsTest { class EdgeCases { @Test - void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() { + void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() { var plan = createPlan(); var existingRecipe = createRecipe("Existing"); addSlot(plan, existingRecipe, MONDAY); @@ -832,7 +947,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No conflicts → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } @Test diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index e827493..77cd512 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -162,7 +162,7 @@ class WeekPlanControllerTest { @Test void getSuggestionsShouldReturn200() throws Exception { var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); - var item = new SuggestionResponse.SuggestionItem(recipe, 9.5); + var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false); var response = new SuggestionResponse(List.of(item)); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); @@ -175,7 +175,8 @@ class WeekPlanControllerTest { .param("slotDate", "2026-04-08")) .andExpect(status().isOk()) .andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry")) - .andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5)); + .andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5)) + .andExpect(jsonPath("$.suggestions[0].hasConflict").value(false)); } @Test From cd7f4a1ea0200a83f47e816d37c0096fb2e82223 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:35:33 +0200 Subject: [PATCH 07/36] chore(planner): delete orphaned SuggestionCard component and test Unused since the suggestions route was removed (commit 4333dc0). RecipePicker.test.ts is the active coverage for suggestion rendering. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/SuggestionCard.svelte | 83 ------------------- .../src/lib/planner/SuggestionCard.test.ts | 60 -------------- 2 files changed, 143 deletions(-) delete mode 100644 frontend/src/lib/planner/SuggestionCard.svelte delete mode 100644 frontend/src/lib/planner/SuggestionCard.test.ts diff --git a/frontend/src/lib/planner/SuggestionCard.svelte b/frontend/src/lib/planner/SuggestionCard.svelte deleted file mode 100644 index 3d3f85e..0000000 --- a/frontend/src/lib/planner/SuggestionCard.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -
- -
- {rank} -
- - -
-

- {suggestion.recipe?.name ?? 'Unbekanntes Rezept'} -

- {#if metadata} -

{metadata}

- {/if} - - - {#if suggestion.reasoningType && suggestion.reasoningLabel} -
- {suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel} -
- {/if} -
- - -
- - - - - -
-
diff --git a/frontend/src/lib/planner/SuggestionCard.test.ts b/frontend/src/lib/planner/SuggestionCard.test.ts deleted file mode 100644 index 48ad994..0000000 --- a/frontend/src/lib/planner/SuggestionCard.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import SuggestionCard from './SuggestionCard.svelte'; - -const goodSuggestion = { - recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 }, - simulatedScore: 9.2, - reasoningType: 'good' as const, - reasoningLabel: 'Frisches Protein · Aufwandsbalance' -}; - -const warningSuggestion = { - recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 }, - simulatedScore: 6.1, - reasoningType: 'warning' as const, - reasoningLabel: 'Hähnchen schon 2 Tage dabei' -}; - -describe('SuggestionCard', () => { - it('renders recipe name', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText('Pasta al Limone')).toBeTruthy(); - }); - - it('renders rank number', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText('1')).toBeTruthy(); - }); - - it('renders cook time and effort metadata', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText(/25 Min/)).toBeTruthy(); - expect(screen.getByText(/Easy/)).toBeTruthy(); - }); - - it('renders green reasoning badge for good suggestions', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - const badge = screen.getByTestId('reasoning-badge'); - expect(badge.getAttribute('data-type')).toBe('good'); - expect(badge.textContent).toContain('Frisches Protein'); - }); - - it('renders yellow reasoning badge for warnings', () => { - render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - const badge = screen.getByTestId('reasoning-badge'); - expect(badge.getAttribute('data-type')).toBe('warning'); - expect(badge.textContent).toContain('Hähnchen'); - }); - - it('renders a pick button/form', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy(); - }); - - it('card without reasoning renders without crashing', () => { - const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined }; - render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText('Pasta al Limone')).toBeTruthy(); - }); -}); From 257808016d44bb8b5f096da8ac478a74403ce218 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:35:57 +0200 Subject: [PATCH 08/36] =?UTF-8?q?chore(api):=20update=20SuggestionItem=20s?= =?UTF-8?q?chema=20=E2=80=94=20scoreDelta=20+=20hasConflict=20replace=20si?= =?UTF-8?q?mulatedScore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api/openapi.json | 2 +- frontend/src/lib/api/schema.d.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/api/openapi.json b/frontend/src/lib/api/openapi.json index 0b6b1ec..91b09db 100644 --- a/frontend/src/lib/api/openapi.json +++ b/frontend/src/lib/api/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/v1/recipes/{id}":{"get":{"tags":["recipe-controller"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"put":{"tags":["recipe-controller"],"operationId":"updateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"delete":{"tags":["recipe-controller"],"operationId":"deleteRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/v1/week-plans":{"get":{"tags":["week-plan-controller"],"operationId":"getWeekPlan","parameters":[{"name":"weekStart","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}},"post":{"tags":["week-plan-controller"],"operationId":"createWeekPlan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWeekPlanRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/week-plans/{id}/slots":{"post":{"tags":["week-plan-controller"],"operationId":"addSlot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/week-plans/{id}/shopping-list":{"post":{"tags":["shopping-list-controller"],"operationId":"generateFromPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/week-plans/{id}/confirm":{"post":{"tags":["week-plan-controller"],"operationId":"confirmPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/tags":{"get":{"tags":["tag-controller"],"operationId":"listTags","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"post":{"tags":["tag-controller"],"operationId":"createTag","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"/v1/shopping-lists/{id}/items":{"post":{"tags":["shopping-list-controller"],"operationId":"addItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddItemRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/recipes":{"get":{"tags":["recipe-controller"],"operationId":"listRecipes","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"effort","in":"query","required":false,"schema":{"type":"string"}},{"name":"isChildFriendly","in":"query","required":false,"schema":{"type":"boolean"}},{"name":"cookTimeMin.lte","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListRecipeSummaryResponse"}}}}}},"post":{"tags":["recipe-controller"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}}},"/v1/pantry-items":{"get":{"tags":["pantry-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"post":{"tags":["pantry-controller"],"operationId":"createItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/invites/{code}/accept":{"post":{"tags":["household-controller"],"operationId":"acceptInvite","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAcceptInviteResponse"}}}}}}},"/v1/ingredient-categories":{"get":{"tags":["ingredient-category-controller"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"post":{"tags":["ingredient-category-controller"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientCategoryCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"/v1/households":{"post":{"tags":["household-controller"],"operationId":"createHousehold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHouseholdRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/invites":{"post":{"tags":["household-controller"],"operationId":"createInvite","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseInviteResponse"}}}}}}},"/v1/cooking-logs":{"get":{"tags":["cooking-log-controller"],"operationId":"listCookingLogs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":30}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"post":{"tags":["cooking-log-controller"],"operationId":"createCookingLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCookingLogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"/v1/auth/signup":{"post":{"tags":["auth-controller"],"operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/auth/logout":{"post":{"tags":["auth-controller"],"operationId":"logout","responses":{"200":{"description":"OK"}}}},"/v1/auth/login":{"post":{"tags":["auth-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isActive","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAdminUserResponse"}}}}}},"post":{"tags":["admin-controller"],"operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/admin/users/{id}/reset-password":{"post":{"tags":["admin-controller"],"operationId":"resetPassword","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseResetPasswordResponse"}}}}}}},"/v1/week-plans/{planId}/slots/{slotId}":{"delete":{"tags":["week-plan-controller"],"operationId":"deleteSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["week-plan-controller"],"operationId":"updateSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/shopping-lists/{listId}/items/{itemId}":{"delete":{"tags":["shopping-list-controller"],"operationId":"deleteItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["shopping-list-controller"],"operationId":"checkItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/pantry-items/{id}":{"delete":{"tags":["pantry-controller"],"operationId":"deleteItem_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["pantry-controller"],"operationId":"updateItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/ingredients/{id}":{"patch":{"tags":["ingredient-controller"],"operationId":"patchIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientPatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth-controller"],"operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}},"patch":{"tags":["auth-controller"],"operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users/{id}":{"patch":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/week-plans/{id}/variety-score":{"get":{"tags":["week-plan-controller"],"operationId":"getVarietyScore","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VarietyScoreResponse"}}}}}}},"/v1/week-plans/{id}/suggestions":{"get":{"tags":["week-plan-controller"],"operationId":"getSuggestions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuggestionResponse"}}}}}}},"/v1/shopping-lists/{id}":{"get":{"tags":["shopping-list-controller"],"operationId":"getShoppingList","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/shopping-list":{"get":{"tags":["shopping-list-controller"],"operationId":"getByWeekStart","parameters":[{"name":"weekStart","in":"query","required":false,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/ingredients":{"get":{"tags":["ingredient-controller"],"operationId":"searchIngredients","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isStaple","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}}},"/v1/households/mine":{"get":{"tags":["household-controller"],"operationId":"getMyHousehold","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/members":{"get":{"tags":["household-controller"],"operationId":"getMembers","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}}}}}},"/v1/admin/audit-log":{"get":{"tags":["admin-controller"],"operationId":"listAuditLog","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"targetUserId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAuditLogResponse"}}}}}}}},"components":{"schemas":{"IngredientEntry":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"newIngredientName":{"type":"string","maxLength":200,"minLength":0},"quantity":{"type":"number","minimum":0.01},"unit":{"type":"string","maxLength":20,"minLength":0},"sortOrder":{"type":"integer","format":"int32"}},"required":["quantity","unit"]},"RecipeCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"minLength":0},"serves":{"type":"integer","format":"int32","maximum":20,"minimum":1},"cookTimeMin":{"type":"integer","format":"int32","minimum":0},"effort":{"type":"string","minLength":1,"pattern":"easy|medium|hard"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string","maxLength":500,"minLength":0},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientEntry"},"minItems":1},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepEntry"}},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}},"required":["effort","ingredients","name","tagIds"]},"StepEntry":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32","minimum":1},"instruction":{"type":"string","minLength":1}},"required":["instruction"]},"CategoryRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"IngredientItem":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"sortOrder":{"type":"integer","format":"int32"}}},"RecipeDetailResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientItem"}},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepItem"}},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}},"StepItem":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32"},"instruction":{"type":"string"}}},"TagItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"CreateWeekPlanRequest":{"type":"object","properties":{"weekStart":{"type":"string","format":"date"}},"required":["weekStart"]},"SlotRecipe":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"effort":{"type":"string"},"cookTimeMin":{"type":"integer","format":"int32"},"heroImageUrl":{"type":"string"}}},"SlotResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slotDate":{"type":"string","format":"date"},"recipe":{"$ref":"#/components/schemas/SlotRecipe"}}},"WeekPlanResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekStart":{"type":"string","format":"date"},"status":{"type":"string"},"confirmedAt":{"type":"string","format":"date-time"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/SlotResponse"}}}},"CreateSlotRequest":{"type":"object","properties":{"slotDate":{"type":"string","format":"date"},"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId","slotDate"]},"RecipeRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"ShoppingListItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"isChecked":{"type":"boolean"},"checkedBy":{"type":"string","format":"uuid"},"sourceRecipes":{"type":"array","items":{"$ref":"#/components/schemas/RecipeRef"}}}},"ShoppingListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekPlanId":{"type":"string","format":"uuid"},"generatedAt":{"type":"string","format":"date-time"},"filteredStaplesCount":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}},"TagCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0},"tagType":{"type":"string","minLength":1,"pattern":"protein|dietary|cuisine|other"}},"required":["name","tagType"]},"TagResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"AddItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"}}},"CreatePantryItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"PantryItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"AcceptInviteResponse":{"type":"object","properties":{"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"role":{"type":"string"}}},"ApiResponseAcceptInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AcceptInviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"Meta":{"type":"object","properties":{"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"limit":{"type":"integer","format":"int32"},"offset":{"type":"integer","format":"int32"},"hasMore":{"type":"boolean"}}},"IngredientCategoryCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0}},"required":["name"]},"IngredientCategoryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"CreateHouseholdRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0}},"required":["name"]},"ApiResponseHouseholdResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/HouseholdResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"HouseholdResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}},"MemberResponse":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string","format":"date-time"}}},"ApiResponseInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/InviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"InviteResponse":{"type":"object","properties":{"inviteCode":{"type":"string"},"shareUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}}},"CreateCookingLogRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"},"cookedOn":{"type":"string","format":"date"}},"required":["recipeId"]},"CookingLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"recipeId":{"type":"string","format":"uuid"},"recipeName":{"type":"string"},"cookedOn":{"type":"string","format":"date"},"cookedBy":{"type":"string","format":"uuid"}}},"SignupRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","maxLength":2147483647,"minLength":8},"displayName":{"type":"string","maxLength":100,"minLength":0}},"required":["displayName","email","password"]},"ApiResponseUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/UserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"UserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"householdRole":{"type":"string"},"systemRole":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","minLength":1}},"required":["email","password"]},"CreateUserRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"displayName":{"type":"string","maxLength":100,"minLength":0},"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"systemRole":{"type":"string"}},"required":["displayName","email","tempPassword"]},"AdminUserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"ApiResponseAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminUserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordRequest":{"type":"object","properties":{"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"reason":{"type":"string"}},"required":["tempPassword"]},"ApiResponseResetPasswordResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/ResetPasswordResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordResponse":{"type":"object","properties":{"message":{"type":"string"},"mustChangePassword":{"type":"boolean"}}},"UpdateSlotRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId"]},"CheckItemRequest":{"type":"object","properties":{"isChecked":{"type":"boolean"}}},"UpdatePantryItemRequest":{"type":"object","properties":{"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"IngredientPatchRequest":{"type":"object","properties":{"name":{"type":"string"},"isStaple":{"type":"boolean"},"categoryId":{"type":"string","format":"uuid"}}},"IngredientResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"isStaple":{"type":"boolean"}}},"UpdateProfileRequest":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"minLength":0},"currentPassword":{"type":"string"},"newPassword":{"type":"string","maxLength":2147483647,"minLength":8}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"}}},"IngredientOverlap":{"type":"object","properties":{"ingredientName":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"TagRepeat":{"type":"object","properties":{"tagName":{"type":"string"},"tagType":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"VarietyScoreResponse":{"type":"object","properties":{"score":{"type":"number","format":"double"},"tagRepeats":{"type":"array","items":{"$ref":"#/components/schemas/TagRepeat"}},"ingredientOverlaps":{"type":"array","items":{"$ref":"#/components/schemas/IngredientOverlap"}},"recentRepeats":{"type":"array","items":{"type":"string"}},"duplicatesInPlan":{"type":"array","items":{"type":"string"}}}},"SuggestionItem":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/SlotRecipe"},"simulatedScore":{"type":"number","format":"double"}}},"SuggestionResponse":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionItem"}}}},"ApiResponseListRecipeSummaryResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"RecipeSummaryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"}}},"ApiResponseListAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"ApiResponseListAuditLogResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"AuditLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"adminId":{"type":"string","format":"uuid"},"adminEmail":{"type":"string"},"targetUserId":{"type":"string","format":"uuid"},"targetEmail":{"type":"string"},"action":{"type":"string"},"detail":{"type":"object","additionalProperties":{}},"performedAt":{"type":"string","format":"date-time"}}}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/v1/recipes/{id}":{"get":{"tags":["recipe-controller"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"put":{"tags":["recipe-controller"],"operationId":"updateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"delete":{"tags":["recipe-controller"],"operationId":"deleteRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/v1/week-plans":{"get":{"tags":["week-plan-controller"],"operationId":"getWeekPlan","parameters":[{"name":"weekStart","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}},"post":{"tags":["week-plan-controller"],"operationId":"createWeekPlan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWeekPlanRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/week-plans/{id}/slots":{"post":{"tags":["week-plan-controller"],"operationId":"addSlot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/week-plans/{id}/shopping-list":{"post":{"tags":["shopping-list-controller"],"operationId":"generateFromPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/week-plans/{id}/confirm":{"post":{"tags":["week-plan-controller"],"operationId":"confirmPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/tags":{"get":{"tags":["tag-controller"],"operationId":"listTags","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"post":{"tags":["tag-controller"],"operationId":"createTag","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"/v1/shopping-lists/{id}/items":{"post":{"tags":["shopping-list-controller"],"operationId":"addItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddItemRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/recipes":{"get":{"tags":["recipe-controller"],"operationId":"listRecipes","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"effort","in":"query","required":false,"schema":{"type":"string"}},{"name":"isChildFriendly","in":"query","required":false,"schema":{"type":"boolean"}},{"name":"cookTimeMin.lte","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListRecipeSummaryResponse"}}}}}},"post":{"tags":["recipe-controller"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}}},"/v1/pantry-items":{"get":{"tags":["pantry-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"post":{"tags":["pantry-controller"],"operationId":"createItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/invites/{code}/accept":{"post":{"tags":["household-controller"],"operationId":"acceptInvite","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAcceptInviteResponse"}}}}}}},"/v1/ingredient-categories":{"get":{"tags":["ingredient-category-controller"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"post":{"tags":["ingredient-category-controller"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientCategoryCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"/v1/households":{"post":{"tags":["household-controller"],"operationId":"createHousehold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHouseholdRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/invites":{"post":{"tags":["household-controller"],"operationId":"createInvite","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseInviteResponse"}}}}}}},"/v1/cooking-logs":{"get":{"tags":["cooking-log-controller"],"operationId":"listCookingLogs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":30}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"post":{"tags":["cooking-log-controller"],"operationId":"createCookingLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCookingLogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"/v1/auth/signup":{"post":{"tags":["auth-controller"],"operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/auth/logout":{"post":{"tags":["auth-controller"],"operationId":"logout","responses":{"200":{"description":"OK"}}}},"/v1/auth/login":{"post":{"tags":["auth-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isActive","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAdminUserResponse"}}}}}},"post":{"tags":["admin-controller"],"operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/admin/users/{id}/reset-password":{"post":{"tags":["admin-controller"],"operationId":"resetPassword","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseResetPasswordResponse"}}}}}}},"/v1/week-plans/{planId}/slots/{slotId}":{"delete":{"tags":["week-plan-controller"],"operationId":"deleteSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["week-plan-controller"],"operationId":"updateSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/shopping-lists/{listId}/items/{itemId}":{"delete":{"tags":["shopping-list-controller"],"operationId":"deleteItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["shopping-list-controller"],"operationId":"checkItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/pantry-items/{id}":{"delete":{"tags":["pantry-controller"],"operationId":"deleteItem_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["pantry-controller"],"operationId":"updateItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/ingredients/{id}":{"patch":{"tags":["ingredient-controller"],"operationId":"patchIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientPatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth-controller"],"operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}},"patch":{"tags":["auth-controller"],"operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users/{id}":{"patch":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/week-plans/{id}/variety-score":{"get":{"tags":["week-plan-controller"],"operationId":"getVarietyScore","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VarietyScoreResponse"}}}}}}},"/v1/week-plans/{id}/suggestions":{"get":{"tags":["week-plan-controller"],"operationId":"getSuggestions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuggestionResponse"}}}}}}},"/v1/shopping-lists/{id}":{"get":{"tags":["shopping-list-controller"],"operationId":"getShoppingList","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/shopping-list":{"get":{"tags":["shopping-list-controller"],"operationId":"getByWeekStart","parameters":[{"name":"weekStart","in":"query","required":false,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/ingredients":{"get":{"tags":["ingredient-controller"],"operationId":"searchIngredients","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isStaple","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}}},"/v1/households/mine":{"get":{"tags":["household-controller"],"operationId":"getMyHousehold","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/members":{"get":{"tags":["household-controller"],"operationId":"getMembers","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}}}}}},"/v1/admin/audit-log":{"get":{"tags":["admin-controller"],"operationId":"listAuditLog","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"targetUserId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAuditLogResponse"}}}}}}}},"components":{"schemas":{"IngredientEntry":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"newIngredientName":{"type":"string","maxLength":200,"minLength":0},"quantity":{"type":"number","minimum":0.01},"unit":{"type":"string","maxLength":20,"minLength":0},"sortOrder":{"type":"integer","format":"int32"}},"required":["quantity","unit"]},"RecipeCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"minLength":0},"serves":{"type":"integer","format":"int32","maximum":20,"minimum":1},"cookTimeMin":{"type":"integer","format":"int32","minimum":0},"effort":{"type":"string","minLength":1,"pattern":"easy|medium|hard"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string","maxLength":500,"minLength":0},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientEntry"},"minItems":1},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepEntry"}},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}},"required":["effort","ingredients","name","tagIds"]},"StepEntry":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32","minimum":1},"instruction":{"type":"string","minLength":1}},"required":["instruction"]},"CategoryRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"IngredientItem":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"sortOrder":{"type":"integer","format":"int32"}}},"RecipeDetailResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientItem"}},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepItem"}},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}},"StepItem":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32"},"instruction":{"type":"string"}}},"TagItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"CreateWeekPlanRequest":{"type":"object","properties":{"weekStart":{"type":"string","format":"date"}},"required":["weekStart"]},"SlotRecipe":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"effort":{"type":"string"},"cookTimeMin":{"type":"integer","format":"int32"},"heroImageUrl":{"type":"string"}}},"SlotResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slotDate":{"type":"string","format":"date"},"recipe":{"$ref":"#/components/schemas/SlotRecipe"}}},"WeekPlanResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekStart":{"type":"string","format":"date"},"status":{"type":"string"},"confirmedAt":{"type":"string","format":"date-time"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/SlotResponse"}}}},"CreateSlotRequest":{"type":"object","properties":{"slotDate":{"type":"string","format":"date"},"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId","slotDate"]},"RecipeRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"ShoppingListItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"isChecked":{"type":"boolean"},"checkedBy":{"type":"string","format":"uuid"},"sourceRecipes":{"type":"array","items":{"$ref":"#/components/schemas/RecipeRef"}}}},"ShoppingListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekPlanId":{"type":"string","format":"uuid"},"generatedAt":{"type":"string","format":"date-time"},"filteredStaplesCount":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}},"TagCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0},"tagType":{"type":"string","minLength":1,"pattern":"protein|dietary|cuisine|other"}},"required":["name","tagType"]},"TagResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"AddItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"}}},"CreatePantryItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"PantryItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"AcceptInviteResponse":{"type":"object","properties":{"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"role":{"type":"string"}}},"ApiResponseAcceptInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AcceptInviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"Meta":{"type":"object","properties":{"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"limit":{"type":"integer","format":"int32"},"offset":{"type":"integer","format":"int32"},"hasMore":{"type":"boolean"}}},"IngredientCategoryCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0}},"required":["name"]},"IngredientCategoryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"CreateHouseholdRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0}},"required":["name"]},"ApiResponseHouseholdResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/HouseholdResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"HouseholdResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}},"MemberResponse":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string","format":"date-time"}}},"ApiResponseInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/InviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"InviteResponse":{"type":"object","properties":{"inviteCode":{"type":"string"},"shareUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}}},"CreateCookingLogRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"},"cookedOn":{"type":"string","format":"date"}},"required":["recipeId"]},"CookingLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"recipeId":{"type":"string","format":"uuid"},"recipeName":{"type":"string"},"cookedOn":{"type":"string","format":"date"},"cookedBy":{"type":"string","format":"uuid"}}},"SignupRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","maxLength":2147483647,"minLength":8},"displayName":{"type":"string","maxLength":100,"minLength":0}},"required":["displayName","email","password"]},"ApiResponseUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/UserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"UserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"householdRole":{"type":"string"},"systemRole":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","minLength":1}},"required":["email","password"]},"CreateUserRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"displayName":{"type":"string","maxLength":100,"minLength":0},"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"systemRole":{"type":"string"}},"required":["displayName","email","tempPassword"]},"AdminUserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"ApiResponseAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminUserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordRequest":{"type":"object","properties":{"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"reason":{"type":"string"}},"required":["tempPassword"]},"ApiResponseResetPasswordResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/ResetPasswordResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordResponse":{"type":"object","properties":{"message":{"type":"string"},"mustChangePassword":{"type":"boolean"}}},"UpdateSlotRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId"]},"CheckItemRequest":{"type":"object","properties":{"isChecked":{"type":"boolean"}}},"UpdatePantryItemRequest":{"type":"object","properties":{"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"IngredientPatchRequest":{"type":"object","properties":{"name":{"type":"string"},"isStaple":{"type":"boolean"},"categoryId":{"type":"string","format":"uuid"}}},"IngredientResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"isStaple":{"type":"boolean"}}},"UpdateProfileRequest":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"minLength":0},"currentPassword":{"type":"string"},"newPassword":{"type":"string","maxLength":2147483647,"minLength":8}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"}}},"IngredientOverlap":{"type":"object","properties":{"ingredientName":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"TagRepeat":{"type":"object","properties":{"tagName":{"type":"string"},"tagType":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"VarietyScoreResponse":{"type":"object","properties":{"score":{"type":"number","format":"double"},"tagRepeats":{"type":"array","items":{"$ref":"#/components/schemas/TagRepeat"}},"ingredientOverlaps":{"type":"array","items":{"$ref":"#/components/schemas/IngredientOverlap"}},"recentRepeats":{"type":"array","items":{"type":"string"}},"duplicatesInPlan":{"type":"array","items":{"type":"string"}}}},"SuggestionItem":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/SlotRecipe"},"scoreDelta":{"type":"number","format":"double"},"hasConflict":{"type":"boolean"}}},"SuggestionResponse":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionItem"}}}},"ApiResponseListRecipeSummaryResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"RecipeSummaryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"}}},"ApiResponseListAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"ApiResponseListAuditLogResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"AuditLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"adminId":{"type":"string","format":"uuid"},"adminEmail":{"type":"string"},"targetUserId":{"type":"string","format":"uuid"},"targetEmail":{"type":"string"},"action":{"type":"string"},"detail":{"type":"object","additionalProperties":{}},"performedAt":{"type":"string","format":"date-time"}}}}}} \ No newline at end of file diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 74d1952..9ee4ede 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -914,7 +914,8 @@ export interface components { SuggestionItem: { recipe?: components["schemas"]["SlotRecipe"]; /** Format: double */ - simulatedScore?: number; + scoreDelta?: number; + hasConflict?: boolean; }; SuggestionResponse: { suggestions?: components["schemas"]["SuggestionItem"][]; From 8234c2f162bcadf3bd4741abfbb3c75a5e350f3b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:38:47 +0200 Subject: [PATCH 09/36] feat(planner): RecipePicker uses scoreDelta/hasConflict, drop currentVarietyScore, add isLoading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suggestion interface: { recipe, scoreDelta, hasConflict } (no simulatedScore) - Badge renders from hasConflict directly — no client-side delta computation needed - New isLoading prop shows skeleton rows while suggestions fetch is in flight - currentVarietyScore prop removed from component and both call sites follow in next commit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 34 +++++++++++++++---- frontend/src/lib/planner/RecipePicker.test.ts | 25 ++++++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 1d2ffde..51940ac 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -8,24 +8,25 @@ interface Suggestion { recipe: Recipe; - simulatedScore: number; + scoreDelta: number; + hasConflict: boolean; } let { planId, date, dateLabel, - currentVarietyScore = 0, suggestions = [], allRecipes = [], + isLoading = false, onpick }: { planId: string; date: string; dateLabel: string; - currentVarietyScore?: number; suggestions: Suggestion[]; allRecipes: Recipe[]; + isLoading?: boolean; onpick: (recipeId: string, recipeName: string) => void; } = $props(); @@ -71,7 +72,27 @@ - {#if suggestions.length > 0} + {#if isLoading} +
+ {#each [1, 2, 3] as i (i)} +
+
+
+
+
+
+
+ {/each} +
+ {:else if suggestions.length > 0}
@@ -79,7 +100,6 @@
{#each suggestions as suggestion (suggestion.recipe.id)} - {@const delta = suggestion.simulatedScore - currentVarietyScore} {@const meta = recipeMetadata(suggestion.recipe)}
{/if} - {#if delta > 0} + {#if !suggestion.hasConflict} - ↑ +{delta.toFixed(0)} Punkte + ↑ +{suggestion.scoreDelta.toFixed(0)} Punkte {:else} { expect(screen.getByText('Hähnchen-Curry')).toBeTruthy(); }); - it('shows green badge for suggestions with positive delta', () => { + it('shows green badge when hasConflict is false', () => { render(RecipePicker, { props: baseProps }); - // Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge + // Lachsfilet: hasConflict = false → green badge const badge = screen.getByTestId('badge-s1'); expect(badge.getAttribute('data-type')).toBe('good'); }); - it('shows yellow badge for suggestions with zero or negative delta', () => { + it('shows yellow badge when hasConflict is true', () => { render(RecipePicker, { props: baseProps }); - // Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge + // Hähnchen-Curry: hasConflict = true → yellow badge const badge = screen.getByTestId('badge-s2'); expect(badge.getAttribute('data-type')).toBe('warning'); }); @@ -98,4 +97,16 @@ describe('RecipePicker', () => { await userEvent.type(input, 'xyznotfound'); expect(screen.getByText(/Keine Treffer/i)).toBeTruthy(); }); + + it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => { + render(RecipePicker, { props: { ...baseProps, isLoading: true } }); + expect(screen.getByTestId('suggestions-loading')).toBeTruthy(); + expect(screen.queryByText(/Empfohlen/i)).toBeNull(); + }); + + it('hides loading skeleton when isLoading is false and suggestions are present', () => { + render(RecipePicker, { props: { ...baseProps, isLoading: false } }); + expect(screen.queryByTestId('suggestions-loading')).toBeNull(); + expect(screen.getByText(/Empfohlen/i)).toBeTruthy(); + }); }); From a751b0758a2ea5a4e9870d33b073992f8eb523e1 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:39:50 +0200 Subject: [PATCH 10/36] feat(planner): add server.test.ts for GET /planner, fix sort + add error handling - Sort uses scoreDelta instead of removed simulatedScore - try/catch degrades gracefully to suggestions=[] on backend errors - 6 tests cover: missing params, success, backend error, network throw, empty result Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/planner/+server.ts | 20 ++-- .../src/routes/(app)/planner/server.test.ts | 91 +++++++++++++++++++ 2 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 frontend/src/routes/(app)/planner/server.test.ts diff --git a/frontend/src/routes/(app)/planner/+server.ts b/frontend/src/routes/(app)/planner/+server.ts index 910efc5..af3aa80 100644 --- a/frontend/src/routes/(app)/planner/+server.ts +++ b/frontend/src/routes/(app)/planner/+server.ts @@ -11,14 +11,18 @@ export const GET: RequestHandler = async ({ fetch, url }) => { return json({ suggestions: [] }); } - const api = apiClient(fetch); - const { data } = await api.GET('/v1/week-plans/{id}/suggestions', { - params: { path: { id: planId }, query: { slotDate: date } } - }); + try { + const api = apiClient(fetch); + const { data } = await api.GET('/v1/week-plans/{id}/suggestions', { + params: { path: { id: planId }, query: { slotDate: date } } + }); - const suggestions = (data?.suggestions ?? []).sort( - (a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0) - ); + const suggestions = (data?.suggestions ?? []).sort( + (a: any, b: any) => (b.scoreDelta ?? 0) - (a.scoreDelta ?? 0) + ); - return json({ suggestions }); + return json({ suggestions }); + } catch { + return json({ suggestions: [] }); + } }; diff --git a/frontend/src/routes/(app)/planner/server.test.ts b/frontend/src/routes/(app)/planner/server.test.ts new file mode 100644 index 0000000..023d0fd --- /dev/null +++ b/frontend/src/routes/(app)/planner/server.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet }) +})); + +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; +const DATE = '2026-04-09'; + +const mockSuggestions = [ + { recipe: { id: 'r1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 0.0, hasConflict: true }, + { recipe: { id: 'r2', name: 'Nudeln', effort: 'easy', cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true } +]; + +describe('GET /planner — suggestions route handler', () => { + let GET: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+server'); + GET = mod.GET; + }); + + it('returns { suggestions: [] } when planId is missing', async () => { + const url = new URL('http://localhost/planner?date=' + DATE); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + expect(body).toEqual({ suggestions: [] }); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('returns { suggestions: [] } when date is missing', async () => { + const url = new URL('http://localhost/planner?planId=' + PLAN_UUID); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + expect(body).toEqual({ suggestions: [] }); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('returns sorted suggestions from backend on success', async () => { + mockGet.mockResolvedValueOnce({ data: { suggestions: mockSuggestions }, error: undefined }); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({ + params: { path: { id: PLAN_UUID }, query: { slotDate: DATE } } + })); + expect(body.suggestions).toHaveLength(2); + // sorted by scoreDelta desc: 0.0 before -1.5 + expect(body.suggestions[0].recipe.name).toBe('Lachsfilet'); + expect(body.suggestions[1].recipe.name).toBe('Nudeln'); + }); + + it('returns { suggestions: [] } when backend returns error', async () => { + mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(body).toEqual({ suggestions: [] }); + }); + + it('returns { suggestions: [] } when backend throws (network error)', async () => { + mockGet.mockRejectedValueOnce(new Error('Network error')); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(body).toEqual({ suggestions: [] }); + }); + + it('returns empty suggestions when backend returns empty array', async () => { + mockGet.mockResolvedValueOnce({ data: { suggestions: [] }, error: undefined }); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(body).toEqual({ suggestions: [] }); + }); +}); From 2bbc3762e29181889f2bc8b391aac531ab778c28 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:46:25 +0200 Subject: [PATCH 11/36] feat(planner): lazy-fetch variety suggestions in RecipePicker for empty slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derives activePickerDate from mobile pickerOpen/selectedDay and desktop recipe-picker panel state, then uses $effect to fetch /planner?planId&date on demand — wires suggestions and isLoading into both RecipePicker instances. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/planner/+page.svelte | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index ba03be8..bf055d1 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -63,6 +63,15 @@ let swapSheetOpen = $state(false); let swapLoading = $state(false); + const activePickerDate = $derived( + pickerOpen ? selectedDay + : panelState.kind === 'recipe-picker' ? panelState.date + : null + ); + + let suggestions: any[] = $state([]); + let isLoadingSuggestions = $state(false); + // Recipes already in any slot this week — used for ⚠ overlap warnings let currentWeekRecipeIds = $derived( new Set(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id)) @@ -91,6 +100,20 @@ let undoVisible = $state(false); let undoMessage = $state(''); + $effect(() => { + if (!activePickerDate || !weekPlan?.id) { + suggestions = []; + isLoadingSuggestions = false; + return; + } + isLoadingSuggestions = true; + fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`) + .then((r) => r.json()) + .then((d) => { suggestions = d.suggestions ?? []; }) + .catch(() => { suggestions = []; }) + .finally(() => { isLoadingSuggestions = false; }); + }); + function handleSelectDay(day: string) { selectedDay = day; panelState = { kind: 'day-detail', date: day }; @@ -282,9 +305,9 @@ planId={weekPlan?.id ?? ''} date={selectedDay} dateLabel={formatDayLabel(selectedDay)} - currentVarietyScore={varietyScore?.score ?? 0} - suggestions={[]} + suggestions={suggestions} allRecipes={data.recipes} + isLoading={isLoadingSuggestions} onpick={handleRecipePick} /> @@ -560,9 +583,9 @@ planId={weekPlan?.id ?? ''} date={pickerDate} dateLabel={formatDayLabel(pickerDate)} - currentVarietyScore={varietyScore?.score ?? 0} - suggestions={[]} + suggestions={suggestions} allRecipes={data.recipes} + isLoading={isLoadingSuggestions} onpick={handleRecipePick} />
From b45ab0fd4659fded91ea3f021b041cc35d64e153 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:00:37 +0200 Subject: [PATCH 12/36] fix(planner): guard scoreDelta against undefined in RecipePicker badge Defensive null-coalescing prevents crash when suggestion data arrives without scoreDelta (e.g. stale backend or mismatched schema). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 51940ac..ec3fd69 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -121,7 +121,7 @@ data-type="good" style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);" > - ↑ +{suggestion.scoreDelta.toFixed(0)} Punkte + ↑ +{(suggestion.scoreDelta ?? 0).toFixed(0)} Punkte {:else} Date: Thu, 9 Apr 2026 12:09:08 +0200 Subject: [PATCH 13/36] refactor(planner): extract MAX_VARIETY_SCORE constant in PlanningService Replaces magic literal 10.0 with a named constant in all four scoring sites: getSuggestions, getVarietyPreview, scoreFromSimulatedSlots, and getVarietyScore. Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/planning/PlanningService.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 356c694..04fcf11 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -26,6 +26,8 @@ import java.util.stream.Collectors; @Service public class PlanningService { + private static final double MAX_VARIETY_SCORE = 10.0; + private final WeekPlanRepository weekPlanRepository; private final WeekPlanSlotRepository weekPlanSlotRepository; private final CookingLogRepository cookingLogRepository; @@ -138,7 +140,7 @@ public class PlanningService { List currentSlots = plan.getSlots().stream() .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) .toList(); - double currentScore = currentSlots.isEmpty() ? 10.0 + double currentScore = currentSlots.isEmpty() ? MAX_VARIETY_SCORE : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); @@ -202,7 +204,7 @@ public class PlanningService { List currentSlots = plan.getSlots().stream() .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) .toList(); - double currentScore = currentSlots.isEmpty() ? 10.0 + double currentScore = currentSlots.isEmpty() ? MAX_VARIETY_SCORE : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); @@ -255,12 +257,12 @@ public class PlanningService { .mapToLong(c -> c - 1) .sum(); - double score = 10.0; + double score = MAX_VARIETY_SCORE; score -= tagRepeatCount * wTagRepeat; score -= ingredientOverlapCount * wIngredientOverlap; score -= recentRepeatCount * wRecentRepeat; score -= duplicatePenaltyCount * wPlanDuplicate; - return Math.max(0, Math.min(10, score)); + return Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); } @Transactional(readOnly = true) @@ -349,12 +351,12 @@ public class PlanningService { } // Calculate score - double score = 10.0; + double score = MAX_VARIETY_SCORE; score -= tagRepeats.size() * wTagRepeat; score -= overlaps.size() * wIngredientOverlap; score -= recentRepeats.size() * wRecentRepeat; score -= duplicatePenaltyCount * wPlanDuplicate; - score = Math.max(0, Math.min(10, score)); + score = Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); } From c24281dd4c32ad85c116ea2c115c6edebca1b231 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:10:33 +0200 Subject: [PATCH 14/36] test(planner): cover topN=0 and topN=-1 boundary in SuggestionsTest Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/SuggestionsTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 97eecb2..333193d 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -205,6 +205,28 @@ class SuggestionsTest { .isInstanceOf(ResourceNotFoundException.class); } + @Test + void topNZeroShouldReturnEmptyList() { + var plan = createPlan(); + stubPlan(plan); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 0); + + assertThat(result.suggestions()).isEmpty(); + } + + @Test + void topNNegativeShouldReturnEmptyList() { + var plan = createPlan(); + stubPlan(plan); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), -1); + + assertThat(result.suggestions()).isEmpty(); + } + @Test void singleCandidateShouldReturnOne() { var plan = createPlan(); From 89a549a1c8f51a72bb0b335d557207dbb38cafe7 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:11:00 +0200 Subject: [PATCH 15/36] test(planner): assert hasConflict=true for neutral scoreDelta on empty plan Documents the surprising-but-correct behavior: recipes on an empty plan get scoreDelta=0.0, which satisfies scoreDelta<=0, so hasConflict=true. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/recipeapp/planning/SuggestionsTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 333193d..b461ffb 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -179,9 +179,12 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(3); - // Empty plan → currentScore = 10.0; no conflicts → scoreDelta = 0.0 for all - assertThat(result.suggestions()).allSatisfy(s -> - assertThat(s.scoreDelta()).isEqualTo(0.0)); + // Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all + // hasConflict = (scoreDelta <= 0) = true even for neutral recipes + assertThat(result.suggestions()).allSatisfy(s -> { + assertThat(s.scoreDelta()).isEqualTo(0.0); + assertThat(s.hasConflict()).isTrue(); + }); } @Test From 9928591b482a4662af2c6735aab531e0873910a4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:11:44 +0200 Subject: [PATCH 16/36] refactor(planner): extract computeCurrentScore helper in PlanningService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates duplicated currentSlots→score pattern that appeared in both getSuggestions and getVarietyPreview. Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/PlanningService.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 04fcf11..c85dcc1 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -137,11 +137,7 @@ public class PlanningService { .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); - List currentSlots = plan.getSlots().stream() - .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) - .toList(); - double currentScore = currentSlots.isEmpty() ? MAX_VARIETY_SCORE - : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); @@ -184,6 +180,14 @@ public class PlanningService { return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds); } + private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set recentlyCookedIds) { + List currentSlots = plan.getSlots().stream() + .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) + .toList(); + return currentSlots.isEmpty() ? MAX_VARIETY_SCORE + : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + } + private record SimulatedSlot(Recipe recipe, LocalDate date) {} @Transactional(readOnly = true) @@ -201,11 +205,7 @@ public class PlanningService { .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); - List currentSlots = plan.getSlots().stream() - .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) - .toList(); - double currentScore = currentSlots.isEmpty() ? MAX_VARIETY_SCORE - : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore); From ccec0baa99b43b9d554f89a9c6a60e1d74098847 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:15:17 +0200 Subject: [PATCH 17/36] feat(planner): add AbortController to suggestion fetch $effect Cancels the inflight request when activePickerDate changes or picker closes, preventing stale responses from overwriting suggestions. Adds page.test.ts covering fetch trigger, suggestion rendering, and AbortSignal presence. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/planner/+page.svelte | 6 +- .../src/routes/(app)/planner/page.test.ts | 84 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/(app)/planner/page.test.ts diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index bf055d1..b6f5e4d 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -106,12 +106,14 @@ isLoadingSuggestions = false; return; } + const controller = new AbortController(); isLoadingSuggestions = true; - fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`) + fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal }) .then((r) => r.json()) .then((d) => { suggestions = d.suggestions ?? []; }) - .catch(() => { suggestions = []; }) + .catch((e) => { if (e.name !== 'AbortError') suggestions = []; }) .finally(() => { isLoadingSuggestions = false; }); + return () => controller.abort(); }); function handleSelectDay(day: string) { diff --git a/frontend/src/routes/(app)/planner/page.test.ts b/frontend/src/routes/(app)/planner/page.test.ts new file mode 100644 index 0000000..c400a88 --- /dev/null +++ b/frontend/src/routes/(app)/planner/page.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import Page from './+page.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); +vi.mock('$app/forms', () => ({ + enhance: () => () => ({ destroy: () => {} }) +})); + +const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001'; +// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday) +const DATE = '2025-01-06'; // Monday, January 6 2025 + +const mockData = { + weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] }, + varietyScore: null, + weekStart: DATE, + recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }], + benutzer: { rolle: 'planer' } +}; + +const mockSuggestions = [ + { + recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 }, + scoreDelta: 1.5, + hasConflict: false + } +]; + +describe('+page.svelte — $effect suggestion fetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls fetch when picker opens with correct planId and date', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: mockSuggestions }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`); + expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`); + }); + + it('shows suggestions in RecipePicker after fetch resolves', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: mockSuggestions }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + expect(await screen.findByText('Lachsfilet')).toBeTruthy(); + }); + + it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: [] }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const fetchOptions = (fetch as any).mock.calls[0][1]; + expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal); + }); +}); From 1de4b15e3436ec03635a595dabd270483069562b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:16:02 +0200 Subject: [PATCH 18/36] refactor(planner): extract Suggestion type to $lib/planner/types.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the inline interface from RecipePicker.svelte and replaces any[] in +page.svelte with Suggestion[] — compile-time safety. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 13 +------------ frontend/src/lib/planner/types.ts | 12 ++++++++++++ frontend/src/routes/(app)/planner/+page.svelte | 3 ++- 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/planner/types.ts diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index ec3fd69..41544f2 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -1,16 +1,5 @@ +{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)} + {#if delta > 0} + + ↑ +{delta.toFixed(1)} Punkte + + {:else if hasConflict} + + ↓ {delta.toFixed(1)} Punkte + + {:else} + + = {delta.toFixed(1)} Punkte + + {/if} +{/snippet} +
@@ -81,107 +117,91 @@
{/each}
- {:else if suggestions.length > 0} -
- Empfohlen · Beste Abwechslung -
- - {#each suggestions as suggestion (suggestion.recipe.id)} - {@const meta = recipeMetadata(suggestion.recipe)} + {:else if topRecommendations.length > 0} +
-
-

- {suggestion.recipe.name} -

- {#if meta} -

- {meta} -

- {/if} - {#if (suggestion.scoreDelta ?? 0) > 0} - - ↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - - {:else if suggestion.hasConflict} - - ↓ {(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - - {:else} - - = {(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - - {/if} -
- + Empfohlen · Beste Abwechslung
- {/each} + + {#each topRecommendations as suggestion (suggestion.recipe.id)} + {@const meta = recipeMetadata(suggestion.recipe)} +
+
+

+ {suggestion.recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} + {@render scoreBadge(suggestion.recipe.id, suggestion.scoreDelta ?? 0, suggestion.hasConflict)} +
+ +
+ {/each} +
{/if} -
- Alle Rezepte -
+
+
+ Alle Rezepte +
- {#if filteredRecipes.length === 0} -

- Keine Treffer -

- {:else} - {#each filteredRecipes as recipe (recipe.id)} - {@const meta = recipeMetadata(recipe)} -
-
-

- {recipe.name} -

- {#if meta} -

- {meta} -

- {/if} -
- -
- {/each} - {/if} +
+

+ {recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} + {#if score} + {@render scoreBadge(recipe.id, score.scoreDelta ?? 0, score.hasConflict)} + {/if} +
+ +
+ {/each} + {/if} + + + + +

Mealplan · Planer · Flip Tiles

+

Kachel-Flip + Zutaten-Farben

+

+ Klick auf eine gefüllte Kachel → sie dreht sich um. Auf der Rückseite: Rezeptname, Hauptzutaten, Aktionen. + Kein Expansion-Panel mehr. Leere Kacheln bleiben unverändert mit Inline-Vorschlägen. +

+ + + + + +
+
+ Palette + Farben nach Hauptzutat / Küchenstil + Fallback wenn heroImageUrl fehlt +
+ +
+ +
Hähnchen
Protein
+
Rind
Protein
+
Fisch
Protein
+
Tofu
Protein
+
vegetarisch
Protein
+
Schwein
Protein
+
Lamm
Protein
+
Ei
Protein
+
Hülsenfrüchte
Protein
+ +
Italienisch
Küche
+
Asiatisch
Küche
+
Indisch
Küche
+
Mexikanisch
Küche
+
Mediterran
Küche
+
+ +
+ Priorität: Wenn heroImageUrl vorhanden → echtes Foto. + Sonst: Farbe nach erstem Protein-Tag (z.B. tagType=protein, tagName=Hähnchen). + Wenn kein Protein-Tag → Farbe nach Küchenstil-Tag (tagType=cuisine). + Fallback auf --color-surface neutral. + Die Farbwerte werden als CSS-Klassen gemappt: protein-haehnchen, cuisine-asiatisch etc. +
+
+ + + + + +
+
+ Demo + Flip-Interaktion — zum Klicken + Echte CSS-3D-Transition +
+ +

Klicke auf eine Kachel um sie umzudrehen. × auf der Rückseite klappt zurück.

+ +
+ + +
+
Standard
+
+
+
+
+
+ Mo + 7 +
+
+
Hähnchen-Curry
+
35 Min · mittel
+
+ Hähnchen + 4 Port. +
+
+
+
+
+
+
+ Mo · 7. Apr + +
+
Hähnchen-Curry
+
35 Min · mittel · 4 Port.
+
+ Hähnchen + Kokosmilch + Paprika + Spinat + Curry + Knoblauch +
+
+ + + + +
+
+
+
+
+
+ + +
+
Heute
+
+
+
+
+
+ Di + 8 +
+
+
Pasta Bolognese
+
45 Min · mittel
+
+ Rind + Heute +
+
+
+
+
+
+
+ Di · Heute + +
+
Pasta Bolognese
+
45 Min · mittel · 4 Port.
+
+ Rinderhack + Pasta + Tomaten + Zwiebeln + Olivenöl + Knoblauch +
+
+ + + + +
+
+
+
+
+
+ + +
+
Ausgewählt (bereits umgedreht)
+
+
+
+
+
+ Mi + 9 +
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
Tofu
+
+
+
+
+
+
+ Mi · 9. Apr + +
+
Gemüse-Stir-fry
+
20 Min · einfach · 2 Port.
+
+ Tofu + Paprika + Brokkoli + Karotten + Sesamöl + Sojasauce +
+
+ + + + +
+
+
+
+
+
+ + +
+
Leer — kein Flip
+
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
Ramen mit EiNeues Protein
+
ShakshukaKein Overlap
+
TacosAufwand: leicht
+
Alle Rezepte →
+
+
+
+ +
+ +
+ Flip-Mechanik: CSS transform:rotateY(180deg) auf dem .card wrapper, + backface-visibility:hidden auf beiden Faces, perspective:900px auf der Scene. + Transition: .45s cubic-bezier(.4,0,.2,1) (Material-Easing — schnell herein, weich heraus). + Der × Button auf der Rückseite stoppt den Klick-Event mit stopPropagation() + und dreht die Karte zurück. Kein zusätzlicher State nötig — die Karte ist selbst das State-Element. +

+ Farbstreifen oben auf der Rückseite = 5px Gradient, identisch mit der Front-Farbe. + Gibt visuelle Kontinuität zwischen Vorder- und Rückseite. +
+
+ + + + + +
+
+ Seite + Vollansicht — Mittwoch umgedreht + Kein rechtes Panel. Kacheln bis zum Rand. +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+
Mo7
+
Hähnchen-Curry
35 Min · mittel
+
+
+
+ + +
+
+
+
+
Di8
+
Pasta Bolognese
45 Min · mittel
+
+
+
+ + +
+
+
+
+
+ Mi + 9 +
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
Tofu
+
+
+
+
+
+
+ Mi · 9. Apr + +
+
Gemüse-Stir-fry
+
20 Min · einfach · 2 Port.
+
+ Tofu + Paprika + Brokkoli + Karotten + Ingwer + Sesamöl + Sojasauce +
+
+ + + + +
+
+
+
+
+ + +
+
+
+
+
Do10
+
Lachs mit Kartoffeln
30 Min · einfach
+
+
+
+ + +
+
+
+
+
Fr11
+
Pizza Margherita
50 Min · aufwändig
+
+
+
+ + +
+
Sa12
+
+
Gericht wählen
+
+
Vorschläge
+
Ramen mit EiNeues Protein
+
ShakshukaKein Overlap
+
Alle Rezepte →
+
+
+ + +
+
So13
+
+
Gericht wählen
+
+
Vorschläge
+
Grünes Thai-CurryNeues Protein
+
TacosAufwand: leicht
+
Alle Rezepte →
+
+
+ +
+
+
+
+ +
+ Layout: Linke Sidebar (Variety-Score) bleibt. Kein rechtes Panel mehr. + Die Kacheln füllen den gesamten verbleibenden Platz (flex:1) — 7 gleich breite Spalten, + volle Höhe (height:100% auf Grid und Kacheln). Kein Layout-Shift, kein After-Scroll. +

+ Dimm-Effekt: Beim Flip werden alle anderen Kacheln auf 38% gedimmt. + Kein neuer API-Aufruf nötig — reine CSS-Klasse per JS. +

+ „Gericht tauschen": Öffnet den Rezept-Picker als Slide-in-Drawer von rechts + (kein persistentes Panel). Drawer schließt sich nach Auswahl oder Abbruch. +

+ Leere Kacheln: Zeigen Inline-Vorschläge auch im gedimmten Zustand (wenn + eine andere Kachel geflippt ist). Kein Flip auf leeren Kacheln. +
+
+ + + + + diff --git a/specs/planner-redesign-flip-tiles.html b/specs/planner-redesign-flip-tiles.html new file mode 100644 index 0000000..740d6b4 --- /dev/null +++ b/specs/planner-redesign-flip-tiles.html @@ -0,0 +1,459 @@ + + + + + + Planner Redesign — Flip Tiles · Final Spec + + + + + +
+ +
+
+

Planner Desktop Redesign

+

Flip Tiles · Final Spec · Route: /planner

+
+
+ Version 1.0
+ 2026-04
+ Mockup: specs/planner-flip-tiles.html +
+
+ +
+

+ Der Wochenplaner hat auf Desktop aktuell ~80 % vertikalen Leerraum unterhalb des 7-Spalten-Kalenders. + Zusätzlich ist das rechte Panel im Leerlauf nicht genutzt. Dieses Spec beschreibt ein vollständiges + Redesign der Desktop-Hauptfläche: Die Kacheln füllen die volle Höhe und Breite, Rezeptdetails werden + über einen CSS-3D-Flip direkt in der Kachel angezeigt, und leere Tage zeigen Inline-Vorschläge. +

+

+ Das rechte Panel entfällt dauerhaft. Der Rezept-Picker öffnet sich als Slide-in-Drawer ausschließlich + auf Anfrage (Aktion „Gericht tauschen" auf der Kachel-Rückseite). Der Toolbar-Button + „Gericht hinzufügen" entfällt, da jede leere Kachel eine eigene CTA hat. +

+
+ + + +
+ +

Seitenstruktur

+ +

Desktop-Layout: 2 Spalten. Kein persistentes rechtes Panel mehr.

+ +
┌─────────────────────────────────────────────────────────────┐
+│  Toolbar (Wochenplaner · 7.–13. Apr  ‹ ›  Heute)           │
+├──────────┬──────────────────────────────────────────────────┤
+│ Sidebar  │  7-Spalten-Kachelgrid (flex: 1, height: 100%)   │
+│ 184 px   │                                                  │
+│ Variety  │  Mo    Di    Mi    Do    Fr    Sa    So          │
+│ Score    │  ████  ████  ████  ████  ████  ░░░░  ░░░░       │
+│          │  ████  ████  ████  ████  ████  ░+░░  ░+░░       │
+│          │  ████  ████  ████  ████  ████  ░Vor░  ░Vor░      │
+└──────────┴──────────────────────────────────────────────────┘
+ +
    +
  • Sidebar (184 px, flex-shrink: 0): Variety-Score-Card, Sub-Scores, Überschneidungs-Warnungen, Link zur Variety-Analyse. Unverändert.
  • +
  • Main (flex: 1): display: grid; grid-template-columns: repeat(7, 1fr); gap: 7px; height: 100%. Kacheln füllen die gesamte verbleibende Breite und Höhe.
  • +
  • Toolbar: Nur Navigation — Wochenbezeichnung, Zurück/Vor-Pfeile, Heute-Button. Kein „+ Gericht hinzufügen" mehr.
  • +
+ +
+ Entfernt: Das rechte Panel (width: 228px) mit der „Heute Abend"-Karte und dem Leerlauf-Hinweis entfällt vollständig. Koch-Modus ist auf der Kachel-Rückseite zugänglich. +
+
+ + + +
+ +

Tile States

+ +
+
+
Standard (gefüllt)
+
+ Vollbild-Farbhintergrund (Gradient nach Zutat/Küche) oder heroImageUrl. + Dual-Gradient-Overlay (oben + unten dunkel, Mitte klar). + Oben: Tageskürzel + Datumsziffer. Unten: Rezeptname, Kochzeit, Tags. +

+ box-shadow: var(--sh-card) — kein sichtbarer Ring. +
+
+
+
Heute (gefüllt)
+
+ Identisch wie Standard, aber mit gelbem Ring via + box-shadow: 0 0 0 2px var(--yellow), var(--sh-card). + Datumsziffer-Badge in --yellow. Tag-Label „Heute" zusätzlich als frosted Tag. +
+
+
+
Ausgewählt / Geflippt
+
+ Grüner Ring: box-shadow: 0 0 0 2px var(--green), var(--sh-raised). + Karte dreht sich 180° (CSS 3D, siehe §04). Alle anderen Kacheln werden auf 38 % Deckkraft + gedimmt und sind nicht klickbar. +
+
+
+
Leer
+
+ Kein Flip. Gestrichelter Rahmen (border: 1.5px dashed var(--color-border)), + background: var(--color-surface). Oben: Tageskürzel + Datum. + Darunter: + Icon + „Gericht wählen". Rest der Kachel: Inline-Vorschläge (§05). +
+
+
+ +
+ box-shadow statt border: Statusringe werden via box-shadow gesetzt, nicht via border, + um Layout-Shift zu vermeiden. Die Kacheln behalten identische Außenmaße in allen Zuständen. +
+
+ + + +
+ +

Ingredient & Cuisine Colors

+ +

+ Wenn heroImageUrl vorhanden ist, wird das echte Foto als background-image gesetzt. + Fehlt es, greift die folgende Prioritätskette: +

+
    +
  1. Ersten Tag mit tagType = "protein" finden → Protein-Farbe
  2. +
  3. Ersten Tag mit tagType = "cuisine" finden → Küchenstil-Farbe
  4. +
  5. Fallback: background: var(--color-surface) (neutral)
  6. +
+ +

Protein-Farben

+
+
Hähnchen
protein-haehnchen
+
Rind
protein-rind
+
Fisch
protein-fisch
+
Tofu
protein-tofu
+
Vegetarisch
protein-veg
+
Schwein
protein-schwein
+
Lamm
protein-lamm
+
Ei
protein-ei
+
Hülsen­früchte
protein-huelsenfruechte
+
+ +

Küchenstil-Farben

+
+
Italienisch
cuisine-italienisch
+
Asiatisch
cuisine-asiatisch
+
Indisch
cuisine-indisch
+
Mexikanisch
cuisine-mexikanisch
+
Mediterran
cuisine-mediterran
+
+ +

+ Die CSS-Klassen (protein-haehnchen, cuisine-asiatisch, …) werden + serverseitig aus den Rezept-Tags abgeleitet und als Svelte-Prop übergeben, z.B. + colorClass="protein-haehnchen". Das Component setzt die Klasse auf dem Kachel-Wrapper. +

+
+ + + +
+ +

CSS 3D Card Flip

+ +

Jede gefüllte Kachel besteht aus drei verschachtelten Elementen:

+
.scene   → perspective: 900px; border-radius: var(--radius-lg); cursor: pointer
+  .card  → position: relative; transform-style: preserve-3d
+           transition: transform .45s cubic-bezier(.4,0,.2,1)
+           .card.flipped → transform: rotateY(180deg)
+    .card-front → backface-visibility: hidden; position: absolute; inset: 0
+    .card-back  → backface-visibility: hidden; transform: rotateY(180deg)
+                   position: absolute; inset: 0; background: var(--color-page)
+ +

Vorderseite

+
    +
  • Vollbild-Farbe oder background-image: url(heroImageUrl) mit background-size: cover
  • +
  • Dual-Gradient-Overlay als absolutes ::after-Pseudo-Element:
    + linear-gradient(to bottom, rgba(0,0,0,.38) 0%, transparent 28%, transparent 48%, rgba(0,0,0,.62) 100%)
  • +
  • Oben links: Tageskürzel (9px uppercase). Oben rechts: Datums-Badge (Kreis)
  • +
  • Unten: Rezeptname (Fraunces 13px), Meta-Zeile (Kochzeit · Aufwand), Tag-Chips
  • +
+ +

Rückseite

+
    +
  • Farbstreifen (5 px) oben — identischer Gradient wie die Vorderseite. Gibt visuelle Kontinuität.
  • +
  • Tageskürzel + Datum (links) · × Schließen-Button (rechts)
  • +
  • Rezeptname (Fraunces 15px)
  • +
  • Meta: Kochzeit · Aufwand · Portionen
  • +
  • Zutaten-Pills: normale Zutaten als .ingredient, Vorrats-Zutaten (Staples) gedimmt als .ingredient--staple
  • +
  • Aktionen (gestapelt, volle Breite):
  • +
+ + + + + + + + + +
AktionStilVerhalten
Koch-Modus startenPrimary (grün ausgefüllt)Navigiert zu /planner/cook/[slotId]
Rezept ansehenSecondary (Rahmen)Navigiert zu /recipes/[recipeId]
Gericht tauschenSecondary (Rahmen)Öffnet Rezept-Picker-Drawer (§06)
EntfernenDanger (roter Text, transparenter BG)Löscht den Slot, Kachel wird leer
+ +

Interaction Flow

+
    +
  • Klick auf .scene.card.classList.toggle('flipped')
  • +
  • Alle Geschwister-Kacheln im Grid → opacity: 0.38; pointer-events: none
  • +
  • × Button auf Rückseite → event.stopPropagation(), classList.remove('flipped'), Geschwister-Opacity zurücksetzen
  • +
  • Escape-Taste → aktive Kachel zurückdrehen
  • +
+ +
+ Kein API-Aufruf beim Flip. Alle dargestellten Daten (Name, Zutaten, Aktionen) sind bereits + im vorhandenen slotMap-State vorhanden. Der Flip ist eine rein visuelle Operation. +
+
+ + + +
+ +

Empty Tile — Inline Suggestions

+ +

Leere Kacheln haben denselben height: 100% wie gefüllte Kacheln. Kein Flip.

+ +
┌─────────────────┐
+│ Sa     12       │  ← Tageskürzel + Datum
+│─────────────────│
+│       +         │
+│  Gericht wählen │  ← Klick öffnet Rezept-Picker-Drawer
+│─────────────────│
+│ VORSCHLÄGE      │
+│ Ramen mit Ei  [Neues Protein]  │
+│ Shakshuka     [Kein Overlap]   │
+│ Tacos         [Aufwand: leicht]│
+│                                │
+│     Alle Rezepte →             │
+└────────────────────────────────┘
+ +

Vorschlag-Tags (Reasoning)

+

Anstelle numerischer Score-Deltas (die für leere Slots immer positiv sind und daher keine Information tragen) + werden Begründungs-Tags angezeigt:

+ + + + + + + + + +
TagFarbeBedeutung
Neues ProteinGrünProteinquelle kommt diese Woche noch nicht vor
Kein OverlapGrünKeine Zutaten-Überschneidung mit anderen Tagen
Aufwand: leichtGelbKochzeit < 30 Min oder Aufwand = einfach
Aufwand: mittelNeutralMittlerer Aufwand
+ +
+ Datenquelle: Die vorhandene GET /api/suggestions?weekId=&dayOfWeek= API liefert + SuggestionItem { recipe, scoreDelta, hasConflict }. Die Reasoning-Tags werden frontend-seitig + aus den Rezept-Tags und dem vorhandenen slotMap abgeleitet, kein Backend-Änderungsbedarf. +
+
+ + + +
+ +

Recipe Picker Drawer

+ +

+ Der Rezept-Picker öffnet sich als Slide-in-Drawer von rechts — ausschließlich auf explizite Anfrage. + Er hat keinen persistenten Platz im Layout mehr. +

+ +

Trigger

+
    +
  • Klick auf „Gericht tauschen" auf der Kachel-Rückseite
  • +
  • Klick auf „Gericht wählen" CTA oder Vorschlag-Zeile auf einer leeren Kachel
  • +
+ +

Drawer-Verhalten

+
    +
  • Slide-in von rechts, überlagert den Inhalt (kein Layout-Shift)
  • +
  • Breite: min(480px, 90vw)
  • +
  • Backdrop (halbtransparent) schließt den Drawer bei Klick
  • +
  • Nach Auswahl: Drawer schließt sich, Slot wird aktualisiert, Kachel zeigt neues Rezept
  • +
+ +
+ Der bestehende RecipePicker-Komponente (aktuell im rechten Panel) wird in einen + generischen Drawer gewrappt. Der Drawer-Wrapper ist neu; der Picker selbst bleibt unverändert. +
+
+ + + +
+ +

Mobile — Out of Scope

+ +

+ Dieses Spec betrifft ausschließlich die Desktop-Ansicht (≥ 768px). + Das mobile Layout (vertikaler Stack, DayMealCard, ActionSheet) bleibt unverändert. + CSS-3D-Flips auf Touch-Geräten haben bekannte Rendering-Unterschiede auf älteren Android-Browsern — + ein separates Issue sollte die mobile Interaktion (ggf. Slide-up Sheet statt Flip) spezifizieren. +

+
+ + + +
+ +

Komponenten-Übersicht

+ +
+ src/routes/(app)/planner/+page.svelte + Ändern + Rechtes Panel entfernen. Layout auf 2-spaltig (sidebar + main) umstellen. Toolbar-Button entfernen. Grid-Höhe auf 100% setzen. +
+
+ src/lib/planner/DayMealCard.svelte + Ersetzen / umbenennen + Zur Flip-Kachel umbauen: .scene → .card → .card-front + .card-back. Farb-Klassen-Prop, Gradient-Overlay, Back-Face mit Aktionen. +
+
+ src/lib/planner/EmptyDayTile.svelte + Neu + Leere Kachel: + CTA + Inline-Suggestion-Liste mit Reasoning-Tags. Ersetzt den bisherigen leeren Slot-Platzhalter. +
+
+ src/lib/planner/RecipePickerDrawer.svelte + Neu + Drawer-Wrapper um den bestehenden RecipePicker. Slide-in von rechts, Backdrop, Schließ-Logik. +
+
+ src/lib/planner/RecipePicker.svelte + Ändern + Aus dem rechten Panel lösen. Bekommt slotId als Prop. Keine Änderung an der Such-/Auswahl-Logik nötig. +
+
+ src/app.css + Ergänzen + 14 Farb-Klassen für Protein- und Küchenstil-Gradients hinzufügen (.protein-haehnchen, .cuisine-asiatisch, …). +
+
+ + + +
+ +

A11y-Anforderungen

+ +
    +
  • .scene: role="button", tabindex="0", aria-expanded="false|true", aria-label="[Rezeptname] — Details anzeigen"
  • +
  • .card-back: aria-hidden="true" solange nicht geflippt
  • +
  • × Schließen-Button: aria-label="Schließen", type="button"
  • +
  • Keyboard: Enter / Space flippt, Escape dreht zurück
  • +
  • Dimming: gedimmte Kacheln bekommen aria-hidden="true" wenn eine andere geflippt ist
  • +
+
+ +
+ +