feat(suggestions): C2 — Meal suggestions (variety-aware) #40

Merged
marcel merged 2 commits from feat/issue-27-meal-suggestions into master 2026-04-03 11:18:45 +02:00
Owner

Summary

Implements issue #27: variety-aware meal suggestions screen (C2).

  • Mobile: sticky header, collapsible SuggestionContextBanner with week-so-far context, ranked suggestion list, browse library link
  • Desktop: 2-panel — left (280px, --color-surface) with week-so-far mini cards + filter reasons + library link; right (flex, --color-page) with ranked suggestion cards
  • SuggestionCard: rank number (Fraunces 32px/300), recipe name + metadata, green/yellow reasoning badge (--radius-full), pick form
  • SuggestionContextBanner: collapsible (Svelte 5 $state), shows planned days + filter criterion bullets
  • Server load: fetches week plan for context, then suggestions sorted by simulatedScore descending
  • pickSuggestion action: role guard (planer only), date format validation, POST /v1/week-plans/{id}/slots
  • Browse full library: passes ?selectFor={day}&week={weekStart} to B1 (selection mode integration point)

Test plan

  • 11 server tests: load, sorting, selected day, error handling, weekPlan context, pickSuggestion (happy/error/member/invalid date)
  • 6 SuggestionCard tests: name, rank, metadata, green badge, yellow badge, pick button
  • 4 SuggestionContextBanner tests: renders, shows meals, toggle, empty slots
  • All 427 tests pass, 0 type errors

Closes #27

🤖 Generated with Claude Code

## Summary Implements issue #27: variety-aware meal suggestions screen (C2). - **Mobile**: sticky header, collapsible `SuggestionContextBanner` with week-so-far context, ranked suggestion list, browse library link - **Desktop**: 2-panel — left (280px, `--color-surface`) with week-so-far mini cards + filter reasons + library link; right (flex, `--color-page`) with ranked suggestion cards - **SuggestionCard**: rank number (Fraunces 32px/300), recipe name + metadata, green/yellow reasoning badge (`--radius-full`), pick form - **SuggestionContextBanner**: collapsible (Svelte 5 `$state`), shows planned days + filter criterion bullets - **Server load**: fetches week plan for context, then suggestions sorted by `simulatedScore` descending - **pickSuggestion action**: role guard (planer only), date format validation, POST `/v1/week-plans/{id}/slots` - **Browse full library**: passes `?selectFor={day}&week={weekStart}` to B1 (selection mode integration point) ## Test plan - [x] 11 server tests: load, sorting, selected day, error handling, weekPlan context, pickSuggestion (happy/error/member/invalid date) - [x] 6 SuggestionCard tests: name, rank, metadata, green badge, yellow badge, pick button - [x] 4 SuggestionContextBanner tests: renders, shows meals, toggle, empty slots - [x] All 427 tests pass, 0 type errors Closes #27 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 1 commit 2026-04-03 11:12:45 +02:00
Two-panel desktop layout (280px context panel + flex suggestions panel)
and mobile layout (sticky header + collapsible context banner + ranked list).

- SuggestionCard: rank number, recipe metadata, green/yellow reasoning badge, pick form
- SuggestionContextBanner: collapsible week-so-far list + filter reasons
- Server load: fetches week plan + variety-aware suggestions sorted by simulatedScore
- pickSuggestion action: role guard, date validation, POST to /v1/week-plans/{id}/slots
- Browse full library link passes selectFor+week params to recipe library (B1 integration point)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Kai — Frontend Engineer

Verdict: ⚠️ Approved with concerns

Good structure — Svelte 5 runes used correctly, $derived for computed values, clean component decomposition. TDD is evident. A few things need addressing.

Blockers

1. Reasoning badge derived from score threshold (7.5) — not from API data
In +page.svelte, rankedSuggestions hardcodes the reasoning label as either "Passt gut zur Woche" or "Wiederholung möglich" based on simulatedScore >= 7.5. This throws away the backend's actual reasoning. The issue spec says the badge text is server-generated (e.g., "✓ Fresh protein · effort balance", "⚠ Chicken again — 2 days apart"). The API response should include badge type and label — but looking at SuggestionItem in the schema, neither reasoningType nor reasoningLabel exists. Either: (a) the schema needs extending, or (b) the frontend derives minimal labels until the schema is updated. The current approach is a workaround — it should be documented with a // TODO: replace with API-provided reasoning when available comment, not silently shipped as the intended behavior.

2. planId can be empty string in suggestion cards when weekPlan?.id is nullish
When weekPlan?.id ?? '' evaluates to '', the form submits planId: "" to the action. The action doesn't validate planId — it passes "" to the API which will either 400 or 404. Add a planId validation check in the action (like the existing slotDate validation). If planId is missing, return { success: false, error: 'Fehlende Plan-ID.' }.

3. Missing redirect after successful pick
The pickSuggestion action returns { success: true } but doesn't redirect to C1. The user stays on C2 after picking a suggestion. The issue spec says "Pick a suggestion → writes week_plan_slot → returns to C1." Use redirect(303, /planner?week=${weekStart}) (import from @sveltejs/kit) after the successful POST.

Suggestions

  • SuggestionContextBanner toggle button text "Filter ausblenden ▲ / Filter einblenden ▼" — chevron arrows are text characters, which may render inconsistently. A simple Tailwind rotate transform on an SVG chevron is more reliable.
  • The desktop filter reasons are hardcoded bullets ("Keine Zutatenwiederholungen", etc.) — these should eventually come from the API. Fine for now, but worth a TODO comment.
  • _locals param in +page.server.ts load function — the underscore prefix is correct to indicate unused param. Consider whether to destructure only what's needed from locals in the future.
## 👨‍💻 Kai — Frontend Engineer **Verdict: ⚠️ Approved with concerns** Good structure — Svelte 5 runes used correctly, `$derived` for computed values, clean component decomposition. TDD is evident. A few things need addressing. ### Blockers **1. Reasoning badge derived from score threshold (7.5) — not from API data** In `+page.svelte`, `rankedSuggestions` hardcodes the reasoning label as either "Passt gut zur Woche" or "Wiederholung möglich" based on `simulatedScore >= 7.5`. This throws away the backend's actual reasoning. The issue spec says the badge text is server-generated (e.g., "✓ Fresh protein · effort balance", "⚠ Chicken again — 2 days apart"). The API response should include badge type and label — but looking at `SuggestionItem` in the schema, neither `reasoningType` nor `reasoningLabel` exists. Either: (a) the schema needs extending, or (b) the frontend derives minimal labels until the schema is updated. The current approach is a workaround — it should be documented with a `// TODO: replace with API-provided reasoning when available` comment, not silently shipped as the intended behavior. **2. `planId` can be empty string in suggestion cards when `weekPlan?.id` is nullish** When `weekPlan?.id ?? ''` evaluates to `''`, the form submits `planId: ""` to the action. The action doesn't validate `planId` — it passes `""` to the API which will either 400 or 404. Add a `planId` validation check in the action (like the existing `slotDate` validation). If `planId` is missing, return `{ success: false, error: 'Fehlende Plan-ID.' }`. **3. Missing redirect after successful pick** The `pickSuggestion` action returns `{ success: true }` but doesn't redirect to C1. The user stays on C2 after picking a suggestion. The issue spec says "Pick a suggestion → writes week_plan_slot → **returns to C1**." Use `redirect(303, `/planner?week=${weekStart}`)` (import from `@sveltejs/kit`) after the successful POST. ### Suggestions - `SuggestionContextBanner` toggle button text "Filter ausblenden ▲ / Filter einblenden ▼" — chevron arrows are text characters, which may render inconsistently. A simple Tailwind rotate transform on an SVG chevron is more reliable. - The desktop filter reasons are hardcoded bullets ("Keine Zutatenwiederholungen", etc.) — these should eventually come from the API. Fine for now, but worth a TODO comment. - `_locals` param in `+page.server.ts` load function — the underscore prefix is correct to indicate unused param. Consider whether to destructure only what's needed from `locals` in the future.
Author
Owner

🧪 QA Engineer

Verdict: 🚫 Changes requested

Good coverage on the server side (11 tests). Component tests cover the card and banner adequately. But there are critical gaps.

Blockers

1. No test for the missing redirect after successful pick
After a successful pickSuggestion, the action should redirect to C1. There is no test asserting that a redirect() is thrown/returned. Once the redirect is implemented (it's currently missing), it needs a test: shouldRedirectToC1AfterSuccessfulPick().

2. No test for planId validation in the action
The planId field isn't validated in the action (can be empty string ""). When validation is added, it needs a test: shouldReturnErrorForMissingPlanId().

3. No test for suggestions returned in reversed order (already sorted)
The test "returns suggestions list sorted by simulatedScore descending" covers the sort — but only tests with 9.2 > 6.1. There's no test for the edge case where suggestions arrive already sorted (no-op sort), or all with equal scores (stable sort), or a single suggestion. Add at minimum a test for empty suggestions list returning [].

4. SuggestionContextBanner toggle test is fragile
The test asserts detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true' equals initiallyVisible. This is checking two different attributes depending on implementation and can produce confusing results. Simplify: after toggle, check that the hidden attribute state is opposite of the initial state. The test currently passes by coincidence because both attributes are set — but if one is removed, the test still passes.

5. No test for SuggestionCard with no recipe
What happens if suggestion.recipe is undefined? The component falls back to "Unbekanntes Rezept" — but there's no test for this case. The existing "card without reasoning" test doesn't cover missing recipe.

Suggestions

  • Add a test that verifies the "Browse full library" link has the correct selectFor parameter in its href.
  • The component tests don't test the desktop layout at all — but since it's CSS-breakpoint-based and not logic-based, this is acceptable for unit tests.
## 🧪 QA Engineer **Verdict: 🚫 Changes requested** Good coverage on the server side (11 tests). Component tests cover the card and banner adequately. But there are critical gaps. ### Blockers **1. No test for the missing redirect after successful pick** After a successful `pickSuggestion`, the action should redirect to C1. There is no test asserting that a `redirect()` is thrown/returned. Once the redirect is implemented (it's currently missing), it needs a test: `shouldRedirectToC1AfterSuccessfulPick()`. **2. No test for `planId` validation in the action** The `planId` field isn't validated in the action (can be empty string `""`). When validation is added, it needs a test: `shouldReturnErrorForMissingPlanId()`. **3. No test for suggestions returned in reversed order (already sorted)** The test "returns suggestions list sorted by simulatedScore descending" covers the sort — but only tests with `9.2 > 6.1`. There's no test for the edge case where suggestions arrive already sorted (no-op sort), or all with equal scores (stable sort), or a single suggestion. Add at minimum a test for empty suggestions list returning `[]`. **4. `SuggestionContextBanner` toggle test is fragile** The test asserts `detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true'` equals `initiallyVisible`. This is checking two different attributes depending on implementation and can produce confusing results. Simplify: after toggle, check that the hidden attribute state is opposite of the initial state. The test currently passes by coincidence because both attributes are set — but if one is removed, the test still passes. **5. No test for `SuggestionCard` with no recipe** What happens if `suggestion.recipe` is `undefined`? The component falls back to "Unbekanntes Rezept" — but there's no test for this case. The existing "card without reasoning" test doesn't cover missing `recipe`. ### Suggestions - Add a test that verifies the "Browse full library" link has the correct `selectFor` parameter in its `href`. - The component tests don't test the desktop layout at all — but since it's CSS-breakpoint-based and not logic-based, this is acceptable for unit tests.
Author
Owner

🔒 Sable — Security Engineer

Verdict: ⚠️ Approved with concerns

Role guard on pickSuggestion is present. Date validation exists. No {@html} usage. However, two blockers.

Blockers

1. planId from user-supplied form data passes unvalidated to the API path parameter
+page.server.ts line 51: params: { path: { id: planId } }planId comes from a hidden form field. A planner can manipulate the form's planId value in the browser to inject another household's plan ID. The backend should enforce household scoping (and should be the authoritative guard), but the frontend action doesn't validate that planId is non-empty. At minimum, add: if (!planId) return { success: false, error: 'Fehlende Plan-ID.' }. The deeper fix is server-side validation, but a blank/garbage planId should be caught early.

2. recipeId from form data has no format validation
recipeId is submitted as a form field from the hidden input. No UUID format validation — a crafted request could submit arbitrary strings. The backend validates, but defense-in-depth suggests: if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) return { success: false, error: '...' }. Low risk (backend controls authorization), but consistent with the slotDate pattern already in place.

3. selectFor parameter in "Browse full library" link is not validated on the recipes page
The URL /recipes?selectFor={selectedDay}&week={weekStart} passes untrusted selectedDay from page data. When B1 reads this, it must validate selectFor server-side (correct household, correct plan slot). This is an implementation note for when B1's selection mode is built — flag it clearly with a code comment or issue.

Observations

  • Recipe names and metadata are rendered as plain text — no XSS risk.
  • Load function uses session-authenticated fetch — household scoping is at the API layer. Correct.
  • The _locals underscore correctly documents that locals aren't used in the load function.
## 🔒 Sable — Security Engineer **Verdict: ⚠️ Approved with concerns** Role guard on `pickSuggestion` is present. Date validation exists. No `{@html}` usage. However, two blockers. ### Blockers **1. `planId` from user-supplied form data passes unvalidated to the API path parameter** `+page.server.ts` line 51: `params: { path: { id: planId } }` — `planId` comes from a hidden form field. A planner can manipulate the form's `planId` value in the browser to inject another household's plan ID. The backend should enforce household scoping (and should be the authoritative guard), but the frontend action doesn't validate that `planId` is non-empty. At minimum, add: `if (!planId) return { success: false, error: 'Fehlende Plan-ID.' }`. The deeper fix is server-side validation, but a blank/garbage `planId` should be caught early. **2. `recipeId` from form data has no format validation** `recipeId` is submitted as a form field from the hidden input. No UUID format validation — a crafted request could submit arbitrary strings. The backend validates, but defense-in-depth suggests: `if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) return { success: false, error: '...' }`. Low risk (backend controls authorization), but consistent with the `slotDate` pattern already in place. **3. `selectFor` parameter in "Browse full library" link is not validated on the recipes page** The URL `/recipes?selectFor={selectedDay}&week={weekStart}` passes untrusted `selectedDay` from page data. When B1 reads this, it must validate `selectFor` server-side (correct household, correct plan slot). This is an implementation note for when B1's selection mode is built — flag it clearly with a code comment or issue. ### Observations - Recipe names and metadata are rendered as plain text — no XSS risk. - Load function uses session-authenticated `fetch` — household scoping is at the API layer. Correct. - The `_locals` underscore correctly documents that locals aren't used in the load function.
Author
Owner

🎨 Atlas — UI/UX Designer

Verdict: ⚠️ Approved with concerns

Token usage is mostly correct. The two-panel desktop structure matches the spec. Minor issues.

Blockers

1. SuggestionCard recipe name is truncated with truncate — single line only
The spec doesn't explicitly require truncation for recipe names, but the Atlas spec consistently uses line-clamp-2 rather than single-line truncation for recipe titles in list contexts. A recipe name like "Hähnchenbrust mit Zitronensauce und Kartoffelgratin" will truncate badly. Suggest line-clamp-2 over truncate for the recipe name <p>.

2. Mobile context banner defaults to expanded — should default collapsed
The spec says the banner is "collapsible" — meaning it should default collapsed on mobile to minimize vertical space. let expanded = $state(true) means it defaults open. Change to $state(false) so the banner defaults collapsed, saving screen space for the suggestion list.

3. Rank number should be self-start to top-align with multi-line recipe names
The rank column uses w-10 flex-shrink-0 text-right. Without self-start, it will stretch to match the full card height when recipe names wrap. Add self-start to the rank <div>.

Suggestions

  • Reasoning badge checkmark "✓" renders correctly — good.
  • "Gesamte Rezeptbibliothek durchsuchen →" text link — DM Sans 13px, muted color, centered. Correct per spec.
  • Desktop left panel "— Nicht geplant" for empty slots — no italic. Correct.
  • Card shadow --shadow-card — correct for at-rest interactive surfaces.
  • "Wählen" button font: 13px/500/tracking-[0.04em] — correct button token usage.
## 🎨 Atlas — UI/UX Designer **Verdict: ⚠️ Approved with concerns** Token usage is mostly correct. The two-panel desktop structure matches the spec. Minor issues. ### Blockers **1. `SuggestionCard` recipe name is truncated with `truncate` — single line only** The spec doesn't explicitly require truncation for recipe names, but the Atlas spec consistently uses `line-clamp-2` rather than single-line truncation for recipe titles in list contexts. A recipe name like "Hähnchenbrust mit Zitronensauce und Kartoffelgratin" will truncate badly. Suggest `line-clamp-2` over `truncate` for the recipe name `<p>`. **2. Mobile context banner defaults to expanded — should default collapsed** The spec says the banner is "collapsible" — meaning it should default collapsed on mobile to minimize vertical space. `let expanded = $state(true)` means it defaults open. Change to `$state(false)` so the banner defaults collapsed, saving screen space for the suggestion list. **3. Rank number should be `self-start` to top-align with multi-line recipe names** The rank column uses `w-10 flex-shrink-0 text-right`. Without `self-start`, it will stretch to match the full card height when recipe names wrap. Add `self-start` to the rank `<div>`. ### Suggestions - Reasoning badge checkmark "✓" renders correctly — good. - "Gesamte Rezeptbibliothek durchsuchen →" text link — DM Sans 13px, muted color, centered. Correct per spec. - Desktop left panel "— Nicht geplant" for empty slots — no italic. Correct. - Card shadow `--shadow-card` — correct for at-rest interactive surfaces. - "Wählen" button font: 13px/500/tracking-[0.04em] — correct button token usage.
Author
Owner

🔧 Backend Engineer

Verdict: Approved

Frontend-only PR. API contract usage is correct.

Checked

  • GET /v1/week-plans with { query: { weekStart } } — correct for fetching the week plan for context.
  • GET /v1/week-plans/{id}/suggestions with { path: { id }, query: { slotDate } } — matches getSuggestions operation with slotDate as required query param. Fixed correctly after the type error.
  • POST /v1/week-plans/{id}/slots with { path: { id: planId }, body: { slotDate, recipeId } } — matches addSlot / CreateSlotRequest. Correct.
  • Response shape: suggestionsData?.suggestions accesses the SuggestionResponse.suggestions array correctly.
  • simulatedScore is a double in the schema — sorting by it numerically is correct.

One note

The load function ignores the slotDate query param when calling suggestions — it uses selectedDay from the URL which is the same value. This is fine. But worth noting that if selectedDay defaults to weekStart (no day param), the suggestions will be for Monday, which may not be the intended slot. The backend handles this correctly (returns suggestions for whatever date is passed), so this is a UX concern, not a bug.

## 🔧 Backend Engineer **Verdict: ✅ Approved** Frontend-only PR. API contract usage is correct. ### Checked - `GET /v1/week-plans` with `{ query: { weekStart } }` — correct for fetching the week plan for context. - `GET /v1/week-plans/{id}/suggestions` with `{ path: { id }, query: { slotDate } }` — matches `getSuggestions` operation with `slotDate` as required query param. Fixed correctly after the type error. - `POST /v1/week-plans/{id}/slots` with `{ path: { id: planId }, body: { slotDate, recipeId } }` — matches `addSlot` / `CreateSlotRequest`. Correct. - Response shape: `suggestionsData?.suggestions` accesses the `SuggestionResponse.suggestions` array correctly. - `simulatedScore` is a `double` in the schema — sorting by it numerically is correct. ### One note The load function ignores the `slotDate` query param when calling suggestions — it uses `selectedDay` from the URL which is the same value. This is fine. But worth noting that if `selectedDay` defaults to `weekStart` (no `day` param), the suggestions will be for Monday, which may not be the intended slot. The backend handles this correctly (returns suggestions for whatever date is passed), so this is a UX concern, not a bug.
marcel added 1 commit 2026-04-03 11:18:39 +02:00
- Add planId and recipeId (UUID) validation to pickSuggestion action
- Redirect to planner after successful slot pick (spec: returns to C1)
- Fix SuggestionCard recipe name truncation: truncate → line-clamp-2
- Add self-start alignment to rank number column in SuggestionCard
- Collapse SuggestionContextBanner by default (expanded → collapsed)
- Add TODO comment for hardcoded reasoning score threshold in page
- Update tests: redirect assertion, planId/recipeId validation cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 7c07bc443b into master 2026-04-03 11:18:45 +02:00
marcel deleted branch feat/issue-27-meal-suggestions 2026-04-03 11:18:47 +02:00
Sign in to join this conversation.