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 Inhalt title + explanation (String) Strukturierte Zeilen: Wochentag · Rezeptname · Tauschen-Link
Tages-Angabe API-Code MON, WED Abkürzung Mo, Mi
Rezeptname Fehlt Aus weekPlan.slots[].recipe.name
Tausch-Navigation Fehlt — Nutzer verlässt die Seite manuell /planner?week={weekStart}&swap={slotId}
Datenbasis computeWarnings() aus variety.ts Inline $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.weekStart string (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:

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 Slot Filter-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 null Bestehende {#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 vor items 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 Rezeptname CSS 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.svelte DAY_SHORT-Konstante, slotsByDay-Derived, actionWarnings-Derived, Template-Update (2×), Import-Bereinigung
frontend/src/lib/planner/VarietyWarningCards.svelte Keine — bereits auf ActionWarning-Format aktualisiert
frontend/src/lib/planner/variety.ts Keine — 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