1 · Überblick
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)
2 · Aktueller Ist-Zustand und Problem
| Element | Aktuell | Soll (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 |
3 · Datenfluss
Alle nötigen Daten werden bereits im Server-Load geladen. Kein neuer API-Call erforderlich.
| Quelle | Feld | Verwendung |
| 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'
};
4 · Typen
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.
5 · Implementierung
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.
6 · VarietyWarningCards.svelte — bereits korrekt
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 →
7 · Edge Cases
| Fall | Verhalten |
| 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. |
8 · Abnahmekriterien
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)
9 · Betroffene Dateien
| 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) |
LLM-Agent-Lesbereich
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