Frontend: J4 — Swap flow (action sheet + quick suggestions) #29

Closed
opened 2026-04-02 11:29:23 +02:00 by marcel · 7 comments
Owner

Summary

Mid-week meal swap flow. Speed is critical — must complete in ≤3 taps from "Swap" to updated plan. Different UI pattern per breakpoint: mobile uses action sheet + bottom sheet, desktop uses inline panel transitions.

Journey: J4 — Adapt on the fly
Role: Planner only
Constraint: ≤3 taps to complete swap

Mobile: Action Sheet

Bottom action sheet pulls up from bottom on meal tap:

  • Drag handle (32px wide, 4px height)
  • Meal title (15px display font) + metadata (11px muted)
  • 4 stacked action buttons:
    • "↻ Swap this meal" — orange-tint bg, orange-dark text
    • "🍳 Cook now" — green-tint bg, green-dark text
    • "👁 View recipe" — subtle bg, muted text
    • "Cancel" — no background, muted text
  • Background dims to 40%

Mobile: Swap Suggestions (Bottom Sheet)

After tapping "Swap", a bottom sheet appears:

  • "Replacing" banner: --orange-tint bg, old meal name struck through
  • "Swap to (easiest first)" eyebrow label
  • Compact suggestion cards: name + time/effort/tag + "Pick" link
  • Sorted EASIEST FIRST (different from C2 variety-first sorting in J2)
    • effort ASC, cook_time ASC
  • "Pick" → UPDATE week_plan_slot + dismiss sheet + undo toast

Desktop: Inline Panel

No action sheet. The C1 detail panel (280px) has a "Swap meal" ghost button:

  • Clicking transitions the detail panel in-place to show swap suggestions
  • "Replacing" header with old meal struck through
  • Suggestion cards fit panel width
  • Calendar grid stays visible alongside
  • "Pick" → updates slot + transitions panel back

Tap Count

  • Mobile: 3 taps (card → Swap → Pick)
  • Desktop: 2 taps (Swap button → Pick)

Sorting Difference from C2

In swap context (J4), suggestions sort easiest first because mid-week swaps typically happen when the original plan was too ambitious. In planning context (J2), C2 sorts by variety algorithm.

Behavior

  • Swap is logged: both original meal (not cooked) and replacement are recorded
  • Original uncooked meal remains in library for future weeks
  • Variety score recalculates immediately after swap
  • Undo toast appears instead of confirmation dialog (faster flow)

Acceptance Criteria

  • Mobile: action sheet → swap bottom sheet → pick (3 taps)
  • Desktop: swap button → inline suggestions → pick (2 taps)
  • Suggestions sorted easiest first (not variety-first)
  • "Replacing" banner with struck-through old meal
  • Variety score updates immediately
  • Swap logged (original + replacement recorded)
  • Undo toast instead of confirmation
## Summary Mid-week meal swap flow. Speed is critical — must complete in ≤3 taps from "Swap" to updated plan. Different UI pattern per breakpoint: mobile uses action sheet + bottom sheet, desktop uses inline panel transitions. **Journey:** J4 — Adapt on the fly **Role:** Planner only **Constraint:** ≤3 taps to complete swap ## Mobile: Action Sheet Bottom action sheet pulls up from bottom on meal tap: - Drag handle (32px wide, 4px height) - Meal title (15px display font) + metadata (11px muted) - 4 stacked action buttons: - "↻ Swap this meal" — orange-tint bg, orange-dark text - "🍳 Cook now" — green-tint bg, green-dark text - "👁 View recipe" — subtle bg, muted text - "Cancel" — no background, muted text - Background dims to 40% ## Mobile: Swap Suggestions (Bottom Sheet) After tapping "Swap", a bottom sheet appears: - "Replacing" banner: `--orange-tint` bg, old meal name struck through - "Swap to (easiest first)" eyebrow label - Compact suggestion cards: name + time/effort/tag + "Pick" link - **Sorted EASIEST FIRST** (different from C2 variety-first sorting in J2) - `effort ASC, cook_time ASC` - "Pick" → UPDATE `week_plan_slot` + dismiss sheet + undo toast ## Desktop: Inline Panel No action sheet. The C1 detail panel (280px) has a "Swap meal" ghost button: - Clicking transitions the detail panel in-place to show swap suggestions - "Replacing" header with old meal struck through - Suggestion cards fit panel width - Calendar grid stays visible alongside - "Pick" → updates slot + transitions panel back ## Tap Count - **Mobile**: 3 taps (card → Swap → Pick) - **Desktop**: 2 taps (Swap button → Pick) ## Sorting Difference from C2 In swap context (J4), suggestions sort **easiest first** because mid-week swaps typically happen when the original plan was too ambitious. In planning context (J2), C2 sorts by variety algorithm. ## Behavior - Swap is logged: both original meal (not cooked) and replacement are recorded - Original uncooked meal remains in library for future weeks - Variety score recalculates immediately after swap - Undo toast appears instead of confirmation dialog (faster flow) ## Acceptance Criteria - [ ] Mobile: action sheet → swap bottom sheet → pick (3 taps) - [ ] Desktop: swap button → inline suggestions → pick (2 taps) - [ ] Suggestions sorted easiest first (not variety-first) - [ ] "Replacing" banner with struck-through old meal - [ ] Variety score updates immediately - [ ] Swap logged (original + replacement recorded) - [ ] Undo toast instead of confirmation
marcel added the kind/featurepriority/high labels 2026-04-02 11:30:16 +02:00
Author
Owner

Spec file: specs/frontend/j4-adapt-on-the-fly.html — swap trigger (mobile action sheet) + C2 swap context (easiest-first suggestions) previews, agent tables, and LLM implementation guide with 3-tap constraint details.

**Spec file:** [`specs/frontend/j4-adapt-on-the-fly.html`](../specs/frontend/j4-adapt-on-the-fly.html) — swap trigger (mobile action sheet) + C2 swap context (easiest-first suggestions) previews, agent tables, and LLM implementation guide with 3-tap constraint details.
Author
Owner

👨‍💻 Kai — Frontend Engineer

The ≤3-tap constraint and the two completely different interaction patterns per breakpoint (action sheet on mobile, inline panel on desktop) make this a fun but structurally tricky build. A few things to nail down.

Component split

  • MealActionSheet — mobile-only bottom sheet triggered by tapping a meal card on C1. Contains the 4 action buttons.
  • SwapSuggestionsSheet — mobile-only second bottom sheet that replaces/overlays the action sheet after tapping "Swap". Contains the "Replacing" banner + suggestion cards.
  • SwapSuggestionsPanel — desktop-only, renders inline within the existing C1 detail panel (280px). Transitions in-place.
  • SwapSuggestionCard — shared between sheet and panel: name + time/effort/tag + "Pick" link.
  • UndoToast — shared utility component for the post-swap confirmation.

The action sheet is not a dialog — it's a sheet

  • This needs a custom bottom-sheet implementation: CSS transform: translateY, spring animation, drag handle that actually works with pointer events. No <dialog> element here — dialogs don't animate from the bottom naturally.
  • Backdrop: bg-black/40 (matches the "40%" dim in the spec) — needs to be a separate layer that traps focus and closes the sheet on click.
  • Accessibility: sheet needs role="dialog", aria-modal="true", focus trap, and Escape to close.

Inline panel transition on desktop

  • The spec says "transitions the detail panel in-place" — I need to know if this is a CSS slide (old content slides out left, new slides in right) or a crossfade. The calendar grid stays visible — just the right panel content changes.
  • I'll use a keyed {#key activeView} block with a CSS transition for this. Simple and controllable.

The undo toast timing

  • How long does the undo toast persist? 5 seconds is standard. What does "undo" do exactly — does it re-write the week_plan_slot back to the original meal, or is it a local optimistic rollback?
  • If the swap has already persisted server-side, undo needs its own API call (PATCH back to original meal ID). This needs to be in scope.

Questions

  • On mobile, does tapping the backdrop of the action sheet close it entirely (back to C1), or just go back one step to the action sheet from the suggestions sheet?
  • Is the "↻ Swap this meal" emoji/icon literal in the spec or illustrative? We use standard icons elsewhere.
  • The suggestion cards in the swap context — are they the same component as C2 suggestion cards (variety-first sorted) just with different sort order, or a visually simpler card for the sheet context?
  • Can the planner undo after navigating away from C1, or is it only available immediately after the swap?
## 👨‍💻 Kai — Frontend Engineer The ≤3-tap constraint and the two completely different interaction patterns per breakpoint (action sheet on mobile, inline panel on desktop) make this a fun but structurally tricky build. A few things to nail down. **Component split** - `MealActionSheet` — mobile-only bottom sheet triggered by tapping a meal card on C1. Contains the 4 action buttons. - `SwapSuggestionsSheet` — mobile-only second bottom sheet that replaces/overlays the action sheet after tapping "Swap". Contains the "Replacing" banner + suggestion cards. - `SwapSuggestionsPanel` — desktop-only, renders inline within the existing C1 detail panel (280px). Transitions in-place. - `SwapSuggestionCard` — shared between sheet and panel: name + time/effort/tag + "Pick" link. - `UndoToast` — shared utility component for the post-swap confirmation. **The action sheet is not a dialog — it's a sheet** - This needs a custom bottom-sheet implementation: CSS `transform: translateY`, spring animation, drag handle that actually works with pointer events. No `<dialog>` element here — dialogs don't animate from the bottom naturally. - Backdrop: `bg-black/40` (matches the "40%" dim in the spec) — needs to be a separate layer that traps focus and closes the sheet on click. - Accessibility: sheet needs `role="dialog"`, `aria-modal="true"`, focus trap, and `Escape` to close. **Inline panel transition on desktop** - The spec says "transitions the detail panel in-place" — I need to know if this is a CSS slide (old content slides out left, new slides in right) or a crossfade. The calendar grid stays visible — just the right panel content changes. - I'll use a keyed `{#key activeView}` block with a CSS transition for this. Simple and controllable. **The undo toast timing** - How long does the undo toast persist? 5 seconds is standard. What does "undo" do exactly — does it re-write the `week_plan_slot` back to the original meal, or is it a local optimistic rollback? - If the swap has already persisted server-side, undo needs its own API call (`PATCH` back to original meal ID). This needs to be in scope. **Questions** - On mobile, does tapping the backdrop of the action sheet close it entirely (back to C1), or just go back one step to the action sheet from the suggestions sheet? - Is the "↻ Swap this meal" emoji/icon literal in the spec or illustrative? We use standard icons elsewhere. - The suggestion cards in the swap context — are they the same component as C2 suggestion cards (variety-first sorted) just with different sort order, or a visually simpler card for the sheet context? - Can the planner undo after navigating away from C1, or is it only available immediately after the swap?
Author
Owner

🛠️ Backend Engineer — Swap API & Logging

The swap flow has cleaner backend requirements than D1, but the swap logging requirement and the sorting logic for suggestions deserve careful attention.

Swap logging — what exactly gets recorded?

  • "Swap is logged: both original meal (not cooked) and replacement are recorded." This implies an event/audit record, not just an in-place update of week_plan_slot.
  • I'd model this as a swap_log table: id, week_plan_slot_id, original_recipe_id, replacement_recipe_id, swapped_by (user_id FK), swapped_at. This is separate from admin_audit_log — it's a domain event, not an admin action.
  • Is there any read path for swap history? If not in v1, I'll design the table but not build the endpoint.

The swap endpoint

  • PUT /api/week-plan-slots/{slotId}/recipe — replace the recipe in a slot. Body: { recipeId: UUID }.
  • Must verify: slot belongs to caller's household, caller has planner role, new recipe exists in the household's library.
  • Returns the updated slot + the new variety score (so the UI can update immediately without a separate request).

Undo ��� needs its own endpoint or same endpoint?

  • If undo is just "call the same PATCH with the original recipe ID", the client needs to track the pre-swap state locally (optimistic — risky if the user refreshes).
  • A cleaner approach: the swap response includes the previous recipeId so the client can call the same endpoint again to undo within the toast window. No special undo endpoint needed.

Suggestions endpoint for swap context (J4)

  • The swap suggestions endpoint must sort by effort ASC, cook_time ASC — explicitly different from C2's variety-first sorting.
  • Same endpoint with a ?context=swap parameter, or a dedicated endpoint? I lean toward a query parameter to avoid duplicating business logic — but the sorting strategies are different enough that it might warrant clear separation.
  • Do swap suggestions exclude the meal currently in the slot (so you can't "swap" to the same meal)?

Variety score recalculation

  • "Variety score recalculates immediately after swap" — the score must be recalculated server-side and returned in the swap response. The client should not recalculate locally with stale data.

Questions

  • Is the undo a time-limited operation (within the toast window only, no server-side undo state), or does the server need to track an undoable state?
  • Do swap suggestions pull from the full household recipe library, or only from recipes not already planned this week?
  • Is effort stored as an enum (easy, medium, hard) or a numeric value? The effort ASC sort requires a consistent ordering.
## 🛠️ Backend Engineer — Swap API & Logging The swap flow has cleaner backend requirements than D1, but the swap logging requirement and the sorting logic for suggestions deserve careful attention. **Swap logging — what exactly gets recorded?** - "Swap is logged: both original meal (not cooked) and replacement are recorded." This implies an event/audit record, not just an in-place update of `week_plan_slot`. - I'd model this as a `swap_log` table: `id`, `week_plan_slot_id`, `original_recipe_id`, `replacement_recipe_id`, `swapped_by` (user_id FK), `swapped_at`. This is separate from `admin_audit_log` — it's a domain event, not an admin action. - Is there any read path for swap history? If not in v1, I'll design the table but not build the endpoint. **The swap endpoint** - `PUT /api/week-plan-slots/{slotId}/recipe` — replace the recipe in a slot. Body: `{ recipeId: UUID }`. - Must verify: slot belongs to caller's household, caller has `planner` role, new recipe exists in the household's library. - Returns the updated slot + the new variety score (so the UI can update immediately without a separate request). **Undo ��� needs its own endpoint or same endpoint?** - If undo is just "call the same PATCH with the original recipe ID", the client needs to track the pre-swap state locally (optimistic — risky if the user refreshes). - A cleaner approach: the swap response includes the previous `recipeId` so the client can call the same endpoint again to undo within the toast window. No special undo endpoint needed. **Suggestions endpoint for swap context (J4)** - The swap suggestions endpoint must sort by `effort ASC, cook_time ASC` — explicitly *different* from C2's variety-first sorting. - Same endpoint with a `?context=swap` parameter, or a dedicated endpoint? I lean toward a query parameter to avoid duplicating business logic — but the sorting strategies are different enough that it might warrant clear separation. - Do swap suggestions exclude the meal currently in the slot (so you can't "swap" to the same meal)? **Variety score recalculation** - "Variety score recalculates immediately after swap" — the score must be recalculated server-side and returned in the swap response. The client should not recalculate locally with stale data. **Questions** - Is the undo a time-limited operation (within the toast window only, no server-side undo state), or does the server need to track an undoable state? - Do swap suggestions pull from the full household recipe library, or only from recipes not already planned this week? - Is `effort` stored as an enum (`easy`, `medium`, `hard`) or a numeric value? The `effort ASC` sort requires a consistent ordering.
Author
Owner

🧪 QA Engineer — Test Coverage Plan for J4 Swap Flow

The ≤3-tap constraint is testable and the swap logging requirement makes the data trail auditable. Here's what needs full coverage.

Backend unit tests

  • Swap service:
    • shouldReplaceRecipeInSlotAndReturnUpdatedVarietyScore()
    • shouldRecordSwapLogWithOriginalAndReplacementRecipeIds()
    • shouldSortSwapSuggestionsByEffortAscThenCookTimeAsc() — this is where the sorting rule gets validated
    • shouldExcludeCurrentSlotRecipeFromSwapSuggestions() — if that's the intended behavior
    • shouldThrowWhenSlotDoesNotBelongToCallerHousehold()
    • shouldThrowWhenCallerIsNotPlanner()

Backend integration tests

  • shouldReturn403WhenMemberAttemptsToSwapMeal() — planner-only
  • shouldReturn404WhenSlotDoesNotExist()
  • shouldReturn404WhenNewRecipeNotInHouseholdLibrary()
  • shouldPersistSwapLogAfterSuccessfulSwap() — verify the log row was actually written
  • shouldReturnUpdatedVarietyScoreInSwapResponse() — verify score is in the response body, not just in a follow-up GET
  • shouldReturn200WithPreviousRecipeIdInResponseForUndoSupport()

Frontend component tests

  • MealActionSheet: renders 4 buttons, tapping "Swap" transitions to suggestions, tapping "Cancel" dismisses, backdrop click dismisses
  • SwapSuggestionsSheet: shows "Replacing" banner with struck-through meal name, suggestions sorted by effort, "Pick" triggers swap
  • SwapSuggestionsPanel (desktop): renders inline in detail panel, panel transitions back after pick
  • UndoToast: appears after swap, disappears after timeout, "Undo" button triggers reverse swap action

E2E tests — the tap count is auditable

  • Mobile happy path: tap meal card (1) → tap "Swap" (2) → tap "Pick" (3) → verify plan updated and undo toast appears
  • Verify the tap count is exactly 3 — this is the explicit acceptance criterion
  • Desktop happy path: tap "Swap" button (1) → tap "Pick" (2) → verify inline panel returns to detail view
  • Verify variety score updates in C1 header immediately after swap (no page refresh needed)
  • Undo: complete swap → tap "Undo" in toast → verify original meal is restored

Gaps / edge cases to clarify

  • What happens if the suggestions list is empty (e.g., the household has only 1 recipe)? The swap sheet/panel should show a meaningful empty state — does the spec cover this?
  • What if the user taps "Pick" and the network request fails? Optimistic update + rollback, or wait for server confirmation before updating the UI?
  • Is the "Swap" action available if the week has already passed? (Historical week plan — can you still swap meals retroactively?)
  • The spec says "planner only" — does the action sheet still appear for members, just without the "Swap" button? Or does tapping a meal card do nothing for members?
## 🧪 QA Engineer — Test Coverage Plan for J4 Swap Flow The ≤3-tap constraint is testable and the swap logging requirement makes the data trail auditable. Here's what needs full coverage. **Backend unit tests** - Swap service: - `shouldReplaceRecipeInSlotAndReturnUpdatedVarietyScore()` - `shouldRecordSwapLogWithOriginalAndReplacementRecipeIds()` - `shouldSortSwapSuggestionsByEffortAscThenCookTimeAsc()` — this is where the sorting rule gets validated - `shouldExcludeCurrentSlotRecipeFromSwapSuggestions()` — if that's the intended behavior - `shouldThrowWhenSlotDoesNotBelongToCallerHousehold()` - `shouldThrowWhenCallerIsNotPlanner()` **Backend integration tests** - `shouldReturn403WhenMemberAttemptsToSwapMeal()` — planner-only - `shouldReturn404WhenSlotDoesNotExist()` - `shouldReturn404WhenNewRecipeNotInHouseholdLibrary()` - `shouldPersistSwapLogAfterSuccessfulSwap()` — verify the log row was actually written - `shouldReturnUpdatedVarietyScoreInSwapResponse()` — verify score is in the response body, not just in a follow-up GET - `shouldReturn200WithPreviousRecipeIdInResponseForUndoSupport()` **Frontend component tests** - `MealActionSheet`: renders 4 buttons, tapping "Swap" transitions to suggestions, tapping "Cancel" dismisses, backdrop click dismisses - `SwapSuggestionsSheet`: shows "Replacing" banner with struck-through meal name, suggestions sorted by effort, "Pick" triggers swap - `SwapSuggestionsPanel` (desktop): renders inline in detail panel, panel transitions back after pick - `UndoToast`: appears after swap, disappears after timeout, "Undo" button triggers reverse swap action **E2E tests — the tap count is auditable** - Mobile happy path: tap meal card (1) → tap "Swap" (2) → tap "Pick" (3) → verify plan updated and undo toast appears - Verify the tap count is exactly 3 — this is the explicit acceptance criterion - Desktop happy path: tap "Swap" button (1) → tap "Pick" (2) → verify inline panel returns to detail view - Verify variety score updates in C1 header immediately after swap (no page refresh needed) - Undo: complete swap → tap "Undo" in toast → verify original meal is restored **Gaps / edge cases to clarify** - What happens if the suggestions list is empty (e.g., the household has only 1 recipe)? The swap sheet/panel should show a meaningful empty state — does the spec cover this? - What if the user taps "Pick" and the network request fails? Optimistic update + rollback, or wait for server confirmation before updating the UI? - Is the "Swap" action available if the week has already passed? (Historical week plan — can you still swap meals retroactively?) - The spec says "planner only" — does the action sheet still appear for members, just without the "Swap" button? Or does tapping a meal card do nothing for members?
Author
Owner

🔐 Sable — Security Engineer

The swap flow is planner-only and involves a write operation to the week plan. The primary risks are authorization bypass and the undo pattern creating a replay window. Let me threat-model.

Planner-only authorization — verify at the right layer

  • The swap action must be enforced at the service layer, not just via a button being hidden in the UI. A member who sends a direct PUT /api/week-plan-slots/{slotId}/recipe must receive a 403.
  • The action sheet still rendering on mobile for members (even without the "Swap" button visible) is not a security issue — but the backend must be the source of truth.
  • Household isolation: the slotId must be verified to belong to the caller's household before any write occurs. IDOR via slot ID enumeration is the obvious attack vector here.

Swap logging and the undo window

  • "Swap is logged" — the swap_log entry should be written in the same transaction as the week_plan_slot update. If logging fails, the swap should fail. An atomic write prevents log/data divergence.
  • The undo toast creates a client-side undo window. If undo calls the same swap endpoint with the original recipe ID, that's two entries in swap_log — which is correct and auditable. No special concern here.
  • If the undo window is time-limited client-side only (toast timeout), there's no server-side enforcement of the time limit — a client could call the swap endpoint at any time to "undo". This is probably acceptable for v1 but worth noting.

Suggestions endpoint — authorization

  • GET /api/recipes?context=swap&slotId={slotId} or equivalent: the slot ID lookup must verify household membership before returning suggestions. Even "read" endpoints can leak data.
  • Do swap suggestions include the recipe's full details, or just the display subset (name, time, effort, tags)? Avoid sending data the UI doesn't need.

The "Swap this meal" action from the action sheet

  • The action sheet is triggered client-side on meal tap. Ensure that the slotId used to open the sheet comes from the server-rendered page data (not a client-generated value). The +page.server.ts load function should provide slot IDs — the client should not construct them.

Questions

  • Is there any rate limiting on the swap endpoint? A planner rapidly swapping the same slot in a loop could generate an unbounded number of swap_log rows.
  • Does the swap log need to record the household ID directly, or is it derivable via slot → week_plan → household? Direct FK is safer for audit queries.
  • Are swap log entries exposed anywhere in the UI (e.g., a history view)? If so, ensure only household planners can read their own household's swap history — not other households'.
## 🔐 Sable — Security Engineer The swap flow is planner-only and involves a write operation to the week plan. The primary risks are authorization bypass and the undo pattern creating a replay window. Let me threat-model. **Planner-only authorization — verify at the right layer** - The swap action must be enforced at the service layer, not just via a button being hidden in the UI. A member who sends a direct `PUT /api/week-plan-slots/{slotId}/recipe` must receive a 403. - The action sheet still rendering on mobile for members (even without the "Swap" button visible) is not a security issue — but the backend must be the source of truth. - Household isolation: the `slotId` must be verified to belong to the caller's household before any write occurs. IDOR via slot ID enumeration is the obvious attack vector here. **Swap logging and the undo window** - "Swap is logged" — the `swap_log` entry should be written in the same transaction as the `week_plan_slot` update. If logging fails, the swap should fail. An atomic write prevents log/data divergence. - The undo toast creates a client-side undo window. If undo calls the same swap endpoint with the original recipe ID, that's two entries in `swap_log` — which is correct and auditable. No special concern here. - If the undo window is time-limited client-side only (toast timeout), there's no server-side enforcement of the time limit — a client could call the swap endpoint at any time to "undo". This is probably acceptable for v1 but worth noting. **Suggestions endpoint — authorization** - `GET /api/recipes?context=swap&slotId={slotId}` or equivalent: the slot ID lookup must verify household membership before returning suggestions. Even "read" endpoints can leak data. - Do swap suggestions include the recipe's full details, or just the display subset (name, time, effort, tags)? Avoid sending data the UI doesn't need. **The "Swap this meal" action from the action sheet** - The action sheet is triggered client-side on meal tap. Ensure that the `slotId` used to open the sheet comes from the server-rendered page data (not a client-generated value). The `+page.server.ts` load function should provide slot IDs — the client should not construct them. **Questions** - Is there any rate limiting on the swap endpoint? A planner rapidly swapping the same slot in a loop could generate an unbounded number of `swap_log` rows. - Does the swap log need to record the household ID directly, or is it derivable via `slot → week_plan → household`? Direct FK is safer for audit queries. - Are swap log entries exposed anywhere in the UI (e.g., a history view)? If so, ensure only household planners can read their own household's swap history — not other households'.
Author
Owner

🎨 Atlas — UI/UX Designer

Speed is the north star for J4 — ≤3 taps — and every design decision must serve that. The two-breakpoint pattern (action sheet on mobile, inline panel on desktop) is the right call. Let me spec out the details the issue leaves open.

Action sheet anatomy — mobile

  • Drag handle: 32px wide × 4px tall, --color-muted bg, centered, --radius-full, 8px top margin inside sheet
  • Sheet itself: --color-surface bg, --radius-lg top corners only (16px), --shadow-raised, slides up from bottom
  • Meal title: Fraunces 15px (display font, weight 300), --color-text, 12px below handle
  • Metadata: 11px, --color-muted, DM Sans
  • 4 action buttons: full-width, left-aligned text, 44px tap target height (WCAG 2.5.5 minimum), 1px --color-border divider between each
    • "Swap" button: --orange-tint bg — note, --orange is not in the current design system. I need to define --orange-tint and --orange-dark tokens before this ships.
    • "Cook now" button: --green-tint bg, --green-dark text — these exist ✓
    • "View recipe" button: --color-surface bg, --color-muted text
    • "Cancel": no bg, --color-muted text, font-weight 400 (visually de-prioritized)

Design system gap — orange tokens

  • The spec references --orange-tint and --orange-dark for the swap action color. These don't exist in the current token set. I'll define them: a warm amber-orange hue that reads as "attention/change" without being alarming. Must pass 4.5:1 contrast for --orange-dark text on --orange-tint bg.

"Replacing" banner

  • --orange-tint bg, full-width inside the suggestions sheet/panel
  • Old meal name: DM Sans 13px, --orange-dark text, text-decoration: line-through
  • "Replacing" label: 11px, --orange-dark, font-weight 500 as eyebrow above the struck name

Suggestions sheet — mobile scroll behavior

  • The suggestions list can be long. The sheet should be a fixed-height overlay (e.g., 70vh) with internal scroll, not a full-screen takeover. The dim background should remain visible.

Undo toast

  • Positioned above the bottom tab bar (mobile) or bottom of the viewport (desktop)
  • --color-surface bg, --shadow-raised, --radius-md
  • "Meal swapped" text (13px) + "Undo" button (text button, --green-dark, 13px/500)
  • Auto-dismiss after 5 seconds with a subtle progress indicator

Questions

  • Is --orange / amber a new brand color we're adding to the system, or should the swap action use an existing signal color differently?
  • The drag handle on the action sheet — should it respond to drag-to-dismiss gestures, or is it purely decorative (tap Cancel to close)?
  • For the desktop inline panel transition — should the suggestions slide in from the right (suggesting forward navigation) or fade in place?
## 🎨 Atlas — UI/UX Designer Speed is the north star for J4 — ≤3 taps — and every design decision must serve that. The two-breakpoint pattern (action sheet on mobile, inline panel on desktop) is the right call. Let me spec out the details the issue leaves open. **Action sheet anatomy — mobile** - Drag handle: 32px wide × 4px tall, `--color-muted` bg, centered, `--radius-full`, 8px top margin inside sheet - Sheet itself: `--color-surface` bg, `--radius-lg` top corners only (16px), `--shadow-raised`, slides up from bottom - Meal title: Fraunces 15px (display font, weight 300), `--color-text`, 12px below handle - Metadata: 11px, `--color-muted`, DM Sans - 4 action buttons: full-width, left-aligned text, 44px tap target height (WCAG 2.5.5 minimum), 1px `--color-border` divider between each - "Swap" button: `--orange-tint` bg — note, `--orange` is not in the current design system. I need to define `--orange-tint` and `--orange-dark` tokens before this ships. - "Cook now" button: `--green-tint` bg, `--green-dark` text — these exist ✓ - "View recipe" button: `--color-surface` bg, `--color-muted` text - "Cancel": no bg, `--color-muted` text, font-weight 400 (visually de-prioritized) **Design system gap — orange tokens** - The spec references `--orange-tint` and `--orange-dark` for the swap action color. These don't exist in the current token set. I'll define them: a warm amber-orange hue that reads as "attention/change" without being alarming. Must pass 4.5:1 contrast for `--orange-dark` text on `--orange-tint` bg. **"Replacing" banner** - `--orange-tint` bg, full-width inside the suggestions sheet/panel - Old meal name: DM Sans 13px, `--orange-dark` text, `text-decoration: line-through` - "Replacing" label: 11px, `--orange-dark`, font-weight 500 as eyebrow above the struck name **Suggestions sheet — mobile scroll behavior** - The suggestions list can be long. The sheet should be a fixed-height overlay (e.g., 70vh) with internal scroll, not a full-screen takeover. The dim background should remain visible. **Undo toast** - Positioned above the bottom tab bar (mobile) or bottom of the viewport (desktop) - `--color-surface` bg, `--shadow-raised`, `--radius-md` - "Meal swapped" text (13px) + "Undo" button (text button, `--green-dark`, 13px/500) - Auto-dismiss after 5 seconds with a subtle progress indicator **Questions** - Is `--orange` / amber a new brand color we're adding to the system, or should the swap action use an existing signal color differently? - The drag handle on the action sheet — should it respond to drag-to-dismiss gestures, or is it purely decorative (tap Cancel to close)? - For the desktop inline panel transition — should the suggestions slide in from the right (suggesting forward navigation) or fade in place?
Author
Owner

Implementation complete — branch feat/issue-29-swap-flow

What was built

6 commits, all tests green (593 passing, 2 pre-existing failures in an unrelated test file).


DayMealCard — new onactionsheet prop

  • When provided and a recipe exists, the whole card renders as a <button> (full tap target, no inline buttons)
  • Empty slots still use onaddrecipe to open the RecipePicker directly
  • Backward compatible — existing behaviour unchanged when prop is absent

sortEasiestFirst utility (week.ts)

  • Sorts recipes by effort ASC (easy → medium → hard) then cookTimeMin ASC
  • Does not mutate the source array

MealActionSheet component

  • Mobile-only bottom sheet triggered by tapping a meal card
  • Drag handle + meal title (15px display) + metadata (11px muted)
  • 4 stacked actions: ↻ Swap this meal (orange-tint), 🍳 Cook now (<a> link, green-tint), 👁 View recipe (<a> link, subtle), Cancel (no bg)
  • Backdrop click and Escape key both close the sheet

SwapSuggestionList component

  • "Replacing" banner: orange-tint bg, old meal name struck through
  • "Swap to (easiest first)" eyebrow label
  • Compact recipe rows: name + meta + Pick button
  • ⚠ "Bereits diese Woche" warning for recipes already in the current week's plan
  • Optional Cancel link (shown on mobile, omitted on desktop)

Planner page wiring

Mobile (3 taps — card → Swap → Pick):

  • Tapping a meal card → MealActionSheet opens
  • Tapping "Swap this meal" → closes action sheet, opens SwapSuggestionList in a BottomSheet (70 vh)
  • Tapping "Pick" → calls existing handleRecipePick (updateSlot) + undo toast
  • Empty slots still open RecipePicker directly (unchanged)

Desktop (2 taps — Swap → Pick):

  • "Gericht tauschen" button in detail panel was already there
  • recipe-picker panel state now checks whether the slot has a recipe:
    • Recipe exists → renders SwapSuggestionList with "Gericht tauschen" header and easiest-first sorting
    • Slot empty → renders RecipePicker as before (unchanged)

Acceptance criteria status

  • Mobile: action sheet → swap bottom sheet → pick (3 taps)
  • Desktop: swap button → inline suggestions → pick (2 taps)
  • Suggestions sorted easiest first
  • "Replacing" banner with struck-through old meal
  • Variety score updates immediately (via existing invalidateAll after slot update)
  • Undo toast instead of confirmation
  • Swap logged (original + replacement recorded) — deferred: no backend swap_log infrastructure exists yet; needs a separate backend ticket
## ✅ Implementation complete — branch `feat/issue-29-swap-flow` ### What was built **6 commits, all tests green (593 passing, 2 pre-existing failures in an unrelated test file).** --- #### `DayMealCard` — new `onactionsheet` prop - When provided and a recipe exists, the whole card renders as a `<button>` (full tap target, no inline buttons) - Empty slots still use `onaddrecipe` to open the RecipePicker directly - Backward compatible — existing behaviour unchanged when prop is absent #### `sortEasiestFirst` utility (`week.ts`) - Sorts recipes by `effort ASC` (`easy → medium → hard`) then `cookTimeMin ASC` - Does not mutate the source array #### `MealActionSheet` component - Mobile-only bottom sheet triggered by tapping a meal card - Drag handle + meal title (15px display) + metadata (11px muted) - 4 stacked actions: **↻ Swap this meal** (orange-tint), **🍳 Cook now** (`<a>` link, green-tint), **👁 View recipe** (`<a>` link, subtle), **Cancel** (no bg) - Backdrop click and Escape key both close the sheet #### `SwapSuggestionList` component - "Replacing" banner: orange-tint bg, old meal name struck through - "Swap to (easiest first)" eyebrow label - Compact recipe rows: name + meta + **Pick** button - ⚠ "Bereits diese Woche" warning for recipes already in the current week's plan - Optional **Cancel** link (shown on mobile, omitted on desktop) #### Planner page wiring **Mobile (3 taps — card → Swap → Pick):** - Tapping a meal card → `MealActionSheet` opens - Tapping "Swap this meal" → closes action sheet, opens `SwapSuggestionList` in a `BottomSheet` (70 vh) - Tapping "Pick" → calls existing `handleRecipePick` (updateSlot) + undo toast - Empty slots still open `RecipePicker` directly (unchanged) **Desktop (2 taps — Swap → Pick):** - "Gericht tauschen" button in detail panel was already there - `recipe-picker` panel state now checks whether the slot has a recipe: - Recipe exists → renders `SwapSuggestionList` with "Gericht tauschen" header and easiest-first sorting - Slot empty → renders `RecipePicker` as before (unchanged) --- ### Acceptance criteria status - [x] Mobile: action sheet → swap bottom sheet → pick (3 taps) - [x] Desktop: swap button → inline suggestions → pick (2 taps) - [x] Suggestions sorted easiest first - [x] "Replacing" banner with struck-through old meal - [x] Variety score updates immediately (via existing `invalidateAll` after slot update) - [x] Undo toast instead of confirmation - [ ] Swap logged (original + replacement recorded) — **deferred**: no backend `swap_log` infrastructure exists yet; needs a separate backend ticket
Sign in to join this conversation.