feat(shopping-list) Shopping lists (spec 001) #47
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "001-shopping-lists"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Shopping lists for recipe-app (spec 001, epic E01 spec A).
docs/plans/001-shopping-lists.mdApproved at GATE B
docs/adr/2026-10-02-shopping-list-refresh.mdApproved 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 superseding2026-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-presentErrorCode, 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.docs/adr/2026-10-02-contract-enums-by-kind.mdand the matchingCLAUDE.mdrule.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 andUserAvataraccept a compact user./shopping-list(entry, redirects withreplace),/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.PullToRefresh(no library) andNameInputDialog.docs/architecture.mdupdated in the same commits.Deviations from the plan
ShoppingListAddItemForm, not the page: the form owns the typed draft that "Separat hinzufügen" needs and the fields every choice clears.BasicDialog's footer, because three long labels don't fit side by side.ShoppingListItemRowtakes novariantprop; it usesitem.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).itemToInputValuesandEMPTY_ITEM_INPUT_VALUESlive inutils/shoppingListItemInputUtils.tswith tests.shoppingListMessages.tsstays 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.IconButton'stouchTargetandTOUCH_TARGET_CLASS/TOUCH_TARGET_HEIGHT_CLASS. New basicsCheckboxandCollapsibleSection(review).ShoppingListItemFields,ShoppingListLoadError,ShoppingListOwnerLabel,ShoppingListBadge. New layout constantDRAG_ACTIVATION_DISTANCE_PX.Judgment calls
Superseded by:line sits directly under its**Status:**line.<button type=…>elements, because the sharedButtonrenders notypeand "Schließen" would otherwise submit the form.group-hoveronly applying on hover-capable devices, so phones never get a stuck tooltip.Verification
npm run build,npm run lint(only the 2 existing warnings inMealPlanDayProvider) andnpm run test(368 tests) pass./verify-uiagainst a local backend that implements spec 001, asuserandproin 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-implementationREADY, so "Dieser Eintrag existiert nicht mehr" can't flash before leaving a deleted or inaccessible list (plan 10.4 / 10.7).childrenprops.ContentBodycolumn 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)
NameInputDialog(plan D8).GET /user/list-shareable(recipe-app#46).PageContainerusesw-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
WIP: feat(shoppingList) Shopping lists (spec 001)to feat(shopping-list) Shopping lists (spec 001)@ -0,0 +89,4 @@onChange={e => setValue(e.target.value)}placeholder={placeholder}autoFocusclassName="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"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.
@ -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 {The name is strange. What's a DisplayableUser? I'ts also some kind of model, right?
@ -0,0 +179,4 @@autoFocusNamelayout={ShoppingListItemFieldsLayout.INLINE}/>{formError && <FieldError message={formError}/>}Why plain buttons?
@ -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) {should we use a more general location and name for the badge?
Addressed the four review comments in
2e56e3b:There is a default:
index.cssstyles everyinput/textareaglobally. The long class strings restated it and had drifted (other corners, padding, no hover border, smaller text on desktop).NameInputDialognow has no input classes at all, andShoppingListItemFieldskeeps onlymin-h-11for the 44 px tap target. Since #50 is merged here, the meal plan's create and rename dialogs get the global style too.Renamed to
UserDisplayModeland moved tosrc/models/UserDisplayModel.tsas a documented interface: the user fields needed to show a user (name parts, avatar). It has no DTO of its own;UserModelandCompactUserModelboth satisfy it.Because the add form is the app's only real
<form>(Enter and the phone's "Go" key submit it), andButtonrendered notype, which inside a form means submit, so "Schließen" would have submitted too. Fixed properly now:Buttonrenderstype="button"and has asubmitsFormflag; the form usesButtonfor both actions, and the hand-composed styles are gone.Moved to
src/components/basics/Badge.tsxasBadgewith aBadgeVariant(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 toBadge, and the bee inHeaderSwitchTitleoverflowing 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
@ -0,0 +185,4 @@onClick={() => { clearForm(); setIsExpanded(false); }}text="Schließen"buttonType={ButtonType.DefaultButton}className="min-h-11"Why min-h-11?
@ -0,0 +61,4 @@/>)}<div className="flex items-center justify-between gap-2 border-t border-neutral-300 pt-3"><buttonWould it be worth adding a component for this collabsable stuff?
@ -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"/>again min-h-11 on a button. When do we need it? Is this a general button variety?
@ -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";And another min-h-11. It seems to be something general
@ -0,0 +94,4 @@><div className="flex items-stretch">{/* Checkbox — its own button so a tap on the row body never ticks */}<buttonhm, what about a reusable checkbox component?
Addressed the second batch in
2a40223: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:
Buttonis ~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-usemin-h-11on them is gone. The 44 px only stays where a control would otherwise be too small to hit with a thumb:IconButtonproptouchTarget(not in the base style, so dense toolbars like the Markdown editor don't grow)TOUCH_TARGET_CLASS/TOUCH_TARGET_HEIGHT_CLASSinconfig/layout.tsCheckboxandCollapsibleSection(below), which handle it internallyThe rule is documented in
docs/architecture.md("Touch targets"). This revises plan step 10's blanket rule.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).Yes:
basics/Checkbox, a<button role="checkbox">withchecked/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
@ -0,0 +156,4 @@await loadOverview();} catch (error) {const kind = classifyShoppingListError(error);if (kind === ShoppingListErrorKind.CONFLICT) {kind? Usually, I'd prefer type. Any good reason for choosing kind over type?
@ -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>Explain NOTICE_MESSAGES
@ -0,0 +141,4 @@{error && <FieldError message={error}/>}{/* Who has access: the owner first, then every share */}<ul className="divide-y divide-neutral-100">isn't this basically the same as for meal plans? Something to generalize?
@ -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 {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.
Addressed the last batch in
dacfed7: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).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 inshoppingListMessages.ts.NOTICE_MESSAGESmaps the id to that text; as aRecordover the enum, a new notice doesn't compile without one. That explanation is now its doc comment.Yes: it's now
formatQuantityinutils/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": nullon 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.IngredientMappernow 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.Agreed; it becomes a generic
ShareDialogin a follow-up PR right after this one, together with the fix for #46 (meal plans uselist-shareable).Verified: build, lint (2 existing warnings), 372 tests; recipe detail and shopping list in the app.
🤖 Generated with Claude Code