feat: Add-to-Plan flows — C4 recipe picker, C5 quick actions, C6 day picker #42
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?
Add to Plan — Screens C4–C6
Spec:
specs/frontend/j2-add-meal.htmlThree missing interaction surfaces for getting a recipe into the weekly plan. All share the same backend write (
PATCH /api/week-plan/{weekId}/slots/{date} { recipe_id }), but they cover two fundamentally different entry points.Context: Two entry points, different intent
Add ≠ Swap. J4 Swap replaces a filled slot with a system-suggested recipe. C4/C6 fill an empty slot with a user-chosen recipe. Do not reuse the swap sheet component.
C4 — Recipe picker (from planner)
Entry: tap the
+button in the C1 nav header, or tap an empty day slot chip (mobile) / empty calendar tile (desktop).Mobile
Rezept wählen · {Day, Date}header +×closevariety_delta DESC(≠ J4 which sorts by effort ASC)↑ +N Punkteif delta > 0⚠ {reason}if delta ≤ 0 (still selectable)+ Wählen→ immediate PATCH → dismiss sheet → undo toast 4sDesktop
API
C5 — Recipe card quick actions
All recipe cards in the Recipes tab (B1) gain two always-visible action buttons below the tags row.
🍳 Jetzt kochen/cook/{recipeId}(J3)📅 Zur Woche +Always visible — not hover-gated. Touch-capable desktops have unreliable hover; both actions need to be reachable without mouse interaction.
Sizes: 10px mobile / 11px desktop,
font-weight: 500,border-radius: var(--radius-md).C6 — Day picker (from recipe card)
Entry: tap
Zur Woche +on any recipe card (C5). Recipe is known; user picks the day.Mobile
Compact bottom sheet (~55vh). Recipe list dims to 28% opacity behind it.
Sheet structure:
׋ ›week navDay chip states:
.emptygreen-lightgreen-tint.filledcolor-bordercolor-surface+ green dot.todayyellowyellow-tint.sel-emptygreen-darkgreen-tint.sel-filledorange-darkorange-tintReplace confirmation (V2): inline
dp-warnnote below the strip ("Ersetzt Tomaten-Pasta am Mittwoch. Undo möglich.") + orange confirm button. No modal dialog — consistent with J4.Week nav:
‹ ›loads prev/next week. If current week is fully filled, auto-advance to next week.Desktop
Zur Woche +button becomes green filledTag wählen…↑ Score: 8 → 9 · Neues ProteinviaGET /api/variety/preview?add={id}&date={date})API
Desktop panel state machine
Tap counts
Open questions for discussion
C4 entry via "+": When no day is pre-selected (user taps
+in the nav, not a specific slot), should the sheet pre-select the next empty day automatically, or should it require the user to first select a day from a strip at the top of the sheet?C5 button labels: "Zur Woche +" is short enough for mobile cards at 320px. Alternative: "Einplanen". Does the team prefer the explicit
+suffix or a verb-only label?C6 fully-filled week: If the current week has no empty slots, auto-advance to next week on sheet open? Or show the current week with all filled slots (all selectable for replace)?
Variety preview in C6 mobile: Currently omitted for compactness. Could show a single-line score delta ("↑ +1 Punkt") below the confirm button without adding much height. Worth adding?
C2 vs C4: The existing C2 spec (j2-plan-the-week.html) defines a full-page suggestions screen navigated to from C1. C4 proposes a bottom sheet instead. Should C2 be retired in favour of C4, or kept as an alternative "deep browse" mode accessible from C4's "Alle Rezepte" section?
Spec committed:
specs/frontend/j2-add-meal.html(commit693ec2b)🎨 Atlas — UI/UX Designer
Overall this spec is solid and consistent with the existing system. A few things I want the team to think about before we implement.
Design system consistency
C2 retirement (Open Question #5) — I'd advocate for retiring C2 as a separate screen. The bottom sheet in C4 already covers the "which recipe for this day?" question, and the full-library browse is accessible via the search input. A separate full-page suggestions screen creates two different mental models for the same task. My recommendation: C4 replaces C2; the C2 spec in
j2-plan-the-week.htmlshould be marked deprecated and eventually removed.Sheet height inconsistency — C4 is specced at 75vh, C6 at ~55vh. The difference is intentional (C4 needs room for the two-section recipe list; C6 only needs a 7-chip strip and confirm button). But the drag handle and dim overlay need to be the exact same component. Make sure Kai builds one
BottomSheetwrapper component and both screens use it, rather than two separate implementations."Alle Rezepte" in C4 — missing variety warnings — The spec says the full library section has no variety badges. I'd reconsider. A recipe like "Hähnchen-Curry" that creates a conflict should still carry a subtle warning even when browsed outside the Empfohlen section — at minimum a
⚠icon without the verbose badge. Otherwise the user can unknowingly select something bad. The badge should be optional (show when delta ≤ 0 only), not section-gated.Accessibility gaps
Day chips in C6 — The
.empty/.filled/.sel-empty/.sel-filledstates rely entirely on color and border style. These needaria-labelattributes:aria-label="Samstag, 5. April – leer"/aria-label="Mittwoch, 2. April – belegt: Tomaten-Pasta". The dashed vs solid border is invisible to screen readers and insufficient for colorblind users alone.Bottom sheet focus management — When the sheet opens, focus must move to the first interactive element (the close button or search input). When it closes, focus must return to the trigger element (the
+button or empty slot). This isn't specced and needs to be.Drag handle — The 32×4px handle is too small a tap target. Add
aria-hidden="true"to it and provide an explicit close button (×) as the keyboard-accessible dismiss mechanism (already shown in the spec — just making sure it's not dropped in implementation).Open question responses
Q1 (C4 "+" pre-selection): Auto-select the next empty day silently. Showing a day-strip at the top of the recipe picker adds a step and defeats the tap count target. If all days are filled, the sheet should not open at all — instead show a brief inline message:
"Alle Tage dieser Woche sind bereits belegt."with week navigation.Q2 (C5 button labels): Keep "Zur Woche +" — "Einplanen" is vaguer and doesn't communicate that it goes to the current week's plan specifically. The
+makes the additive action legible at a glance.Q4 (Variety preview in C6 mobile): Add the single-line delta below the confirm button. One line (
↑ +1 Punktin green or⚠ −1 Punktin yellow) is worth the 20px height. Users making a deliberate planning choice from the recipe screen deserve this signal.💻 Kai — Frontend Engineer
Good spec to work from. My main concern is state management complexity — the panel state machine and shared sheet component need deliberate design decisions before we touch a line of Svelte.
Component architecture
One
BottomSheetcomponent, three consumers — C4, C6, and J4 swap all use a bottom sheet with a drag handle, dim overlay, and×close. The spec says "do not reuse the swap sheet component" for content, which is right — the contents are different. But the wrapper (dim overlay, border-radius, shadow, drag handle, focus trap, close on backdrop click) should be a singleBottomSheet.sveltewith a snippet prop for body content. Building three separate sheet wrappers is exactly the kind of duplication that causes divergence.Panel state machine — The desktop detail panel has 4 states:
idle | day-detail | recipe-picker | day-picker. This needs to live in a$statevariable at the planner page level, not inside individual sub-components. I'm thinking:Each panel content component just receives the resolved state as a prop. No child components reach up to mutate parent state.
URL vs in-memory state — Should the panel state be reflected in the URL (e.g.
?panel=recipe-picker&date=2026-04-05)? Pros: deep-linkable, browser back works. Cons: adds complexity, probably unnecessary for v1. My vote: keep it in-memory with$state. If users request deep-linking later, we add it.Data loading
C4 search is client-side — The spec says "client-side filter of visible list". That means we load the full recipe library when the sheet opens. That's fine for small libraries, but we should load it lazily (when sheet first opens, not on page mount). Use
$effectthat triggersfetch('/api/recipes')on sheet open, cache the result in a module-level store so repeated opens don't re-fetch.C6 variety preview on every day selection —
GET /api/variety/previewfires on each chip click on desktop. This is potentially 7 requests if the user browses all days. Debounce by 150ms or cancel the previous request with anAbortControllerbefore issuing the next one.SSR — The bottom sheets and panel picker states are purely client-side interactions. The underlying data (recipes, suggestions, week plan) should still load server-side in
+page.server.ts. The sheet/panel components themselves should be inside an{#if browser}guard or mounted only client-side to avoid SSR hydration mismatches on the$statepanel mode.Undo toast
$statecontext, auto-dismiss timer, and an accessiblerole="status"live region. Worth flagging as a separate task — it will be needed by J4 too if it isn't already built.TDD notes
×and backdrop click both call the dismiss callback; test keyboardEscapedismisses..emptychip sets correct selection state; test that clicking.filledchip shows the replace warning; test confirm button label changes based on selection.🔧 Backend Engineer
The API surface is clean and the intent is clear. Before implementation, I want to nail down the semantics of three things: the suggestion algorithm, the PATCH idempotency, and the undo model.
API semantics
PATCH /api/week-plan/{weekId}/slots/{date}— replace semantics need a decision. The spec says this endpoint both adds (empty slot) and replaces (filled slot) a recipe. That's fine — but what does it return? I'd go with200 OK+ the updated slot as aWeekPlanSlotDTO. A204 No Contentis tempting but gives the frontend nothing to work with for the panel transition. Also: should the endpoint reject arecipe_idthat doesn't belong to the household? That's an authorization question, but the shape of the 403 response needs to be consistent with the rest of the API.DELETE /api/week-plan/{weekId}/slots/{date}for undo. This is semantically "clear the slot", not "delete a resource" —DELETEis appropriate if clearing is idempotent. But the undo scenario is: the user replaced recipe A with recipe B, then taps undo. The spec impliesDELETEjust clears the slot (slot goes back to empty), not restores to recipe A. Is that the intended behavior? A user who accidentally replaced a planned meal might expect the original recipe back. Worth deciding explicitly — if restoration is needed, we need to store previous state server-side, which is a bigger lift.GET /api/suggestions?week={id}&day={date}— algorithm spec missing. The endpoint is listed butvariety_deltais not defined anywhere in the backend. This is business logic that lives in aVarietyService(orSuggestionService). Before implementing, we need to define: what does variety_delta measure? Ingredient overlap score? Protein category diversity? Without a clear algorithm spec, two developers will implement this differently. I'd propose:variety_delta = (projected_score_with_recipe) - (current_score)— but that needs the variety score algorithm to already be tested and stable.{weekId}vs{date}— mixed identifiers. The slots endpoint usesPATCH /api/week-plan/{weekId}/slots/{date}whereweekIdis a plan ID anddateis a specific day. The variety preview usesGET /api/variety/preview?add={recipeId}&date={date}with noweekId. The backend needs to derive the week context from thedateparameter alone for the preview endpoint. Make sure the service layer consistently resolves week context from date — don't let two different resolution strategies live in the codebase.Authorization
Household isolation on
weekId— theWeekPlanServicemust verify that the authenticated user's household owns theweekIdbefore any read or write. This check belongs in the service layer, not just the controller. IfweekIdis an opaque UUID (not sequential), enumeration is harder, but it's not a substitute for explicit authorization.Role check on PATCH/DELETE — members have read-only access to C1. The spec says member role hides edit actions in the UI, but the backend must enforce this independently.
PATCHandDELETEon week plan slots should return403for members, not just be hidden in the frontend.Suggestions
variety_deltashould be pre-computed or fast. Computing a score delta for every recipe in the library on eachGET /api/suggestionsrequest is potentially expensive. Consider: limit suggestions to the top N by delta (already implied by "2–4 items"), and only compute delta for recipes that pass a lightweight pre-filter (e.g., already indexed by protein category). Don't optimize prematurely, but don't design an algorithm that requires a full table scan per request either.Open question #3 (fully-filled week in C6): From a backend perspective, auto-advance to next week should happen client-side based on the slot data returned by
GET /api/week-plan/{weekId}. The backend doesn't need a special "auto-advance" endpoint — the frontend reads the slot states and navigates accordingly. Keep the backend dumb here.🧪 QA Engineer
No acceptance criteria are defined in the spec. That needs to change before implementation starts — I can't write tests without knowing what "done" looks like. Here's what I'd expect, plus every edge case I see.
Missing acceptance criteria
The spec describes UI behavior thoroughly but never states: "this feature is complete when…". Before we build, let's agree on at minimum:
Edge cases I want covered
C4 — Recipe picker:
GET /api/suggestionsreturns an error (network fail, 500) → graceful degradation: show "Alle Rezepte" only, no crashC6 — Day picker:
‹ ›week navigation: what's the minimum week? Can a user navigate to a past week and add a meal there? If not, the‹button needs to be disabled for current/past weeks.dp-warnis not specced.DELETEclears the slot. Is the replaced recipe (Tomaten-Pasta in the example) recoverable? If undo = clear (not restore), the user loses data. This needs explicit documentation at minimum.C5 — Quick actions:
Undo toast:
DELETEis not called after navigationE2E test plan (Playwright)
Critical paths to cover:
@critical— Mobile: open planner → tap empty Saturday → sheet appears → tap recipe → slot fills → undo toast → tap Rückgängig → slot empties@critical— Desktop: click empty Saturday tile → panel shows picker → click recipe → panel shows day-detail for Saturday@critical— Recipe list → "Zur Woche +" on recipe → day picker → select empty day → confirm → recipe appears in planner@smoke— Recipe list → "Jetzt kochen" → navigates to cook mode@full— Day picker: select filled day → replace warning appears → confirm → old recipe gone, new recipe in slot → undo → slot empties (not restores)Use
data-testidattributes on: the+button, each day chip (keyed by date), the sheet overlay, the confirm button, the undo toast, and the "Rückgängig" link.🔐 Sable — Security Engineer
This feature introduces three new write endpoints and one new read endpoint. My main concerns are IDOR on the week plan, cross-household recipe injection, and the missing role check on the mutation endpoints.
Broken access control (OWASP #1)
IDOR on
weekId—PATCH /api/week-plan/{weekId}/slots/{date}takes aweekIdin the path. If this ID is sequential or guessable (e.g., an auto-increment integer), an authenticated user from household A could modify household B's week plan by incrementing the ID. Mitigation: use UUIDs forweekId, and — more importantly — validate in the service layer thatweekIdbelongs to the authenticated user's household on every request. UUID alone is not authorization.Cross-household recipe injection —
PATCH /api/week-plan/{weekId}/slots/{date} { recipe_id }accepts arecipe_idin the request body. The backend must verify thatrecipe_idbelongs to the authenticated user's household before writing it to the slot. If it doesn't, an attacker could enumerate recipes from other households and assign them to their own plan — exposing the existence of another household's recipes (information leak) and potentially corrupting the variety score logic with foreign data.Member role on mutation endpoints — The spec says members see a read-only C1. But the frontend hiding buttons is not a security control.
PATCHandDELETEon week plan slots must return403 Forbiddenfor authenticated users with thememberrole. This check belongs in the service layer, enforced independently of the UI. Verify it's covered in integration tests:shouldReturn403WhenMemberAttemptsToAddMeal().GET /api/suggestions?week={id}&day={date}— same household isolation requirement as above. A member from household B passing household A'sweek={id}must get a 403 or 404, not a list of suggestions computed from household A's meal history.Information leakage
GET /api/variety/preview?add={recipeId}&date={date}— this endpoint computes a score delta. If it returns a meaningful response for arecipeIdthat doesn't belong to the user's household, it leaks: (a) the recipe exists, (b) its ingredient composition (indirectly, via score delta). Validate thatrecipeIdbelongs to the caller's household before computing anything.Error responses — The replace warning message "Ersetzt Tomaten-Pasta" is rendered client-side from the existing slot data (already loaded in C1). That's fine — no server-side information leak there. But if the PATCH returns a 409 or 422 (e.g., slot already modified by another member), make sure the error response body doesn't include internal identifiers or stack traces.
Undo endpoint
DELETE /api/week-plan/{weekId}/slots/{date}— same household ownership check required as the PATCH. Also: confirm this endpoint is not callable by unauthenticated users. ADELETEwith no session should return401, not403or200.No new attack surface beyond the above
reasontext in variety badges comes from the backend algorithm, not from user-entered recipe names — so no XSS vector there, as long as recipe names are properly escaped when rendered (use Svelte's default text interpolation, not{@html}).🗄️ Backend Engineer — Discussion Summary
Working through the open backend items raised during review. Six items discussed and resolved.
✅ Resolved
PATCH return value — Response envelope is
{ slot: WeekPlanSlotDTO, weekVarietyScore: double }. Single round-trip gives the client both the updated slot and the refreshed week score. No separate GET needed.Undo window — Not time-limited. Undo persists as long as the user stays in the current screen. Semantics: Undo Add (was empty) = DELETE slot; Undo Replace (was filled) = PATCH slot with client-held
previousRecipeId.variety_delta algorithm —
simulateVarietyScore()already exists inPlanningServiceand is used ingetSuggestions(). Only missing piece: exposescoreDelta = simulatedScore - currentScoreas a field onSuggestionResponse.SuggestionItem. No new scoring logic needed.Week plan auto-creation — No auto-create on "Zur Woche +". When no active plan exists, the button switches label to "Wochenplan erstellen" and routes the user to plan creation first. Avoids orphaned slots.
Variety preview endpoint ��� Add
weekIdas a query param for consistency with other planning endpoints. Signature:GET /api/plans/{planId}/variety-preview?recipeId=&date=&weekId=.Household isolation — Follow the established pattern:
@PreAuthorize("isAuthenticated()")on controller methods for readability; service-layerfindPlan(planId, householdId)+findRecipe(recipeId, householdId)for ownership enforcement. No customPermissionEvaluatorneeded.Overall read
The spec is well-scoped. The main backend work is small: add
scoreDeltato the suggestion DTO, update the variety preview endpoint signature, and wire up two new controller methods (PATCH/DELETE slot) following the existing patterns. Nothing architecturally novel — the hard parts are already in place.💻 Kai — Frontend Engineer — Implementation Discussion Summary
Working through the open frontend architecture decisions before touching any code. Seven items discussed and resolved.
✅ Resolved
BottomSheet component API — One shared
BottomSheet.sveltewrapper. Props:open: boolean,onclose: () => void,height: string(default'75vh'),childrensnippet. Wrapper owns: dim overlay (fixed opacity — Atlas picks the value), drag handle, focus trap on open, focus return on close,Escapekey dismiss. C4, C6, and J4 swap all use it; content is provided by each consumer via thechildrensnippet.Panel state machine — Discriminated union at planner page level:
Each page owns its own
$state<PanelState>independently. No shared cross-page panel store. Child components receive resolved state as props and call anonTransitioncallback — no child mutates parent state directly.URL vs in-memory panel state — In-memory
$stateonly. No query param reflection for v1. Browser back while panel is open = close panel + navigate back, which is acceptable.Undo bar — No global toast infrastructure. Local undo button rendered in the screen, driven by page-level
$state<UndoAction | null>. Set on PATCH success, cleared on navigation away. Type:"Zur Woche +" active plan check — Recipes page
+page.server.tsloads{ recipes, activePlan: WeekPlanSummary | null }. Button renders "Zur Woche +" whenactivePlanis set, "Wochenplan erstellen" when null. No client-side fetch, no hydration flicker.C4 lazy loading + C6 debounce — Recipe library (
GET /api/recipes) loaded on sheet first-open via$effect, cached in a module-level variable so repeat opens don't re-fetch. Variety preview (GET /api/variety/preview) on desktop chip selection usesAbortController— cancel in-flight request before issuing next one. No debounce timer needed.C2 retirement —
/planner/suggestionsroute is already implemented but will be deleted as part of this feature. Files to remove:+page.svelte,+page.server.ts,page.server.test.tsundersrc/routes/(app)/planner/suggestions/. Any links to this route are removed or redirected. C4 bottom sheet is the replacement.Overall read
The spec is implementable as written. The main frontend work is:
BottomSheet.sveltewrapper,PanelStatemachine in the planner page, the two sheet content components (C4 RecipePicker, C6 DayPicker), C5 quick action buttons, and C2 deletion. No new routing complexity, no global state infrastructure.