feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #46
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
RecipePickeralready renders a fully-built "Empfohlen · Beste Abwechslung" section whensuggestionsis non-empty:↑ +N Punktewhen the recipe improves variety⚠ Variationskonfliktwhen it doesn'tBoth call sites in
+page.svelte(mobile bottom sheet + desktop panel) currently hardcodesuggestions={[]}, so this section never appears. The UI capability exists; the data is not wired up.Problem
When a user opens the recipe picker for an empty slot, all recipes are shown in a flat, unsorted list with no variety signal. The user has no way to know which choices would improve the week's variety score.
What exists today
RecipePicker.svelte— Empfohlen sectionsuggestionsprop is non-emptyGET /v1/week-plans/{id}/variety-preview?recipeId={id}&date={date}{ simulatedScore }for one recipeGET /v1/week-plans/{id}/suggestions?date={date}(batch)+page.server.ts— suggestions fetchsuggestions={[]}alwaysProposed solution
Option A — New batch backend endpoint (recommended)
Add
GET /v1/week-plans/{planId}/suggestions?date={date}to the backend:simulatedScoredescending, with the score includedThen in
+page.server.ts:loaddependency onselectedDay, orIn
+page.svelte:suggestionsto bothRecipePickerinstancesOption B — N parallel frontend-server calls (simpler, no backend change)
In
+page.server.ts, callvariety-previewfor every recipe in parallel and rank client-side. Feasible for small recipe libraries; may be slow at scale (100+ recipes = 100 requests).Option A is preferred — one request instead of N, and the ranking logic lives server-side where it belongs.
Acceptance criteria
RecipePickerfor an empty slot shows a non-empty Empfohlen · Beste Abwechslung section (top 5 recipes by simulated variety score)↑ +N Punkteor⚠ VariationskonfliktbadgeSwapSuggestionList) is not affected — it uses easiest-first sorting intentionallyRecipePickersuggestions rendering already has tests (via existingSuggestionCard.test.ts+RecipePicker.test.ts); add server load tests for the new fetchOut of scope
👨💻 Felix Brandt — Review
Verdict: ⚠️ Approved with concerns
Good issue — problem, existing state, and acceptance criteria are all clear. A few things to nail down before implementation starts.
Blockers
1. Lazy vs. eager loading is unresolved — it matters for architecture
The issue lists both options without choosing:
These lead to very different implementations:
+server.tsendpoint the client fetches from, or an invalidation-based pattern. More correct UX, more complex to implement.Decision needed: Which approach? My recommendation is lazy — fetch suggestions when
pickerOpenbecomes true (mobile) orpanelStatetransitions torecipe-picker(desktop), using a$derived-triggeredfetch. This keeps the initial page load fast and suggestions always current for the selected day.2. Top-N is hardcoded to 5 — is that a product decision or a backend default?
The issue says "top N (e.g. 5)". If it's 5 for now, say so explicitly. If it should be configurable, say who configures it. Ambiguity here leads to inconsistency between backend and frontend implementations.
Suggestions
The
SuggestionCard.sveltecomponent is mentioned but appears orphanedThe issue references
SuggestionCard.test.tsas providing existing test coverage, but after the C2 route was removed (commit4333dc0),SuggestionCard.svelteis no longer used anywhere.RecipePicker.svelterenders suggestion rows inline — it does not useSuggestionCard. The existing test coverage for suggestions therefore lives inRecipePicker.test.ts, notSuggestionCard.test.ts. Worth correcting so the implementer doesn't go looking for coverage that doesn't apply.The acceptance criterion "specific to the target date" needs clarification
The existing
variety-previewendpoint already takes adateparam, so day-awareness is handled backend-side. This criterion is satisfied automatically if the batch endpoint acceptsdate. No special frontend work needed — but worth confirming the backend actually uses the date to compute overlap context (i.e. it excludes the slot being filled from the current-week context).Overall
Solid spec. Fix the lazy/eager decision and the top-N number before handing this to an implementer and it's ready to go.
👨💻 Felix Brandt — Senior Fullstack Developer
I already flagged the two blockers above (lazy/eager decision + top-N ambiguity). Not repeating those — they stand.
One thing I didn't cover:
Questions & Observations
Error handling path is unspecified. What happens if the new
/suggestionsendpoint returns a 5xx, or times out? The issue assumes a happy path. My expectation: treat a failed suggestions fetch assuggestions=[]and still show the picker with the full "Alle Rezepte" list. The user should never see a broken picker because the recommendations couldn't be loaded. This should be an explicit acceptance criterion.SuggestionCard.svelteis orphaned — the issue referencesSuggestionCard.test.tsas existing coverage, but the component is unused since commit4333dc0. Either delete it as part of this issue or explicitly keep it out of scope, but don't cite it as active test coverage.Suggestions
suggestions=[]"SuggestionCard.sveltehere or open a separate chore issue🏛️ Architect
Questions & Observations
Where does the new endpoint belong in the backend layer structure?
The existing
variety-previewlives onWeekPlanController. The new/suggestionsendpoint should live there too. But internally it will need to call the variety-preview logic for every recipe — make sure this goes through the service layer (PlanningService) and not by calling the controller method directly. The service methodgetVarietyPreviewshould be extracted/reused, not duplicated.Lazy loading requires a new API surface on the frontend side.
The existing
+page.server.tsloadfunction runs once per navigation. To load suggestions lazily (when the picker opens for a specific day), you need either:+server.tsroute (GET /planner/suggestions?planId=&date=) that the client fetches from with a regularfetch()call — this is clean and testable$effectthat fires afetch()directly to the backend — bypasses SvelteKit's server layer and loses type safetyOption 1 is architecturally cleaner: the frontend server proxies the backend, keeps auth headers, and stays within the SvelteKit layer boundary.
Graceful degradation must be explicit in the architecture.
The suggestions fetch is optional enrichment. The component tree must be designed so that
suggestions=[]is always a valid state, and the fetch failure doesn't block rendering the picker. This is already true ofRecipePicker(it renders fine with no suggestions), but the fetch logic needs to explicitly catch errors and default to[].The
slotMap+selectedDay→pickerDate→suggestionschain.On the desktop panel,
pickerDatecomes frompanelState.date. On mobile, it'sselectedDay. These are different state sources — ensure the lazy fetch is triggered by the right variable in each context, not a shared one that could cause stale data when switching between mobile and desktop.Suggestions
GET /planner/suggestionsSvelteKit server route as the frontend API surface[]🧪 Tester
Questions & Observations
Edge cases not covered by the acceptance criteria:
N > library sizegracefully?⚠ Variationskonfliktbadge. Should they still appear in Empfohlen? If yes, the user sees 5 warnings with no green option. Is that intentional? TheexcludeRecipeIdfilter (from J4) already removes the slot's current recipe — but the rest could all be conflicts.simulatedScore(probably the score of adding just that one recipe), or does it error?Test strategy questions:
+server.tsroute (as the architect recommends), the test target is the route handler, not theloadfunction. Which is it?$effector$derivedthat callsfetch()— this is harder to unit-test than a serverload. What's the plan for testing the trigger logic?Suggestions
🔒 Security Expert
Questions & Observations
Authorization scope on the new batch endpoint
The existing
variety-previewendpoint is guarded with@RequiresHouseholdRole("member"). The new/suggestionsbatch endpoint must carry the same guard — and critically, it must verify that theplanIdin the path belongs to the requesting user's household. A user from household A should not be able to call/v1/week-plans/{householdB-planId}/suggestionsand get recipe data for another household.This check almost certainly exists in the service layer already (the old
getVarietyPreviewresolves the householdId from the principal and validates plan ownership). Confirm this validation is inherited by the new endpoint — don't assume it comes for free.Recipe data in the response
The suggestions response will return recipe names and metadata for the top N recipes. These are household-owned recipes — confirm the backend scopes the recipe library query to the caller's household before scoring and returning. If recipe IDs are globally unique UUIDs and the library query is household-scoped, this is fine. Worth a quick audit.
The SvelteKit
+server.tsproxy route (if adopted)If the frontend introduces a
GET /planner/suggestionsserver route that proxies to the backend, it must forward the session cookie / auth token to the backend. SvelteKit'sevent.fetchdoes this automatically — but only if the backend URL is fetched via the SvelteKitfetchwrapper, not nativefetch. Verify the existingapiClientpattern is used here (it is in+page.server.ts).No new input validation concerns —
planIdis a UUID (validated by Spring path variable binding),dateis aLocalDate(type-safe). No free-form user input in the request.Suggestions
🎨 UI / UX Expert
Questions & Observations
What does the picker look like while suggestions are loading?
The issue describes lazy loading but says nothing about the loading state. If suggestions take 300–800ms to arrive, the picker opens showing only "Alle Rezepte" and then the "Empfohlen" section suddenly appears above it — this is a jarring layout shift. Options:
The third option is best UX: trigger the suggestions fetch the moment the user taps the empty slot (before the animation completes), and delay showing the picker by 200ms if needed. This hides latency behind the open animation.
What if there are zero suggestions?
The Empfohlen section is currently hidden when
suggestions=[]. If the batch endpoint returns an empty list (e.g., all recipes conflict), the section disappears entirely and the user sees only "Alle Rezepte" — which is the current broken state, just correctly hidden. That's fine, but worth stating explicitly: the section should not show at all if empty, not show with "Keine Empfehlungen" messaging.Badge copy — "⚠ Variationskonflikt" for every recipe in a full week
If all 5 suggestions carry the yellow warning badge, the Empfohlen section becomes a list of warnings. This could feel discouraging. Consider suppressing the Empfohlen section entirely if all top-5 scores are neutral/negative — or at minimum reorder so any positive suggestions come first (the backend presumably already does this by
simulatedScore DESC).The "Alle Rezepte" list after suggestions
Currently the full recipe list is unsorted (API order). After the Empfohlen section is visible, the same recipes will appear again in "Alle Rezepte". A user might wonder why a recipe recommended above also appears in the full list. Adding a subtle "Bereits empfohlen" label or filtering them out of "Alle Rezepte" would reduce confusion — though this is polish, not a blocker.
Suggestions
⚙️ DevOps
Questions & Observations
Performance budget for the new endpoint
The batch endpoint will call the variety-preview scoring logic for every recipe in the household library on each request. What's acceptable latency? The existing
/variety-scoreendpoint presumably completes quickly (it scores the current plan), but scoring all N recipes is O(N × overlap-computation). With 50 recipes this may be fine; with 500 it may not. No latency target is mentioned in the issue.Suggested addition: define a p95 latency target (e.g. < 500ms for up to 100 recipes) and add a note to the Spring Boot test about verifying the query count doesn't grow with recipe library size (N+1 query risk).
Will this endpoint be called on every slot click?
If suggestions are fetched lazily every time the picker opens, a user who opens and closes the picker five times in one session fires five backend requests. For a household that plans together, this could happen concurrently. Not a scalability concern for a household app, but worth noting — if the response is stable between slot mutations, a short client-side cache (e.g. memoised by
planId + date, invalidated oninvalidateAll) would eliminate redundant calls.No new infrastructure needed
openapi.yamlor equivalent) is updated if one existsObservability
No structured logging or metrics are mentioned. For a household app this is fine. If latency becomes an issue later, the endpoint is a natural place to add a
@Timedannotation.Suggestions
planId + date)⚙️ Senior Backend Engineer — Spring Boot / PostgreSQL Specialist
Backend-focused discussion — clarifying DTO shape, query strategy, top-N, filter semantics, and test depth.
Resolved
Response DTO shape —
SuggestionItemgets two new fields:scoreDelta(double) andhasConflict(boolean). Backend computes both: current score is fetched once from existing slots,scoreDelta = simulatedScore − currentScore,hasConflict = scoreDelta <= 0. Recipe metadata fields (id,name,effort,cookTimeMin) are already present viaSlotRecipe. ThecurrentVarietyScoreprop onRecipePickercan be removed — it's no longer needed on the frontend.N+1 query risk — none —
PlanningService.getSuggestions()already loads all data once (plan, household recipes, cooking history) and scores in-memory. No per-recipe DB queries. The batch scoring is already safe.Top-N — Already implemented as an optional
topNquery param defaulting to 5. No changes needed here.Filter clause ("not currently possible for an empty slot") — The
usedRecipeIdsfilter is week-level by design: any recipe already planned anywhere in the week is excluded from suggestions, regardless of day. This is intentional.Test depth — Service unit tests covering
scoreDeltaandhasConflictcomputation (current score, delta, conflict flag). The existing controller test (getSuggestionsShouldReturn200) will need to be updated to match the newSuggestionItemshape.Key finding
The batch endpoint already exists.
GET /v1/week-plans/{id}/suggestionsis fully implemented inWeekPlanControllerandPlanningService. The issue describes it as ❌, but it's live. The actual backend work is narrow: addscoreDeltaandhasConflicttoSuggestionResponse.SuggestionItem, update the service to compute them, fix one controller test. The frontend work is: call the endpoint, update theSuggestiontype, drop thecurrentVarietyScoreprop.Overall: smaller than it looks. Most of the backend scaffolding is already in place — this is a DTO update and a wiring task, not a new feature build.
🎨 Kai — Frontend Engineer
Frontend-focused discussion — settling the fetch strategy, Svelte 5 trigger pattern, loading state, dead code, and prop cleanup.
Resolved
Lazy fetch via
+server.ts— Suggestions are fetched lazily when the picker opens, not on page load. A newGET /planner/suggestions?planId=&date=SvelteKit+server.tsroute proxies to the backend usingevent.fetch(auth handled automatically). No direct backend calls from the client.Fetch trigger pattern — A single
$derived pickerDateunifies both contexts:One
$effectwatchespickerDate— fetches when it becomes non-null, clearssuggestionswhen it goes back to null. Errors caught and degraded tosuggestions = [].Loading state — in scope — Add
isLoadingSuggestionsboolean state. Show a skeleton/spinner in the Empfohlen area while the fetch is in flight to prevent layout shift when the section appears.SuggestionCard.svelte— delete it — The component is dead code (unused since4333dc0). Delete it and its test file as part of this issue. Update the acceptance criteria: replace theSuggestionCard.test.tsreference withRecipePicker.test.ts.currentVarietyScoreprop removal — WithscoreDeltapre-computed by the backend,RecipePickerno longer needscurrentVarietyScore. Remove the prop from the component and both call sites in+page.svelte(lines 285 and 563). TheSuggestioninterface changes from{ recipe, simulatedScore }to{ recipeId, name, effort?, cookTimeMin?, scoreDelta, hasConflict }.varietyScoreitself stays — it's still used byVarietyScoreCard.Overall: the frontend work is well-scoped — one new
+server.tsroute, one$derived+ one$effectin+page.svelte, a loading state, prop cleanup, and dead code removal. Nothing architecturally surprising.Implemented ✅
All tasks completed on branch
feat/issue-46-wire-variety-aware-suggestions.What was built
Backend
SuggestionItemDTO: replacedsimulatedScore: doublewithscoreDelta: double+hasConflict: booleanPlanningService.getSuggestions(): computescurrentScoreonce from existing slots, then derivesscoreDelta = simulatedScore - currentScoreandhasConflict = scoreDelta <= 0per candidate; results sorted byscoreDeltadescFrontend
SuggestionCard.svelte+SuggestionCard.test.tsschema.d.ts+openapi.json:SuggestionItemupdated to{ scoreDelta, hasConflict }, removedsimulatedScoreRecipePicker.svelte: badge logic now useshasConflict(green = variety gain, yellow = conflict); addedisLoadingprop with pulse skeleton; droppedcurrentVarietyScoreprop+server.ts(GET /planner): proxy route sorts byscoreDeltadesc, degrades gracefully on all error paths+page.svelte:$derived activePickerDateunifies mobile + desktop picker triggers;$effectlazy-fetches/planner?planId&datewhen picker opens, wiressuggestions+isLoadinginto bothRecipePickerinstancesCommits
feat(planner): remove SuggestionCard — dead code replaced by RecipePickerfeat(planner): replace simulatedScore with scoreDelta and hasConflict in SuggestionItemfeat(planner): compute scoreDelta and hasConflict in PlanningService.getSuggestionsfeat(planner): update SuggestionItem schema — scoreDelta + hasConflictfeat(planner): wire scoreDelta/hasConflict badges and isLoading into RecipePickerfeat(planner): add GET /planner proxy route for variety suggestionsfeat(planner): lazy-fetch variety suggestions in RecipePicker for empty slotsTest results
recipes/page.test.tsunrelated to this issue)npm run check: 0 errors in production code (2 pre-existing errors intest-setup.ts)