feat(shopping-list) Fill a shopping list from a meal plan (spec 004) #64

Open
araemer wants to merge 7 commits from 004-fill-list-from-meal-plan into stage
Owner

Implements spec 004 (fill a shopping list from a meal plan) in recipe-backend.

  • Plan: docs/plans/004-fill-list-from-meal-plan.md
  • Spec: ../recipe-platform/docs/features/004-fill-list-from-meal-plan.md
  • Contract pin: contract.ref = cf101fc4b28dbd3665fd296324b1a8be2316e60a (tip of the unmerged platform branch 004-fill-list-from-meal-plan-contract)

What was implemented, by plan step

  • 0 Contract pinned to cf101fc.
  • 1–2 Migration 1780012800014-AddMealPlanReasonsAndItemContentEdit: the meal_plan enum value; the reason's meal plan columns with FKs ON DELETE SET NULL, the check constraint and two indexes; content_edited_at / content_edited_by on items. Entities updated to match.
  • 3 isValidIsoDate, itemUnitsMatch, and the pure src/utils/shoppingListFillUtils.ts: scaling, normalization, rounding, spelling, grouping into connected components plus split, lines, keys, token (fp1. + sha256), and the write plan. Unit-tested without mocks, one test per spec rule and acceptance criterion.
  • 4 SOURCE_MEAL_PLAN_* codes added to the 403/404 code unions.
  • 5 MealPlanPermissionHelper (pure, extracted from MealPlanHandler), MealPlanTransactionHelper, meal plan and recipe repositories in ShoppingListTransactionRepositories, and the new MealPlanRepository locks and reads.
  • 6 MealPlanHandler.moveDish, which locks plan, then days, then the dish. Day create takes the plan lock first; day saves reject dish ids of other days. New MealPlanDishRestResource (POST /meal-plan-dish/:dishId/move).
  • 7 ShoppingListItemRepository: an edit marker set by a CASE … IS DISTINCT FROM in the edit statement, a coverage query using the dish identity rule, the fill's conditional writes, reason inserts and deletes, and deleteMealPlanItemsOnList. Plus RecipeRepository.lockForKeyShare.
  • 8 editItem sets the edit marker; removeTicked now takes the list lock (resolves recipe-backend#27); new removeMealPlanItems.
  • 9 ShoppingListFillHandler: preview takes no lock. fill runs in one transaction in the order list row → KEY SHARE on plan, dishes and recipes → items (ascending id) → reasons → change row last.
  • 10 The mealPlan reason block in ShoppingListItemDtoMapper, and a new ShoppingListFillReviewMapper.
  • 11 Service validators and the routes POST /shopping-list/:id/fill-preview, /fill and /remove-meal-plan-items; factories wired.
  • 12 Bruno: fillPreview, fill, removeMealPlanItems, and the new MealPlanDishRestResource/move. Folder seqs renumbered.
  • 13 Integration tests: new ShoppingListFill.integration.test.ts and shared mealPlanFixtureHelper.ts. The change-time file is extended: remove-ticked moved to the list-locked paths; fill and remove-meal-plan-items were added to change-row-last, recorded/not-recorded and the concurrency matrix.
  • 15 docs/howto-locking.md and docs/architecture.md updated.

Deviations (recorded in the plan's Deviations section)

  • Step 6.2: the day save checks dish ids inside the locked update. The check now runs as a callback in MealPlanRepository.updateDay(), after the day row is locked and its version checked. The plan had it in the handler before updateDay(), using a new findDishIdsOfDay, which was not added. In that position, saving the source day with its pre-move version got 400 for the moved dish id instead of the 409 that the contract and the move ADR promise. The plan's own comment there expected 409. Found by integration test 8.

Non-obvious judgment calls

  1. A dish whose recipe has no usable ingredients never counts as covered, and it does not trigger the ticked-items question either (tickedCoverage). This follows the plan's rule that such a dish "can never be covered".
  2. Half-up rounding gets a 1e-9 nudge (an unexported constant), so 9.95 g rounds to 10 g even though 9.95 is stored in binary as 9.9499…
  3. The write plan recomputes the add-onto result with the same function as the review. With removeNoLongerPlanned it therefore gives exactly the reviewed result; without it, the gone shares stay included.
  4. moveDishToDay and incrementDayVersion are raw SQL. They set update_date explicitly. The dish's own version is not bumped; the ADR allows that either way, since day saves do not check dish versions.
  5. In the coverage and dish-range queries, column names (meal_plan_day_id, recipe_id, meal_plan_id) appear in raw where clauses. This avoids relying on TypeORM's relation-name resolution in string conditions.
  6. The new routes sit after remove-ticked, before reorder, in ShoppingListRestResource.
  7. Unused stubs for the meal plan and recipe repositories were added to the existing ShoppingList handler tests, for the extended transaction repositories.
  8. A docs fix outside the plan: docs/architecture.md §5 said meal_plan_day_dish → recipe does not cascade. The migration has ON DELETE CASCADE, which matters for reasons: deleting a recipe deletes its dishes. I corrected it.
  9. Per the plan (judgment call 8), the reasons of one fill are ordered by reason id, not plan order. The integration test compares them order-independently.

Review fixes (commit 619e941)

  • Inline German texts moved into tested builders in src/utils/fillReviewUtils.ts: line checkbox label, "kommt zu „…“ auf der Liste → …", "Du hast … auf … geändert." (fallback "–"), the edited-item radio legend "Bearbeiteten Eintrag „…“", the "Dazu addieren" hint "→ …", the join toggle label and text, skipped dish "… – auf …", "…: keine Zutaten", gone dish "für … (…)", and the no-longer-planned entry details (quantity, "(von dir geändert)", outcome). The create-list dialog's texts in FillReviewInputs became module constants.
  • Recipe link back state kept and recorded as a plan deviation.
  • Picker blocking during a fill (D3): SelectField gains an optional native disabled, which the list picker now uses (blocked for keyboard too). The date pickers already used native disabled.
  • moveDish: comment stating the accepted residual risk (a note save firing during the move hits the 409 dialog; practically unreachable).
  • Verification after the fixes: build ✅, tests ✅ (43 files, 531 tests), lint ✅ (0 errors, the 2 pre-existing warnings). /verify-ui is arranged by the platform session.

Verification

  • npm run build: passes.

  • npm test: 62 files, 801 tests, all pass.

  • npm run test:integration: 81 tests, all pass in three runs in a row. This includes the deterministic lock tests, change-row-last for fill and remove-meal-plan-items, and 20 rounds of each concurrent pair. CI does not run these tests.

  • npm run migration on the local dev DB: applied. Schema checked (columns, FKs SET NULL, check constraint, indexes, enum {manual,meal_plan}). Then down(), then up() again: both work.

  • A manual API scenario against the running backend passed in full, with two throwaway users: A owns the plan and the list; B has READONLY on the plan and READ_WRITE on the list. It covered:

    • preview, fill, and reasons with day, servings and share;
    • skipped dishes on a second preview; tickedCoverage; clearTicked;
    • the move keeping the dish id, with the reason showing the new day;
    • an old day version giving 409, and a foreign dish id giving 400;
    • noLongerPlanned and its removal;
    • a malformed token giving 400 and a stale token giving 409;
    • remove-meal-plan-items keeping the hand-added item, and the list coming first in the overview;
    • a READONLY move giving 403.

    The test data was removed afterwards. Bruno itself was not used (its environment credentials are secret); the same requests were sent with curl and locally signed tokens.

  • contract_pin CI check: expected red until the platform contract PR is merged and the check is re-run. Judge this PR by its build and test checks.

🤖 Generated with Claude Code

Implements spec 004 (fill a shopping list from a meal plan) in recipe-backend. - Plan: `docs/plans/004-fill-list-from-meal-plan.md` - Spec: `../recipe-platform/docs/features/004-fill-list-from-meal-plan.md` - Contract pin: `contract.ref` = `cf101fc4b28dbd3665fd296324b1a8be2316e60a` (tip of the unmerged platform branch `004-fill-list-from-meal-plan-contract`) ## What was implemented, by plan step - **0** Contract pinned to cf101fc. - **1–2** Migration `1780012800014-AddMealPlanReasonsAndItemContentEdit`: the `meal_plan` enum value; the reason's meal plan columns with FKs `ON DELETE SET NULL`, the check constraint and two indexes; `content_edited_at` / `content_edited_by` on items. Entities updated to match. - **3** `isValidIsoDate`, `itemUnitsMatch`, and the pure `src/utils/shoppingListFillUtils.ts`: scaling, normalization, rounding, spelling, grouping into connected components plus split, lines, keys, token (`fp1.` + sha256), and the write plan. Unit-tested without mocks, one test per spec rule and acceptance criterion. - **4** `SOURCE_MEAL_PLAN_*` codes added to the 403/404 code unions. - **5** `MealPlanPermissionHelper` (pure, extracted from `MealPlanHandler`), `MealPlanTransactionHelper`, meal plan and recipe repositories in `ShoppingListTransactionRepositories`, and the new `MealPlanRepository` locks and reads. - **6** `MealPlanHandler.moveDish`, which locks plan, then days, then the dish. Day create takes the plan lock first; day saves reject dish ids of other days. New `MealPlanDishRestResource` (`POST /meal-plan-dish/:dishId/move`). - **7** `ShoppingListItemRepository`: an edit marker set by a `CASE … IS DISTINCT FROM` in the edit statement, a coverage query using the dish identity rule, the fill's conditional writes, reason inserts and deletes, and `deleteMealPlanItemsOnList`. Plus `RecipeRepository.lockForKeyShare`. - **8** `editItem` sets the edit marker; `removeTicked` now takes the list lock (resolves recipe-backend#27); new `removeMealPlanItems`. - **9** `ShoppingListFillHandler`: `preview` takes no lock. `fill` runs in one transaction in the order list row → KEY SHARE on plan, dishes and recipes → items (ascending id) → reasons → change row last. - **10** The `mealPlan` reason block in `ShoppingListItemDtoMapper`, and a new `ShoppingListFillReviewMapper`. - **11** Service validators and the routes `POST /shopping-list/:id/fill-preview`, `/fill` and `/remove-meal-plan-items`; factories wired. - **12** Bruno: `fillPreview`, `fill`, `removeMealPlanItems`, and the new `MealPlanDishRestResource/move`. Folder seqs renumbered. - **13** Integration tests: new `ShoppingListFill.integration.test.ts` and shared `mealPlanFixtureHelper.ts`. The change-time file is extended: remove-ticked moved to the list-locked paths; fill and remove-meal-plan-items were added to change-row-last, recorded/not-recorded and the concurrency matrix. - **15** `docs/howto-locking.md` and `docs/architecture.md` updated. ## Deviations (recorded in the plan's Deviations section) - **Step 6.2: the day save checks dish ids inside the locked update.** The check now runs as a callback in `MealPlanRepository.updateDay()`, after the day row is locked and its version checked. The plan had it in the handler before `updateDay()`, using a new `findDishIdsOfDay`, which was not added. In that position, saving the source day with its pre-move version got 400 for the moved dish id instead of the 409 that the contract and the move ADR promise. The plan's own comment there expected 409. Found by integration test 8. ## Non-obvious judgment calls 1. **A dish whose recipe has no usable ingredients never counts as covered**, and it does not trigger the ticked-items question either (`tickedCoverage`). This follows the plan's rule that such a dish "can never be covered". 2. **Half-up rounding gets a 1e-9 nudge** (an unexported constant), so 9.95 g rounds to 10 g even though 9.95 is stored in binary as 9.9499… 3. **The write plan recomputes the add-onto result with the same function as the review.** With `removeNoLongerPlanned` it therefore gives exactly the reviewed result; without it, the gone shares stay included. 4. **`moveDishToDay` and `incrementDayVersion` are raw SQL.** They set `update_date` explicitly. The dish's own `version` is not bumped; the ADR allows that either way, since day saves do not check dish versions. 5. **In the coverage and dish-range queries, column names (`meal_plan_day_id`, `recipe_id`, `meal_plan_id`) appear in raw where clauses.** This avoids relying on TypeORM's relation-name resolution in string conditions. 6. **The new routes sit after `remove-ticked`, before `reorder`**, in `ShoppingListRestResource`. 7. **Unused stubs for the meal plan and recipe repositories** were added to the existing ShoppingList handler tests, for the extended transaction repositories. 8. **A docs fix outside the plan:** `docs/architecture.md` §5 said `meal_plan_day_dish → recipe` does not cascade. The migration has `ON DELETE CASCADE`, which matters for reasons: deleting a recipe deletes its dishes. I corrected it. 9. **Per the plan (judgment call 8), the reasons of one fill are ordered by reason id, not plan order.** The integration test compares them order-independently. ## Review fixes (commit 619e941) - **Inline German texts** moved into tested builders in `src/utils/fillReviewUtils.ts`: line checkbox label, "kommt zu „…“ auf der Liste → …", "Du hast … auf … geändert." (fallback "–"), the edited-item radio legend "Bearbeiteten Eintrag „…“", the "Dazu addieren" hint "→ …", the join toggle label and text, skipped dish "… – auf …", "…: keine Zutaten", gone dish "für … (…)", and the no-longer-planned entry details (quantity, "(von dir geändert)", outcome). The create-list dialog's texts in `FillReviewInputs` became module constants. - **Recipe link back state** kept and recorded as a plan deviation. - **Picker blocking during a fill (D3)**: `SelectField` gains an optional native `disabled`, which the list picker now uses (blocked for keyboard too). The date pickers already used native `disabled`. - **`moveDish`**: comment stating the accepted residual risk (a note save firing during the move hits the 409 dialog; practically unreachable). - Verification after the fixes: build ✅, tests ✅ (43 files, 531 tests), lint ✅ (0 errors, the 2 pre-existing warnings). `/verify-ui` is arranged by the platform session. ## Verification - `npm run build`: passes. - `npm test`: 62 files, 801 tests, all pass. - **`npm run test:integration`: 81 tests, all pass in three runs in a row.** This includes the deterministic lock tests, change-row-last for fill and remove-meal-plan-items, and 20 rounds of each concurrent pair. CI does not run these tests. - `npm run migration` on the local dev DB: applied. Schema checked (columns, FKs `SET NULL`, check constraint, indexes, enum `{manual,meal_plan}`). Then `down()`, then `up()` again: both work. - **A manual API scenario against the running backend passed in full**, with two throwaway users: A owns the plan and the list; B has READONLY on the plan and READ_WRITE on the list. It covered: - preview, fill, and reasons with day, servings and share; - skipped dishes on a second preview; `tickedCoverage`; `clearTicked`; - the move keeping the dish id, with the reason showing the new day; - an old day version giving 409, and a foreign dish id giving 400; - `noLongerPlanned` and its removal; - a malformed token giving 400 and a stale token giving 409; - remove-meal-plan-items keeping the hand-added item, and the list coming first in the overview; - a READONLY move giving 403. The test data was removed afterwards. Bruno itself was not used (its environment credentials are secret); the same requests were sent with curl and locally signed tokens. - `contract_pin` CI check: **expected red** until the platform contract PR is merged and the check is re-run. Judge this PR by its build and test checks. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
docs(adr) Add the fill-from-meal-plan ADR for spec 004
All checks were successful
/ test (pull_request) Successful in 1m5s
/ contract_pin (pull_request) Successful in 3s
4ad8a14ecf
Approved at GATE B of spec 004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(plan) Add the implementation plan for spec 004
Some checks failed
/ contract_pin (pull_request) Has been cancelled
/ test (pull_request) Has been cancelled
8301803ab3
Approved at GATE C of spec 004 (on the user's delegation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chore(contract) Pin the contract for spec 004
Some checks failed
/ test (pull_request) Failing after 46s
/ contract_pin (pull_request) Failing after 3s
535b392e27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(shopping-list) #004 Add abort support, fill range utils, meal-plan reasons and fill preview models
Some checks failed
/ test (pull_request) Successful in 1m10s
/ contract_pin (pull_request) Failing after 2s
0526c3ec8c
- apiClient takes an optional abort signal on get/post/delete; isAbortError
- routes for the fill review page
- formatPlanDay, isIsoDate and the fill range rules
- reason union with the meal-plan variant, reason lines with a recipe link
- fill preview models and mapper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(shopping-list) Add fill and move resources, fill error messages and removing meal-plan items
Some checks failed
/ test (pull_request) Successful in 1m5s
/ contract_pin (pull_request) Failing after 2s
16bfd7c48e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(shopping-list) Add the fill review page and move dishes through the move route
Some checks failed
/ test (pull_request) Successful in 1m7s
/ contract_pin (pull_request) Failing after 2s
af24921a97
- review page with range and list pickers, decisions, lines and skipped dishes
- useFillReview: debounced, abortable previews; fill with 409 handling
- RadioGroup basic
- meal plan header entry point
- MealPlanDayProvider.moveDish via POST /meal-plan-dish/:dishId/move, waiting for saves in flight
- move popup: delayed busy state and inline error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
araemer changed title from WIP: feat(shopping-list) Fill a shopping list from a meal plan (spec 004) to feat(shopping-list) Fill a shopping list from a meal plan (spec 004) 2026-10-24 10:33:20 +00:00
fix(shopping-list) Move fill review texts into builders and block the list picker during a fill
Some checks failed
/ test (pull_request) Successful in 1m7s
/ contract_pin (pull_request) Failing after 3s
619e94193d
- inline German texts of the review components into tested builders in fillReviewUtils.ts
- SelectField gains native disabled; the list picker uses it while a fill runs
- document the accepted residual risk of a note save during a dish move
- record the recipe link's back state as a plan deviation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some checks failed
/ test (pull_request) Successful in 1m7s
/ contract_pin (pull_request) Failing after 3s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin 004-fill-list-from-meal-plan:004-fill-list-from-meal-plan
git switch 004-fill-list-from-meal-plan

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch stage
git merge --no-ff 004-fill-list-from-meal-plan
git switch 004-fill-list-from-meal-plan
git rebase stage
git switch stage
git merge --ff-only 004-fill-list-from-meal-plan
git switch 004-fill-list-from-meal-plan
git rebase stage
git switch stage
git merge --no-ff 004-fill-list-from-meal-plan
git switch stage
git merge --squash 004-fill-list-from-meal-plan
git switch stage
git merge --ff-only 004-fill-list-from-meal-plan
git switch stage
git merge 004-fill-list-from-meal-plan
git push origin stage
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
araemer/recipe-app!64
No description provided.