feat(shopping-list) Shopping lists (spec 001) #47

Merged
araemer merged 26 commits from 001-shopping-lists into stage 2026-10-03 11:43:20 +00:00
Owner

Shopping lists for recipe-app (spec 001, epic E01 spec A).

Approved at GATE B

  • docs/adr/2026-10-02-shopping-list-refresh.md

Approved at GATE C

  • docs/plans/001-shopping-lists.md. Step 2 replaces the list of allowed contract enums with a rule by kind (new ADR, partially superseding 2026-09-20-contract-enums-in-models.md). Lint enforcement follows separately in recipe-platform#25.

What's in it

  • ApiError.code (step 1): a typed, always-present ErrorCode, read from the error body or derived from the HTTP status (utils/apiErrorUtils.ts, tested). The body is now read once as text and then parsed, which fixes the double-read when an error body isn't JSON.
  • Contract enums by kind (step 2): new ADR docs/adr/2026-10-02-contract-enums-by-kind.md and the matching CLAUDE.md rule.
  • Data layer (steps 3–6): models, response-only mappers, ShoppingListRestResource (returns only models), fetchShareableUsers (GET /user/list-shareable), and pure helpers for item input, display texts, error classification and optimistic state changes, each with tests. The name helpers and UserAvatar accept a compact user.
  • Routes (step 7): /shopping-list (entry, redirects with replace), /shopping-list/overview (never redirects), /shopping-list/:id; menu entry "Einkaufslisten".
  • ShoppingListProvider (step 8): loads and refreshes the list in place per the refresh ADR (timer after each response, visibility, pull to refresh, settle-refresh after every mutation; skipped while dragging or mutating; stale responses discarded). Tick, remove, move, reorder and remove-ticked are optimistic.
  • Basics (step 9): PullToRefresh (no library) and NameInputDialog.
  • UI (step 10): overview, list page, item rows (checkbox, tap panel with "why is this here", desktop hover tooltip, drag handle), add form with duplicate prompt, edit / move / share dialogs, done section. All texts are German and chosen from the error code.
  • Docs (step 11): docs/architecture.md updated in the same commits.

Deviations from the plan

  • D8 revised after review: "Entfernen" on an item and "Erledigte entfernen" now ask for confirmation. This is its own commit so it can be reverted on its own.
  • 10.10 revised after review: a full-access user's own share row, marked "(Du)", has the same controls as every other share, consistent with the meal plan share dialog. Removing it ends their access (overview with the access-lost notice), and losing FULL closes the share dialog. Note: the spec lists "leaving a list that was shared with you" as out of scope; this is the narrower case of a FULL user removing their own share, which the contract allows.
  • The duplicate prompt lives in ShoppingListAddItemForm, not the page: the form owns the typed draft that "Separat hinzufügen" needs and the fields every choice clears.
  • The duplicate dialog draws its own buttons, stacked full-width at every size, instead of BasicDialog's footer, because three long labels don't fit side by side.
  • ShoppingListItemRow takes no variant prop; it uses item.ticked, and only open rows get a drag handle.
  • renameList(name, version) takes the version from when the rename dialog opened, instead of reading the live version (plan step 8). Without that, polling reset an open rename dialog and a concurrent rename never got its 409 (fixed after review).
  • itemToInputValues and EMPTY_ITEM_INPUT_VALUES live in utils/shoppingListItemInputUtils.ts with tests. shoppingListMessages.ts stays component-side as planned, but gets a test (components/shopping-list/tests/) and is listed in CLAUDE.md's Tier 1 test scope, because it decides when an error is shown at all.
  • Plan step 10's blanket 44 px tap-target rule revised after review: text buttons, inputs and selects keep the app's defaults (~40–42 px); only small controls (icon buttons in rows, checkbox, drag handle, fold toggle, remove link) get 44 px, via IconButton's touchTarget and TOUCH_TARGET_CLASS / TOUCH_TARGET_HEIGHT_CLASS. New basics Checkbox and CollapsibleSection (review).
  • Extra shared components because each is used in two or more places: ShoppingListItemFields, ShoppingListLoadError, ShoppingListOwnerLabel, ShoppingListBadge. New layout constant DRAG_ACTIVATION_DISTANCE_PX.

Judgment calls

  • The old enums ADR predates the template's status comments, so its Superseded by: line sits directly under its **Status:** line.
  • The add form uses native <button type=…> elements, because the shared Button renders no type and "Schließen" would otherwise submit the form.
  • The editor opens through an effect that runs after the list has refreshed, so "Bestehenden bearbeiten" on a ticked item shows the reconciled item. After a 409 the edit dialog re-seeds its draft the same way.
  • The item removal confirmation is rendered by the page, not the row, because a fixed dialog inside a dnd-kit-transformed row would be positioned relative to the row.
  • The desktop tooltip relies on Tailwind 4's group-hover only applying on hover-capable devices, so phones never get a stuck tooltip.

Verification

  • npm run build, npm run lint (only the 2 existing warnings in MealPlanDayProvider) and npm run test (368 tests) pass.
  • /verify-ui against a local backend that implements spec 001, as user and pro in parallel sessions, on all five viewports, plus a touch-enabled phone context: entry / overview / default flows, add and validation, duplicate prompt (all three choices, including on a ticked item), tick / untick / done section, reorder by keyboard, mouse and touch, tap panel and hover tooltip, edit conflict, move, sharing at both levels, live sync (about 1–2 s), deletion and share removal while the other user has the list open, pull to refresh, and the two review changes above. All test data was removed afterwards.

Fixed after review-implementation

  • The rename dialog on the list page keeps the user's typing across polling and gets the 409 on a concurrent rename (see Deviations).
  • The page shows its error popup only while the list is READY, so "Dieser Eintrag existiert nicht mehr" can't flash before leaving a deleted or inaccessible list (plan 10.4 / 10.7).
  • Tests for the error-message selection and the form value helpers.
  • "(Du)" stays visible next to a truncated name on phones; the overview prompt uses the plan's wording; JSDoc on two children props.
  • UI after manual testing: the overview and list use the centred ContentBody column like the recipe pages, "Eintrag hinzufügen" is primary, and "Erledigte entfernen" is a small link-style action.
    Each fix was checked in the app; the share row on phone portrait, the confirmations on all five viewports.

Follow-ups (not in this PR)

  • Move the meal plan create and rename dialogs onto NameInputDialog (plan D8).
  • The meal plan share dialog should use GET /user/list-shareable (recipe-app#46).
  • PageContainer uses w-screen (100vw), which adds a few pixels of horizontal overflow next to a desktop scrollbar on long pages. Affects every page; not changed here.

🤖 Generated with Claude Code

Shopping lists for recipe-app (spec 001, epic E01 spec A). - Spec: https://git.raemer.net/araemer/recipe-platform/src/branch/main/docs/features/001-shopping-lists.md - Plan: [`docs/plans/001-shopping-lists.md`](https://git.raemer.net/araemer/recipe-app/src/branch/001-shopping-lists/docs/plans/001-shopping-lists.md) ## Approved at GATE B - `docs/adr/2026-10-02-shopping-list-refresh.md` ## Approved at GATE C - `docs/plans/001-shopping-lists.md`. Step 2 replaces the list of allowed contract enums with a rule by kind (new ADR, partially superseding `2026-09-20-contract-enums-in-models.md`). Lint enforcement follows separately in recipe-platform#25. ## What's in it - **`ApiError.code`** (step 1): a typed, always-present `ErrorCode`, read from the error body or derived from the HTTP status (`utils/apiErrorUtils.ts`, tested). The body is now read once as text and then parsed, which fixes the double-read when an error body isn't JSON. - **Contract enums by kind** (step 2): new ADR `docs/adr/2026-10-02-contract-enums-by-kind.md` and the matching `CLAUDE.md` rule. - **Data layer** (steps 3–6): models, response-only mappers, `ShoppingListRestResource` (returns only models), `fetchShareableUsers` (`GET /user/list-shareable`), and pure helpers for item input, display texts, error classification and optimistic state changes, each with tests. The name helpers and `UserAvatar` accept a compact user. - **Routes** (step 7): `/shopping-list` (entry, redirects with `replace`), `/shopping-list/overview` (never redirects), `/shopping-list/:id`; menu entry "Einkaufslisten". - **`ShoppingListProvider`** (step 8): loads and refreshes the list in place per the refresh ADR (timer after each response, visibility, pull to refresh, settle-refresh after every mutation; skipped while dragging or mutating; stale responses discarded). Tick, remove, move, reorder and remove-ticked are optimistic. - **Basics** (step 9): `PullToRefresh` (no library) and `NameInputDialog`. - **UI** (step 10): overview, list page, item rows (checkbox, tap panel with "why is this here", desktop hover tooltip, drag handle), add form with duplicate prompt, edit / move / share dialogs, done section. All texts are German and chosen from the error code. - **Docs** (step 11): `docs/architecture.md` updated in the same commits. ## Deviations from the plan - **D8 revised after review:** "Entfernen" on an item and "Erledigte entfernen" now ask for confirmation. This is its own commit so it can be reverted on its own. - **10.10 revised after review:** a full-access user's own share row, marked "(Du)", has the same controls as every other share, consistent with the meal plan share dialog. Removing it ends their access (overview with the access-lost notice), and losing FULL closes the share dialog. Note: the spec lists "leaving a list that was shared with you" as out of scope; this is the narrower case of a FULL user removing their own share, which the contract allows. - The duplicate prompt lives in `ShoppingListAddItemForm`, not the page: the form owns the typed draft that "Separat hinzufügen" needs and the fields every choice clears. - The duplicate dialog draws its own buttons, stacked full-width at every size, instead of `BasicDialog`'s footer, because three long labels don't fit side by side. - `ShoppingListItemRow` takes no `variant` prop; it uses `item.ticked`, and only open rows get a drag handle. - `renameList(name, version)` takes the version from when the rename dialog opened, instead of reading the live version (plan step 8). Without that, polling reset an open rename dialog and a concurrent rename never got its 409 (fixed after review). - `itemToInputValues` and `EMPTY_ITEM_INPUT_VALUES` live in `utils/shoppingListItemInputUtils.ts` with tests. `shoppingListMessages.ts` stays component-side as planned, but gets a test (`components/shopping-list/tests/`) and is listed in CLAUDE.md's Tier 1 test scope, because it decides when an error is shown at all. - **Plan step 10's blanket 44 px tap-target rule revised after review:** text buttons, inputs and selects keep the app's defaults (~40–42 px); only small controls (icon buttons in rows, checkbox, drag handle, fold toggle, remove link) get 44 px, via `IconButton`'s `touchTarget` and `TOUCH_TARGET_CLASS` / `TOUCH_TARGET_HEIGHT_CLASS`. New basics `Checkbox` and `CollapsibleSection` (review). - Extra shared components because each is used in two or more places: `ShoppingListItemFields`, `ShoppingListLoadError`, `ShoppingListOwnerLabel`, `ShoppingListBadge`. New layout constant `DRAG_ACTIVATION_DISTANCE_PX`. ## Judgment calls - The old enums ADR predates the template's status comments, so its `Superseded by:` line sits directly under its `**Status:**` line. - The add form uses native `<button type=…>` elements, because the shared `Button` renders no `type` and "Schließen" would otherwise submit the form. - The editor opens through an effect that runs after the list has refreshed, so "Bestehenden bearbeiten" on a ticked item shows the reconciled item. After a 409 the edit dialog re-seeds its draft the same way. - The item removal confirmation is rendered by the page, not the row, because a fixed dialog inside a dnd-kit-transformed row would be positioned relative to the row. - The desktop tooltip relies on Tailwind 4's `group-hover` only applying on hover-capable devices, so phones never get a stuck tooltip. ## Verification - `npm run build`, `npm run lint` (only the 2 existing warnings in `MealPlanDayProvider`) and `npm run test` (368 tests) pass. - `/verify-ui` against a local backend that implements spec 001, as `user` and `pro` in parallel sessions, on all five viewports, plus a touch-enabled phone context: entry / overview / default flows, add and validation, duplicate prompt (all three choices, including on a ticked item), tick / untick / done section, reorder by keyboard, mouse and touch, tap panel and hover tooltip, edit conflict, move, sharing at both levels, live sync (about 1–2 s), deletion and share removal while the other user has the list open, pull to refresh, and the two review changes above. All test data was removed afterwards. ## Fixed after `review-implementation` - The rename dialog on the list page keeps the user's typing across polling and gets the 409 on a concurrent rename (see Deviations). - The page shows its error popup only while the list is `READY`, so "Dieser Eintrag existiert nicht mehr" can't flash before leaving a deleted or inaccessible list (plan 10.4 / 10.7). - Tests for the error-message selection and the form value helpers. - "(Du)" stays visible next to a truncated name on phones; the overview prompt uses the plan's wording; JSDoc on two `children` props. - UI after manual testing: the overview and list use the centred `ContentBody` column like the recipe pages, "Eintrag hinzufügen" is primary, and "Erledigte entfernen" is a small link-style action. Each fix was checked in the app; the share row on phone portrait, the confirmations on all five viewports. ## Follow-ups (not in this PR) - Move the meal plan create and rename dialogs onto `NameInputDialog` (plan D8). - The meal plan share dialog should use `GET /user/list-shareable` (recipe-app#46). - `PageContainer` uses `w-screen` (100vw), which adds a few pixels of horizontal overflow next to a desktop scrollbar on long pages. Affects every page; not changed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
docs(adr) Add shopping list refresh ADR for spec 001
All checks were successful
/ test (pull_request) Successful in 1m9s
a5661d6382
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(plan) Add shopping list implementation plan (spec 001)
All checks were successful
/ test (pull_request) Successful in 1m2s
eb28899d1b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApiError now carries code: ErrorCode, read from the error body or derived
from the HTTP status when the body has no known code. The error body is read
once as text and then parsed, fixing the double-consume when it is not JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New ADR partially supersedes 2026-09-20-contract-enums-in-models (only its
explicit list of 9 enums). CLAUDE.md states the rule by kind and points
server-refreshed contexts at the shopping list refresh ADR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Response-only mappers for lists, items, shares and compact users;
ShoppingListRestResource returning only models; GET /user/list-shareable;
item input parsing, display texts, error classification and optimistic
state transforms, each with tests. Name helpers and UserAvatar accept
CompactUserModel via DisplayableUser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entry, overview and list routes; ShoppingListProvider loads and refreshes
the list in place (polling, visibility, pull to refresh, settle-refresh)
with optimistic tick/remove/move/reorder/remove-ticked; item rows with tap
panel and drag handle, add form with duplicate prompt, edit/move/share
dialogs; PullToRefresh and NameInputDialog basics; menu entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On desktop the three buttons wrapped into a ragged right-aligned stack in
the narrow dialog card; stack them full-width with the primary on top, as
on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entfernen in an item's panel and Erledigte entfernen now ask for
confirmation (DeleteModal), revising plan D8 after review. The item
confirmation lives on the page, not in the dnd-kit-transformed row.
Kept as its own commit so it can be reverted on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(shopping-list) give a full-access user's own share the usual controls
All checks were successful
/ test (pull_request) Successful in 1m4s
a2be4dfac8
Consistent with the meal plan share dialog: the user's own share row,
marked (Du), has the permission select and the remove button. Removing it
ends their access (the page goes to the overview with the access-lost
notice); losing FULL closes the share dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
araemer changed title from WIP: feat(shoppingList) Shopping lists (spec 001) to feat(shopping-list) Shopping lists (spec 001) 2026-10-03 04:54:05 +00:00
fix(shopping-list) keep the rename dialog's draft across polling
All checks were successful
/ test (pull_request) Successful in 1m4s
184e659029
The list page's rename dialog was keyed on the live list version, so a
rename by someone else reset the user's typing on the next poll, and the
save used the live version, so it never got the 409. The dialog now
snapshots name and version when it opens; renameList takes that version,
and a conflict re-seeds the dialog from the refreshed list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test(shopping-list) cover the error-message selection and form value helpers
All checks were successful
/ test (pull_request) Successful in 1m3s
977b2b22ae
itemToInputValues and EMPTY_ITEM_INPUT_VALUES move into
shoppingListItemInputUtils next to formatAmountForInput, with tests.
shoppingListMessages stays component-side and gets its own test, since it
decides when an error is shown and when navigation makes it unnecessary;
CLAUDE.md's Tier 1 scope lists it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(shopping-list) don't flash "item gone" when the whole list is gone
All checks were successful
/ test (pull_request) Successful in 1m3s
dea07c397b
An item route's 404/403 set the item-gone message in the same tick as the
reload that finds the list deleted or inaccessible, so the popup could
paint before the page navigated to the overview. The page now shows its
error popup only while the list is READY (plan 10.4 / 10.7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(shopping-list) keep (Du) visible, use the plan's prompt text, document children
All checks were successful
/ test (pull_request) Successful in 1m5s
9a46c30495
- Share dialog: only the name truncates, so the (Du) marker stays visible
  on narrow phones.
- Overview: the choose-a-default prompt uses the plan's wording; the extra
  touch-specific sentence is gone.
- JSDoc on the children props of ShoppingListProvider and PullToRefresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(shopping-list) centre the list column, make add primary, tone down remove-ticked
All checks were successful
/ test (pull_request) Successful in 1m9s
294ea7b63e
- Overview and list page bodies use ContentBody (centred max-w-2xl column,
  like the recipe pages) instead of spanning the full desktop width; px-0
  keeps the full width on phones.
- "Eintrag hinzufügen" uses the primary style, like "Schritt hinzufügen".
- "Erledigte entfernen" is a small link-style action (TextLinkButton) with
  a 44 px tap area instead of a full button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(shopping-list) light hover tooltip in the InfoTooltip style
All checks were successful
/ test (pull_request) Successful in 1m4s
55b616e0b7
The item hover was a dark grey bubble, unlike every other tooltip in the
app. It now uses InfoTooltip's white/bordered style, sizes to its lines
(one line per reason on desktop), and never exceeds the row width, so a
narrow desktop window doesn't scroll sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(shopping-list) show the item hover tooltip only after a short rest
All checks were successful
/ test (pull_request) Successful in 1m3s
06bff72488
The tooltip appeared the moment the pointer touched a row, so moving the
mouse across the list made bubbles flicker. It now appears after the
pointer rests on a row for 500 ms and hides immediately on leave
(DELAYED_HOVER_TOOLTIP_CLASS in config/layout.ts). Touch devices are
unchanged: no tooltip, the tap panel shows the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ -0,0 +89,4 @@
onChange={e => setValue(e.target.value)}
placeholder={placeholder}
autoFocus
className="w-full px-3 py-2 text-base sm:text-sm border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent transition-colors"
Author
Owner

why do we need such a longwinded class name. Isn't this a default component? I'd have thought that we have a customer input for the recipe app instead of repeating class names risking drift.

why do we need such a longwinded class name. Isn't this a default component? I'd have thought that we have a customer input for the recipe app instead of repeating class names risking drift.
araemer marked this conversation as resolved
@ -2,3 +2,3 @@
/** Returns the two-character initials for a user, used as the avatar fallback. */
function getInitials(user?: UserModel): string {
function getInitials(user?: DisplayableUser): string {
Author
Owner

The name is strange. What's a DisplayableUser? I'ts also some kind of model, right?

The name is strange. What's a DisplayableUser? I'ts also some kind of model, right?
araemer marked this conversation as resolved
@ -0,0 +179,4 @@
autoFocusName
layout={ShoppingListItemFieldsLayout.INLINE}
/>
{formError && <FieldError message={formError}/>}
Author
Owner

Why plain buttons?

Why plain buttons?
araemer marked this conversation as resolved
@ -0,0 +5,4 @@
};
/** Small pill marking the user's default list, in the overview rows and the list header. */
export default function ShoppingListBadge({text}: ShoppingListBadgeProps) {
Author
Owner

should we use a more general location and name for the badge?

should we use a more general location and name for the badge?
araemer marked this conversation as resolved
refactor(meal-plan) use NameInputDialog for creating and renaming meal plans
All checks were successful
/ test (pull_request) Successful in 1m9s
f49a258665
MealPlanCreateDialog and MealPlanRenameDialog were the same single-field
dialog as the new NameInputDialog (trimmed, non-empty, rename disabled
while unchanged). The meal plan page now uses NameInputDialog with its own
texts, and the two files are gone (follow-up from plan 001, D8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #50
- Inputs in NameInputDialog and ShoppingListItemFields rely on the global
  input style from index.css instead of restating (and drifting from) it;
  the item fields keep only min-h-11 for the tap target.
- DisplayableUser becomes UserDisplayModel in src/models, a documented
  interface that UserModel and CompactUserModel both satisfy.
- Button renders type="button" and gains submitsForm; the add form uses
  Button for both actions instead of hand-composed native buttons.
- ShoppingListBadge becomes basics/Badge with a BadgeVariant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Addressed the four review comments in 2e56e3b:

NameInputDialog.tsx: why do we need such a longwinded class name … I'd have thought that we have a custom input

There is a default: index.css styles every input/textarea globally. The long class strings restated it and had drifted (other corners, padding, no hover border, smaller text on desktop). NameInputDialog now has no input classes at all, and ShoppingListItemFields keeps only min-h-11 for the 44 px tap target. Since #50 is merged here, the meal plan's create and rename dialogs get the global style too.

UserAvatar.tsx: What's a DisplayableUser? It's also some kind of model, right?

Renamed to UserDisplayModel and moved to src/models/UserDisplayModel.ts as a documented interface: the user fields needed to show a user (name parts, avatar). It has no DTO of its own; UserModel and CompactUserModel both satisfy it.

ShoppingListAddItemForm.tsx: Why plain buttons?

Because the add form is the app's only real <form> (Enter and the phone's "Go" key submit it), and Button rendered no type, which inside a form means submit, so "Schließen" would have submitted too. Fixed properly now: Button renders type="button" and has a submitsForm flag; the form uses Button for both actions, and the hand-composed styles are gone.

ShoppingListBadge.tsx: should we use a more general location and name for the badge?

Moved to src/components/basics/Badge.tsx as Badge with a BadgeVariant (accent for now).

The cleanup of the same patterns in older code follows as a separate PR against this branch: the remaining inputs that restate the global style, the two older hand-written pills (SettingsMenu, AiAnalyticsPage) moving to Badge, and the bee in HeaderSwitchTitle overflowing on phones with long list names (found while verifying this).

Verified: build, lint (2 existing warnings), 368 tests; in the app the add form (Enter and "Hinzufügen" add, "Schließen" closes without adding), the badge, and the input styles on desktop and phones.

🤖 Generated with Claude Code

Addressed the four review comments in 2e56e3b: > **`NameInputDialog.tsx`**: why do we need such a longwinded class name … I'd have thought that we have a custom input There is a default: `index.css` styles every `input`/`textarea` globally. The long class strings restated it and had drifted (other corners, padding, no hover border, smaller text on desktop). `NameInputDialog` now has no input classes at all, and `ShoppingListItemFields` keeps only `min-h-11` for the 44 px tap target. Since #50 is merged here, the meal plan's create and rename dialogs get the global style too. > **`UserAvatar.tsx`**: What's a DisplayableUser? It's also some kind of model, right? Renamed to `UserDisplayModel` and moved to `src/models/UserDisplayModel.ts` as a documented interface: the user fields needed to show a user (name parts, avatar). It has no DTO of its own; `UserModel` and `CompactUserModel` both satisfy it. > **`ShoppingListAddItemForm.tsx`**: Why plain buttons? Because the add form is the app's only real `<form>` (Enter and the phone's "Go" key submit it), and `Button` rendered no `type`, which inside a form means submit, so "Schließen" would have submitted too. Fixed properly now: `Button` renders `type="button"` and has a `submitsForm` flag; the form uses `Button` for both actions, and the hand-composed styles are gone. > **`ShoppingListBadge.tsx`**: should we use a more general location and name for the badge? Moved to `src/components/basics/Badge.tsx` as `Badge` with a `BadgeVariant` (accent for now). The cleanup of the same patterns in older code follows as a separate PR against this branch: the remaining inputs that restate the global style, the two older hand-written pills (`SettingsMenu`, `AiAnalyticsPage`) moving to `Badge`, and the bee in `HeaderSwitchTitle` overflowing on phones with long list names (found while verifying this). Verified: build, lint (2 existing warnings), 368 tests; in the app the add form (Enter and "Hinzufügen" add, "Schließen" closes without adding), the badge, and the input styles on desktop and phones. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
SearchField, LinkInput and MealPlanDayNoteEditor restated the global
input/textarea style from index.css (border, background, focus ring).
They now keep only their deliberate differences: the search field's icon
padding and compact toolbar look, the link input's tiny toolbar size and
thinner ring, the note's small text and fixed size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Badge gains success and neutral variants (dev console an/aus) and a
colorClassName override for domain palettes; the AI import status keeps
its colours in aiAnalyticsColors.ts, shared with the chart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HeaderSwitchTitle's plain title positioned the bee outside its box
(-right-12), so a long title that wrapped to the full width on a phone
pushed the bee past the screen edge. It now reserves the bee's width as
padding, as LogoHeading does; one-line titles look the same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #51
@ -0,0 +185,4 @@
onClick={() => { clearForm(); setIsExpanded(false); }}
text="Schließen"
buttonType={ButtonType.DefaultButton}
className="min-h-11"
Author
Owner

Why min-h-11?

Why min-h-11?
araemer marked this conversation as resolved
@ -0,0 +61,4 @@
/>
)}
<div className="flex items-center justify-between gap-2 border-t border-neutral-300 pt-3">
<button
Author
Owner

Would it be worth adding a component for this collabsable stuff?

Would it be worth adding a component for this collabsable stuff?
araemer marked this conversation as resolved
@ -0,0 +47,4 @@
every size, primary on top — three long labels never fit side by side in the card. */}
<div className="flex flex-col-reverse gap-2 mt-4">
<Button onClick={onDontAdd} text="Nicht hinzufügen" buttonType={ButtonType.DefaultButton} disabled={isBusy} className="min-h-11"/>
<Button onClick={onEditExisting} text="Bestehenden bearbeiten" buttonType={ButtonType.DefaultButton} disabled={isBusy} className="min-h-11"/>
Author
Owner

again min-h-11 on a button. When do we need it? Is this a general button variety?

again min-h-11 on a button. When do we need it? Is this a general button variety?
araemer marked this conversation as resolved
@ -0,0 +7,4 @@
* Extra classes for every item input. Everything else (padding, border, focus ring) comes from
* the global `input` style in index.css; this only guarantees the 44 px tap target.
*/
const INPUT_CLASS = "min-h-11";
Author
Owner

And another min-h-11. It seems to be something general

And another min-h-11. It seems to be something general
araemer marked this conversation as resolved
@ -0,0 +94,4 @@
>
<div className="flex items-stretch">
{/* Checkbox — its own button so a tap on the row body never ticks */}
<button
Author
Owner

hm, what about a reusable checkbox component?

hm, what about a reusable checkbox component?
araemer marked this conversation as resolved
- Text buttons, inputs and selects keep the app's defaults (Button ~40 px,
  global input ~42 px); the per-use min-h-11 on them is gone. The 44 px
  touch target applies only to small controls: IconButton's touchTarget
  prop for icon buttons in rows, and TOUCH_TARGET_CLASS /
  TOUCH_TARGET_HEIGHT_CLASS in config/layout.ts for the drag handle, the
  row body and the remove-ticked link. Revises plan step 10's blanket
  44 px rule.
- basics/Checkbox replaces the item row's hand-built checkbox.
- basics/CollapsibleSection replaces the done section's fold header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Addressed the second batch in 2a40223:

min-h-11 on buttons, inputs and the item fields: Why? When do we need it? It seems to be something general.

It was the plan's blanket "every tap target is at least 44 px" rule, applied everywhere, including where the app's defaults were already practically there: Button is ~40 px tall and the global input ~42 px, so it added only 2–4 px. Text buttons, inputs and selects now keep those defaults, and every per-use min-h-11 on them is gone. The 44 px only stays where a control would otherwise be too small to hit with a thumb:

  • icon buttons in list rows, via a new IconButton prop touchTarget (not in the base style, so dense toolbars like the Markdown editor don't grow)
  • the drag handle, the row body and the "Erledigte entfernen" link, via TOUCH_TARGET_CLASS / TOUCH_TARGET_HEIGHT_CLASS in config/layout.ts
  • Checkbox and CollapsibleSection (below), which handle it internally

The rule is documented in docs/architecture.md ("Touch targets"). This revises plan step 10's blanket rule.

Collapsible: Would it be worth adding a component for this collapsible stuff?

Yes: basics/CollapsibleSection. Controlled by the parent (isExpanded/onToggle), with chevron + title toggle (aria-expanded, aria-controls), an optional header action, and the content. The done section uses it. It's the only fold section so far, but reuse is likely (e.g. shop-layout sections in spec E).

Checkbox: what about a reusable checkbox component?

Yes: basics/Checkbox, a <button role="checkbox"> with checked/onChange/label/disabled, a 44 px target around a 24 px box. The item row uses it.

Verified: build, lint (2 existing warnings), 368 tests. In the app the measured sizes match the rule (small controls 44 px; text buttons 40 px, inputs 42 px, selects 40 px), tick/untick and folding work by mouse and by touch, and nothing overflows on the five viewports.

🤖 Generated with Claude Code

Addressed the second batch in 2a40223: > **`min-h-11`** on buttons, inputs and the item fields: *Why? When do we need it? It seems to be something general.* It was the plan's blanket "every tap target is at least 44 px" rule, applied everywhere, including where the app's defaults were already practically there: `Button` is ~40 px tall and the global input ~42 px, so it added only 2–4 px. Text buttons, inputs and selects now keep those defaults, and every per-use `min-h-11` on them is gone. The 44 px only stays where a control would otherwise be too small to hit with a thumb: - icon buttons in list rows, via a new `IconButton` prop `touchTarget` (not in the base style, so dense toolbars like the Markdown editor don't grow) - the drag handle, the row body and the "Erledigte entfernen" link, via `TOUCH_TARGET_CLASS` / `TOUCH_TARGET_HEIGHT_CLASS` in `config/layout.ts` - `Checkbox` and `CollapsibleSection` (below), which handle it internally The rule is documented in `docs/architecture.md` ("Touch targets"). This revises plan step 10's blanket rule. > **Collapsible:** *Would it be worth adding a component for this collapsible stuff?* Yes: `basics/CollapsibleSection`. Controlled by the parent (`isExpanded`/`onToggle`), with chevron + title toggle (`aria-expanded`, `aria-controls`), an optional header action, and the content. The done section uses it. It's the only fold section so far, but reuse is likely (e.g. shop-layout sections in spec E). > **Checkbox:** *what about a reusable checkbox component?* Yes: `basics/Checkbox`, a `<button role="checkbox">` with `checked`/`onChange`/`label`/`disabled`, a 44 px target around a 24 px box. The item row uses it. Verified: build, lint (2 existing warnings), 368 tests. In the app the measured sizes match the rule (small controls 44 px; text buttons 40 px, inputs 42 px, selects 40 px), tick/untick and folding work by mouse and by touch, and nothing overflows on the five viewports. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
@ -0,0 +156,4 @@
await loadOverview();
} catch (error) {
const kind = classifyShoppingListError(error);
if (kind === ShoppingListErrorKind.CONFLICT) {
Author
Owner

kind? Usually, I'd prefer type. Any good reason for choosing kind over type?

kind? Usually, I'd prefer type. Any good reason for choosing kind over type?
araemer marked this conversation as resolved
@ -0,0 +292,4 @@
{notice && (
<div role="status" className="flex items-start gap-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-900 px-4 py-3">
<Info size={18} className="flex-shrink-0 mt-0.5"/>
<p className="flex-1 text-sm">{NOTICE_MESSAGES[notice]}</p>
Author
Owner

Explain NOTICE_MESSAGES

Explain NOTICE_MESSAGES
araemer marked this conversation as resolved
@ -0,0 +141,4 @@
{error && <FieldError message={error}/>}
{/* Who has access: the owner first, then every share */}
<ul className="divide-y divide-neutral-100">
Author
Owner

isn't this basically the same as for meal plans? Something to generalize?

isn't this basically the same as for meal plans? Something to generalize?
araemer marked this conversation as resolved
@ -0,0 +15,4 @@
* Formats amount and unit for an item line: "1,5 l", "500 g", "2", "Packung" (unit only),
* or "" when both are absent.
*/
export function formatItemQuantity(amount?: number, unit?: string): string {
Author
Owner

Isn't this something that we should also use in the recipe details? There we display 0.5. Makes it inconsistent on my notebook at least.

Isn't this something that we should also use in the recipe details? There we display 0.5. Makes it inconsistent on my notebook at least.
araemer marked this conversation as resolved
docs(plan) record the deviations from plan 001 in an append-only section
All checks were successful
/ test (pull_request) Successful in 1m3s
58611570a5
The approved plan stays unchanged; a Deviations section lists each place
the implementation departs from it (plan reference, what changed, why,
who decided), the status of the GATE C points, and the follow-up issues.
First example of a convention proposed for all plans in recipe-platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ShoppingListErrorKind becomes ShoppingListErrorType (the app's const
  enums are named ...Type); action results report errorType.
- NOTICE_MESSAGES documents why router state carries a notice id.
- formatItemQuantity becomes formatQuantity in utils/quantityUtils and is
  used for recipe ingredients too: German decimal comma, scaled amounts
  rounded to three fraction digits.
- IngredientMapper turns the backend's null amount/unit into undefined.
  The backend sends null contrary to the contract's optional fields; the
  shared formatter exposed it as a crash of the recipe detail page during
  verification, before this was pushed.
- Plan deviations recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Addressed the last batch in dacfed7:

kind — Any good reason for choosing kind over type?

No strong one; the plan named it. Renamed to ShoppingListErrorType (the app's const enums are …Type, e.g. ButtonType); action results now report { ok: false, errorType }. Recorded in the plan's Deviations; ADR 2026-10-02-contract-enums-by-kind still mentions the old name and stays as is (ADRs are immutable).

NOTICE_MESSAGES — Explain

When the list page sends you to the overview because a list was deleted or your access was removed, the router state carries only the notice id (LIST_DELETED / ACCESS_LOST), not the text. Router state lives in the browser history and survives reloads and back navigation, so a stable id is safer there than a sentence, and all user-facing texts stay in shoppingListMessages.ts. NOTICE_MESSAGES maps the id to that text; as a Record over the enum, a new notice doesn't compile without one. That explanation is now its doc comment.

formatItemQuantity — Isn't this something that we should also use in the recipe details?

Yes: it's now formatQuantity in utils/quantityUtils.ts, used by the shopping list and the recipe ingredients — "0,5 TL" instead of "0.5", and scaled amounts rounded to three decimals instead of every digit.

Verifying this turned up a crash: for many recipes the backend sends "amount": null / "unit": null on ingredients (Salz, Pfeffer, Ei, …), although the contract types both as optional, never null. The old display tolerated it by accident; the new formatter crashed the recipe page. IngredientMapper now turns null into "absent" at the DTO boundary (with a test); 15 recipes, including those with null amounts, render cleanly. The backend/contract mismatch itself is outside this PR.

Share dialog — isn't this basically the same as for meal plans?

Agreed; it becomes a generic ShareDialog in a follow-up PR right after this one, together with the fix for #46 (meal plans use list-shareable).

Verified: build, lint (2 existing warnings), 372 tests; recipe detail and shopping list in the app.

🤖 Generated with Claude Code

Addressed the last batch in dacfed7: > **`kind`** — *Any good reason for choosing kind over type?* No strong one; the plan named it. Renamed to `ShoppingListErrorType` (the app's const enums are `…Type`, e.g. `ButtonType`); action results now report `{ ok: false, errorType }`. Recorded in the plan's Deviations; ADR 2026-10-02-contract-enums-by-kind still mentions the old name and stays as is (ADRs are immutable). > **`NOTICE_MESSAGES`** — *Explain* When the list page sends you to the overview because a list was deleted or your access was removed, the router state carries only the notice id (`LIST_DELETED` / `ACCESS_LOST`), not the text. Router state lives in the browser history and survives reloads and back navigation, so a stable id is safer there than a sentence, and all user-facing texts stay in `shoppingListMessages.ts`. `NOTICE_MESSAGES` maps the id to that text; as a `Record` over the enum, a new notice doesn't compile without one. That explanation is now its doc comment. > **`formatItemQuantity`** — *Isn't this something that we should also use in the recipe details?* Yes: it's now `formatQuantity` in `utils/quantityUtils.ts`, used by the shopping list and the recipe ingredients — "0,5 TL" instead of "0.5", and scaled amounts rounded to three decimals instead of every digit. Verifying this turned up a crash: for many recipes the backend sends `"amount": null` / `"unit": null` on ingredients (Salz, Pfeffer, Ei, …), although the contract types both as optional, never null. The old display tolerated it by accident; the new formatter crashed the recipe page. `IngredientMapper` now turns null into "absent" at the DTO boundary (with a test); 15 recipes, including those with null amounts, render cleanly. The backend/contract mismatch itself is outside this PR. > **Share dialog** — *isn't this basically the same as for meal plans?* Agreed; it becomes a generic `ShareDialog` in a follow-up PR right after this one, together with the fix for #46 (meal plans use `list-shareable`). Verified: build, lint (2 existing warnings), 372 tests; recipe detail and shopping list in the app. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
araemer merged commit 5089044175 into stage 2026-10-03 11:43:20 +00:00
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!47
No description provided.