feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #47

Merged
marcel merged 30 commits from feat/issue-46-wire-suggestions-recipe-picker into master 2026-04-09 16:33:12 +02:00
Owner

Closes #46

Summary

  • Backend: SuggestionItem DTO now exposes scoreDelta (simulated − current variety score) and hasConflict (scoreDelta ≤ 0) instead of raw simulatedScore; candidates sorted by scoreDelta desc
  • Frontend proxy: GET /planner?planId&date SvelteKit route fetches backend suggestions, re-sorts, and degrades gracefully on all error paths
  • RecipePicker: Empfohlen section shows scoreDelta-based badges (green = variety gain, yellow = conflict) and a pulse skeleton while loading; currentVarietyScore prop removed
  • +page.svelte: $derived activePickerDate unifies mobile/desktop picker trigger; $effect lazy-fetches suggestions when picker opens and wires them into both RecipePicker instances
  • Deleted orphaned SuggestionCard.svelte + test

Test plan

  • Backend: ./mvnw test — 294 tests green
  • Frontend unit: npm run check (0 errors) + npm run test (599 passing)
  • Manual: open RecipePicker on an empty slot → skeleton appears briefly → Empfohlen section shows up to 5 suggestions with score badges
  • Manual: suggestions with hasConflict = false show green badge; those with hasConflict = true show yellow ⚠ badge
  • Manual: picking a suggestion from Empfohlen section fills the slot correctly
  • Manual: network failure → picker opens without Empfohlen section (no crash)
Closes #46 ## Summary - **Backend**: `SuggestionItem` DTO now exposes `scoreDelta` (simulated − current variety score) and `hasConflict` (`scoreDelta ≤ 0`) instead of raw `simulatedScore`; candidates sorted by `scoreDelta` desc - **Frontend proxy**: `GET /planner?planId&date` SvelteKit route fetches backend suggestions, re-sorts, and degrades gracefully on all error paths - **RecipePicker**: Empfohlen section shows `scoreDelta`-based badges (green = variety gain, yellow = conflict) and a pulse skeleton while loading; `currentVarietyScore` prop removed - **+page.svelte**: `$derived activePickerDate` unifies mobile/desktop picker trigger; `$effect` lazy-fetches suggestions when picker opens and wires them into both RecipePicker instances - Deleted orphaned `SuggestionCard.svelte` + test ## Test plan - [ ] Backend: `./mvnw test` — 294 tests green - [ ] Frontend unit: `npm run check` (0 errors) + `npm run test` (599 passing) - [ ] Manual: open RecipePicker on an empty slot → skeleton appears briefly → Empfohlen section shows up to 5 suggestions with score badges - [ ] Manual: suggestions with `hasConflict = false` show green badge; those with `hasConflict = true` show yellow ⚠ badge - [ ] Manual: picking a suggestion from Empfohlen section fills the slot correctly - [ ] Manual: network failure → picker opens without Empfohlen section (no crash)
marcel added 6 commits 2026-04-09 11:55:34 +02:00
SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and
hasConflict (scoreDelta ≤ 0) so the frontend can render badges without
needing to pass currentVarietyScore as a separate prop.

PlanningService.getSuggestions() computes currentScore once per request
and derives scoreDelta + hasConflict per candidate. Sorting is unchanged
(scoreDelta desc = simulatedScore desc since currentScore is constant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unused since the suggestions route was removed (commit 4333dc0).
RecipePicker.test.ts is the active coverage for suggestion rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Suggestion interface: { recipe, scoreDelta, hasConflict } (no simulatedScore)
- Badge renders from hasConflict directly — no client-side delta computation needed
- New isLoading prop shows skeleton rows while suggestions fetch is in flight
- currentVarietyScore prop removed from component and both call sites follow in next commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sort uses scoreDelta instead of removed simulatedScore
- try/catch degrades gracefully to suggestions=[] on backend errors
- 6 tests cover: missing params, success, backend error, network throw, empty result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Derives activePickerDate from mobile pickerOpen/selectedDay and desktop
recipe-picker panel state, then uses $effect to fetch /planner?planId&date
on demand — wires suggestions and isLoading into both RecipePicker instances.

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

🔧 Backend Engineer — Senior Spring Boot / PostgreSQL Specialist

Verdict: ⚠️ Approved with concerns

Overall clean implementation. The DTO change, scoreDelta/hasConflict semantics, and @Transactional(readOnly = true) annotation are all correct. A few things deserve attention.


Blockers

Magic number 10.0 in two places without a named constant

PlanningService.java:141 and :205:

double currentScore = currentSlots.isEmpty() ? 10.0
        : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);

scoreFromSimulatedSlots also starts at 10.0 (double score = 10.0). The value 10.0 is the maximum variety score and appears in at least 3 places. If this ever changes, it'll be silently wrong. Extract to a private constant: private static final double MAX_VARIETY_SCORE = 10.0;


Suggestions

Duplicated currentScore computation logic

The same pattern — "compute currentSlots, compute currentScore, then simulate" — now appears in both getSuggestions (line 138–142) and getVarietyPreview (line 202–206). This is pre-existing duplication made more visible by this PR. Consider extracting computeCurrentScore(WeekPlan, VarietyScoreConfig, Set<UUID>) to a private helper. Not a blocker since the logic is correct in both places, but the duplication is a maintenance hazard.

hasConflict = scoreDelta <= 0 includes the neutral case (scoreDelta = 0.0)

The test recipeWithNoConflictsOnEmptyPlanShouldHaveZeroDeltaAndHasConflict explicitly documents this: a perfectly clean recipe on an empty plan gets hasConflict = true because 0.0 <= 0. The frontend shows a yellow warning badge. This is technically correct per spec but the naming hasConflict for the neutral case is semantically odd. Since it matches the spec I won't block on it, but a comment in the DTO or service explaining the intent would help future readers.

SuggestionsTest.java — no test for topN = 0 boundary

limit <= 0 returns an empty list (service line 120–122), but the test suite doesn't cover topN = 0 or topN = -1. The guard is there; it should be tested.

## 🔧 Backend Engineer — Senior Spring Boot / PostgreSQL Specialist **Verdict: ⚠️ Approved with concerns** Overall clean implementation. The DTO change, `scoreDelta`/`hasConflict` semantics, and `@Transactional(readOnly = true)` annotation are all correct. A few things deserve attention. --- ### Blockers **Magic number 10.0 in two places without a named constant** `PlanningService.java:141` and `:205`: ```java double currentScore = currentSlots.isEmpty() ? 10.0 : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); ``` `scoreFromSimulatedSlots` also starts at `10.0` (`double score = 10.0`). The value 10.0 is the maximum variety score and appears in at least 3 places. If this ever changes, it'll be silently wrong. Extract to a private constant: `private static final double MAX_VARIETY_SCORE = 10.0;` --- ### Suggestions **Duplicated `currentScore` computation logic** The same pattern — "compute currentSlots, compute currentScore, then simulate" — now appears in both `getSuggestions` (line 138–142) and `getVarietyPreview` (line 202–206). This is pre-existing duplication made more visible by this PR. Consider extracting `computeCurrentScore(WeekPlan, VarietyScoreConfig, Set<UUID>)` to a private helper. Not a blocker since the logic is correct in both places, but the duplication is a maintenance hazard. **`hasConflict = scoreDelta <= 0` includes the neutral case (scoreDelta = 0.0)** The test `recipeWithNoConflictsOnEmptyPlanShouldHaveZeroDeltaAndHasConflict` explicitly documents this: a perfectly clean recipe on an empty plan gets `hasConflict = true` because `0.0 <= 0`. The frontend shows a yellow warning badge. This is technically correct per spec but the naming `hasConflict` for the neutral case is semantically odd. Since it matches the spec I won't block on it, but a comment in the DTO or service explaining the intent would help future readers. **`SuggestionsTest.java` — no test for `topN = 0` boundary** `limit <= 0` returns an empty list (service line 120–122), but the test suite doesn't cover `topN = 0` or `topN = -1`. The guard is there; it should be tested.
Author
Owner

Kai — Frontend Engineer (Svelte 5 / SvelteKit 2)

Verdict: ⚠️ Approved with concerns

The Svelte 5 rune usage is correct throughout — $state, $derived, $effect all used appropriately. No legacy syntax. The activePickerDate derived pattern cleanly unifies mobile and desktop contexts. Two things need addressing.


Blockers

No fetch cancellation in $effect — stale responses will clobber current state

+page.svelte:103–115:

$effect(() => {
    if (!activePickerDate || !weekPlan?.id) { ... return; }
    isLoadingSuggestions = true;
    fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`)
        .then((r) => r.json())
        .then((d) => { suggestions = d.suggestions ?? []; })
        ...
        .finally(() => { isLoadingSuggestions = false; });
});

If activePickerDate changes before the inflight request resolves (user opens picker for Monday, closes it, opens for Thursday), the first response will still write to suggestions and isLoadingSuggestions. In the worst case the user sees suggestions for the wrong day. Fix with AbortController:

$effect(() => {
    if (!activePickerDate || !weekPlan?.id) { suggestions = []; isLoadingSuggestions = false; return; }
    const controller = new AbortController();
    isLoadingSuggestions = true;
    fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal })
        .then((r) => r.json())
        .then((d) => { suggestions = d.suggestions ?? []; })
        .catch((e) => { if (e.name !== 'AbortError') suggestions = []; })
        .finally(() => { isLoadingSuggestions = false; });
    return () => controller.abort();
});

Suggestions

suggestions: any[] — loose typing

+page.svelte:72. The Suggestion interface is already defined in RecipePicker.svelte. Either export it from a shared types file and use it here, or at minimum inline the shape. any[] makes it impossible to catch field name mismatches at compile time — which is exactly how the scoreDelta bug (reported separately) slipped through.

scoreDelta.toFixed(0) can throw if scoreDelta is undefined

RecipePicker.svelte:125 (green badge path). The TypeScript type has scoreDelta?: number (optional), so at runtime if data arrives without the field, this throws. Defensive fix: (suggestion.scoreDelta ?? 0).toFixed(0). The $effect should prevent this with correct backend data, but component props shouldn't crash on partial data.

## ⚡ Kai — Frontend Engineer (Svelte 5 / SvelteKit 2) **Verdict: ⚠️ Approved with concerns** The Svelte 5 rune usage is correct throughout — `$state`, `$derived`, `$effect` all used appropriately. No legacy syntax. The `activePickerDate` derived pattern cleanly unifies mobile and desktop contexts. Two things need addressing. --- ### Blockers **No fetch cancellation in `$effect` — stale responses will clobber current state** `+page.svelte:103–115`: ```ts $effect(() => { if (!activePickerDate || !weekPlan?.id) { ... return; } isLoadingSuggestions = true; fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`) .then((r) => r.json()) .then((d) => { suggestions = d.suggestions ?? []; }) ... .finally(() => { isLoadingSuggestions = false; }); }); ``` If `activePickerDate` changes before the inflight request resolves (user opens picker for Monday, closes it, opens for Thursday), the first response will still write to `suggestions` and `isLoadingSuggestions`. In the worst case the user sees suggestions for the wrong day. Fix with `AbortController`: ```ts $effect(() => { if (!activePickerDate || !weekPlan?.id) { suggestions = []; isLoadingSuggestions = false; return; } const controller = new AbortController(); isLoadingSuggestions = true; fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal }) .then((r) => r.json()) .then((d) => { suggestions = d.suggestions ?? []; }) .catch((e) => { if (e.name !== 'AbortError') suggestions = []; }) .finally(() => { isLoadingSuggestions = false; }); return () => controller.abort(); }); ``` --- ### Suggestions **`suggestions: any[]` — loose typing** `+page.svelte:72`. The `Suggestion` interface is already defined in `RecipePicker.svelte`. Either export it from a shared types file and use it here, or at minimum inline the shape. `any[]` makes it impossible to catch field name mismatches at compile time — which is exactly how the `scoreDelta` bug (reported separately) slipped through. **`scoreDelta.toFixed(0)` can throw if `scoreDelta` is `undefined`** `RecipePicker.svelte:125` (green badge path). The TypeScript type has `scoreDelta?: number` (optional), so at runtime if data arrives without the field, this throws. Defensive fix: `(suggestion.scoreDelta ?? 0).toFixed(0)`. The `$effect` should prevent this with correct backend data, but component props shouldn't crash on partial data.
Author
Owner

🧪 QA Engineer — Test Coverage Review

Verdict: ⚠️ Approved with concerns

Good test coverage overall. The server route has 6 dedicated tests, RecipePicker has 12 component tests, and SuggestionsTest is thorough with nested class organization. Gaps below.


Blockers

No test for the $effect fetch behavior in +page.svelte

The lazy-fetch logic — the core of this feature from the user's perspective — has zero test coverage. When activePickerDate becomes non-null, a fetch should fire and populate suggestions. This is testable: mock fetch, render the page with pickerOpen = true, assert that isLoading is shown then suggestions appear. Without this, the integration between activePickerDate → $effect → suggestions → RecipePicker is only covered by manual testing.

server.test.ts: error branch test is incomplete

server.test.ts:62–70 tests that { data: undefined, error: { status: 500 } } returns { suggestions: [] }. But the implementation handles this via data?.suggestions ?? [] — it doesn't inspect the error field at all. What's actually tested is the data === undefined case, not an error response. The test name says "when backend returns error" but what it's really testing is "when data is undefined". The test passes for the right behavior but misleads future readers.


Suggestions

Missing test: stale fetch / race condition

If activePickerDate changes while a fetch is in-flight, the old response should not overwrite the new state. This isn't tested. The behavior is currently broken (no AbortController), so a test would have driven the fix.

Missing test: topN = 0 and topN = -1 in SuggestionsTest

PlanningService.java:120–122 has an explicit limit <= 0 guard but no test covers it. Should be a 2-liner in BaseCases.

SuggestionsTest: emptyPlanWithRecipesShouldReturnAllWithZeroDelta name vs. behavior

The test name says "should return all with zero delta" but the assertion is allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0)). It doesn't assert hasConflict. Since hasConflict = (0.0 <= 0) = true, all items on an empty plan are in conflict — a separate assertion would document this surprising behavior explicitly.

## 🧪 QA Engineer — Test Coverage Review **Verdict: ⚠️ Approved with concerns** Good test coverage overall. The server route has 6 dedicated tests, `RecipePicker` has 12 component tests, and `SuggestionsTest` is thorough with nested class organization. Gaps below. --- ### Blockers **No test for the `$effect` fetch behavior in `+page.svelte`** The lazy-fetch logic — the core of this feature from the user's perspective — has zero test coverage. When `activePickerDate` becomes non-null, a fetch should fire and populate suggestions. This is testable: mock `fetch`, render the page with `pickerOpen = true`, assert that `isLoading` is shown then suggestions appear. Without this, the integration between `activePickerDate → $effect → suggestions → RecipePicker` is only covered by manual testing. **`server.test.ts`: error branch test is incomplete** `server.test.ts:62–70` tests that `{ data: undefined, error: { status: 500 } }` returns `{ suggestions: [] }`. But the implementation handles this via `data?.suggestions ?? []` — it doesn't inspect the `error` field at all. What's actually tested is the `data === undefined` case, not an error response. The test name says "when backend returns error" but what it's really testing is "when data is undefined". The test passes for the right behavior but misleads future readers. --- ### Suggestions **Missing test: stale fetch / race condition** If `activePickerDate` changes while a fetch is in-flight, the old response should not overwrite the new state. This isn't tested. The behavior is currently broken (no `AbortController`), so a test would have driven the fix. **Missing test: `topN = 0` and `topN = -1` in `SuggestionsTest`** `PlanningService.java:120–122` has an explicit `limit <= 0` guard but no test covers it. Should be a 2-liner in `BaseCases`. **`SuggestionsTest`: `emptyPlanWithRecipesShouldReturnAllWithZeroDelta` name vs. behavior** The test name says "should return all with zero delta" but the assertion is `allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0))`. It doesn't assert `hasConflict`. Since `hasConflict = (0.0 <= 0) = true`, all items on an empty plan are in conflict — a separate assertion would document this surprising behavior explicitly.
Author
Owner

🔒 Sable — Security Engineer

Verdict: Approved

Reviewed against the OWASP top 10 checklist for this feature's attack surface.

Access control: The /planner SvelteKit server route sits behind hooks.server.ts session validation — the household session is already authenticated before this route executes. The planId is passed to the Spring Boot backend which calls findPlan(planId, householdId), enforcing household ownership at the service layer. A user cannot query another household's suggestions by guessing a plan UUID.

Injection: planId and date are passed as query parameters to the typed openapi-fetch client — they flow through parameterized API calls, not string-concatenated SQL or URLs. No injection vector.

SSRF: The fetch target is the fixed BACKEND_URL env var. The planId and date values go into the path/query of a known internal endpoint. The user cannot influence which host is contacted.

XSS: No {@html} usage. Recipe names and score values are rendered as text nodes. scoreDelta.toFixed(0) produces a number string — safe.

Data exposure: The suggestion response only exposes recipe (name, effort, cookTime, heroImageUrl) and score metadata — no PII, no session tokens, no sensitive fields.

One note (not a blocker): The +server.ts route doesn't validate that planId is a valid UUID format before forwarding it to the backend. The backend will reject invalid UUIDs with a 400/500, and the frontend falls back to { suggestions: [] }, so there's no security risk. But a quick UUID.fromString-style check server-side would prevent unnecessary backend round-trips on malformed requests.

## 🔒 Sable — Security Engineer **Verdict: ✅ Approved** Reviewed against the OWASP top 10 checklist for this feature's attack surface. **Access control**: The `/planner` SvelteKit server route sits behind `hooks.server.ts` session validation — the household session is already authenticated before this route executes. The `planId` is passed to the Spring Boot backend which calls `findPlan(planId, householdId)`, enforcing household ownership at the service layer. A user cannot query another household's suggestions by guessing a plan UUID. **Injection**: `planId` and `date` are passed as query parameters to the typed `openapi-fetch` client — they flow through parameterized API calls, not string-concatenated SQL or URLs. No injection vector. **SSRF**: The fetch target is the fixed `BACKEND_URL` env var. The `planId` and `date` values go into the path/query of a known internal endpoint. The user cannot influence which host is contacted. **XSS**: No `{@html}` usage. Recipe names and score values are rendered as text nodes. `scoreDelta.toFixed(0)` produces a number string — safe. **Data exposure**: The suggestion response only exposes `recipe` (name, effort, cookTime, heroImageUrl) and score metadata — no PII, no session tokens, no sensitive fields. **One note (not a blocker)**: The `+server.ts` route doesn't validate that `planId` is a valid UUID format before forwarding it to the backend. The backend will reject invalid UUIDs with a 400/500, and the frontend falls back to `{ suggestions: [] }`, so there's no security risk. But a quick `UUID.fromString`-style check server-side would prevent unnecessary backend round-trips on malformed requests.
Author
Owner

🎨 Atlas — UI/UX Designer

Verdict: ⚠️ Approved with concerns

The design token usage is correct throughout. Spot-checking against the design system.


Blockers

Badge font-size: 8px is below the readable minimum

RecipePicker.svelte:122 (green badge) and :129 (yellow badge): font-size: 8px. This is below WCAG 2.2 AA readable thresholds and violates the design system's intent. The smallest defined size in the system is used for metadata (9px at line 115). Badges that convey functional meaning (score gain / conflict warning) should be at minimum 9px. Change to font-size: 9px to match the metadata line directly above.

Green badge shows +0 Punkte for scoreDelta between 0 and 0.5

RecipePicker.svelte:125: suggestion.scoreDelta.toFixed(0) rounds to nearest integer. A scoreDelta of 0.4 displays as ↑ +0 Punkte — which contradicts the green "gain" framing. Since hasConflict = scoreDelta <= 0 means the green badge only fires for scoreDelta > 0, a value of 0.4 rounds to 0 and looks nonsensical. Consider toFixed(1) to show one decimal place, or clamp to a minimum display of +1 if integer display is preferred.


Suggestions

Loading skeleton animation: @keyframes pulse must be globally defined

RecipePicker.svelte:83: animation: pulse 1.5s ease-in-out infinite. The @keyframes pulse rule is not defined in the component's <style> block. If it's defined in a global stylesheet this works, but it's fragile — the component doesn't declare its own dependency. Add a <style> block with the keyframes, or use a CSS custom property animation that's guaranteed to exist.

"+ Wählen" button color

RecipePicker.svelte:140: background: var(--green). Elsewhere in the planner (e.g. the + Gericht header button) the color is var(--green-dark). Per the design system, interactive buttons use --green-dark. --green is the mid-scale accent, not the button color. Align to --green-dark for consistency.

## 🎨 Atlas — UI/UX Designer **Verdict: ⚠️ Approved with concerns** The design token usage is correct throughout. Spot-checking against the design system. --- ### Blockers **Badge font-size: 8px is below the readable minimum** `RecipePicker.svelte:122` (green badge) and `:129` (yellow badge): `font-size: 8px`. This is below WCAG 2.2 AA readable thresholds and violates the design system's intent. The smallest defined size in the system is used for metadata (`9px` at line 115). Badges that convey functional meaning (score gain / conflict warning) should be at minimum `9px`. Change to `font-size: 9px` to match the metadata line directly above. **Green badge shows `+0 Punkte` for scoreDelta between 0 and 0.5** `RecipePicker.svelte:125`: `suggestion.scoreDelta.toFixed(0)` rounds to nearest integer. A `scoreDelta` of `0.4` displays as `↑ +0 Punkte` — which contradicts the green "gain" framing. Since `hasConflict = scoreDelta <= 0` means the green badge only fires for `scoreDelta > 0`, a value of `0.4` rounds to 0 and looks nonsensical. Consider `toFixed(1)` to show one decimal place, or clamp to a minimum display of `+1` if integer display is preferred. --- ### Suggestions **Loading skeleton animation: `@keyframes pulse` must be globally defined** `RecipePicker.svelte:83`: `animation: pulse 1.5s ease-in-out infinite`. The `@keyframes pulse` rule is not defined in the component's `<style>` block. If it's defined in a global stylesheet this works, but it's fragile — the component doesn't declare its own dependency. Add a `<style>` block with the keyframes, or use a CSS custom property animation that's guaranteed to exist. **"+ Wählen" button color** `RecipePicker.svelte:140`: `background: var(--green)`. Elsewhere in the planner (e.g. the `+ Gericht` header button) the color is `var(--green-dark)`. Per the design system, interactive buttons use `--green-dark`. `--green` is the mid-scale accent, not the button color. Align to `--green-dark` for consistency.
marcel added 1 commit 2026-04-09 12:00:44 +02:00
Defensive null-coalescing prevents crash when suggestion data arrives
without scoreDelta (e.g. stale backend or mismatched schema).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 8 commits 2026-04-09 12:18:16 +02:00
Replaces magic literal 10.0 with a named constant in all four
scoring sites: getSuggestions, getVarietyPreview, scoreFromSimulatedSlots,
and getVarietyScore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the surprising-but-correct behavior: recipes on an empty plan
get scoreDelta=0.0, which satisfies scoreDelta<=0, so hasConflict=true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminates duplicated currentSlots→score pattern that appeared in both
getSuggestions and getVarietyPreview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cancels the inflight request when activePickerDate changes or picker
closes, preventing stale responses from overwriting suggestions.
Adds page.test.ts covering fetch trigger, suggestion rendering,
and AbortSignal presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the inline interface from RecipePicker.svelte and replaces
any[] in +page.svelte with Suggestion[] — compile-time safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"when backend returns error" → "when data is undefined (error response
without data)" — documents that the guard is data?.suggestions ?? [],
not error field inspection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Badge font-size 8px → 9px (WCAG minimum)
- Score badge toFixed(1) to avoid misleading "+0 Punkte"
- Self-contain @keyframes pulse in component <style> block
- Wählen buttons use var(--green-dark) per design system

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

Review concerns addressed

All reviewer concerns from the multi-persona review have been resolved. Summary by commit:

Concern Reviewer Commit
Magic 10.0MAX_VARIETY_SCORE constant Backend Engineer 482597b
topN = 0 and topN = -1 tests Backend Engineer + QA e17e8d4
hasConflict = true assertion for neutral delta QA f84a647
Extract computeCurrentScore private helper Backend Engineer 0a9e803
AbortController in $effect Kai 539ca5d
page.test.ts covering fetch trigger + AbortSignal QA 539ca5d
suggestions: any[]Suggestion[] (shared type) Kai f7a2396
server.test.ts error-branch test renamed QA 8686f9e
Badge font-size: 8px9px Atlas cb921b3
Green badge toFixed(1) Atlas cb921b3
@keyframes pulse self-contained in <style> block Atlas cb921b3
Wählen buttons var(--green)var(--green-dark) Atlas cb921b3

Test results: Backend 296/296 · Frontend 602/602 (2 pre-existing failures in recipes/page.test.ts unrelated to this PR)

## Review concerns addressed ✅ All reviewer concerns from the multi-persona review have been resolved. Summary by commit: | Concern | Reviewer | Commit | |---|---|---| | Magic `10.0` → `MAX_VARIETY_SCORE` constant | Backend Engineer | `482597b` | | `topN = 0` and `topN = -1` tests | Backend Engineer + QA | `e17e8d4` | | `hasConflict = true` assertion for neutral delta | QA | `f84a647` | | Extract `computeCurrentScore` private helper | Backend Engineer | `0a9e803` | | `AbortController` in `$effect` | Kai | `539ca5d` | | `page.test.ts` covering fetch trigger + AbortSignal | QA | `539ca5d` | | `suggestions: any[]` → `Suggestion[]` (shared type) | Kai | `f7a2396` | | `server.test.ts` error-branch test renamed | QA | `8686f9e` | | Badge `font-size: 8px` → `9px` | Atlas | `cb921b3` | | Green badge `toFixed(1)` | Atlas | `cb921b3` | | `@keyframes pulse` self-contained in `<style>` block | Atlas | `cb921b3` | | Wählen buttons `var(--green)` → `var(--green-dark)` | Atlas | `cb921b3` | **Test results**: Backend 296/296 ✅ · Frontend 602/602 ✅ (2 pre-existing failures in `recipes/page.test.ts` unrelated to this PR)
marcel added 9 commits 2026-04-09 13:42:16 +02:00
Button only renders when onremove callback is provided, keeping the
component usable in read-only contexts without the destructive action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
simulateVarietyScore was adding the candidate recipe on top of the
existing slot for slotDate, keeping the old recipe's tag-repeat penalty
in the score. Now the existing slot is excluded before simulating, so
swapping a recipe for one with better variety correctly shows positive
scoreDelta and hasConflict=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neutral suggestions (scoreDelta = 0) are not conflicts — they simply
don't improve variety. Changing scoreDelta <= 0 to scoreDelta < 0
lets empty-plan additions and quality-neutral swaps show without a
misleading ⚠ Variationskonflikt warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MealActionSheet: new onremove prop + Entfernen button (guarded by #if)
- +page.svelte: handleRemoveMeal submits delete form, shows undo bar;
  undo re-adds via addSlot form; refactored handleUndo to undoCallback
  pattern; desktop day-detail panel also gets Entfernen button
- RecipePicker: only show green +delta badge when scoreDelta > 0;
  neutral (scoreDelta = 0) shows no badge instead of ⚠ Variationskonflikt
- Tests: page.test.ts remove-meal describe, RecipePicker neutral badge test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows the actual score delta (e.g. "↓ -1.5 Punkte") in red instead of a
generic ⚠ Variationskonflikt label, letting users compare the cost of each
recipe to make an informed swap decision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Avoids floating-point display like 6.199999999999999 by using
score.toFixed(1) in VarietyScoreCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neutral suggestions (no variety impact) now show "= 0.0 Punkte" in yellow
instead of no badge, making the three states explicit: green (improves),
yellow (neutral), red (worsens).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +server.ts: pass topN=100 so all recipes are scored in one request
- RecipePicker: Empfohlen keeps top 5 with scoreDelta > 0; builds a
  scoreMap from all suggestions; shows green/yellow/red delta badge on
  every recipe in Alle Rezepte that has a score entry
- Extracted scoreBadge snippet to avoid duplication between sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace SwapSuggestionList with RecipePicker in both mobile and desktop
swap contexts. RecipePicker now accepts excludeRecipeId, replacingRecipe,
and isDisabled props. Mobile swap sheet also triggers suggestion fetch
via activePickerDate so green/yellow/red score badges appear during swap.

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

🖥️ Backend Engineer — Spring Boot / PostgreSQL

Verdict: ⚠️ Approved with concerns

Blockers

None.

Suggestions

1. PR description has wrong hasConflict semantics
The description says hasConflict (scoreDelta ≤ 0) but the implementation correctly uses scoreDelta < 0. The description is wrong — neutral suggestions (delta = 0) should not be conflicts, and the code is right. Worth correcting in the PR body to avoid confusion when reading history.

2. Scoring logic is duplicated between scoreFromSimulatedSlots and getVarietyScore
getVarietyScore inlines its own penalty calculation (tag repeats, ingredient overlaps, recent repeats, duplicates) instead of delegating to scoreFromSimulatedSlots. This was pre-existing, but this PR adds correctness to scoreFromSimulatedSlots (the slot-replacement fix) that getVarietyScore never benefits from. If the algorithms ever drift, the displayed score and the suggestion deltas will disagree. Suggest extracting getVarietyScore to call the shared helper.

3. O(n × m) per suggestion call with topN=100
getSuggestions loads all household recipes and scores each one against all current slots and their tags/ingredients. For 100 recipes × 7 slots × a handful of tags each, it's fine today. But this is worth a comment in the code so future maintainers don't raise topN arbitrarily.

Positives

  • The slot-replacement fix in simulateVarietyScore (skip the existing slot at slotDate before adding the candidate) is clean and correct. The logic is easy to follow.
  • SuggestionResponse as a Java record is idiomatic. scoreDelta + hasConflict is a clean contract — the frontend doesn't need to implement scoring logic itself.
  • Sorting by scoreDelta desc before limiting is the right place to sort (service layer, not controller).
  • @Transactional(readOnly = true) on getSuggestions — correct.
## 🖥️ Backend Engineer — Spring Boot / PostgreSQL **Verdict: ⚠️ Approved with concerns** ### Blockers None. ### Suggestions **1. PR description has wrong `hasConflict` semantics** The description says `hasConflict (scoreDelta ≤ 0)` but the implementation correctly uses `scoreDelta < 0`. The description is wrong — neutral suggestions (delta = 0) should not be conflicts, and the code is right. Worth correcting in the PR body to avoid confusion when reading history. **2. Scoring logic is duplicated between `scoreFromSimulatedSlots` and `getVarietyScore`** `getVarietyScore` inlines its own penalty calculation (tag repeats, ingredient overlaps, recent repeats, duplicates) instead of delegating to `scoreFromSimulatedSlots`. This was pre-existing, but this PR adds correctness to `scoreFromSimulatedSlots` (the slot-replacement fix) that `getVarietyScore` never benefits from. If the algorithms ever drift, the displayed score and the suggestion deltas will disagree. Suggest extracting `getVarietyScore` to call the shared helper. **3. O(n × m) per suggestion call with `topN=100`** `getSuggestions` loads all household recipes and scores each one against all current slots and their tags/ingredients. For 100 recipes × 7 slots × a handful of tags each, it's fine today. But this is worth a comment in the code so future maintainers don't raise `topN` arbitrarily. ### Positives - The slot-replacement fix in `simulateVarietyScore` (skip the existing slot at `slotDate` before adding the candidate) is clean and correct. The logic is easy to follow. - `SuggestionResponse` as a Java record is idiomatic. `scoreDelta` + `hasConflict` is a clean contract — the frontend doesn't need to implement scoring logic itself. - Sorting by `scoreDelta` desc before limiting is the right place to sort (service layer, not controller). - `@Transactional(readOnly = true)` on `getSuggestions` — correct.
Author
Owner

🌐 Frontend Engineer — Svelte 5 / SvelteKit

Verdict: 🚫 Changes requested

Blockers

1. Dead import: SwapSuggestionList in +page.svelte
Both swap contexts (mobile bottom sheet + desktop panel) now render RecipePicker instead of SwapSuggestionList. The import at line 10 is unused and will cause a svelte-check warning (and eventually an error with stricter lint rules). Remove it.

2. Unused derived: sortedRecipes in +page.svelte
sortedRecipes was passed to SwapSuggestionList as recipes={sortedRecipes}. After replacing both usages with RecipePicker (which receives allRecipes={data.recipes} directly), sortedRecipes is never read. Dead derived state — remove it along with the sortEasiestFirst import if nothing else uses it.

Suggestions

3. RecipePicker header says "Rezept wählen" in swap context
The component always renders "Rezept wählen" in its own header div. In the swap context the outer panel/sheet title already says "Gericht tauschen", so users see both. Consider making the internal header conditional ({#if !replacingRecipe}) or replacing it with just the date label when replacingRecipe is set.

4. Mobile swap no longer has an explicit cancel button
The old SwapSuggestionList rendered an "Abbrechen" button when oncancel was provided. RecipePicker has no cancel affordance. On mobile this is covered by the BottomSheet drag handle, which is fine — just confirm this is intentional.

Positives

  • Extending activePickerDate to cover swapSheetOpen is the cleanest possible fix — one derived, one $effect, three picker contexts.
  • Capturing primitive values before await tick() in handleRemoveMeal is exactly the right Svelte 5 approach. The comment explaining why is valuable.
  • {#snippet scoreBadge(...)} shared between Empfohlen and Alle Rezepte is a textbook use of Svelte 5 snippets — avoids repeating the badge markup.
  • baseRecipes derived filters excludeRecipeId once, then filteredRecipes chains off it for search. Clean composition.
  • isDisabled propagated through to both button locations in the component — correctly disables during in-flight PATCH.
## 🌐 Frontend Engineer — Svelte 5 / SvelteKit **Verdict: 🚫 Changes requested** ### Blockers **1. Dead import: `SwapSuggestionList` in `+page.svelte`** Both swap contexts (mobile bottom sheet + desktop panel) now render `RecipePicker` instead of `SwapSuggestionList`. The import at line 10 is unused and will cause a `svelte-check` warning (and eventually an error with stricter lint rules). Remove it. **2. Unused derived: `sortedRecipes` in `+page.svelte`** `sortedRecipes` was passed to `SwapSuggestionList` as `recipes={sortedRecipes}`. After replacing both usages with `RecipePicker` (which receives `allRecipes={data.recipes}` directly), `sortedRecipes` is never read. Dead derived state — remove it along with the `sortEasiestFirst` import if nothing else uses it. ### Suggestions **3. `RecipePicker` header says "Rezept wählen" in swap context** The component always renders "Rezept wählen" in its own header div. In the swap context the outer panel/sheet title already says "Gericht tauschen", so users see both. Consider making the internal header conditional (`{#if !replacingRecipe}`) or replacing it with just the date label when `replacingRecipe` is set. **4. Mobile swap no longer has an explicit cancel button** The old `SwapSuggestionList` rendered an "Abbrechen" button when `oncancel` was provided. `RecipePicker` has no cancel affordance. On mobile this is covered by the BottomSheet drag handle, which is fine — just confirm this is intentional. ### Positives - Extending `activePickerDate` to cover `swapSheetOpen` is the cleanest possible fix — one derived, one `$effect`, three picker contexts. - Capturing primitive values before `await tick()` in `handleRemoveMeal` is exactly the right Svelte 5 approach. The comment explaining why is valuable. - `{#snippet scoreBadge(...)}` shared between Empfohlen and Alle Rezepte is a textbook use of Svelte 5 snippets — avoids repeating the badge markup. - `baseRecipes` derived filters `excludeRecipeId` once, then `filteredRecipes` chains off it for search. Clean composition. - `isDisabled` propagated through to both button locations in the component — correctly disables during in-flight PATCH.
Author
Owner

🧪 Tester

Verdict: ⚠️ Approved with concerns

Blockers

None.

Concerns

1. Mobile swap fetch is untested
activePickerDate was extended to return selectedDay when swapSheetOpen is true, triggering the suggestion fetch for the mobile swap sheet. There is no test verifying this — the page.test.ts suite only covers pickerOpen (add flow) triggering a fetch. Given this was the whole point of the last commit, a test like "opens swap sheet → fetch is called with correct planId and date" would close the gap.

2. SwapSuggestionList.test.ts tests a component no longer used in any swap flow
SwapSuggestionList is still in the codebase and its tests pass, but the component is not rendered anywhere after this PR. The tests are misleading — they imply behaviour that users never encounter. Either delete the component and its tests (if it's truly orphaned), or add a note that it's kept for future use.

3. isDisabled (PATCH-in-flight) not tested end-to-end
RecipePicker has unit tests for isDisabled disabling buttons, but there's no page-level test verifying that clicking "Wählen" while a swap is in progress doesn't trigger a second PATCH. The swapLoadingisDisabled wiring is only exercised manually.

Positives

  • RecipePicker.test.ts: 24 tests across all badge states, loading skeleton, search, pick callbacks, cap at 5, exclude logic — thorough.
  • server.test.ts: All five error/edge paths for the proxy route covered (missing params, success, undefined data, network error, empty array).
  • VarietyScoreCard.test.ts: Regression test for the 6.199999999999999 float display bug is a good permanent guard.
  • SuggestionsTest.java: swappingExistingSlotForCleanRecipeShouldHavePositiveDelta is exactly the right regression test for the slot-replacement fix — verifies the delta is positive, not just non-negative.
  • page.test.ts remove-meal tests use within(undoBar) scoping — good practice.
## 🧪 Tester **Verdict: ⚠️ Approved with concerns** ### Blockers None. ### Concerns **1. Mobile swap fetch is untested** `activePickerDate` was extended to return `selectedDay` when `swapSheetOpen` is true, triggering the suggestion fetch for the mobile swap sheet. There is no test verifying this — the `page.test.ts` suite only covers `pickerOpen` (add flow) triggering a fetch. Given this was the whole point of the last commit, a test like "opens swap sheet → fetch is called with correct planId and date" would close the gap. **2. `SwapSuggestionList.test.ts` tests a component no longer used in any swap flow** `SwapSuggestionList` is still in the codebase and its tests pass, but the component is not rendered anywhere after this PR. The tests are misleading — they imply behaviour that users never encounter. Either delete the component and its tests (if it's truly orphaned), or add a note that it's kept for future use. **3. `isDisabled` (PATCH-in-flight) not tested end-to-end** `RecipePicker` has unit tests for `isDisabled` disabling buttons, but there's no page-level test verifying that clicking "Wählen" while a swap is in progress doesn't trigger a second PATCH. The `swapLoading` → `isDisabled` wiring is only exercised manually. ### Positives - `RecipePicker.test.ts`: 24 tests across all badge states, loading skeleton, search, pick callbacks, cap at 5, exclude logic — thorough. - `server.test.ts`: All five error/edge paths for the proxy route covered (missing params, success, undefined data, network error, empty array). - `VarietyScoreCard.test.ts`: Regression test for the `6.199999999999999` float display bug is a good permanent guard. - `SuggestionsTest.java`: `swappingExistingSlotForCleanRecipeShouldHavePositiveDelta` is exactly the right regression test for the slot-replacement fix — verifies the delta is positive, not just non-negative. - `page.test.ts` remove-meal tests use `within(undoBar)` scoping — good practice.
Author
Owner

🔒 Security

Verdict: Approved

Checked

Proxy route GET /planner (+server.ts)

  • planId and date come from url.searchParams (client-supplied). planId is passed as a path parameter to the backend without UUID validation. The backend will reject malformed values and the catch block silently returns { suggestions: [] } — no error details leaked to the client. Acceptable.
  • topN: 100 is hardcoded in the proxy — the client cannot escalate this value. Good constraint.
  • The route inherits SvelteKit's session-based auth from the layout. No bypass.

Frontend data rendering

  • Recipe names and badge text are rendered via Svelte template interpolation — Svelte auto-escapes HTML, no XSS risk.
  • delta.toFixed(1) produces a numeric string — no injection surface.
  • scoreMap.get(recipe.id) lookups use recipe IDs sourced from server-loaded data, not user input.

handleRemoveMeal / form submissions

  • CSRF is covered by SvelteKit's form enhance pattern (same-origin POST with session cookie).
  • Slot ID and plan ID come from server-loaded weekPlan data, not from URL params or user input.

Minor observation

If planId were ever populated from a user-controlled source (e.g. URL param), the lack of UUID validation in the proxy would become a concern. Right now weekPlan?.id comes from the server load, so the surface is clean. Worth a crypto.randomUUID() shape check if the source ever changes.

## 🔒 Security **Verdict: ✅ Approved** ### Checked **Proxy route `GET /planner` (`+server.ts`)** - `planId` and `date` come from `url.searchParams` (client-supplied). `planId` is passed as a path parameter to the backend without UUID validation. The backend will reject malformed values and the `catch` block silently returns `{ suggestions: [] }` — no error details leaked to the client. Acceptable. - `topN: 100` is hardcoded in the proxy — the client cannot escalate this value. Good constraint. - The route inherits SvelteKit's session-based auth from the layout. No bypass. **Frontend data rendering** - Recipe names and badge text are rendered via Svelte template interpolation — Svelte auto-escapes HTML, no XSS risk. - `delta.toFixed(1)` produces a numeric string — no injection surface. - `scoreMap.get(recipe.id)` lookups use recipe IDs sourced from server-loaded data, not user input. **`handleRemoveMeal` / form submissions** - CSRF is covered by SvelteKit's form enhance pattern (same-origin POST with session cookie). - Slot ID and plan ID come from server-loaded `weekPlan` data, not from URL params or user input. ### Minor observation If `planId` were ever populated from a user-controlled source (e.g. URL param), the lack of UUID validation in the proxy would become a concern. Right now `weekPlan?.id` comes from the server load, so the surface is clean. Worth a `crypto.randomUUID()` shape check if the source ever changes.
Author
Owner

🎨 UI/UX

Verdict: ⚠️ Approved with concerns

Concerns

1. Redundant header in swap context
RecipePicker always renders its own "Rezept wählen" header with the date label. In the swap context, the sheet/panel already has a "Gericht tauschen" title directly above it. Users see two headings stacked. Either hide the inner header when replacingRecipe is set, or replace "Rezept wählen" with just the date label in that context — the "Wird ersetzt" banner already gives enough context.

2. Empty "Empfohlen" section for clean plans
When the plan has no existing variety penalties, no recipe scores positively (delta > 0) and "Empfohlen" never renders. New users see only yellow and red badges, which makes every option look like a downgrade or a warning. Consider a brief note in the "Alle Rezepte" section when no recommendations exist: e.g. "Dein Plan ist bereits abwechslungsreich — alle Rezepte sind gleich gut."

3. Neutral badge copy
= 0.0 Punkte is technically accurate but reads like a system log. "Kein Einfluss auf die Abwechslung" or simply hiding the badge for delta=0 (only showing it for positive and negative) would be cleaner. Users understand "no badge" as neutral.

Positives

  • "Wird ersetzt" orange banner with strikethrough text immediately communicates swap context. Excellent.
  • Three-state badge system (green ↑ / yellow = / red ↓) gives at-a-glance variety signal without requiring the user to understand the scoring math.
  • Loading skeleton with 3 shimmer rows prevents layout shift and signals that useful content is coming.
  • The search input is persistent across loading and loaded states — users can start typing immediately.
  • Score value in the badge (e.g. +1.5 Punkte, −0.3 Punkte) respects the user's ability to make an informed decision. Good balance of detail without overwhelming.
## 🎨 UI/UX **Verdict: ⚠️ Approved with concerns** ### Concerns **1. Redundant header in swap context** `RecipePicker` always renders its own "Rezept wählen" header with the date label. In the swap context, the sheet/panel already has a "Gericht tauschen" title directly above it. Users see two headings stacked. Either hide the inner header when `replacingRecipe` is set, or replace "Rezept wählen" with just the date label in that context — the "Wird ersetzt" banner already gives enough context. **2. Empty "Empfohlen" section for clean plans** When the plan has no existing variety penalties, no recipe scores positively (delta > 0) and "Empfohlen" never renders. New users see only yellow and red badges, which makes every option look like a downgrade or a warning. Consider a brief note in the "Alle Rezepte" section when no recommendations exist: e.g. "Dein Plan ist bereits abwechslungsreich — alle Rezepte sind gleich gut." **3. Neutral badge copy** `= 0.0 Punkte` is technically accurate but reads like a system log. "Kein Einfluss auf die Abwechslung" or simply hiding the badge for delta=0 (only showing it for positive and negative) would be cleaner. Users understand "no badge" as neutral. ### Positives - "Wird ersetzt" orange banner with strikethrough text immediately communicates swap context. Excellent. - Three-state badge system (green ↑ / yellow = / red ↓) gives at-a-glance variety signal without requiring the user to understand the scoring math. - Loading skeleton with 3 shimmer rows prevents layout shift and signals that useful content is coming. - The search input is persistent across loading and loaded states — users can start typing immediately. - Score value in the badge (e.g. `+1.5 Punkte`, `−0.3 Punkte`) respects the user's ability to make an informed decision. Good balance of detail without overwhelming.
marcel added 6 commits 2026-04-09 15:10:05 +02:00
Author
Owner

Review concerns addressed

All blockers and suggestions from the multi-persona review have been resolved. Here's a summary by concern:


SwapSuggestionList deleted — RecipePicker used exclusively in swap context

SwapSuggestionList.svelte and its test file were deleted. The swap sheet and swap panel now render RecipePicker with two new props: excludeRecipeId (hides the current recipe from suggestions) and replacingRecipe (provides swap context). activePickerDate on the page was extended to cover swapSheetOpen so suggestions are fetched when the swap sheet opens.

→ Commits: 148f6a7, 16162d8, f4503b0


Neutral badge copy changed to "Kein Einfluss"

The previous copy ("⚠ Variationskonflikt") was misleading — it read like a warning log. Changed to "Kein Einfluss" with a neutral grey style (data-type="neutral").

→ Commit: 813ddf8


RecipePicker inner header hidden in swap context

When replacingRecipe is set, the "Rezept wählen / {dateLabel}" header block is suppressed — the panel/sheet title already provides that context.

→ Commit: 7359eba


Mobile swap sheet suggestion fetch verified by test

A new page-level test asserts that opening the mobile swap sheet triggers a fetch with the correct planId and date query params.

→ Commit: 49ed75a


Backend score formula duplication eliminated

PlanningService had identical penalty arithmetic duplicated in scoreFromSimulatedSlots and getVarietyScore. Extracted into a single applyPenalties(tagRepeats, ingredientOverlaps, recentRepeats, duplicates, config) private helper. Both methods now delegate to it. All 290 backend tests remain green.

→ Commit: 116e400

## Review concerns addressed All blockers and suggestions from the multi-persona review have been resolved. Here's a summary by concern: --- ### ✅ SwapSuggestionList deleted — RecipePicker used exclusively in swap context `SwapSuggestionList.svelte` and its test file were deleted. The swap sheet and swap panel now render `RecipePicker` with two new props: `excludeRecipeId` (hides the current recipe from suggestions) and `replacingRecipe` (provides swap context). `activePickerDate` on the page was extended to cover `swapSheetOpen` so suggestions are fetched when the swap sheet opens. → Commits: `148f6a7`, `16162d8`, `f4503b0` --- ### ✅ Neutral badge copy changed to "Kein Einfluss" The previous copy ("⚠ Variationskonflikt") was misleading — it read like a warning log. Changed to "Kein Einfluss" with a neutral grey style (`data-type="neutral"`). → Commit: `813ddf8` --- ### ✅ RecipePicker inner header hidden in swap context When `replacingRecipe` is set, the "Rezept wählen / {dateLabel}" header block is suppressed — the panel/sheet title already provides that context. → Commit: `7359eba` --- ### ✅ Mobile swap sheet suggestion fetch verified by test A new page-level test asserts that opening the mobile swap sheet triggers a fetch with the correct `planId` and `date` query params. → Commit: `49ed75a` --- ### ✅ Backend score formula duplication eliminated `PlanningService` had identical penalty arithmetic duplicated in `scoreFromSimulatedSlots` and `getVarietyScore`. Extracted into a single `applyPenalties(tagRepeats, ingredientOverlaps, recentRepeats, duplicates, config)` private helper. Both methods now delegate to it. All 290 backend tests remain green. → Commit: `116e400`
marcel merged commit 0596fddcd3 into master 2026-04-09 16:33:12 +02:00
marcel deleted branch feat/issue-46-wire-suggestions-recipe-picker 2026-04-09 16:33:15 +02:00
Sign in to join this conversation.