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>
626 lines
32 KiB
HTML
626 lines
32 KiB
HTML
<!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}&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<<span class="ty">string</span>, <span class="ty">string</span>> = {
|
||
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<<span class="ty">string</span>, <span class="ty">string</span>> = {
|
||
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<<span class="ty">string</span>, { slotId: <span class="ty">number</span>; recipeName: <span class="ty">string</span> }> = {};
|
||
<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(() => 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>) < <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>) < <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">- <VarietyWarningCards {warnings} /></span>
|
||
<span class="diff-add">+ {#if actionWarnings.length > 0}</span>
|
||
<span class="diff-add">+ <VarietyWarningCards warnings={actionWarnings} {weekStart} /></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}&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}&swap={slotId} führt</li>
|
||
<li>AC-5: Tags mit nur einem betroffenen Tag (days.length < 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 <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</span></div>
|
||
</div>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|