Files
mealprep/specs/frontend/c3-variety-rework-v1-spec.html
Marcel Raddatz e3066ec3e5 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 <noreply@anthropic.com>
2026-04-09 16:31:14 +02:00

626 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — C3 Abwechslungs-Analyse · Implementierungsspezifikation V1</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{
--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;
--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;
--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;
--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;
--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;
--purple-tint:#EEEDFE;--purple-light:#CECBF6;--purple:#534AB7;--purple-dark:#3C3489;
--orange-tint:#FEF0E6;--orange:#E8862A;
--red-tint:#FDECEA;--red:#DC4C3E;--red-dark:#B03328;
--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;
--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;
--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#DDDBD5;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:40px 40px 28px;margin:-48px -40px 48px;display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid var(--color-border);}
.doc-header h1{font-family:var(--font-display);font-size:26px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-ready{background:var(--green-tint);color:var(--green-dark);}
.pill-warn{background:var(--yellow-tint);color:var(--yellow-text);}
.section{margin-bottom:56px;}
.section-label{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:20px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.7;max-width:720px;margin-bottom:16px;}
.prose strong{color:var(--color-text);font-weight:500;}
/* Code blocks */
.code{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:16px 20px;font-family:var(--font-mono);font-size:12px;line-height:1.7;overflow-x:auto;margin-bottom:16px;white-space:pre;}
.code .cm{color:var(--color-text-muted);}
.code .kw{color:var(--purple);}
.code .ty{color:var(--blue-dark);}
.code .st{color:var(--green-dark);}
.code .nu{color:var(--orange);}
/* Tables */
.tbl{width:100%;border-collapse:collapse;font-size:12px;background:var(--color-surface);border-radius:var(--radius-lg);overflow:hidden;border:1px solid var(--color-border);margin-bottom:16px;}
.tbl thead tr{background:var(--color-subtle);border-bottom:1px solid var(--color-border);}
.tbl th{text-align:left;padding:10px 14px;font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
.tbl td{padding:9px 14px;border-bottom:1px solid var(--color-subtle);vertical-align:top;}
.tbl tr:last-child td{border-bottom:none;}
.tbl td:first-child{font-weight:500;color:var(--color-text-muted);white-space:nowrap;font-size:11px;}
.tbl td.mono{font-family:var(--font-mono);font-size:11px;}
/* Callout boxes */
.box{border-radius:var(--radius-lg);padding:16px 20px;margin-bottom:16px;}
.box-lbl{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px;}
.box ul{list-style:none;display:flex;flex-direction:column;gap:5px;}
.box li{font-size:12px;line-height:1.5;display:flex;align-items:flex-start;gap:8px;}
.box li::before{font-weight:500;flex-shrink:0;}
.box-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}
.box-y .box-lbl,.box-y li::before{color:var(--yellow-text);}
.box-y li{color:var(--yellow-text);}
.box-g{background:var(--green-tint);border:1px solid var(--green-light);}
.box-g .box-lbl,.box-g li::before{color:var(--green-dark);}
.box-g li{color:var(--green-dark);}
.box-b{background:var(--blue-tint);border:1px solid var(--blue-light);}
.box-b .box-lbl,.box-b li::before{color:var(--blue-dark);}
.box-b li{color:var(--blue-dark);}
.box ul.checks li::before{content:'✓';}
.box ul.arrows li::before{content:'→';}
/* State cards */
.state-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;margin-bottom:12px;}
.state-head{background:var(--color-subtle);padding:10px 16px;border-bottom:1px solid var(--color-border);display:flex;align-items:center;gap:10px;}
.state-id{font-family:var(--font-mono);font-size:11px;font-weight:500;color:var(--color-text-muted);}
.state-title{font-size:13px;font-weight:500;}
.state-body{padding:14px 16px;font-size:12px;line-height:1.7;}
/* Device frames (compact preview) */
.prev-row{display:flex;gap:32px;align-items:flex-start;flex-wrap:wrap;margin-bottom:16px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:8px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.phone{width:300px;flex-shrink:0;background:var(--color-page);border-radius:32px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.08);border:5px solid #1C1C18;}
.pst{padding:8px 16px 0;display:flex;justify-content:space-between;align-items:center;font-size:10px;background:var(--color-page);}
.pst b{font-weight:600;font-size:11px;}
/* Warning card preview */
.wcard{border-radius:8px;border:1px solid var(--yellow-light);background:var(--yellow-tint);overflow:hidden;margin-bottom:8px;}
.wcard:last-child{margin-bottom:0;}
.wcard-hd{padding:9px 14px;border-bottom:1px solid var(--yellow-light);}
.wcard-hd-t{font-size:13px;font-weight:500;color:var(--yellow-text);}
.wcard-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 14px;border-bottom:1px solid rgba(249,224,138,.4);}
.wcard-row:last-child{border-bottom:none;}
.wcard-left{display:flex;align-items:center;gap:8px;min-width:0;}
.wcard-day{font-size:11px;font-weight:600;color:var(--yellow-text);width:20px;flex-shrink:0;}
.wcard-recipe{font-size:13px;color:var(--color-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.wcard-swap{font-size:12px;font-weight:500;color:var(--yellow-text);white-space:nowrap;flex-shrink:0;}
.divider{border:none;border-top:1px solid var(--color-border);margin:40px 0;}
/* File diff style */
.diff{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);font-family:var(--font-mono);font-size:12px;line-height:1.6;overflow-x:auto;margin-bottom:16px;}
.diff-file{padding:8px 16px;background:var(--color-subtle);border-bottom:1px solid var(--color-border);font-size:11px;font-weight:500;color:var(--color-text-muted);}
.diff-body{padding:12px 16px;white-space:pre;}
.diff-add{color:var(--green-dark);background:rgba(61,140,74,.06);}
.diff-rem{color:var(--red-dark);background:rgba(220,76,62,.06);}
.diff-ctx{color:var(--color-text-muted);}
</style>
</head>
<body>
<div class="doc">
<!-- Header -->
<div class="doc-header">
<div>
<h1>C3 — Abwechslungs-Analyse · Implementierungsspezifikation</h1>
<p>Recipe App · Variation V1 "Erweiterte Karten" · Rezeptnamen + Tausch-Links in Warnkarten</p>
</div>
<div class="doc-meta">
<span class="pill pill-ready">Final</span><br>
Erstellt: 2026-04<br>
Screen: C3<br>
Bezug: c3-variety-rework.html
</div>
</div>
<!-- ── 1. ÜBERBLICK ── -->
<div class="section">
<div class="section-label">1 · Überblick</div>
<p class="prose">Die Seite <strong>/planner/variety</strong> zeigt derzeit Warnkarten mit technischen Tages-Codes (<code style="font-family:var(--font-mono);font-size:11px;">MON, WED — erwäge einen Tausch</code>). Der Planer muss manuell nachschlagen, welches Gericht an diesen Tagen eingeplant ist, und dann zurück zum Planer navigieren um es zu tauschen.</p>
<p class="prose"><strong>V1 "Erweiterte Karten"</strong> 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.</p>
<div class="box box-b">
<div class="box-lbl">Scope</div>
<ul class="arrows">
<li>Kein neues Backend-Endpoint — alle nötigen Daten sind bereits im weekPlan-Load vorhanden</li>
<li>Kein Layout-Umbau — nur VarietyWarningCards.svelte und die Datenvorbereitung in +page.svelte ändern sich</li>
<li>Protein-Grid und EffortBar bleiben wie bisher (Desktop)</li>
</ul>
</div>
</div>
<!-- ── 2. PROBLEM IM DETAIL ── -->
<div class="section">
<div class="section-label">2 · Aktueller Ist-Zustand und Problem</div>
<table class="tbl">
<thead><tr><th>Element</th><th>Aktuell</th><th>Soll (V1)</th></tr></thead>
<tbody>
<tr>
<td>Warnkarte Inhalt</td>
<td><code style="font-family:var(--font-mono);font-size:11px;">title + explanation (String)</code></td>
<td>Strukturierte Zeilen: Wochentag · Rezeptname · Tauschen-Link</td>
</tr>
<tr>
<td>Tages-Angabe</td>
<td>API-Code <code style="font-family:var(--font-mono);font-size:11px;">MON, WED</code></td>
<td>Abkürzung <code style="font-family:var(--font-mono);font-size:11px;">Mo, Mi</code></td>
</tr>
<tr>
<td>Rezeptname</td>
<td>Fehlt</td>
<td>Aus <code style="font-family:var(--font-mono);font-size:11px;">weekPlan.slots[].recipe.name</code></td>
</tr>
<tr>
<td>Tausch-Navigation</td>
<td>Fehlt — Nutzer verlässt die Seite manuell</td>
<td><code style="font-family:var(--font-mono);font-size:11px;">/planner?week={weekStart}&amp;swap={slotId}</code></td>
</tr>
<tr>
<td>Datenbasis</td>
<td><code style="font-family:var(--font-mono);font-size:11px;">computeWarnings()</code> aus variety.ts</td>
<td>Inline <code style="font-family:var(--font-mono);font-size:11px;">$derived.by()</code> in +page.svelte, direkt aus API-Daten</td>
</tr>
</tbody>
</table>
</div>
<!-- ── 3. DATENFLUSS ── -->
<div class="section">
<div class="section-label">3 · Datenfluss</div>
<p class="prose">Alle nötigen Daten werden bereits im Server-Load geladen. Kein neuer API-Call erforderlich.</p>
<table class="tbl">
<thead><tr><th>Quelle</th><th>Feld</th><th>Verwendung</th></tr></thead>
<tbody>
<tr>
<td>weekPlan.slots[]</td>
<td class="mono">{ id, dayOfWeek, recipe: { id, name } }</td>
<td>Aufbau der <code style="font-family:var(--font-mono);font-size:11px;">slotsByDay</code>-Map: DayCode → { slotId, recipeName }</td>
</tr>
<tr>
<td>varietyScore.tagRepeats[]</td>
<td class="mono">{ tagType, tagName, days: string[] }</td>
<td>Warnkarten für wiederholte Tags (Protein, Cuisine). days[] enthält API-Codes: "MON", "TUE" …</td>
</tr>
<tr>
<td>varietyScore.ingredientOverlaps[]</td>
<td class="mono">{ ingredientName, days: string[] }</td>
<td>Warnkarten für Zutaten-Überschneidungen</td>
</tr>
<tr>
<td>varietyScore.duplicatesInPlan[]</td>
<td class="mono">string[] (Rezeptnamen)</td>
<td>Warnkarte: "X doppelt geplant". Alle Slots mit diesem Rezeptnamen liefern die Items.</td>
</tr>
<tr>
<td>data.weekStart</td>
<td class="mono">string (YYYY-MM-DD)</td>
<td>Swap-URL-Parameter</td>
</tr>
</tbody>
</table>
<p class="prose">Tag-Code → Abkürzung Mapping (konstant):</p>
<div class="code"><span class="cm">// Day code → German short label</span>
<span class="kw">const</span> DAY_SHORT: Record&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; = {
MON: <span class="st">'Mo'</span>, TUE: <span class="st">'Di'</span>, WED: <span class="st">'Mi'</span>,
THU: <span class="st">'Do'</span>, FRI: <span class="st">'Fr'</span>, SAT: <span class="st">'Sa'</span>, SUN: <span class="st">'So'</span>
};</div>
</div>
<!-- ── 4. TYPEN ── -->
<div class="section">
<div class="section-label">4 · Typen</div>
<p class="prose">Die bestehende <code style="font-family:var(--font-mono);font-size:11px;">VarietyWarningCards.svelte</code> definiert bereits die korrekten Interfaces. Diese bleiben unverändert:</p>
<div class="code"><span class="cm">// In VarietyWarningCards.svelte (bereits vorhanden, nicht ändern)</span>
<span class="kw">interface</span> <span class="ty">WarningItem</span> {
dayShort: <span class="ty">string</span>; <span class="cm">// 'Mo', 'Di', …</span>
recipeName: <span class="ty">string</span>; <span class="cm">// aus weekPlan.slots[].recipe.name</span>
slotId: <span class="ty">number</span>; <span class="cm">// für Swap-Link</span>
}
<span class="kw">interface</span> <span class="ty">ActionWarning</span> {
title: <span class="ty">string</span>; <span class="cm">// z.B. "Tofu mehrfach diese Woche"</span>
items: <span class="ty">WarningItem</span>[]; <span class="cm">// eine Zeile pro betroffenem Tag</span>
}</div>
<p class="prose">Die alte <code style="font-family:var(--font-mono);font-size:11px;">Warning</code>-Schnittstelle aus <code style="font-family:var(--font-mono);font-size:11px;">variety.ts</code> (<code style="font-family:var(--font-mono);font-size:11px;">{ title, explanation }</code>) wird nicht mehr verwendet.</p>
</div>
<!-- ── 5. IMPLEMENTIERUNG ── -->
<div class="section">
<div class="section-label">5 · Implementierung</div>
<p class="prose">Es gibt drei Änderungen:</p>
<!-- 5.1 slotsByDay -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.1</div>
<div class="state-title">+page.svelte — slotsByDay Map aufbauen</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">Füge direkt nach den bestehenden <code style="font-family:var(--font-mono);font-size:11px;">$derived</code>-Deklarationen hinzu:</p>
<div class="code" style="margin-bottom:0"><span class="kw">const</span> DAY_SHORT: Record&lt;<span class="ty">string</span>, <span class="ty">string</span>&gt; = {
MON: <span class="st">'Mo'</span>, TUE: <span class="st">'Di'</span>, WED: <span class="st">'Mi'</span>,
THU: <span class="st">'Do'</span>, FRI: <span class="st">'Fr'</span>, SAT: <span class="st">'Sa'</span>, SUN: <span class="st">'So'</span>
};
<span class="cm">// dayOfWeek (API code) → { slotId, recipeName }</span>
<span class="kw">let</span> slotsByDay = $derived.by(() => {
<span class="kw">const</span> map: Record&lt;<span class="ty">string</span>, { slotId: <span class="ty">number</span>; recipeName: <span class="ty">string</span> }&gt; = {};
<span class="kw">for</span> (<span class="kw">const</span> slot <span class="kw">of</span> weekPlan?.slots ?? []) {
<span class="kw">if</span> (slot.dayOfWeek && slot.recipe?.name && slot.id) {
map[slot.dayOfWeek] = { slotId: slot.id, recipeName: slot.recipe.name };
}
}
<span class="kw">return</span> map;
});</div>
</div>
</div>
<!-- 5.2 actionWarnings -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.2</div>
<div class="state-title">+page.svelte — actionWarnings ersetzen computeWarnings()</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">Ersetze den bestehenden <code style="font-family:var(--font-mono);font-size:11px;">let warnings = $derived.by(() =&gt; computeWarnings(…))</code>-Block vollständig:</p>
<div class="code" style="margin-bottom:0"><span class="kw">interface</span> <span class="ty">WarningItem</span> { dayShort: <span class="ty">string</span>; recipeName: <span class="ty">string</span>; slotId: <span class="ty">number</span>; }
<span class="kw">interface</span> <span class="ty">ActionWarning</span> { title: <span class="ty">string</span>; items: <span class="ty">WarningItem</span>[]; }
<span class="kw">let</span> actionWarnings = $derived.by((): <span class="ty">ActionWarning</span>[] => {
<span class="kw">const</span> result: <span class="ty">ActionWarning</span>[] = [];
<span class="kw">const</span> vs = varietyScore;
<span class="kw">if</span> (!vs) <span class="kw">return</span> result;
<span class="cm">// Tag repeats (protein, cuisine, …)</span>
<span class="kw">for</span> (<span class="kw">const</span> repeat <span class="kw">of</span> vs.tagRepeats ?? []) {
<span class="kw">if</span> ((repeat.days?.length ?? <span class="nu">0</span>) &lt; <span class="nu">2</span>) <span class="kw">continue</span>;
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = (repeat.days ?? [])
.map((day) => {
<span class="kw">const</span> slot = slotsByDay[day];
<span class="kw">return</span> slot
? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId }
: <span class="kw">null</span>;
})
.filter((x): x is <span class="ty">WarningItem</span> => x !== <span class="kw">null</span>);
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items });
}
}
<span class="cm">// Ingredient overlaps</span>
<span class="kw">for</span> (<span class="kw">const</span> overlap <span class="kw">of</span> vs.ingredientOverlaps ?? []) {
<span class="kw">if</span> ((overlap.days?.length ?? <span class="nu">0</span>) &lt; <span class="nu">2</span>) <span class="kw">continue</span>;
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = (overlap.days ?? [])
.map((day) => {
<span class="kw">const</span> slot = slotsByDay[day];
<span class="kw">return</span> slot
? { dayShort: DAY_SHORT[day] ?? day, recipeName: slot.recipeName, slotId: slot.slotId }
: <span class="kw">null</span>;
})
.filter((x): x is <span class="ty">WarningItem</span> => x !== <span class="kw">null</span>);
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
result.push({ title: `${overlap.ingredientName} in mehreren Gerichten`, items });
}
}
<span class="cm">// Duplicate recipes — find all slots with that recipe name</span>
<span class="kw">for</span> (<span class="kw">const</span> name <span class="kw">of</span> vs.duplicatesInPlan ?? []) {
<span class="kw">const</span> items: <span class="ty">WarningItem</span>[] = Object.entries(slotsByDay)
.filter(([, s]) => s.recipeName === name)
.map(([day, s]) => ({ dayShort: DAY_SHORT[day] ?? day, recipeName: s.recipeName, slotId: s.slotId }));
<span class="kw">if</span> (items.length > <span class="nu">0</span>) {
result.push({ title: `${name} doppelt geplant`, items });
}
}
<span class="kw">return</span> result;
});</div>
</div>
</div>
<!-- 5.3 Template update -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.3</div>
<div class="state-title">+page.svelte — Template: warnings → actionWarnings, weekStart übergeben</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">An beiden Stellen im Template (Mobile + Desktop) ersetzen:</p>
<div class="diff">
<div class="diff-file">+page.svelte (Mobile, ~Zeile 110 / Desktop, ~Zeile 222)</div>
<div class="diff-body"><span class="diff-rem">- {#if warnings.length > 0}</span>
<span class="diff-rem">- &lt;VarietyWarningCards {warnings} /&gt;</span>
<span class="diff-add">+ {#if actionWarnings.length > 0}</span>
<span class="diff-add">+ &lt;VarietyWarningCards warnings={actionWarnings} {weekStart} /&gt;</span></div>
</div>
<p style="font-size:12px;color:var(--color-text-muted);">Achtung: <code style="font-family:var(--font-mono);font-size:11px;">weekStart</code> ist für die Swap-URL erforderlich und muss explizit übergeben werden.</p>
</div>
</div>
<!-- 5.4 Import cleanup -->
<div class="state-card">
<div class="state-head">
<div class="state-id">5.4</div>
<div class="state-title">+page.svelte — Import aufräumen</div>
</div>
<div class="state-body">
<p style="margin-bottom:10px;">Entferne den nicht mehr genutzten Import:</p>
<div class="diff">
<div class="diff-file">+page.svelte (Script-Block, oben)</div>
<div class="diff-body"><span class="diff-rem">- import { computeSubScores, computeWarnings } from '$lib/planner/variety';</span>
<span class="diff-add">+ import { computeSubScores } from '$lib/planner/variety';</span></div>
</div>
<p style="font-size:12px;color:var(--color-text-muted);">computeSubScores wird noch für die Score-Breakdown-Anzeige genutzt.</p>
</div>
</div>
</div>
<!-- ── 6. KOMPONENTE: VarietyWarningCards ── -->
<div class="section">
<div class="section-label">6 · VarietyWarningCards.svelte — bereits korrekt</div>
<p class="prose">Die Komponente wurde bereits auf das neue <code style="font-family:var(--font-mono);font-size:11px;">ActionWarning</code>-Format aktualisiert. <strong>Keine Änderung erforderlich.</strong> Zur Referenz die erwartete Props-Schnittstelle:</p>
<div class="code"><span class="cm">// Props (bereits implementiert)</span>
<span class="kw">let</span> { warnings, weekStart }: {
warnings: <span class="ty">ActionWarning</span>[];
weekStart: <span class="ty">string</span>;
} = $props();</div>
<p class="prose">Die Komponente rendert für jede Warnung:</p>
<ul style="font-size:12px;color:var(--color-text-muted);margin-left:20px;margin-bottom:16px;line-height:1.9;">
<li>Gelbe Karte (<code style="font-family:var(--font-mono);font-size:11px;">border: yellow-light, bg: yellow-tint</code>) mit Header-Zeile (Titel)</li>
<li>Pro Item: Zeile mit Wochentag-Abkürzung (W=20px, fixed) · Rezeptname (truncate) · "Tauschen →" Link (rechts)</li>
<li>Swap-URL: <code style="font-family:var(--font-mono);font-size:11px;">/planner?week={weekStart}&amp;swap={item.slotId}</code></li>
</ul>
<!-- Visual preview -->
<div class="prev-row">
<div class="prev-col">
<div class="bp-lbl">Warnkarte · Referenz-Darstellung</div>
<div style="width:340px;">
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Tofu mehrfach diese Woche</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mo</span><span class="wcard-recipe">Tofu-Gemüse-Pfanne</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
<div class="wcard">
<div class="wcard-hd"><div class="wcard-hd-t">Paprika in mehreren Gerichten</div></div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Di</span><span class="wcard-recipe">Paprika-Linsen-Eintopf</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
<div class="wcard-row">
<div class="wcard-left"><span class="wcard-day">Mi</span><span class="wcard-recipe">Tofu-Curry mit Reis</span></div>
<span class="wcard-swap">Tauschen →</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 7. EDGE CASES ── -->
<div class="section">
<div class="section-label">7 · Edge Cases</div>
<table class="tbl">
<thead><tr><th>Fall</th><th>Verhalten</th></tr></thead>
<tbody>
<tr>
<td>Tag im tagRepeat hat keinen Slot</td>
<td>Filter-Schritt (.filter(x => x !== null)) entfernt das Item. Warnkarte erscheint nur wenn ≥1 Item vorhanden.</td>
</tr>
<tr>
<td>weekPlan hat keine Slots (leere Woche)</td>
<td>slotsByDay ist {}, actionWarnings ist []. Keine Warnkarten sichtbar.</td>
</tr>
<tr>
<td>varietyScore ist null</td>
<td>Bestehende {#if !varietyScore}-Guard greift — actionWarnings wird nie gerendert.</td>
</tr>
<tr>
<td>Slot hat kein Rezept (slot.recipe === null)</td>
<td>slot.recipe?.name ist undefined → Slot wird nicht in slotsByDay aufgenommen.</td>
</tr>
<tr>
<td>duplicatesInPlan: Rezeptname kommt in slotsByDay nicht vor</td>
<td>items ist leer → Warnkarte wird nicht gepusht.</td>
</tr>
<tr>
<td>Unbekannter Tag-Code (z.B. zukünftige API-Erweiterung)</td>
<td>DAY_SHORT[day] ?? day — Fallback auf den rohen Code.</td>
</tr>
<tr>
<td>Sehr langer Rezeptname</td>
<td>CSS truncate auf .wcard-recipe — kein Überlauf, Swap-Link bleibt sichtbar.</td>
</tr>
</tbody>
</table>
</div>
<!-- ── 8. ABNAHMEKRITERIEN ── -->
<div class="section">
<div class="section-label">8 · Abnahmekriterien</div>
<div class="box box-g">
<div class="box-lbl">Acceptance Criteria</div>
<ul class="checks">
<li>AC-1: Warnkarte zeigt pro betroffenem Tag eine eigene Zeile (nicht mehr einen langen Erklärungstext)</li>
<li>AC-2: Jede Zeile enthält die deutsche Wochentag-Abkürzung (Mo, Di, Mi, Do, Fr, Sa, So)</li>
<li>AC-3: Jede Zeile enthält den Namen des eingeplanten Rezepts</li>
<li>AC-4: Jede Zeile enthält einen "Tauschen →" Link, der zu /planner?week={weekStart}&amp;swap={slotId} führt</li>
<li>AC-5: Tags mit nur einem betroffenen Tag (days.length &lt; 2) erzeugen keine Warnkarte</li>
<li>AC-6: Score-Hero, Bewertungsdetails und Protein-Grid (Desktop) bleiben unverändert</li>
<li>AC-7: Wenn varietyScore null ist, werden keine Warnkarten gerendert (leere-Woche-State bleibt)</li>
<li>AC-8: Der Import von computeWarnings ist entfernt, TypeScript kompiliert fehlerfrei</li>
<li>AC-9: Auf Mobilgerät sind Tausch-Links touch-freundlich (mind. 44px Zeilenhöhe)</li>
</ul>
</div>
<div class="box box-y">
<div class="box-lbl">Nicht in Scope</div>
<ul class="arrows">
<li>Neues Backend-Endpoint — alle Daten kommen aus dem bestehenden Load</li>
<li>Layout-Umbau der Seite — Score bleibt oben, Warnungen unten wie bisher</li>
<li>Protein-Grid oder EffortBar Änderungen</li>
<li>computeSubScores aus variety.ts — bleibt unverändert</li>
<li>Entfernen von computeWarnings aus variety.ts (Funktion bleibt, wird nur nicht mehr aufgerufen)</li>
</ul>
</div>
</div>
<!-- ── 9. DATEIEN ── -->
<div class="section">
<div class="section-label">9 · Betroffene Dateien</div>
<table class="tbl">
<thead><tr><th>Datei</th><th>Änderung</th></tr></thead>
<tbody>
<tr>
<td class="mono">frontend/src/routes/(app)/planner/variety/+page.svelte</td>
<td>DAY_SHORT-Konstante, slotsByDay-Derived, actionWarnings-Derived, Template-Update (2×), Import-Bereinigung</td>
</tr>
<tr>
<td class="mono">frontend/src/lib/planner/VarietyWarningCards.svelte</td>
<td>Keine — bereits auf ActionWarning-Format aktualisiert</td>
</tr>
<tr>
<td class="mono">frontend/src/lib/planner/variety.ts</td>
<td>Keine — computeWarnings bleibt (ungenutzt, aber nicht entfernen um Regressions-Risiko zu vermeiden)</td>
</tr>
</tbody>
</table>
</div>
<!-- ── LLM AGENT REGION ── -->
<div class="section">
<div class="section-label">LLM-Agent-Lesbereich</div>
<p class="prose">Dieser Abschnitt enthält maschinenlesbare Regeln für einen KI-Agenten der die Implementierung durchführt.</p>
<div class="code"><span class="cm">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 &lt;script&gt; block, after imports):
const DAY_SHORT: Record&lt;string, string&gt; = {
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&lt;string, { slotId: number; recipeName: string }&gt; = {};
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) &lt; 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 &gt; 0) result.push({ title: `${repeat.tagName} mehrfach diese Woche`, items });
}
for (const overlap of vs.ingredientOverlaps ?? []) {
if ((overlap.days?.length ?? 0) &lt; 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 &gt; 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 &gt; 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} / &lt;VarietyWarningCards {warnings} /&gt;
NEW: {#if actionWarnings.length > 0} / &lt;VarietyWarningCards warnings={actionWarnings} {weekStart} /&gt;
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</span></div>
</div>
</div>
</body>
</html>