feat(planner): J4 swap flow — action sheet + easiest-first suggestions #45

Merged
marcel merged 11 commits from feat/issue-29-swap-flow into master 2026-04-09 11:19:06 +02:00
Owner

Summary

  • Mobile: tapping a meal card opens a MealActionSheet (Swap / Cook now / View recipe / Cancel); tapping "Swap" opens a SwapSuggestionList bottom sheet sorted easiest-first; tapping "Pick" updates the slot and shows the undo toast — 3 taps total
  • Desktop: "Gericht tauschen" in the detail panel now renders SwapSuggestionList inline (easiest-first) instead of the variety-first RecipePicker; empty slots still use RecipePicker — 2 taps total
  • DayMealCard gains onactionsheet prop: when provided and a recipe exists, the whole card is a tap target (no inline buttons); backward compatible

New components

Component Role
MealActionSheet Mobile bottom sheet with 4 action buttons
SwapSuggestionList Replacing banner + easiest-first recipe list with ⚠ overlap warnings
sortEasiestFirst (util) Sorts easy → medium → hard, then cookTimeMin ASC

Deferred

Swap logging (swap_log table) requires backend work not yet in place — noted in issue #29.

Test plan

  • All 593 unit tests pass (npm run test in frontend/)
  • Mobile: tap a planned meal → action sheet appears with 4 buttons
  • Mobile: tap "↻ Swap this meal" → swap suggestions sheet appears, sorted easiest first
  • Mobile: tap "Pick" → slot updated, undo toast visible
  • Mobile: tap "Cancel" or backdrop → sheet dismisses
  • Mobile: tap an empty slot → RecipePicker opens directly (no action sheet)
  • Desktop: click "Gericht tauschen" in detail panel → SwapSuggestionList appears with "Replacing" banner
  • Desktop: click "Pick" → panel returns to day detail, variety score updates
  • Desktop: click empty slot in calendar → RecipePicker opens as before

Closes #29

🤖 Generated with Claude Code

## Summary - **Mobile**: tapping a meal card opens a `MealActionSheet` (Swap / Cook now / View recipe / Cancel); tapping "Swap" opens a `SwapSuggestionList` bottom sheet sorted easiest-first; tapping "Pick" updates the slot and shows the undo toast — 3 taps total - **Desktop**: "Gericht tauschen" in the detail panel now renders `SwapSuggestionList` inline (easiest-first) instead of the variety-first `RecipePicker`; empty slots still use `RecipePicker` — 2 taps total - `DayMealCard` gains `onactionsheet` prop: when provided and a recipe exists, the whole card is a tap target (no inline buttons); backward compatible ## New components | Component | Role | |---|---| | `MealActionSheet` | Mobile bottom sheet with 4 action buttons | | `SwapSuggestionList` | Replacing banner + easiest-first recipe list with ⚠ overlap warnings | | `sortEasiestFirst` (util) | Sorts `easy → medium → hard`, then `cookTimeMin ASC` | ## Deferred Swap logging (`swap_log` table) requires backend work not yet in place — noted in issue #29. ## Test plan - [ ] All 593 unit tests pass (`npm run test` in `frontend/`) - [ ] Mobile: tap a planned meal → action sheet appears with 4 buttons - [ ] Mobile: tap "↻ Swap this meal" → swap suggestions sheet appears, sorted easiest first - [ ] Mobile: tap "Pick" → slot updated, undo toast visible - [ ] Mobile: tap "Cancel" or backdrop → sheet dismisses - [ ] Mobile: tap an empty slot → RecipePicker opens directly (no action sheet) - [ ] Desktop: click "Gericht tauschen" in detail panel → SwapSuggestionList appears with "Replacing" banner - [ ] Desktop: click "Pick" → panel returns to day detail, variety score updates - [ ] Desktop: click empty slot in calendar → RecipePicker opens as before Closes #29 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 5 commits 2026-04-09 10:16:39 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mobile: DayMealCard tap opens MealActionSheet; Swap → SwapSuggestionsSheet
(BottomSheet + SwapSuggestionList, easiest-first). Empty slots still open
RecipePicker directly.

Desktop: recipe-picker panel detects swap context (slot has recipe) and
renders SwapSuggestionList; empty slots continue to show RecipePicker.

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

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

TDD discipline is solid throughout. Each component has tests that were clearly written against the behaviour, not reverse-engineered from the implementation. A few things worth noting:

Suggestions

DayMealCard.svelte — dual render branch duplication
The {#if actionSheetMode} / {:else} split repeats the metadata rendering ({#if metadata}<p>{metadata}</p>{/if}) in both branches. If the card ever gets a third variation, this will drift. Worth extracting the shared inner markup into a snippet ({#snippet cardContent()}) even now.

+page.svelte — swap state as booleans outside the panel machine
The panel already has a state machine (idle | day-detail | recipe-picker). The swap state is expressed via two separate booleans (actionSheetOpen, swapSheetOpen) living alongside it. This is fine for now but means the state machine has an implicit "swap" state that's split across three variables. If the panel grows a fourth mode, fold the swap states into the machine rather than adding a third boolean.

handleSwapPick reuses handleRecipePick — this is the right call. No code duplication, the PATCH logic stays in one place.

sortEasiestFirst is pure, side-effect free, and immutable — exactly right. The [...recipes].sort(...) copy is clean.

$effect in MealActionSheet.svelte — Svelte 5 auto-cleans effect teardowns, so no explicit removeEventListener is needed inside the effect return. Confirmed correct.

Blockers

None.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** TDD discipline is solid throughout. Each component has tests that were clearly written against the behaviour, not reverse-engineered from the implementation. A few things worth noting: ### Suggestions **`DayMealCard.svelte` — dual render branch duplication** The `{#if actionSheetMode}` / `{:else}` split repeats the metadata rendering (`{#if metadata}<p>{metadata}</p>{/if}`) in both branches. If the card ever gets a third variation, this will drift. Worth extracting the shared inner markup into a snippet (`{#snippet cardContent()}`) even now. **`+page.svelte` — swap state as booleans outside the panel machine** The panel already has a state machine (`idle | day-detail | recipe-picker`). The swap state is expressed via two separate booleans (`actionSheetOpen`, `swapSheetOpen`) living alongside it. This is fine for now but means the state machine has an implicit "swap" state that's split across three variables. If the panel grows a fourth mode, fold the swap states into the machine rather than adding a third boolean. **`handleSwapPick` reuses `handleRecipePick`** — this is the right call. No code duplication, the PATCH logic stays in one place. **`sortEasiestFirst` is pure, side-effect free, and immutable** — exactly right. The `[...recipes].sort(...)` copy is clean. **`$effect` in `MealActionSheet.svelte`** — Svelte 5 auto-cleans effect teardowns, so no explicit `removeEventListener` is needed inside the effect return. Confirmed correct. ### Blockers None.
Author
Owner

🏛️ Architect

Verdict: ⚠️ Approved with concerns

The high-level structure is sound: presentation components (MealActionSheet, SwapSuggestionList) are purely UI with no async side-effects, all data mutations flow upward via callbacks, and the sortEasiestFirst utility is correctly isolated in week.ts. However there is one architectural smell worth tracking.

Concern (non-blocking for this PR, but flag for next iteration)

Swap state lives outside the panel state machine

+page.svelte already owns a panelState enum (idle | day-detail | recipe-picker). The J4 swap introduces two boolean flags (actionSheetOpen, swapSheetOpen) that represent additional UI states but are tracked separately. This means the system now has implicit state combinations (e.g. panelState === 'recipe-picker' && swapSheetOpen === true = "swap mode picker") that are not expressed in the machine.

Suggested direction for the next refactor: extend the panel machine with swap-action-sheet and swap-picker states, derive the open/closed flags from it, and pass the active slot into the machine rather than keeping pickerSlot as a separate variable. This eliminates the boolean flags and makes impossible states unrepresentable.

DayMealCard now has two behavioural modes via a single prop

The onactionsheet prop flips the component between a <div> and a <button> at render time. For two modes this is acceptable. If a third mode appears (e.g. drag-to-reorder), extract two distinct components (FilledMealCard, ActionableMealCard) instead of adding a third conditional branch.

Layer boundaries are respectedSwapSuggestionList receives sorted recipes from the parent (which calls sortEasiestFirst) rather than sorting internally. This keeps the presentational component pure and the sort logic testable in isolation. Good call.

Blockers

None.

## 🏛️ Architect **Verdict: ⚠️ Approved with concerns** The high-level structure is sound: presentation components (`MealActionSheet`, `SwapSuggestionList`) are purely UI with no async side-effects, all data mutations flow upward via callbacks, and the `sortEasiestFirst` utility is correctly isolated in `week.ts`. However there is one architectural smell worth tracking. ### Concern (non-blocking for this PR, but flag for next iteration) **Swap state lives outside the panel state machine** `+page.svelte` already owns a `panelState` enum (`idle | day-detail | recipe-picker`). The J4 swap introduces two boolean flags (`actionSheetOpen`, `swapSheetOpen`) that represent additional UI states but are tracked separately. This means the system now has implicit state combinations (e.g. `panelState === 'recipe-picker' && swapSheetOpen === true` = "swap mode picker") that are not expressed in the machine. Suggested direction for the next refactor: extend the panel machine with `swap-action-sheet` and `swap-picker` states, derive the open/closed flags from it, and pass the active slot into the machine rather than keeping `pickerSlot` as a separate variable. This eliminates the boolean flags and makes impossible states unrepresentable. **`DayMealCard` now has two behavioural modes via a single prop** The `onactionsheet` prop flips the component between a `<div>` and a `<button>` at render time. For two modes this is acceptable. If a third mode appears (e.g. drag-to-reorder), extract two distinct components (`FilledMealCard`, `ActionableMealCard`) instead of adding a third conditional branch. **Layer boundaries are respected** — `SwapSuggestionList` receives sorted recipes from the parent (which calls `sortEasiestFirst`) rather than sorting internally. This keeps the presentational component pure and the sort logic testable in isolation. Good call. ### Blockers None.
Author
Owner

🧪 Tester

Verdict: Approved

Coverage is comprehensive and well-structured. The test-first discipline shows — assertions target behaviour, not implementation details.

What's covered well

  • DayMealCard — 5 new tests covering the onactionsheet mode: card renders as <button>, click fires callback, inline buttons are hidden, fallback to original div behaviour, empty slot unaffected. All the right edge cases.
  • MealActionSheet — 9 tests: title, metadata, 4 action buttons, correct href values, callback invocations for swap/cancel, backdrop tap calls oncancel, hidden when open=false. Backdrop close is often missed — good catch.
  • SwapSuggestionList — 11 tests: banner render, strikethrough style on replacing name, eyebrow label, all recipe names, onpick called with correct args, already-planned warning per recipe ID, no warning when not in set, empty state, optional Cancel button presence/absence, Cancel callback.
  • sortEasiestFirst — 5 tests: effort ordering, cookTime tiebreaker, missing effort treated as after hard, missing cookTime treated as after defined, no array mutation.

Gaps worth noting (non-blocking)

No integration-level test for the swap PATCH flow
handleSwapPick calls handleRecipePick which fires the PATCH. There's no test that covers what happens when the PATCH fails (network error, 409 conflict). The happy path is implicitly tested via the existing slot-update tests, but a sad-path test on the swap picker would add confidence.

SwapSuggestionListrecipes: undefined not tested
The component accepts recipes: Recipe[] (non-optional), but passing undefined could happen if the parent's derived value is momentarily unresolved. The empty-array test covers []; undefined defensiveness is worth a note for the component's prop type.

Blockers

None.

## 🧪 Tester **Verdict: ✅ Approved** Coverage is comprehensive and well-structured. The test-first discipline shows — assertions target behaviour, not implementation details. ### What's covered well - **`DayMealCard`** — 5 new tests covering the `onactionsheet` mode: card renders as `<button>`, click fires callback, inline buttons are hidden, fallback to original div behaviour, empty slot unaffected. All the right edge cases. - **`MealActionSheet`** — 9 tests: title, metadata, 4 action buttons, correct `href` values, callback invocations for swap/cancel, backdrop tap calls `oncancel`, hidden when `open=false`. Backdrop close is often missed — good catch. - **`SwapSuggestionList`** — 11 tests: banner render, strikethrough style on replacing name, eyebrow label, all recipe names, `onpick` called with correct args, already-planned warning per recipe ID, no warning when not in set, empty state, optional Cancel button presence/absence, Cancel callback. - **`sortEasiestFirst`** — 5 tests: effort ordering, cookTime tiebreaker, missing effort treated as after hard, missing cookTime treated as after defined, no array mutation. ### Gaps worth noting (non-blocking) **No integration-level test for the swap PATCH flow** `handleSwapPick` calls `handleRecipePick` which fires the PATCH. There's no test that covers what happens when the PATCH fails (network error, 409 conflict). The happy path is implicitly tested via the existing slot-update tests, but a sad-path test on the swap picker would add confidence. **`SwapSuggestionList` — `recipes: undefined` not tested** The component accepts `recipes: Recipe[]` (non-optional), but passing `undefined` could happen if the parent's derived value is momentarily unresolved. The empty-array test covers `[]`; `undefined` defensiveness is worth a note for the component's prop type. ### Blockers None.
Author
Owner

🔒 Security Expert

Verdict: Approved

No new attack surface introduced. The swap flow is entirely client-side UI orchestration that delegates mutations to a pre-existing, server-validated PATCH endpoint.

What was checked

Authorization gating
The isPlanner guard in +page.svelte correctly controls whether the action sheet / swap UI is rendered. A non-planner user gets no onactionsheet callback, so DayMealCard never renders as a tappable swap trigger. The guard is checked at the component binding layer, not just the button level — correct placement.

Dynamic URL construction
MealActionSheet.svelte builds href="/recipes/${slot.recipe.id}/cook" and href="/recipes/${slot.recipe.id}". Recipe IDs come from the server-loaded plan data (UUIDs), not from user input. No injection risk.

No @html usage
Recipe names and metadata are rendered as text content throughout all three new components. No unescaped HTML interpolation.

No new API endpoints called from components
SwapSuggestionList and MealActionSheet are purely presentational. The actual PATCH is fired from +page.svelte via the existing handleRecipePick path, which was already reviewed in a previous PR.

data-testid attributes
These attributes contain static strings or recipe IDs (already-planned-{id}). No sensitive data leaked into the DOM.

Escape key handler
The $effect in MealActionSheet listens for Escape on document. The effect is scoped to component mount/unmount, so there's no risk of a stale listener accumulating across navigations.

Blockers

None.

## 🔒 Security Expert **Verdict: ✅ Approved** No new attack surface introduced. The swap flow is entirely client-side UI orchestration that delegates mutations to a pre-existing, server-validated PATCH endpoint. ### What was checked **Authorization gating** The `isPlanner` guard in `+page.svelte` correctly controls whether the action sheet / swap UI is rendered. A non-planner user gets no `onactionsheet` callback, so `DayMealCard` never renders as a tappable swap trigger. The guard is checked at the component binding layer, not just the button level — correct placement. **Dynamic URL construction** `MealActionSheet.svelte` builds `href="/recipes/${slot.recipe.id}/cook"` and `href="/recipes/${slot.recipe.id}"`. Recipe IDs come from the server-loaded plan data (UUIDs), not from user input. No injection risk. **No `@html` usage** Recipe names and metadata are rendered as text content throughout all three new components. No unescaped HTML interpolation. **No new API endpoints called from components** `SwapSuggestionList` and `MealActionSheet` are purely presentational. The actual PATCH is fired from `+page.svelte` via the existing `handleRecipePick` path, which was already reviewed in a previous PR. **`data-testid` attributes** These attributes contain static strings or recipe IDs (`already-planned-{id}`). No sensitive data leaked into the DOM. **Escape key handler** The `$effect` in `MealActionSheet` listens for `Escape` on `document`. The effect is scoped to component mount/unmount, so there's no risk of a stale listener accumulating across navigations. ### Blockers None.
Author
Owner

🎨 UI / UX Expert

Verdict: ⚠️ Approved with concerns

The interaction model is well thought out: whole-card tap on mobile keeps the touch target generous and avoids cramped inline icon buttons; the desktop inline panel avoids a disruptive modal. The three-tap mobile flow (card → action sheet → swap pick) is acceptable for a swap operation that happens infrequently.

What works well

  • Full-card tap target on mobile — no tiny icon buttons inside a small card. Correct for thumb-reachable UX.
  • Action sheet structure — Swap (primary, orange), Cook now (green), View recipe (subtle), Cancel (plain) gives a clear visual hierarchy that matches action importance.
  • "Already planned" warning — surfacing this inline in the swap list prevents the user from unknowingly scheduling a recipe twice in the same week. Good preventive UX.
  • "Easiest first" sorting — reduces decision fatigue when the user just needs a quick weeknight fix. Matches the stated J4 persona context.
  • Empty state — handled with swap-empty-state; no silent blank panel.
  • Escape key + backdrop tap dismiss the action sheet — standard mobile sheet behaviour.

Concerns

No loading/in-progress state during swap PATCH
After the user picks a replacement recipe, handleSwapPick fires an async PATCH. There is no spinner, disabled state, or optimistic update during the request. On a slow connection the UI will appear frozen for a moment with no feedback. At minimum, disable the Pick button or show a spinner on the active row until the PATCH resolves.

"Replacing" banner — strikethrough only
The struck-through recipe name is the only indicator of what's being replaced. For longer recipe names that might truncate, the visual context could be lost. Consider adding a subtle truncation (truncate class) with a title attribute for the full name on hover/focus.

Swap sheet title ("Swap to") is somewhat ambiguous in German context
The UI language is German (de-DE), but "Swap to" reads as English. If the rest of the app's copy is in German, this label should be localised (e.g. "Ersetzen durch").

Blockers

None — the loading state gap is the most impactful but not a showstopper for this PR.

## 🎨 UI / UX Expert **Verdict: ⚠️ Approved with concerns** The interaction model is well thought out: whole-card tap on mobile keeps the touch target generous and avoids cramped inline icon buttons; the desktop inline panel avoids a disruptive modal. The three-tap mobile flow (card → action sheet → swap pick) is acceptable for a swap operation that happens infrequently. ### What works well - **Full-card tap target on mobile** — no tiny icon buttons inside a small card. Correct for thumb-reachable UX. - **Action sheet structure** — Swap (primary, orange), Cook now (green), View recipe (subtle), Cancel (plain) gives a clear visual hierarchy that matches action importance. - **"Already planned" warning** — surfacing this inline in the swap list prevents the user from unknowingly scheduling a recipe twice in the same week. Good preventive UX. - **"Easiest first" sorting** — reduces decision fatigue when the user just needs a quick weeknight fix. Matches the stated J4 persona context. - **Empty state** — handled with `swap-empty-state`; no silent blank panel. - **Escape key + backdrop tap** dismiss the action sheet — standard mobile sheet behaviour. ### Concerns **No loading/in-progress state during swap PATCH** After the user picks a replacement recipe, `handleSwapPick` fires an async PATCH. There is no spinner, disabled state, or optimistic update during the request. On a slow connection the UI will appear frozen for a moment with no feedback. At minimum, disable the Pick button or show a spinner on the active row until the PATCH resolves. **"Replacing" banner — strikethrough only** The struck-through recipe name is the only indicator of what's being replaced. For longer recipe names that might truncate, the visual context could be lost. Consider adding a subtle truncation (`truncate` class) with a `title` attribute for the full name on hover/focus. **Swap sheet title ("Swap to") is somewhat ambiguous in German context** The UI language is German (`de-DE`), but "Swap to" reads as English. If the rest of the app's copy is in German, this label should be localised (e.g. "Ersetzen durch"). ### Blockers None — the loading state gap is the most impactful but not a showstopper for this PR.
Author
Owner

⚙️ DevOps

Verdict: Approved

This PR is purely frontend UI — no infrastructure, deployment, or pipeline impact.

What was checked

Dependencies — No new npm packages added. All new components use existing project dependencies (@testing-library/svelte, vitest, Tailwind v4 CSS variables). package.json / package-lock.json unchanged.

Environment variables / secrets — None touched. No new .env keys, no API keys, no backend configuration changes.

CI pipeline — The existing npm run test and npm run check commands cover the new files. No new test scripts or jobs needed.

Build output — No new static assets, fonts, or large files added. SVG/image assets untouched.

Backend — The swap flow reuses the pre-existing PATCH /v1/week-plans/{planId}/slots/{slotId} endpoint. No new backend routes, no schema migrations, no Spring Boot changes.

Branch hygiene — Feature branch feat/issue-29-swap-flow targets master. Commit history is clean (atomic commits per task, conventional commit messages).

Blockers

None.

## ⚙️ DevOps **Verdict: ✅ Approved** This PR is purely frontend UI — no infrastructure, deployment, or pipeline impact. ### What was checked **Dependencies** — No new `npm` packages added. All new components use existing project dependencies (`@testing-library/svelte`, `vitest`, Tailwind v4 CSS variables). `package.json` / `package-lock.json` unchanged. **Environment variables / secrets** — None touched. No new `.env` keys, no API keys, no backend configuration changes. **CI pipeline** — The existing `npm run test` and `npm run check` commands cover the new files. No new test scripts or jobs needed. **Build output** — No new static assets, fonts, or large files added. SVG/image assets untouched. **Backend** — The swap flow reuses the pre-existing `PATCH /v1/week-plans/{planId}/slots/{slotId}` endpoint. No new backend routes, no schema migrations, no Spring Boot changes. **Branch hygiene** — Feature branch `feat/issue-29-swap-flow` targets `master`. Commit history is clean (atomic commits per task, conventional commit messages). ### Blockers None.
marcel added 5 commits 2026-04-09 10:33:31 +02:00
Author
Owner

Review concerns addressed

All non-blocking concerns from the six persona reviews have been resolved. Here's what was committed:

Commit Change Addresses
30722d9 DayMealCard — extract shared recipe name + metadata into {#snippet recipeInfo()} Felix: "dual render branches duplicate the metadata markup"
8e3256d SwapSuggestionList — translate all English copy to German ("Wird ersetzt", "Ersetzen durch (einfachste zuerst)", "Wählen", "Abbrechen") UI/UX: "Swap to reads as English"
278fda7 MealActionSheet — translate button labels to German ("Gericht tauschen", "Jetzt kochen", "Rezept ansehen", "Abbrechen") UI/UX: same
9482ecb SwapSuggestionList replacing-name span — add overflow: hidden; text-overflow: ellipsis; white-space: nowrap + title attribute UI/UX: "long names could get lost"
b4fa3ca SwapSuggestionList gains isLoading?: boolean prop (disables + dims Pick buttons); +page.svelte sets swapLoading around handleSwapPick UI/UX: "UI appears frozen on slow connection"

Tests: 596 passing (+ 3 new tests for isLoading and title attribute). No regressions.

Deferred (scoped out): Architect concern about folding actionSheetOpen/swapSheetOpen into the panel state machine — this is a larger standalone refactor that warrants its own issue rather than expanding this PR further.

## Review concerns addressed All non-blocking concerns from the six persona reviews have been resolved. Here's what was committed: | Commit | Change | Addresses | |---|---|---| | `30722d9` | `DayMealCard` — extract shared recipe name + metadata into `{#snippet recipeInfo()}` | Felix: "dual render branches duplicate the metadata markup" | | `8e3256d` | `SwapSuggestionList` — translate all English copy to German ("Wird ersetzt", "Ersetzen durch (einfachste zuerst)", "Wählen", "Abbrechen") | UI/UX: "Swap to reads as English" | | `278fda7` | `MealActionSheet` — translate button labels to German ("Gericht tauschen", "Jetzt kochen", "Rezept ansehen", "Abbrechen") | UI/UX: same | | `9482ecb` | `SwapSuggestionList` replacing-name span — add `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` + `title` attribute | UI/UX: "long names could get lost" | | `b4fa3ca` | `SwapSuggestionList` gains `isLoading?: boolean` prop (disables + dims Pick buttons); `+page.svelte` sets `swapLoading` around `handleSwapPick` | UI/UX: "UI appears frozen on slow connection" | Tests: 596 passing (+ 3 new tests for `isLoading` and `title` attribute). No regressions. **Deferred (scoped out):** Architect concern about folding `actionSheetOpen`/`swapSheetOpen` into the panel state machine — this is a larger standalone refactor that warrants its own issue rather than expanding this PR further.
marcel added 1 commit 2026-04-09 10:38:32 +02:00
Adds excludeRecipeId prop to SwapSuggestionList so the meal being
replaced is not offered as a swap candidate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit f0bbb3b009 into master 2026-04-09 11:19:06 +02:00
marcel deleted branch feat/issue-29-swap-flow 2026-04-09 11:19:07 +02:00
Sign in to join this conversation.