Skip to content

Development

github-actions[bot] edited this page Sep 4, 2026 · 29 revisions

Development workflow

The loop

  1. Pick the next unblocked task from IMPLEMENTATION_PLAN.md.
  2. Branch from main: feat/<task-id>-<slug> (e.g. feat/2.1-cart-model), fix/... or chore/....
  3. Implement with tests (vitest). Conventional Commits.
  4. Open a PR. The same PR updates the task's row in IMPLEMENTATION_PLAN.md and any affected docs/wiki/ pages.
  5. CodeRabbit reviews. Work through every comment: fix it, or reply with a reasoned rejection — then resolve the thread.
  6. Merge (squash) when CI is green and all conversations are resolved. main is protected by a ruleset that applies to admins as well.

Branch protection (ruleset on main)

  • PR required, direct pushes blocked
  • Required status check: CI
  • Required conversation resolution — merge impossible with unresolved review threads
  • Squash merge only, no force pushes, no deletions
  • No bypass actors: the rules apply to admins too (platform caveat: the repo owner can still edit or delete the ruleset itself — don't)

CI

.github/workflows/ci.yml: lint → typecheck → vitest → build, on every PR and push to main. No secrets in CI — tests never call external services; parsers run on saved HTML fixtures.

Wiki sync

.github/workflows/wiki-sync.yml publishes docs/wiki/** to this wiki on every merge to main that touches those files. The sync overwrites the wiki — never edit pages on GitHub directly. If the sync job fails with a permissions error, create a classic PAT with repo scope and add it as the WIKI_TOKEN repository secret.

Auth (Better Auth)

Two ways in, no passwords: Google OAuth and a magic link emailed through Resend. The OAuth redirect URI to register in Google Cloud Console is {BETTER_AUTH_URL}/api/auth/callback/google — see Env-Setup.

Layout of the pieces:

File Role
src/lib/auth.ts Lazy singleton auth() — importing it never reads env, so CI builds without secrets
src/lib/auth-client.ts Shared browser client (authClient) for client components
src/lib/session.ts getSession() for server components, layouts and server actions
src/app/api/auth/[...all]/ The Better Auth request handler
src/middleware.ts Optimistic cookie check — fast redirect, no database round-trip
src/app/(app)/layout.tsx Authoritative session + household check for every signed-in screen
src/app/(auth)/login/ S1 «Вход» — rendered without the app shell

Adding a protected screen: put it under src/app/(app)/. The layout's session and household checks cover it automatically; no middleware change is needed.

Where you land after signing in (?next=)

A signed-out visitor is not simply dumped on /login: resolveAuthRedirect builds /login?next=<encoded pathname>, and the login page hands that path to Better Auth as the callbackURL for both Google and the magic link. Without it, a partner who taps an invite link while signed out lands on the cart, gets bounced to onboarding, and may create a household of their own — after which the invitation can never be accepted (one household per user, and MVP has no way to leave one).

next is attacker-controlled, so every read of it goes through sanitizeNextPath() (src/lib/auth-redirect.ts), which falls back to / for anything that is not plainly an in-app path: absolute URLs, protocol-relative //host and /\host, values hiding a second URL behind a control character or space, relative paths, and /login itself. Skipping it turns our own sign-in screen into an open redirect. loginPathFor(pathname) is the matching builder — use it instead of a bare LOGIN_PATH wherever a server component redirects someone to sign in.

Regenerating the auth tables

The users / sessions / accounts / verifications tables are generated — never hand-written, and never hand-edited afterwards. Regenerate them when the Better Auth version or its plugin list changes, pinning the generator to the better-auth version in package.json (currently 1.7.1):

pnpm dlx auth@1.7.1 generate --config better-auth.config.ts --output src/db/auth-schema.ts -y
pnpm db:generate   # drizzle-kit turns the schema diff into a migration
pnpm db:migrate

better-auth.config.ts in the repository root exists only for that generator: the CLI needs a plain auth export, while the runtime instance is a lazy factory. It shares the adapter shape with the runtime through src/lib/auth-drizzle-config.ts, so the two cannot drift.

Onboarding & households

Everything in Larder belongs to a household (VISION §5). A signed-in user without one has no data to look at, so src/app/(app)/layout.tsx runs a second gate right after the session check: caller.household.current() returns null → redirect to /onboarding.

Both onboarding screens therefore live outside the (app) group, in src/app/(onboarding)/ — inside it, the gate would redirect them to themselves:

Route What it is
/onboarding S2: create a household, then the «Пригласи своих» view with the invite link and a copy button
/invite/[token] The screen an invite link opens: «Аня приглашает тебя в «Наш дом»» + «Вступить»

Both are still behind the auth middleware, so only signed-in visitors reach them — signed-out ones go through /login?next=… and come back (see above).

/onboarding runs in two phases, and the split is load-bearing: once the household exists it exists for good. Creating it and minting the first invite are separate steps with separate error states, because folding a failed mint back into the create form would strand the user — every retry would hit the "one household per user" CONFLICT and show the same error forever. So a CONFLICT from household.create is treated as success (it already exists: this submit is a retry, or a second tab won), the invite step owns its own retry button, and «Продолжить» is always available — the link can be minted later, the household cannot be created twice. isConflictError() in src/lib/trpc-errors.ts reads the code off the client error.

Invite links

One-time, with a TTL (VISION §6.7). The rules live in src/server/invites.ts, free of tRPC and the database so every branch is unit-tested:

  • createInviteToken() — 32 random bytes, base64url, so the token needs no URL escaping.
  • INVITE_TTL_MS — 7 days. An invite is expired from its expiresAt instant onwards; the boundary itself is already too late.
  • previewInvite() / decideInviteAccept() — the decision tables the two routers execute.

Unknown, expired and already-used tokens all surface as one indistinguishable invalid (preview) / NOT_FOUND (accept): someone guessing tokens must not learn which of the three they hit. The exception is a caller who is already a member of the invite's household — they get a friendly "you're already in" instead, which leaks nothing they don't know.

/onboarding is not the only place that mints a link. Task 1.1 only ever wired the mint button into the onboarding flow, so once a household existed there was nowhere left to get a link for a second partner. Task 7.1a adds that place: HouseholdSection on /settings («Дом», rendered first, above the kitchen profile) — «Пригласить» mints a link the same way, «Новая ссылка» mints another once one exists (each link is one-time), and «Поделиться» calls navigator.share() when the browser has one. invite.create's output additively carries expiresAt (task 7.1a) alongside url, so the Settings section can render «Действует до {date}» instead of a second hardcoded "7 days" — /onboarding's own mint call simply ignores the new field. The read-only field + «Скопировать» pair itself is src/components/invite-link.tsx, extracted out of the onboarding screen so both callers share one implementation of the clipboard fallback (no permission / insecure context → the link stays selectable with a "copy by hand" hint).

The claim UPDATE is the single authority on whether an invite may be redeemed. decideInviteAccept() runs against the application clock on a row read a moment earlier, so it decides what to tell the caller and nothing more. Every condition is then repeated in the write, evaluated once and atomically against the database clock:

UPDATE invites SET used_at = now(), used_by = $2
WHERE id = $1 AND used_at IS NULL AND expires_at > now()

Both conditions have to be there. Dropping used_at IS NULL lets two people redeem one link; dropping expires_at > now() lets a request that crosses the TTL in flight redeem an expired one. The membership insert is guarded the same way, by the unique index on household_members.user_id (the "one household per user" MVP invariant). Two people opening the same link therefore race in Postgres; the loser gets NOT_FOUND or CONFLICT, never a duplicate row.

isUniqueViolation() in src/server/db-errors.ts turns that index violation into a domain error instead of a 500 — the cart's own invariant reuses it (see Cart). It walks the cause chain, which is not optional: since drizzle-orm 0.44 the postgres.js error arrives wrapped in a DrizzleQueryError, so a top-level code check silently stops matching and every lost race becomes an INTERNAL_SERVER_ERROR.

Household routers

Procedure Boundary Notes
household.current protectedProcedure { household, members } | null — null is normal, not an error
household.create protectedProcedure CONFLICT if the caller already has one
invite.create householdProcedure Mints a link for the caller's own household; output is { url, expiresAt } (task 7.1a)
invite.preview protectedProcedure Read-only, for rendering the join screen
invite.accept protectedProcedure Redeems the link and creates the membership

The three protectedProcedure entries cannot use householdProcedure: their whole audience is people who have no household yet.

S12 «Дом» (task 7.1a)

settings/household-section.tsx, prefetched with the page like the other three sections beside it (household.current). Household name, then one row per member (avatar — image or an initial-letter circle, same avatarInitial helper app-header.tsx uses — and a «ты» marker on the caller's own row), then «Пригласить» / «Новая ссылка», then the identity/sign-out line moved down from the old page footer per page.tsx's own doc comment.

household.current returning null is treated as this section's error state (same «не удалось загрузить» + «Повторить» the other sections use), not a crash — /settings lives inside the (app) group's household gate, so it should never actually happen, but the section does not assume that.

The mint button reuses createInvite.mutateAsync() with networkMode: "always" (an invite is not in the offline queue, same rule as dish.* writes) behind a synchronous ref, not mutation.isPending alone — isPending lands a render after the tap, and two fast taps before that render would mint two links. aria-disabled, never disabled, so a double tap is blocked without dropping the keyboard focus of the button just activated.

Once a link exists the block is plain content, not a live regionInviteLink (the field + «Скопировать» pair — see Invite links above), the «Действует до {date}» line from the new expiresAt, and «Поделиться» when navigator.share exists. A role="status" around the whole block would re-announce the URL, the hint and the share label on every change inside it, and a region that mounts together with its own text is not reliably announced at all. The announcements are two always-mounted visually-hidden role="status" regions with keyed children instead — the same shape the cart toast uses (see Cart): the section's «Ссылка-приглашение готова.», keyed on the URL (a fresh link never repeats), and InviteLink's «Скопировано», keyed on a copy sequence number because the same link can be copied twice in a row and a live region only re-announces on an actual node change — guarded the same way «Пригласить» is, by a synchronous ref lock, since two overlapping copies settling with different outcomes could otherwise show «Скопировано» and the failure alert at once.

«Поделиться» carries the same synchronous ref guard + aria-disabled as the mint button — a second tap landing before the OS share sheet takes over would otherwise reject with InvalidStateError. That existence check runs in a useEffect, never during render — a render-time typeof navigator.share read would hydrate differently on the server (no navigator) than the client, the exact class of bug PR #28 fixed. A rejected share is only shown as a failure when isShareCancelled() (src/lib/household-invite.ts, pure and tested) says it was not benign: AbortError (the person closed the sheet) and InvalidStateError (a share was already in progress) are both silent.

isCallerMember(), in the same module, is the «ты» decision — a member row is the caller's own when userId matches the id caller.health.whoami() already resolves server-side and page.tsx passes down as a prop.

Categories (store departments)

Every household groups its cart and catalog by department — "отдел" — (VISION §3.1, §5): categories (src/db/schema.ts) is id, householdId, name, icon (emoji), sortOrder, unique on (householdId, name).

  • Defaults. src/server/catalog/default-categories.ts holds the 7 departments in DESIGN_BRIEF §5's route order (Овощи и фрукты → Молочное и яйца → Мясо и курица → Хлеб и выпечка → Бакалея → Заморозка → Хозяйственное). household.create inserts them, in that order as sortOrder 0–6, in the same transaction that creates the membership — a household is never left without departments to group its cart by.
  • Backfill. Migration 0003_true_tigra adds the table and then backfills the same 7 rows into any household that predates it (INSERT ... WHERE NOT EXISTS (SELECT 1 FROM categories WHERE household_id = ...)). Only that backfill INSERT is idempotent — a household that already has categories is left alone if it runs again. The migration as a whole is not re-runnable: CREATE TABLE "categories" has no IF NOT EXISTS, so drizzle's migration journal (which runs each migration file exactly once) is what actually keeps it from executing twice, not the INSERT's own guard.
  • Reference catalog. src/server/catalog/reference-products.ts is a separate, static, in-code list of common Russian household products (189 items, pinned by test) with an icon/department/default unit — the free, instant half of the task 1.3 autocomplete. It is not database data and the seed script does not touch it.
  • Units. src/lib/units.ts is the shared UNITS/Unit/unitSchema contract — cart items, the reference catalog and recipe ingredients all reuse it rather than redeclaring their own unit list.

category router

Procedure Boundary Notes
category.list householdProcedure The caller's departments, ordered by sortOrder
category.reorder householdProcedure { orderedIds: uuid[] } (1–100). Rewrites sortOrder to each id's array index

reorder validates orderedIds with checkReorderPermutation() (src/server/catalog/reorder.ts, pure and unit-tested on its own) before writing anything: it must be exactly the household's own category ids, each appearing once — a missing id, an extra/foreign id, or a duplicate all reject with BAD_REQUEST and touch no row. There is no create/delete endpoint yet and no drag UI (task 7.1 adds the screen for this router).

Product catalog & AI enrichment

The household's own product list (VISION §3.1, §5). Everything that will ever go into the cart resolves to a row here first — that is what makes "одна активная строка на продукт" expressible at all, and what keeps «помидоры» from appearing twice in one list.

products (src/db/schema.ts): householdId, categoryId (FK restrict — a department with products in it must not vanish), name, normalizedName, icon, defaultUnit, aliases[], createdBy (set null).

The unique index is on (householdId, normalizedName) — a stored canonical column, not an expression over name. That is the whole point: the database enforces the application's own definition of "the same product" rather than a weaker approximation of it. An index on lower(name) folds case and nothing else, so it would happily admit «Сёмга» and «Семга» as two rows that the autocomplete treats as one — a permanent duplicate, mintable through a rename or a concurrent create, which is exactly what this feature exists to prevent.

The column is redundant with name by construction, and that is the trade: a canonical value can be indexed and compared exactly, while a normalization this specific is not something Postgres would still use an index for. Nothing may write name without writing normalizedName in the same statementinsertProduct() derives it centrally so no create path can forget, and product.update rewrites both together on a rename. A name that outran its canonical form would leave the row indexed under its old identity, silently switching uniqueness off for it.

Normalization

normalizeProductName() (src/server/catalog/normalize.ts) is the single definition of "the same product": trim, lower-case, ё → е, collapse whitespace. Every comparison in the feature goes through it — ranking, the reference merge, the duplicate check, and the reference-catalog invariants test — and so does the database, via the stored normalizedName its unique index is built on. There is one definition of product identity and both layers use it.

Migration 0005_breezy_shaman carries a SQL twin of the function for its backfill (regexp_replace + translate(lower(…), 'ё', 'е')). If the normalization ever changes, that backfill is a snapshot of the old rule, not a live copy — existing rows need a fresh backfill migration.

The module is pure string code with no server dependencies, so the S4 sheet imports it too — that is how the client and the server agree on when to offer «Создать „…“».

Search (src/server/catalog/search.ts, pure, unit-tested)

searchCatalog({ query, products, categories }) ranks the household's rows and the built-in 189-item reference catalog by the same rules, and the sheet renders both identically. That sameness is the feature: a shopper typing «пом» should not care whether «Помидоры» already exists in their catalog.

Tiers, best first: exact name → name prefix → word-boundary prefix → substring, then the same four again for aliases. Every name match beats every alias match. Ties break by source (the household's own row first), then shorter name, then alphabetically. At most 10 results; an empty query returns nothing.

Two things worth not breaking:

  • Matching is indexOf/startsWith, never a regex built from the query. A regex would need escaping, and «сыр (твёрдый)» would otherwise be a SyntaxError in the middle of someone's shopping.
  • A reference entry is dropped when the household already owns it under any spelling — names and aliases compared in both directions. Without that, someone who created «Помидорки» with the alias «помидоры» would see their row next to the built-in «Помидоры», and picking the wrong one makes the exact duplicate this design exists to prevent.

Reference entries carry a categorySlug; resolveCategoryIdForSlug() (src/server/catalog/resolve-category.ts) maps it onto the household's own department by name, because categories rows carry no slug — a household may rename or reorder them. A department that no longer matches falls back to «Бакалея», and failing that to the first department by walking order. That same fallbackCategoryId() is what the AI failure path uses.

product router

Procedure Boundary Notes
product.search householdProcedure { query } → ≤ 10 hits; productId is null for a reference hit
product.list householdProcedure The whole catalog, ordered by department sortOrder then name
product.create householdProcedure { source: "reference" | "new", name }{ product, enriched, aiFailed }
product.update householdProcedure Partial patch; categoryId is checked against the caller's own departments

create never trusts the client with anything but a name. source: "reference" re-resolves the entry out of REFERENCE_PRODUCTS server-side and takes the icon, department, unit and aliases from there — a tampered request cannot file a product under an arbitrary category. source: "new" is the only path that spends money.

create is idempotent by name. It looks for an existing row first (normalizedName or an alias match), so a repeat create returns the existing product without an AI call; and if a concurrent insert wins the unique index, the loser reads the winner's row back instead of surfacing a violation. Two taps, two tabs and two partners all end with one product.

The lookup probes the indexed column itself, so it and the index can never disagree about what a duplicate is. That equality is load-bearing: a probe that could miss what the index catches would miss precisely the row an insert just collided with, and the conflict would surface as a 500 — on the paid path, after the AI call was already billed.

AI enrichment

enrichProduct() (src/server/ai/enrich-product.ts) asks for { icon, categoryId, unit } in one structured-output call.

  • Model and prices live in src/server/ai/pricing.tsgpt-5-mini, reasoning_effort: "low" (VISION §6.5: invisible reasoning tokens are billed as output and would multiply a sub-cent call).
  • The JSON schema comes from the Zod schema through Zod v4's own z.toJSONSchema, not the OpenAI SDK's zodResponseFormat helper — that helper targets Zod v3 internals. One schema both describes the response and validates it, so the two cannot drift. Schemas for AI use .nullable(), never .optional(): strict mode cannot express an optional property.
  • categoryId is re-checked against the ids we actually sent, after parsing. Strict mode constrains the shape of the field, never its value, and a hallucinated uuid would otherwise file the product into nothing. The icon is checked for being plausibly a single emoji.
  • The function never throws. Network error, refusal, malformed JSON, invented department — all come back as ok: false.

Failure is not an error the user sees as one. Whatever goes wrong, the product is still created, with 🛒 / «Бакалея» / «шт», and aiFailed: true tells the sheet to show a calm amber "проверь иконку и отдел" (DESIGN_BRIEF §6: yellow, not red). VISION §3.1 is explicit that the AI is a helper and everything is editable — one tap opens the edit form. The router also catches ctx.openai() itself throwing and treats that the same way, which covers an invalid or revoked key.

It does not cover a missing one: env() validates the whole schema on first call and db() calls env(), so a deployment without OPENAI_API_KEY fails every request at context construction, well before the enrichment fallback is reachable. That is the intended behaviour for an absent required variable — see Env-Setup.

AiJob lifecycle and cost

ai_jobs is written for every AI call, from this first one (AGENTS.md):

  1. Insert status: "running", type: "product_enrich", inputRef: <product name>before the call, so the rate limiter counts requests that are still in flight.
  2. On success: status: "done", outputJson, costUsd, finishedAt.
  3. On failure: status: "error", error, costUsd, finishedAt.

costUsd is recorded on the failure branch too whenever a response came back: a validation failure after a successful HTTP call was billed, and a ledger that only counts successes under-reports exactly when things go wrong. numeric(10, 6) — six decimals, so a $0.0002 icon lookup does not round to zero.

AI_MONTHLY_BUDGET_USD is not checked here. It caps the assistant only (task 6.1); icon-picking and recipe import keep working at the cap.

Rate limiting

src/server/ai/rate-limit.ts: 10 per minute and 100 per day, per user, applied to every path that spends money — product.create's enrichment (task 1.3) and dishImport.fromPhoto (task 4.3). The free reference path is never limited, and the batched enrichment inside a dish save goes through the non-throwing aiRateLimitDecision instead, so a quota can never cost someone a recipe they have just spent a minute reviewing.

The same numbers also cap uploads (task 4.3). dishPhoto.middleware() counts the caller's own live photo_uploads rows through the same checkRateLimit/rateLimitWindows pair before the presign is issued, so a refusal costs no bytes at all. It is deliberately a cap on storage held, not on requests made: discardPhoto deletes the row with the blob, so somebody who uploads and immediately discards is not capped — and is also not filling the 2 GB tier, which is the only thing this defends. (The tier is app-wide, not per household, and sign-up is open with household.create a plain protectedProcedure, so one account could otherwise exhaust everybody's storage.) The count carries household_id alongside user_id so the existing photo_uploads_householdId_idx serves it and no migration is needed. A true request-rate limit would have to count something append-only — which ai_jobs already does, one step later in the same flow.

Counted with one indexed count(*) over ai_jobs — the day's rows counted once, the minute's counted again with a FILTER over the same scan. In the database, not in memory, because the app is serverless: two requests a second apart routinely land in different Vercel instances, so an in-process counter would limit nothing. Windows slide, so nobody gets a fresh allowance at the top of the minute. The decision itself is a pure checkRateLimit(); the router turns a refusal into TOO_MANY_REQUESTS, and isRateLimitedError() (src/lib/trpc-errors.ts) is how the sheet tells it apart from a generic failure.

One trap worth knowing: a bare Date interpolated into a raw sql fragment is bound without its column's type, and postgres.js rejects it at bind time. The FILTER predicate therefore goes through gte(aiJobs.createdAt, …), which reuses the column's encoder. A stub-based test cannot see this — the regression test compiles the projection and asserts no parameter is still a Date.

Screens

File Role
src/components/bottom-sheet.tsx Shared sheet shell: scrim, Esc, square paper panel
src/components/autocomplete-sheet.tsx S4 «Добавление продукта» — search, «Создать „…“», AiProgress, quantity step
src/components/product-edit-form.tsx «Изменить продукт»: emoji, name, department, unit
src/app/(app)/cart-screen.tsx S3 «Корзина» — the screen S4 adds to (see Cart screen)

The sheet debounces input by 200 ms and keeps the previous list on screen while the next one loads, so it never blinks empty between keystrokes. «Создать „…“» appears only when nothing already is what was typed.

There is no separate catalog screen. Task 1.3 shipped one as a stand-in so newly created products had somewhere to land; task 2.3 replaced it with the cart, and the catalog is now reached only through S4's search. Editing an existing product's icon or department therefore goes through «Изменить» on the quantity step for now; row-level editing on S3 is task 2.5.

S4 resolves a product and a quantity, then hands both over. onAdded({ product, qty, unit }) fires on «В корзину», and the sheet deliberately does not close itself: only the caller knows what cart.add answered, and a merge, a unit conflict and an already-bought line are three different screens. It also never imports the cart router, so the same flow can later feed a recipe's ingredient list or the pantry. A successful product.create invalidates the product queries before moving on — otherwise the same search inside staleTime would offer «Создать „…“» again for a product that now exists.

Cart

The shared shopping list (VISION §3.1) — the product's core screen, and the one place a database invariant does most of the design work.

cart_items (src/db/schema.ts): householdId, productId (FK restrict — purchase history must not lose what was actually bought), qty, unit, status, note, addedBy/buyerId (both set null — the cart belongs to the household, not to whoever typed the line), orderedVia, tripId, createdAt, updatedAt.

The one-active-row invariant

CREATE UNIQUE INDEX "cart_items_productId_active_uidx"
  ON "cart_items" ("product_id") WHERE trip_id is null;

Active means "not yet carried off by a closed trip", so a product appears at most once in the live cart and any number of times across history. The index needs no household_id: a product row belongs to exactly one household, so uniqueness per product is already at least as strict as uniqueness per (household, product) — never weaker.

That is a property of products, not of cart_items' own foreign keys. Those are independent, so the database alone does not force cart_items.household_id to equal products.household_id — keeping the two in step is the router's job (cart.add checks the client's productId against the caller's own catalog before inserting). This is the same app-level guard product.update already applies to a categoryId, and it is a deliberate consistency choice rather than an oversight: closing it in the database would mean composite (household_id, id) keys on products, categories and shopping_trips alike, which is a repo-wide tenancy decision rather than something one feature PR should introduce for one table.

The index is the authority, not a pre-check. Adding a product that is already in the cart raises the existing line instead of minting a second one, and two partners doing it at the same instant race in Postgres rather than on a read. «Помидоры и вверху, и внизу» — the note-app pain this whole product started from — is impossible by construction. Application code must never work around it (AGENTS.md).

shopping_trips is written by exactly one endpoint, trip.close (task 3.2, see Closing a trip below). There is deliberately no "open trip" row: a trip is only ever created at the moment it is closed, and "the current trip" is simply the set of rows with trip_id IS NULL. An open-trip row would be a second source of truth for the same fact, and a household could then have zero or two of them.

qty is numeric(10, 3) in drizzle's number mode — «0.5 кг» has to survive a round trip, and a float would make «0.1 + 0.2» a support ticket. unit and orderedVia are text re-validated on read (the same treatment products.default_unit gets); status is a real pg enum, because it drives every branch of the merge rules and an unknown value there would have no safe fallback.

Merge rules (src/server/cart/merge.ts, pure, unit-tested)

decideCartAdd({ existing, addition, restore }) is the decision half of cart.add with no database in it. Given the product's existing active row:

Existing active row Outcome What happens
none added new needed line, addedBy = caller
needed/ordered, same unit merged qty += added (capped at MAX_QTY), nothing else changes; response carries previousQty
needed/ordered, other unit unitMismatch row untouched — the screen asks
bought boughtExists row untouched — the screen offers «вернуть в нужно»
bought + restore: true restored needed, new qty and unit, re-credited to the caller (addedBy), buyer and orderedVia cleared, note kept

Three of those are decisions rather than implementation details:

  • Different units are never summed. «200 г» + «1 шт» has no answer a program can pick, so the row is left alone — the same principle VISION §3.4 states for building the cart from the week's menu. Guessing would quietly corrupt a shopping list and the shopper would find out at the shelf.
  • ordered merges without falling back to needed. The partner has already put that line in a delivery order; raising the quantity does not un-order it. Symmetrically, ordered → bought keeps orderedVia: a delivered Wolt order was still bought at Wolt.
  • A bought line takes two calls. The restored line takes the new quantity rather than a sum, because the old one has been paid for. restore is scoped to exactly that case — sent for a line that is not bought it is ignored and the ordinary rules apply, so a stale confirmation cannot mean something the shopper never asked for.

Quantities are rounded to the column's own scale, so the number a decision reports is the number the row will hold: 0.1 + 0.2 decides 0.3, not 0.30000000000000004. Both bounds are real rather than pedantry: MIN_QTY (0.001) because anything smaller rounds down to zero and creates a line for none of something, and MAX_QTY (10 000) because it bounds a merged total as well as a single addition — nobody buys ten thousand of anything, and capping the sum keeps a long run of merges from pushing numeric(10, 3) past its own range and turning an ordinary tap into a 500.

A unit is compared exactly as the row stores it. list degrades a unit the app no longer recognizes to «шт» so one out-of-band row cannot fail the whole cart's output validation, but the merge decision never sees that substitution — otherwise a row holding «мешок» would look like a «шт» row and silently sum into it, changing the quantity while leaving the stored unit alone. Compared raw, it simply falls to unitMismatch and a person decides.

Concurrency in cart.add

Inside one transaction: SELECT … FOR UPDATE the product's active row, decide, write. The lock is what makes the read-decide-write safe — two partners adding «помидоры» at once would otherwise both read «2 шт», both compute «3 шт», and one increment would vanish.

A product with no active row locks nothing, so the insert can still lose the unique index. It therefore runs inside a savepoint (drizzle's nested transaction), and that is not decoration: in Postgres a unique violation aborts the entire enclosing transaction, so catching 23505 without one would leave the recovery read failing with 25P02 instead of finding the winner. Rolling back to the savepoint restores a usable transaction, the loser re-reads the winner's row under a lock, and the same merge rules apply to it. Two passes is the whole budget — after a lost race an active row provably exists, so a second miss is a bug, not something to retry.

isUniqueViolation() (src/server/db-errors.ts) is what recognizes the violation; it walks the cause chain, which is not optional since drizzle 0.44 wraps driver errors.

cart router

Procedure Boundary Notes
cart.list householdProcedure Active lines only, joined with product, department and both member names
cart.add householdProcedure { productId, qty, unit, note?, restore? } → the five-way outcome union above
cart.updateItem householdProcedure Partial patch of qty/unit/note/buyerId/orderedVia; LWW
cart.setStatus householdProcedure { id, status, orderedVia? }; LWW
cart.remove householdProcedure Hard delete of the active line — idempotent
cart.receiveOrder householdProcedure { orderedVia? } — bulk ordered → bought (task 2.5), see below

list returns rows ordered by department sortOrder then product name, which is exactly the contract groupProductsByCategory (src/lib/group-products.ts) assumes — it cuts an already-ordered list into sections by walking it, so a different order would silently produce two sections for one department. addedBy and buyerId join users twice under aliases: «кто добавил» and «кто купил» are both on the row and are usually different people. updatedAt is on the wire for task 2.2, which highlights lines that changed between refetches.

setStatus gives each status the fields that only make sense in it, so a row can never describe two states at once: bought stamps the caller as buyer, needed clears both the buyer and the delivery service, ordered records orderedVia when the screen offers one.

Every statement repeats household_id alongside the primary key, and every mutation additionally requires trip_id IS NULL — an id from the client never reaches a write on its own (VISION §6.7), and a line carried off by a closed trip is purchase history rather than something the cart screen may edit. A client-sent productId is checked against the caller's own catalog before it reaches a write, and a buyerId against the household's members, for the same reason product.update checks a categoryId: the foreign key only proves the row exists, not that it belongs here.

remove is deliberately idempotent — no NOT_FOUND when nothing matched. The cart is shared, so both partners removing the same line is ordinary rather than an error, and the offline queue task 2.4 adds will replay mutations after a reconnect.

cart.receiveOrder (task 2.5)

«Заказ получен»: every active ordered line becomes bought in one UPDATE, instead of ticking each one by hand. orderedVia is .nullable().optional() — both an absent key and an explicit null mean "every ordered line, regardless of service"; a concrete wolt/carrefour/other narrows the statement to just that service's lines. There is no third reading worth telling apart, and the UI never has a reason to ask for "no service" specifically.

The buyer rule mirrors setStatus's single-row one, expressed for a whole batch in the same statement — buyerId: sql`coalesce(${cartItems.buyerId}, ${ctx.user.id})` — so a line already assigned (updateItem's «кто берёт») keeps its buyer and only an unclaimed line is credited to whoever tapped the control — decided per row by Postgres, not by a loop in the procedure. orderedVia is cleared on every receipted line: the badge exists to answer "is this on its way", and once it has arrived that question is moot the same way the checkbox's ordered → bought needs no separate "received" state.

A household with nothing ordered (or nothing ordered through the given service) is a no-op — { count: 0, ids: [] }, no error — the same idempotence remove already has. The output is Zod'd (receiveOrderOutput) so the caller gets typed ids back rather than a bare count.

The UI on top of both routers is Cart screen below.

Cart sync (task 2.2)

VISION §6.3's MVP sync model is refetch, not push: every mutation persists immediately, and a partner's view catches up the next time it refetches — on focus, on a background interval, or by hand. src/lib/sync/ is the reusable toolkit that model needs; the S3 screen wires it in rather than reimplementing any of it.

File Exports What it's for
cart-sync-presets.ts CART_REFETCH_INTERVAL_MS, cartSyncQueryOptions The refetch preset for cart-family queryOptions() call sites
diff-list-snapshot.ts SyncRow, ListDiff, diffListSnapshot Pure: compares two refetches of the same list, reports added/updated ids
highlight-state.ts ChangedRowsState, INITIAL_HIGHLIGHT_STATE, nextHighlightState, clearHighlight Pure: the state machine behind the highlight, one snapshot fold at a time
use-changed-rows.ts HIGHLIGHT_MS, ChangedRows, useChangedRows Thin hook shell around the state machine above — { changedIds }
use-manual-refresh.ts ManualRefresh, useManualRefresh Thin hook shell around queryClient.refetchQueries(filter){ refresh, isRefreshing }

The preset. cartSyncQueryOptions spreads refetchInterval: CART_REFETCH_INTERVAL_MS (45s, the middle of VISION §6.3's "~30–60 с" band) plus refetchOnWindowFocus: "always" and refetchOnReconnect: "always" into a useQuery(trpc.cart.list.queryOptions(undefined, { ...cartSyncQueryOptions })) call — the same spread-at-the-call-site pattern autocomplete-sheet.tsx already uses to override trpc.product.search.queryOptions(...) with placeholderData: keepPreviousData. "always" matters specifically because query-client.ts sets staleTime: 30_000 for SSR-hydration reasons unrelated to the cart — under the default focus/reconnect behavior, TanStack Query skips refetching a query that isn't stale yet, so a focus landing inside that 30s window would silently do nothing. That is exactly the "opened the phone by the shelf" moment the model exists for, so the cart opts out of the staleness check rather than inheriting it.

This is deliberately not a QueryClient default. The catalog, settings and kitchen-profile screens have no partner racing to see their own edits, and polling every one of them every 45s would burn requests for nothing; only the cart (the one shared, actively-edited list, VISION §3.1) opts in.

The highlight. diffListSnapshot(prev, next) compares two { id, updatedAt } arrays — cart.list rows satisfy this structurally — and returns { addedIds, updatedIds } by comparing updatedAt.getTime(); a row missing from next is not reported, it just disappears. useChangedRows(items) keeps a ChangedRowsState (the last snapshot plus the currently-highlighted ids) in a ref, folds each new snapshot through nextHighlightState, and clears changedIds again after HIGHLIGHT_MS (4s) via clearHighlight. The first-ever snapshot never highlights anything — there is nothing to diff it against yet, and a first load is not a "change" a person made while looking.

nextHighlightState has one more property beyond the diff itself: when the diff between the last snapshot and the new one is empty, it returns its input state unchanged, same reference, rather than a fresh object with an equally-empty changedIds. That is not a micro-optimization — cart.list rows carry a Date, and superjson mints a new Date instance (and TanStack Query a new array) on essentially every refetch, changed or not, so an empty diff is the common case under cartSyncQueryOptions's 45s poll and always-refetch-on-focus, not an edge case. useChangedRows uses the reference itself as the signal to skip its setState and leave any running highlight timer alone; without that, a highlight started by one refetch would get wiped — and its clear timer cancelled — by the very next no-op poll, typically well before HIGHLIGHT_MS elapses. "Latest diff wins" under rapid refetches (mentioned above) accordingly only applies to non-empty diffs; an empty one is defined to change nothing.

All of the actual branching lives in the two pure functions in highlight-state.ts, not in the hook. That split is not just taste: this repo's vitest config (vitest.config.ts) runs in a node environment and only collects src/**/*.test.ts — no .tsx, no DOM — so a hook cannot be rendered or tested here at all. The pure state machine is what carries the test coverage; the hook itself is a ref, a setState and a setTimeout. (items still has to be the query's own data reference, or a useMemo-stabilized derivative of it — an inline-derived array recreated every render defeats the effect's [items] dependency, which the same-reference bail-out only turns from an infinite render loop into wasted work, not into a no-op.)

Manual refresh. useManualRefresh(filter) wraps queryClient.refetchQueries({ type: "active", ...filter }) with an isRefreshing boolean — the «Обновить» control in S3's toolbar. filter is trpc.cart.list.queryFilter(), the same idiom the screen uses for invalidation, just handed to refetchQueries instead of invalidateQueries. type: "active" overrides matchQuery's own default of "all", so a manual refresh only ever touches the query actually mounted on screen, not every cached-but-unmounted query under the same key prefix. isRefreshing is tracked by an in-flight counter rather than a single try/finally around one call: refetchQueries cancels an in-flight fetch by default rather than deduping it, and the cancelled call's own promise still resolves — so two overlapping taps would otherwise flip isRefreshing back to false as soon as the first (now-cancelled) call settles, while the second tap's fetch, the one that actually wins, is still running.

Push is still post-MVP. None of this touches src/trpc/client.tsx's splitLink groundwork (see splitLink groundwork) — refetch is the whole sync story until a realtime channel exists.

Cart screen (S3, task 2.3)

/ (src/app/(app)/page.tsxcart-screen.tsx), the app's main screen. The page prefetches four queries server-side and hydrates them — cart.list and pantry.list (the two halves of the segment control), category.list (the «изменить продукт» form inside S4) and household.current (the row action sheet's «кто берёт» chips); everything else is client.

Composition. Toolbar («Корзина», the item count, «Обновить») → one block per department → a fixed bottom action bar holding «+ Добавить». Sections come from groupProductsByCategory walking cart.list's server-decided order (department sortOrder, then product name); the screen re-orders nothing else except sortBoughtLast inside each section, which is DESIGN_BRIEF S3's «строка зачёркивается и опускается вниз секции». Sorting the flat list first would move a bought row across a department boundary and split that department into two sections under the same walk.

The four decisions the screen makes are pure modules under src/lib/cart/, for the same reason src/lib/sync/ splits its hooks: vitest here runs in a node environment and collects src/**/*.test.ts only, so nothing that must be rendered can be covered.

File Exports What it decides
sort-rows.ts sortBoughtLast Stable partition, bought last, ordered staying live
status-toggle.ts toggledCartStatus, applyStatusToggle What the checkbox means, and the optimistic cache patch
add-outcome.ts describeCartAddOutcome, CartAddToastKey cart.add's five outcomes → toast / highlight / confirm
qty-step.ts stepQty, canStepQty, parseTypedQty, resolveDraft, nextQtyFromDraft, qtyForUnitChange, qtyStepFor, defaultQtyFor, formatQtyNumber The S4/row-editor qty field's stepping, typing and unit-switch rules
own-changes.ts markOwnChange, withoutOwnChanges Which rows this client changed, so the highlight is honest

Quantity stepper: per-unit steps and typed values (task Б4). Before this, «+»/«−» moved every unit by 1, so buying 250 g meant 250 taps — and the value could not be typed at all. QtyStepper's value is now a real <input type="text" inputmode="decimal"> between the two buttons, and src/lib/cart/qty-step.ts owns every rule that decides what it shows next — two independent halves, typing and stepping, plus the draft-resolution glue between them that the component itself now only calls into rather than deciding inline:

Unit Step (qtyStepFor) Default on a fresh line (defaultQtyFor)
шт / уп / пучок / банка / плитка 1 1
г / мл 50 100
кг / л 0.5 1
  • Stepping (stepQty) snaps to the unit's own grid rather than adding the step outright, because the current value need not be on it (a typed «30 г» is not a multiple of 50): «+» moves to the next grid line above the current value, «−» to the next one below, floored at one whole step — never at MIN_QTY — and disabled there (canStepQty). A value sitting below the floor already (that «30 г») is left unchanged by «−», not snapped up to the floor (fixed in a review round after this task's own PR opened, task Б4): snapping up would make «−», labelled «Уменьшить количество», increase the value to the same number «+» would reach, with no way back to the sub-floor value except retyping it. canStepQty agrees — «−» disables at a below-floor value exactly as it does at the floor itself, since there is genuinely nowhere lower on the grid for it to go.
  • Typing (parseTypedQty) is deliberately looser: it accepts a plain integer, either decimal separator («0,5» or «0.5» — the on-screen keyboard's own key), and space-grouped thousands («1 500»), and allows a value below one step or fractional (0,3 кг is a real quantity) — clamped only to [MIN_QTY, MAX_QTY], the same bounds addCartItemInput/updateCartItemInput already enforce. A whole-item unit (шт/уп/пучок/банка/плитка) rounds a typed fraction to the nearest integer instead of accepting it («2,5 шт» → 3). Empty input, non-numeric text and non-positive values return null; the field reverts to the last valid value and shows an inline hint rather than passing anything on.
  • Resolving the draft (resolveDraft(text, qty, unit)) is the one place that turns the field's raw text into { value, display, invalid } — a parse success always reformats display through formatQtyNumber, even when the parsed number equals the value already showing (a discrete unit's «2,4» rounding back to 2, or mere reformatting like «0.5» → «0,5»), so the shown text never lags the committed number; a parse failure falls back to the last committed qty and reports invalid: true. Blur, Enter and QtyStepperHandle.commitPending() (the imperative ref both callers hold, flushed right before «В корзину» / «Сохранить» and before S4's «Изменить», which unmounts the stepper for ProductEditForm) all commit through this one function.
  • Stepping from the draft (nextQtyFromDraft(text, qty, delta, unit)) is what the «+»/«−» buttons call — it resolves the draft first and steps from that, not from the possibly-stale qty prop (WebKit and macOS Firefox do not blur a focused input on a <button> tap). Two fixes from a review round after this task's own PR opened (task Б4) live here: an invalid draft does not step at all — stepping it would step from qty and, whenever that step happened to be available, silently clear the rejection hint the same tap was meant to leave visible; and the caller always writes the field's text/invalid state directly from this function's return value rather than relying on a qty-keyed resync effect, because the stepped result can land exactly on the value already sitting in qty (e.g. a committed «1 шт» with an uncommitted «2» typed, «−» steps the draft to 1 — the number already there — so onQtyChange(1) is a no-op React bails on, and only an explicit redraw shows the field the corrected «1» instead of leaving the stale «2» on screen). aria-disabled on both buttons is computed from this same resolved draft value (parseTypedQty(text, unit) ?? qty), not from qty alone, so the greyed/enabled state never describes a different number than the one a tap would act on.
  • Unit changes (qtyForUnitChange) keep a shopper-set number exactly as typed — a person who typed 250 and then switched «г» → «кг» meant their 250, not 250 kg. Only a line still showing its old unit's own default (untouched) swaps to the new unit's default.
  • The «+»/«−» buttons use aria-disabled with a click-handler guard rather than the disabled attribute — inside BottomSheet's focus trap, disabling the button that currently holds focus would drop focus to <body>, outside the dialog.
  • formatQtyNumber (comma decimal, no thousands grouping) is the one place the stepper's own field, formatRecipeQty (src/lib/recipes/rescale.ts) and the cart row itself (its qty text and its checkbox's aria-label, cart-screen.tsx) get a number's text from, so a fractional quantity (a кг/л line at its 0.5 step, or any typed decimal) can never render with a stray Latin-dot «0.5» in one place and a comma «0,5» in another.
  • Focus rescue on S4's phase switch. Switching between the "quantity" and "editing" phases (AutocompleteSheet) unmounts whichever control currently holds focus — «Изменить», or ProductEditForm's own Cancel/Save — with nothing else in the sheet to claim it, dropping focus to <body> outside BottomSheet's aria-modal panel. ProductEditForm rescues focus onto its «Название» field on mount; AutocompleteSheet rescues it back onto «Изменить» when the phase returns to "quantity". Both use the same document.activeElement === document.body (or null) guard as adaptation-sheet.tsx's own rescue and pickImportFocusTarget (src/lib/recipes/import-focus.ts) — rescue only when focus is actually lost, never steal it from something legitimately focused.

The optimistic checkbox is the first optimistic mutation in the repo and the pattern the rest should copy. onMutate awaits queryClient.cancelQueries(cartFilter), applies applyStatusToggle, and remembers the row's previous status; onError re-applies that one status; onSettled invalidates. Two fields are deliberately not patched: updatedAt (see the highlight note below) and buyerId (the server stamps the caller on bought and clears it on needed; guessing would render a «кто берёт» a failed request has to take back).

Rollback is per row, not a whole-list snapshot. Overlapping toggles are ordinary — ticking down a shelf is exactly that — and a snapshot taken before row A's request knows nothing about row B's. Restoring it would wipe B's optimistic tick when A fails, and re-apply A's when B fails. Re-applying the inverse to the failed row alone touches only what actually failed, cannot resurrect a row a refetch removed in the meantime, and leaves onSettled's invalidate as the healer for everything else.

Three separate things stop a refetch from un-ticking a row mid-flight, and they cover different windows:

  • cancelQueries in onMutate stops what is already in flight.
  • The query options mute refetchInterval / refetchOnWindowFocus / refetchOnReconnect while useIsMutating({ mutationKey: trpc.cart.pathKey() }) is non-zero, because a trigger firing after onMutate starts a fresh request that was dispatched before the write landed and answers with the pre-write list. pathKey() is already the shared key across every cart.* mutation — tRPC sets mutationKey itself, after spreading the caller's options, so it cannot be overridden per call site. «Обновить» is disabled for the same window.
  • Nothing is lost by waiting: onSettled's invalidate refetches the moment the write settles. What remains is a genuine last-write-wins tolerance (partner writing the same row in the same instant), which VISION §3.1 accepts.

The highlight only ever means "someone else". useChangedRows diffs updatedAt and cannot tell whose change it is looking at — and every toggle ends with the server stamping now() and the screen invalidating, so without help the refetch reports your own tick and lights your own row up. Not patching updatedAt optimistically is necessary but not sufficient: the snapshot the diff compares against still holds the old timestamp, so the flash happens on the refetch regardless. own-changes.ts closes it — markOwnChange records the id in onMutate with an expiry of now + HIGHLIGHT_MS, and withoutOwnChanges filters those ids out of changedIds at render. It is time-bounded rather than consume-on-first-sight because changedIds stays populated for the whole window, so a mark that cleared on first observation would just let the highlight reappear on the next render. The cost is that a partner's change to the same row inside that window is muted too — a few seconds of silence on one row, against a false "someone else touched this" on every single tap. Scoped to setStatus: the add flow's own highlight is wanted.

Two highlight treatments, not one. .rowChanged is mockup 1b's plain --accent-soft wash — «партнёр что-то поменял», arriving unprompted, so it stays quiet. .rowActed adds mockup #1h's inset 2px 0 0 var(--accent) edge — it answers a tap, and on a merge it is the only thing pointing at the row the quantity went into. Own action wins when both would apply.

Double-tap guards are refs, never render state. Worth stating plainly, because the wrong version looks correct: mutation.isPending, a useState flag and a disabled attribute are all applied by React after the handler returns, so two taps landing in the same event-loop turn read the same pre-tap value and both get through. Only a ref is written and read synchronously.

What losing that race costs differs per call site, which is why each one is locked:

  • The checkboxpendingRef, a Set keyed by row id. Per row, because ticking three things one after another is ordinary shopping and must never block. Two setStatus calls for one row race, and last-write-wins settles on whichever arrived second rather than on what the shopper last tapped.
  • «В корзину»addBusyRef in the screen, plus the sheet's own busyRef. cart.add merges, so two adds of «2 шт» leave 4 in the cart: stateful damage, with nothing on screen admitting the second tap did anything. Both entry points (S4 and the restore confirmation) route through submitAdd, so one lock covers both.
  • «Создать „…“» — the sheet's busyRef again. Two AI calls and two ai_jobs rows; this one costs money.

isPending and disabled stay, but for rendering only — the pending label and the greyed-out button. They are feedback, not the guard. A useState mirror of pendingRef exists for exactly that reason.

The row checkbox is never given the disabled attribute, though. A control that becomes disabled loses focus (verified in-browser: focus survives aria-disabled, and is dropped by the real attribute), so every keyboard toggle would throw the user back to the top of the page mid-shop. The ref lock is what prevents the second fire; aria-disabled and aria-busy expose the state, and a data-pending attribute carries the visual affordance.

The toast is a permanently-mounted live region plus a separate visual card. A role="status" node that is mounted together with its content is not reliably announced — assistive technology has to have been watching the region before the text arrived — so the region is an always-present visually-hidden <p> whose text swaps, and the ink card is aria-hidden. Toast state is { message, seq } rather than a bare string: raising the same message twice (adding «Помидоры» twice in a row) must restart the 2.5s dismiss timer, and an unchanged string leaves the effect's dependency unchanged.

Add flow. «+ Добавить» opens S4; S4 reports { product, qty, unit } and the screen calls cart.add. describeCartAddOutcome maps the answer:

Outcome Toast Highlight Then
added «{icon} {name} — в корзине» the row close S4
merged «…уже в корзине — количество обновлено» the row close S4 (mockup #1h)
unitMismatch «…в других единицах — измени вручную» the row close S4; no auto-edit — that is 2.5
boughtExists none the row confirmation sheet → re-add with restore: true
restored «{name} — снова в корзине» the row close S4

Every outcome highlights its row rather than leaving it to the refetch. For the three that wrote something the refetch would eventually notice anyway and this just beats the round trip; for unitMismatch and boughtExists it is the only way, because nothing was written, updatedAt did not move and diffListSnapshot has nothing to see. boughtExists carries no toast on purpose: a question and an announcement competing for the same corner is how the question gets missed. The confirmation is a separate BottomSheet that opens as S4 closes rather than a second modal stacked on it — focus, Esc and the body scroll lock all stay owned by exactly one sheet.

The header (app-header.tsx) shows the participants' avatars — partners first, the caller's own last so the one that is also a 44px link into Settings is never partly covered — and DESIGN_BRIEF's «тихая иконка-часики» while useIsFetching(trpc.cart.pathFilter()) or useIsMutating({ mutationKey: trpc.cart.pathKey() }) is non-zero. Both are router-level key helpers and TanStack matches keys by prefix, so one filter each covers cart.list's refetches and every cart.* mutation, including the ones 2.5 adds. Idle shows nothing at all; offline (the brief's third state) replaces the mark with a --null-txt dot — see the offline queue below for why that state has to win over «синхронизируем». Members come from the household.current the (app) layout already loads for its gate, passed down as props — no second query.

Deferred, and why the screen looks incomplete without it: the «Корзина | Кладовая» segment control (3.1, replaces the toolbar's title/count pair) and «Завершить закупку» (3.2, joins «+ Добавить» in the action bar). An ordered row renders with an unticked box, and ticking it sets bought — which is the safe direction under last-write-wins whichever way the line got there.

Row action sheet, badge, buyer avatar, «Заказ получен» (task 2.5)

A tap on a row's body — not the checkbox — opens CartItemSheet. The checkbox needed its own dedicated hit area to make that split possible: .checkboxTarget is a <label> padded out to the 44pt minimum and pulled back with an equal negative margin (the standard "bigger tap target, same footprint" trick), and .rowBody is a plain reset <button> holding everything else in the row. Splitting a <label> that used to wrap the whole row into these two siblings is what lets the checkbox keep doing exactly one thing while the rest of the row does another.

CartItemSheet is plain mutate + invalidate, deliberately unlike the checkbox: none of qty/unit/note, «кто берёт» or «заказано» is perf-critical the way ticking down a shelf is, so there is no optimistic cache patch to keep in sync with a rollback for any of it. One busyRef locks the whole sheet rather than one per control — a person edits one field at a time here. item is looked up fresh from cart.list's cache on every render of the parent screen (cart-screen.tsx's editingItem = items.find(...)) rather than captured once at open, so a save is visible in the sheet itself the moment the invalidate it triggers lands, without closing and reopening it.

The local qty/unit/note draft is reset on the edited row's id, not on the item object. Task 2.2's background poll and always-refetch-on-focus give every cart.list snapshot a fresh object identity whether or not the row actually changed, so an effect keyed on item itself would reset the draft on every one of those refetches — silently overwriting a note or a stepped qty the shopper has typed but not yet saved. Keying the effect's dependency array on item?.id instead (reading the current item.qty/.unit/.note inside it, deliberately left out of the array) makes it re-seed only when the sheet opens or the row it is showing changes, never merely because the same row's object reference did.

A paused updateItem/setStatus is not "busy" — a paused remove still is. All three use the default networkMode: "online", so a write made offline pauses before its request ever leaves (task 2.4) and its mutateAsync promise does not settle until the connection returns — sometimes minutes later. For updateItem/setStatus, busy is derived as isPending && !isPaused, not from a plain useState, and a separate effect releases busyRef the moment either pauses rather than holding it for the whole round trip: both are last-write-wins on a row that still exists, never merging like cart.add, so a second tap queuing a newer edit behind an already-paused one is an ordinary conflict the queue already resolves.

remove gets no such exception — busy includes a bare remove.isPending, paused or not, and the release effect skips entirely while it is pending. Once a removal is queued the row is on its way out entirely, and the offline queue delivers concurrently rather than in dispatch order (Promise.all over whatever is queued): a same-row updateItem/setStatus allowed to queue behind it can just as easily land after the delete, and activeItemScope then finds no row to act on — a NOT_FOUND for an edit the shopper has no reason to think failed. Locking the whole sheet for as long as a removal is in flight, including while it waits in the queue, is what rules that race out; a queued boolean (the same three isPaused flags, remove included) renders S3's own «ждёт синхронизации» copy as a status line so the lock does not look like nothing is happening.

The sheet's own edits get the same own-change suppression the checkbox does. CartItemSheet takes an onMutated(rowId) prop — cart-screen.tsx wires it to markOwnChange(ownChangesRef.current, rowId, …) — called once before an action dispatches and again on success, the same double-mark setStatus already does for the same reason: a write queued offline lands, and triggers its own refetch, minutes later, well past the first mark's window. Without this a note, buyer or service set from the sheet would flash as «партнёр что-то поменял» on the very next refetch the sheet's own invalidate causes.

Sections:

  • Qty/unit reuse QtyStepper (src/components/qty-stepper.tsx), extracted out of AutocompleteSheet in this task so S4's stepper and the row sheet's editor can never quietly drift apart on step size or bounds. It takes its aria-label strings as props rather than calling useTranslations itself — S4 and the row sheet read different namespaces (autocomplete, cart), and a shared presentational component has no business picking one for the other. The row editor opens the field on the row's own stored qty/unit (item.qty/item.unit, seeded by the effect above) rather than a default — see «Quantity stepper: per-unit steps and typed values» above for the per-unit step/default table and the typing rules task Б4 added.
  • Note is a plain text input; saving trims it and maps an empty string to null (updateItem's "clear it" reading), bundled into the same updateItem call as qty/unit.
  • «Кто берёт» is a chip row: «Никто» plus one chip per household member (household.current's members, prefetched in page.tsx alongside cart.list/category.list — a plain client useQuery in the screen, not props threaded through the (app) layout, since a layout cannot hand a page anything but an opaque children). Tapping a chip calls updateItem({ buyerId }) immediately.
  • «Заказано» shows the three services; tapping one calls setStatus({ status: "ordered", orderedVia }) on a still-needed line or updateItem({ orderedVia }) on one already ordered (re-picking the same service on an ordered line needs the latter — setStatus's ordered branch only touches orderedVia when the status is changing). Hidden entirely once a line is bought — nothing here un-buys a line; the checkbox does. «Вернуть в «нужно»» (setStatus({ status: "needed" })) only shows once the line actually is ordered.
  • «Удалить» calls cart.remove and closes the sheet on success.

The badge, the avatar, and the note are the same ProductRow, rendered differently. avatarInitial (src/lib/avatar-initial.ts) — the first grapheme of a name, upper-cased, via Intl.Segmenter rather than .charAt(0) (which would cut a leading surrogate-pair emoji in half) — is shared between this avatar and the header's own, so the two can never disagree about which letter represents the same person. An ordered row switches .rowName to flex: none and gives .rowQty margin-left: auto (mockup 1a): the badge sits right after the name instead of being stretched away from it, and the quantity — and the buyer avatar after it — claim the freed-up space instead. The badge itself (.rowBadge) is «Заказано · Wolt» / «· Carrefour» for those two services and plain «Заказано» for other; orderedVia is cleared by receiveOrder, so a received line shows no badge at all, plain bought like any other.

«Заказ получен» (groupOrderedByService, applyReceiveOrder, rollbackReceiveOrder, receivableServiceGroups — all pure, in src/lib/cart/receive-order.ts) is the bulk counterpart of the checkbox's optimistic toggle, adapted to a batch:

  • groupOrderedByService(items) walks the current list and returns one entry per distinct orderedVia among ordered rows (fixed order woltcarrefourothernull, so the bar does not reshuffle as rows move between services), each with a count.
  • receivableServiceGroups(groups) drops the null group before the bar renders anything: cart.receiveOrder({ orderedVia: null }) means "every service" server-side, not "only the service-less rows", so a button for that group could mark Wolt/Carrefour rows bought too. Such a row cannot arise from this app's own writes (CartItemSheet's service picker always supplies a concrete service alongside the ordered transition) — it stays individually receivable via its own checkbox regardless. cart-screen.tsx's handleReceiveOrder is typed on a bare OrderedVia (no null) so this cannot regress by accident at a call site.
  • applyReceiveOrder(list, orderedVia) is the optimistic patch: every ordered row (or, narrowed, every row ordered through the given service) moves to bought with orderedVia cleared. buyerId and updatedAt are deliberately left alone — same reasoning as applyStatusToggle's single-row patch: the server decides the buyer (COALESCE) and stamps the real timestamp, and guessing either here would just have to be taken back the moment the invalidate lands.
  • rollbackReceiveOrder(list, snapshots) undoes exactly the rows applyReceiveOrder touched — the bulk analogue of the checkbox's per-row inverse, for the same reason: an unrelated tick landing mid-flight on a different row must survive a failed batch's rollback.
  • The own-change mark (markOwnChange) is written for every affected row, both at the tap (onMutate) and again at settle (onSettled) — same double-mark reasoning setStatus already has for the queue: a receive queued offline can be delivered minutes later, well past the first mark's window, and the refetch it triggers must not light those rows up as «партнёр что-то поменял».
  • Per-group pending state (receivePendingRef/receivePendingKeys, keyed by orderedVia ?? "__none__") is the same ref-then-state pairing the checkbox's pendingRef/pendingIds uses, sized for the handful of buttons the bar ever shows.

The offline queue (task 2.4) now also covers cart.receiveOrder. installOfflineQueue registers it exactly like the other three mutations; the persist filter needed no change at all — matchesTrpcPath is a path-prefix match against trpc.cart.pathKey(), so anything under cart.* is already covered, new procedures included (a dedicated test proves this rather than assumes it).

The 🕐 "queued" mark needed real work, though, and not the first version this task shipped: queuedCartRowIds (src/lib/sync/queued-mutations.ts) cannot resolve a queued receive's rows against the current cart list, because by the time anything downstream looks, onMutate's own optimistic patch has already moved every affected row out of the ordered status that would otherwise identify it — a status === "ordered" lookup against live data finds nothing, every time, for every bulk receive that ever gets queued. The fix reads a queued receive's row ids from its own onMutate context instead (applyReceiveOrder's return shape, { snapshots }) — useQueuedCartRows passes mutation.state.context through, and for a mutation whose variables have neither id nor productId (the one shape left once those two are ruled out — receiveOrder's input is { orderedVia? } and nothing else) queuedCartRowIds reads the snapshot ids straight out of it. dehydrateMutation persists the whole state object, context included, so this holds for a mutation restored from IndexedDB after a reload too, not only a live one.

Offline queue (task 2.4)

VISION §6.3: a tap made in a basement supermarket must survive the phone being put in a pocket and iOS killing the PWA. There is no Background Sync API on iOS and an in-memory queue dies with the process, so the queue is persisted to IndexedDB and delivered while the app is open — on the online event, and on reopening.

There is no bespoke queue. TanStack Query already has one: with the default networkMode: "online" a mutation dispatched while offline is paused before its mutationFn ever runs, and sits in the mutation cache until something resumes it. Task 2.4 makes that cache durable and gives it delivery triggers, rather than reimplementing it beside itself. Three new dependencies: @tanstack/react-query-persist-client, @tanstack/query-async-storage-persister, idb-keyval. No new env vars.

File What it is
src/lib/sync/offline-cache.ts Pure: buster/max-age constants, the superjson envelope, the "what may be persisted" filters
src/lib/sync/delivery.ts Pure: what may be sent again, the retry policy, queue membership, cross-context identity
src/trpc/offline-queue.ts Browser wiring: IndexedDB storage, persister, mutation defaults, delivery triggers
src/lib/sync/use-is-online.ts useIsOnline() over onlineManager, plus primeOnlineManager()
src/lib/sync/queued-mutations.ts / use-queued-rows.ts Pure extraction + hook: which rows carry a queued change (the 🕐 marks)

Where it is mounted. TRPCReactProvider (src/trpc/client.tsx) now renders PersistQueryClientProvider in place of QueryClientProvider — the same provider plus a restore-from-storage effect and an isRestoring context. It is rendered on the server too, with an inert persister that stores nothing (createInertPersistOptions). Swapping providers by environment instead would give the two sides different isRestoring values, and that context forces fetchStatus to idle — i.e. a hydration mismatch on any screen that renders a loading state. The browser query client, the tRPC client and the queue's event listeners are all built once, lazily, in a module-level singleton (getRuntime), so nothing is installed twice.

What is persisted (dehydrateOptions, filtered by the real trpc.cart.pathKey() / trpc.cart.list.queryKey() rather than by hardcoded strings):

  • cart.* mutations the router provably has not seenisQueuedMutationState decides, and it is the same test used to pick what gets resumed. Two states qualify: paused (with networkMode: "online" a mutation dispatched offline pauses before its mutationFn runs, so it never left the device) and retrying after an undelivered failure (it left and came back unanswered, so the write did not happen). This is deliberately wider than TanStack's own defaultShouldDehydrateMutation, which persists paused mutations only — see the retry policy below for why a paused-only filter erases the queue.
  • A successful cart.list — a warm list on reopen when the server prefetch is slow or fails. It cannot overwrite the RSC prefetch, because that prefetch is dehydrated as success with a real dataUpdatedAt (see the prefetch/hydration contract): on a cold load the cache is still empty when HydrationBoundary runs — the persister's restore is async — so the RSC entry is hydrated during render, and the restore's own hydrate() then takes the already-in-cache branch and loses the dataUpdatedAt comparison against it. An envelope that is newer than the server's answer still wins, which is the intended order. cartSyncQueryOptions sets gcTime to the offline cache's own maxAge for this reason: the persister dehydrates whatever is in the cache at the time of a save, so under TanStack's 5-minute default, walking away from S3 garbage-collects the list, the removed event triggers a save, and the envelope loses it hours early. Scoped to this one query — making every product.search immortal would grow the cache with each keystroke.

What is deliberately not persisted:

  • A first attempt still in flight. There is no evidence either way about whether it landed — the tab can be killed between the request leaving and the response arriving — and cart.add merges, so guessing wrong silently turns «2 шт» into «4 шт».
  • Every other query. The catalog, categories and kitchen profile are cheap or irrelevant to someone standing in a shop, and would only make the payload bigger and staler.

When it is written. The persister saves on every mutation- and query-cache event, but only from the moment PersistQueryClientProvider subscribes it — which happens after the restore, not before — so nothing is durable during startup. throttleTime is 0 rather than the library's 1000ms default: on iOS a backgrounded PWA has its timers suspended and may then be killed, so a deferred save is a save that never happens, and the payload (one cart list plus a few mutations) is far too small to trade a lost tap for. On top of the event-driven saves there are explicit ones on pagehide and on visibilitychange → hidden — the last moments an iOS PWA is guaranteed to run — and one after each delivery round, so storage stops listing what has already gone.

The envelope is validated on the way back in. superjson.parse<PersistedClient> is a type assertion, not a check, and hydrate walks queries and mutations without guards — a truncated write or a hand-edited entry throws mid-loop, leaving the cache half-populated and the storage purged by the restore's error path. deserializeOfflineCache runs the decoded value through a Zod schema first, so a bad payload is rejected before anything is hydrated and the app simply boots on the server's data. The schema is deliberately shallow — the entries are TanStack's own DehydratedQuery/DehydratedMutation, and re-declaring their inner state would break on the library's next minor release, a worse failure than the one it guards. It checks exactly what hydrate dereferences; everything deeper is already treated as untrusted where it is read (queuedCartRowIds, isQueuedMutationState).

The envelope is superjson, not JSON.stringify. Query data is already superjson-encoded by the dehydrate/hydrate hooks in query-client.ts, but a mutation is dehydrated raw — TanStack copies state.variables and whatever onMutate returned as context straight into the payload with no serializeData hook. Today's cart variables are plain uuids and numbers; the first optimistic context holding a row snapshot (updatedAt is a Date) would come back as a string and blow up on the next .getTime().

Buster and max age. OFFLINE_CACHE_BUSTER is derived from OFFLINE_CACHE_VERSION (larder-cart-v1) — bump the version, never the string. Bump it when cart.list's output shape, any cart.* mutation input shape, or the serializer changes: a stored payload whose buster does not match is dropped whole rather than migrated, which is the right trade against replaying writes at a contract that has moved. maxAge is 48h; a queued tap older than that is no longer a fact about the cart (someone has since bought, removed or re-added the thing), and the cached list goes with it because the queue and the snapshot it was made against are one payload.

Restoring a mutation needs its function back. A dehydrated mutation carries its key, variables and state — never its mutationFn, which is a closure. installOfflineQueue therefore calls queryClient.setMutationDefaults(key, { mutationFn }) for each of cart.add/setStatus/updateItem/remove/receiveOrder, using the standalone tRPC options proxy (createTRPCOptionsProxy({ client, queryClient })). Without it, resuming fails with «No mutationFn found».

The defaults are only the function; the rich optimistic wiring stays at the S3 call sites and deliberately does not run on resume. TanStack skips onMutate entirely for a mutation whose state is already pending (Mutation#execute), which is exactly right — the patch it would apply was applied in the previous session — and there is nothing left to roll back to, because the snapshot lived in the cache of a tab that no longer exists. So the resume path is simply: deliver, then re-read the cart.

Delivery triggers. QueryClient#mount already resumes paused mutations on onlineManager's online event and on focusManager's visibilitychange — together exactly VISION §6.3's «доставка при открытом приложении», including the iOS-PWA reopen. installOfflineQueue subscribes to the same two managers for the parts TanStack's own resume does not cover:

  • Restored mutations that are not paused. resumePausedMutations() looks only at isPaused; one retrying after an undelivered failure is just as much a member of the queue. flush() calls mutation.continue() over everything isQueuedMutationState matches, which resumes a paused retryer or executes a restored mutation from its variables.
  • The refetch after the queue drains. S3 mutes its passive refetch triggers while useIsMutating is non-zero, resumed mutations count as mutating, and TanStack's own post-resume queryCache.onOnline() can be swallowed by a mute React has not re-rendered out of yet.

flush() resolves when the round is done and never rejects; production callers ignore the promise. onRestored() — what PersistQueryClientProvider's onSuccess gets — returns void immediately and must keep doing so. The provider chains onSuccess before flipping isRestoring to false, and it does not subscribe the persister until that flips: awaiting delivery there would leave the app in its restoring state, with nothing persisted at all, for as long as delivery took — which, with a queue that retries until the server answers, can be the whole session.

Retry policy: keep what the server has not answered, drop what it has. This is the one decision that protects the queue from a silent wipe, and it lives in shouldRetryDelivery/isUndeliveredFailure (src/lib/sync/delivery.ts), registered through setMutationDefaults so live and resumed writes share it.

TanStack's default retry: 0 rejects on the first failure without re-consulting onlineManager. A premature online event — a captive-portal Wi-Fi association, where navigator.onLine reports a connection that cannot reach anything — therefore resumes the whole queue, every call dies at the network layer, and every mutation settles as an error. An errored mutation is no longer queued, so the next persist writes an envelope without it: the entire queue erased from IndexedDB, silently, having never reached the server, under a banner promising «изменения сохранятся».

The classifier is the tRPC error code (trpcErrorCode, src/lib/trpc-errors.ts): the router puts a code on everything it produces, so a coded error is the server's answer (fail fast — the row a partner removed, a CONFLICT) and an uncoded one never reached a procedure (a dead fetch, a proxy's 502, a portal's interception page — retry). Retries are unbounded for the uncoded case: a capped count would only move the cliff a minute later. The retryer's own backoff (1s, 2s … 30s) makes that cheap, and it is self-limiting where it matters — when the device really goes offline, canContinue() fails, the retryer pauses rather than running, and the mutation is written back to storage as a paused one.

A dropped mutation settles as an error, stops matching the queue test, and is therefore no longer persisted — it cannot wedge the queue or return on the next reload, and since no mutation sets a scope, nothing queues behind it. Nothing is announced: the tap belonged to a previous session, and the invalidate that follows puts the true state on screen. cart.remove is already idempotent for exactly this reason (no NOT_FOUND when nothing matched).

Two contexts, one envelope. A PWA and a browser tab can be open on the same origin and share this storage, so both can restore the same queue and both would deliver it. Two things guard that:

  • Delivery runs under an exclusive Web Lock (feature-detected, iOS 15.4+; a 5s acquisition timeout means a context stuck retrying can never block another one's startup — the round is simply skipped and retried on the next trigger). The rewrite-to-storage is inside that lock, not after it: released a moment early, the other context could acquire the lock and read an envelope that still listed everything this one just delivered.
  • Before sending, flush() re-reads the stored envelope and drops restored mutations storage no longer lists (mutationIdentity = mutation key + submittedAt, since mutationId is not dehydrated). Only mutations captured at restore are eligible — a live tap has not been anywhere near another context.

An absent envelope is deliberately not treated as evidence of delivery: a context that drained the queue always leaves one behind, so "delivered elsewhere" always looks like a present envelope that no longer lists the mutation. Absent means something else (expired, purged by sign-out, a read that raced a write), and dropping on it would throw away taps nobody has sent.

The residual: two contexts that restore in the same instant, before either has rewritten the envelope, can still both deliver. Only cart.add is non-idempotent under that, and it surfaces as a quantity the household can see and correct.

Mutations delivered into a closed trip land in the current cart by the ordinary merge rules — the server needs no special case, because an active row is trip-less by design (see the one-active-row invariant).

onlineManager is primed from navigator.onLine once, at client creation (primeOnlineManager). It otherwise starts optimistically true and only moves on the window's online/offline events, so a tab loaded while offline would report online and every mutation would fail outright instead of joining the queue.

The UI reads the queue, it does not track it (mockup 1c). The banner («Нет связи — изменения сохранятся») is useIsOnline(), a useSyncExternalStore over onlineManager — the same source of truth that decides whether a mutation runs or pauses, so it can never disagree with what the queue is doing. getServerSnapshot is always true: HTML that arrived over the network has no business saying there is none. The per-row 🕐 marks come from useMutationState filtered to trpc.cart.pathKey(), with queuedCartRowIds (pure, tested) pulling row ids out of the variables — or, for cart.receiveOrder (task 2.5), out of its own onMutate context instead, since by the time this runs the row has already been optimistically moved out of the status that variables-based matching would need (see «Заказ получен» above). Paused only, never merely in flight: a mutation on the wire already shows data-pending from task 2.3, and 🕐 means "waiting for the connection", not "waiting for the server". A queued cart.add marks nothing — it names a product, not a row, and the line it will create or merge into does not exist yet. In the header, offline wins over «синхронизируем»: a paused mutation still counts as mutating, so without that precedence the header would claim to be syncing for as long as the connection is gone. «Обновить» is disabled while offline, where a refetch would not fail but pause, leaving the control spinning on a promise it cannot keep.

Signing out clears the cache (sign-out-button.tsx): the query client is a tab-lifetime singleton and sign-out does not reload the page, so without queryClient.clear() the next person to sign in on the device would be shown the previous household's cart — now out of storage rather than just out of memory. clear() alone is not enough for the stored copy: it emits the cache events the persister listens to, but that write goes through the persister's throttle and nothing awaits it, so a reload landing in the gap would restore the household just signed out of. queue.purge() (persister.removeClient(), which goes straight at the store) is therefore awaited right after. A save already scheduled can still land afterwards, but asyncThrottle keeps only the latest arguments and the cache is empty by then, so the worst it re-creates is an empty envelope. Before all of that, one bounded delivery attempt runs (resumePausedMutations() raced against a 2s timeout) — and it has to run before signOut(), because afterwards every queued write would come back UNAUTHORIZED. It is an attempt, not a guarantee: offline, resumePausedMutations resolves at once and the queue is dropped with the cache. The auth boundary wins over an undelivered tap, because the alternative is keeping one person's writes on a device the next person is about to sign in on.

Known limits, all accepted for the MVP.

  • No service worker yet, so opening the app cold while offline still fails at the HTML request. The queue covers "went offline while it was open, then the PWA was killed" — the iOS case VISION §6.3 is written about.
  • The per-row tap lock from task 2.3 is not released when a mutation pauses, so a row with a queued change cannot be re-tapped until the queue drains within that session. Across a reload the lock is gone while the queued mutation is not; two queued changes to one row then resolve by last-write-wins like any other conflict.
  • The add flow is effectively online-only. S4's autocomplete query pauses offline and says so («Нет связи — поиск недоступен»), so the flow normally dead-ends at the search step. The one way past it is to open S4 online, get results, then go offline before tapping «В корзину»: the add is queued and will be delivered, but submitAdd awaits mutateAsync for the outcome that decides the toast — added / merged / unit conflict / already bought — so the sheet's «Добавляем…» stays up until the connection returns or the person closes it by hand. Guessing an outcome the server has not decided is worse than waiting for it, so the honest fix is an offline add path with its own copy — a job for 2.5, which reworks that sheet anyway.
  • Delivery is at-least-once, not exactly-once. Two cases can send a write the server already applied: a crash after the write but before the response (indistinguishable from "never arrived"), and two contexts restoring the same envelope in the same instant. setStatus, updateItem and remove are idempotent, so only cart.add can show it — as a doubled quantity, which the household can see and correct. The alternative (drop anything unproven) trades a visible, correctable error for an invisible, uncorrectable one; idempotency keys are the real fix and are post-MVP.
  • A queued change delivered in a later session can still flash as a partner change. cart-screen.tsx re-marks the row in onSettled so a same-session delivery is suppressed, but a mutation restored from storage has no observer and no ownChangesRef continuity across a reload. Highlighting a row that genuinely changed since the screen opened is defensible; it is simply not distinguishable from the partner's edit.

Pantry

"What's at home" (VISION §3.2) — presence only, no quantities, no expiry dates: a conscious scope cut, the same reasoning VISION gives for why full stock-level tracking gets abandoned in practice. Task 3.1 builds the model, the pantry router, the S5 screen and the S3/S5 «Корзина | Кладовая» segment control; task 3.2's «Завершить закупку» (Closing a trip) is what populates the table — bought cart lines moving here on trip close is the only way a row ever appears.

pantry_items (src/db/schema.ts): householdId, productId (FK restrict — same reasoning as cart_items.product_id), createdAt, updatedAt. No qty, no note — a row's mere existence is the entire fact.

The unique index is on productId alone, no householdId — exactly cart_items' own partial-unique-index reasoning: a product row belongs to exactly one household, so uniqueness per product is already at least as strict as uniqueness per (household, product). Unlike cart_items' index it is not partial — a pantry row has no tripId-style "still active" qualifier to scope it by; one row per product, full stop.

pantry router

Procedure Boundary Notes
pantry.list householdProcedure The household's pantry, in cart.list's own walking order (department sortOrder, then product name)
pantry.ranOut householdProcedure { id } (the pantry row) → the four-way outcome union below

There is no create/remove endpoint. Rows are populated by trip.close and emptied exclusively by ranOut — a pantry fact is never edited by hand, only asserted true (a purchase) or false (running out).

ranOut's transaction opens with the household's advisory lock (task 3.2) — see Lock ordering for why that line is load-bearing rather than ceremony.

«Кончилось» (pantry.ranOut, src/server/pantry/ran-out.ts, pure, unit-tested)

Ensure-in-cart, not add-quantity. «Кончилось» asserts presence-needed, not a quantity to add on top of whatever is already on the list — the pantry itself tracks no quantities to compute one from. decidePantryRanOut({ existing, defaultUnit }) is the decision half, with no database in it, mirroring decideCartAdd (src/server/cart/merge.ts) but narrower — no merge branch, no unit-mismatch question, because there is nothing to sum:

Existing active cart row Outcome What happens
none added new needed line, qty 1, the product's defaultUnit
needed / ordered alreadyInCart row left completely untouched — not bumped
bought restored needed, keeping the row's own qty/unit (there is no new quantity to replace them with), buyer and orderedVia cleared, note kept

The router (src/server/api/routers/pantry.ts) supplies the locked rows the same way cart.add does, reusing its exact tested helpers rather than a second copy: lockActiveItem, insertActiveItem, activeItemScope, cartItemColumns and toCartItemOutput/toUnit are exported from cart.ts for this — behaviour unchanged, export added to existing functions.

The delete is one DELETE … RETURNING, not a separate locking SELECT followed by a delete. DELETE FROM pantry_items WHERE id = … AND household_id = … RETURNING product_id already gives the atomicity a SELECT … FOR UPDATE would: it either removes a row nobody else has removed yet and hands back its productId, or it matches nothing and the call knows at once there is nothing left to do. Two overlapping calls for the same row — a double tap, or an offline-queue replay racing a live one — can never both see a row to act on; exactly one wins the delete.

gone is a no-op, not an error, and it is what makes the whole mutation replay-safe for at-least-once delivery: a pantry row a partner already cleared (or a queued tap replayed after this one landed) is simply too late to mean anything — the cart line it would have ensured already exists from whichever call won. A ranOut sent twice is gone the second time, never a duplicate line.

Ensuring the cart line then follows cart.add's own shape: lock the product's active row, decide, and — for the one outcome with nothing to lock (added) — retry once against whatever a concurrent insert won the race with (RAN_OUT_ATTEMPTS = 2, same bound and same reasoning as cart.add's ADD_ATTEMPTS).

Pantry screen (S5, task 3.1) and the segment control

purchases-screen.tsx is the «Покупки» tab's actual root now (page.tsx renders it, prefetching both cart.list and pantry.list): the «Корзина | Кладовая» segment control, local useState, defaulting to «Корзина», with CartScreen or the new PantryScreen mounted underneath. The control sits above both screens rather than folded into CartScreen's own toolbar — cart-screen.module.css had a forward-looking comment speculating the control would replace that toolbar's title/count pair; it does not, by decision recorded in that file and in purchases-screen.tsx's own doc comment. CartScreen is a large, already-tested, actively-synced component (tasks 2.2–2.5), and reaching into its toolbar to also drive a sibling screen would mean threading tab state through it for no benefit to the cart itself. A thin wrapper gets the same DESIGN_BRIEF layout with a far smaller blast radius.

PantryScreen (pantry-screen.tsx) is CartScreen's shape pared down to what S5 actually shows: groupProductsByCategory sections, no checkbox, one action per row («Кончилось»), no offline banner, no highlight-on-refetch machinery, no row action sheet — none of that is in task 3.1's scope (long-tap «изменить продукт» mirrors S3's row sheet and is still deferred; «Ревизия» shipped in task 3.3, see below). It reuses cartSyncQueryOptions (src/lib/sync/cart-sync-presets.ts) for pantry.list as-is — that preset's own doc comment already anticipated being used for "cart.list and, later, anything else task 2.3+ renders alongside it".

«Кончилось» is optimistic, the same pattern as the S3 checkbox — but a removal, not a field patch, because a pantry row's whole lifecycle is presence: removePantryRow/restorePantryRow (src/lib/pantry/optimistic-remove.ts, pure, tested) take the row out of the cached pantry.list in onMutate and can put it back at the exact index it came from if the mutation fails — never appending it at the list's end, which would jump it across a department-section boundary the same way an unscoped sortBoughtLast would in the cart. The double-tap guard is a synchronous ref (pendingRef), not render state, for the same reason cart-screen.tsx's own is. describePantryRanOutOutcome (src/lib/pantry/ran-out-outcome.ts, pure, tested) maps the four outcomes to a toast: added/restored both read «В корзине» (the distinction is server bookkeeping, not something worth a different sentence to the shopper), alreadyInCart reads «Уже в корзине», gone is silent — the row is already off the screen by the time the answer comes back, so there is nothing left to point a toast at.

Fire-and-observe, never mutateAsync awaitedranOut.mutate(...), exactly the rule cart-screen.tsx follows for the same reason: a mutation TanStack pauses for being offline may not resolve for as long as the connection is down.

Not wired into the IndexedDB offline queue (src/trpc/offline-queue.ts), and this is a deliberate, documented scope decision rather than an oversight. That module's persistence filter (createOfflineCacheFilters, src/lib/sync/offline-cache.ts) is hard-scoped to the cart router's path key and cart.list's query key; widening it to a second router — and bumping OFFLINE_CACHE_VERSION/the buster, since the persisted shape would change — is a real piece of work with its own blast radius (every existing stored envelope, every offline-cache test), not something that belongs as a side effect of the pantry screen's PR (AGENTS.md: no drive-by refactors inside feature PRs). What this costs in practice: a pantry.ranOut made while offline still pauses rather than fails — that much is TanStack's own default networkMode behaviour, independent of this app's queue — and resumes automatically as long as the app stays open until the connection returns. What it does not survive is the app being killed while still offline (the iOS-PWA case the cart's queue exists for): the paused mutation and its optimistic patch are both only in memory, so on the next load pantry.list simply refetches from the server and the row reappears, exactly as if the tap never happened. Nothing is corrupted on either side — the mutation was designed to be replay-safe (gone) precisely so that widening the queue to cover it later is a config change, not a rewrite.

Revision mode (S5, task 3.3)

«Ревизия» (DESIGN_BRIEF S5, VISION §3.2): a full-screen, one-card-at-a-time pass through the pantry — swipe (or tap the two buttons below the card, or ArrowRight/ArrowLeft) «есть» / «кончилось» for each, watch «12 из 34» climb, land on a summary once the deck runs out. revision-mode.tsx (RevisionMode), launched from a toolbar button on PantryScreen (hidden — not disabled — while the pantry is empty).

Pure logic lives in src/lib/pantry/, unit-tested without a DOM:

  • revision-deck.tsbuildRevisionDeck (a defensive per-row copy, not just an array copy — a future mutation to a cached pantry row, or to a deck row, can never leak across the snapshot boundary either way), the { index, ranOutIds } reducer (decideRevisionCard), revisionProgress (the «12 из 34» + finished flag), and summarizeRevision (a discriminated { kind: "empty" } vs. { kind: "counted", count }, not just a bare count — see below for why).
  • swipe-commit.tsdecideSwipeCommit({ dx, dy, recentDx, recentElapsedMs }): a 96px total-distance floor (dx/dy) commits any deliberate drag regardless of speed; a 24px/0.5px-per-ms pair, measured only over the most recent ~100ms window (recentDx/recentElapsedMs), separately commits a fast short flick — but only when that flick's direction agrees with the drag's total dx (a drag left followed by a flick back right that stops short of the origin springs back instead of committing «кончилось»; fixed 2026-09-03 after CodeRabbit found the same gap in the sibling src/lib/cooking/step-swipe.ts, which mirrors this math and must stay in step); a drag that moved more vertically than horizontally never commits. recentDx/recentElapsedMs are deliberately not the same span dx/elapsedMs cover: a drag that holds still for a while and then flings at the very end must still read as fast, and averaging over the whole gesture's elapsed time would dilute a genuinely fast release into a slow one.

The deck is a one-time snapshot, not a live view. buildRevisionDeck runs once, inside a lazy useState initializer in RevisionMode — so a pantry.list refetch landing behind the overlay (background poll, focus regain, a partner's own tap) can never reshuffle or grow the deck mid-run. A partner's addition simply isn't part of this session; it'll be there next time «Ревизия» opens.

«Кончилось» reuses pantry-screen.tsx's own ranOut mutation, not a second copy. fireRanOut (extracted out of what was handleRanOut) does the "mark pending, remember the name, call ranOut.mutate" half shared by both the list row's own button and the revision mode; each caller keeps its own pre-guard on top (the row moves focus and starts the fly-to-cart ghost; the revision mode advances its own deck and plays its own card-exit animation instead — DESIGN_BRIEF makes no mention of the ghost for this mode, and the underlying list isn't even visible behind the full-screen overlay to fly toward).

The mutation fires on commit, before the card's own exit animation plays — not after. commitDecision calls the caller's onRanOut synchronously the instant a swipe/tap/arrow-key commits; only the visible deck advance (setState) waits ~220ms for the fling-away transition to finish. Closing the overlay mid-fling (the header's crest, or Esc) must not "un-decide" a swipe that already committed — decisions made this run stay made, matching the plan's own "выход на середине — крестик в шапке; ... no batch/undo" framing. The double-tap/double-swipe guard is a synchronous ref (decidingRef), the same reasoning pantry-screen.tsx's own pendingRef documents, backed by a 250ms COMMIT_COOLDOWN_MS: decidingRef alone unlocks synchronously under prefers-reduced-motion (there is no fling to wait out), so without the cooldown a held arrow key's OS auto-repeat — separately rejected outright via event.repeat — or a mashed button could otherwise commit the whole remaining deck in under a second.

A settled or failed outcome surfaces inside the dialog itself, not pantry-screen.tsx's own toast. onRanOut's second argument is an onOutcome callback RevisionMode hands the caller; handleRevisionRanOut/fireRanOut register it per tap (pendingOutcomeCallbacks, keyed by pantry-row id, since ranOut is one shared mutation instance) and invoke it from onSuccess/onError in place of showToast for that id. This is not cosmetic: pantry-screen.tsx's toast sits at a lower z-index than the overlay and its live region lives outside the dialog's aria-modal subtree, so a failed «кончилось» would otherwise be both invisible and unannounced — the card already flung off screen as if the tap succeeded, and the only sign anything went wrong would be the product quietly still sitting in the pantry afterwards. RanOutFeedback (src/lib/pantry/ran-out-outcome.ts) is the shared { visible, sr } shape both screens' toasts render.

Every drag in progress is cancelled the instant any commit starts (cancelActiveDrag, first thing inside commitDecision, before anything else) — a keyboard/button commit landing while a finger is still down on the card would otherwise leave the drag to resolve against whichever card comes next once that finger eventually lifts. Pointer handling is also hardened against a few edge cases a plain drag-and-drop implementation misses: a second finger touching the card while the first is still down is rejected outright (dragRef.current !== null, plus event.isPrimary), rather than silently overwriting the active drag's baseline and stranding the first finger's own release; releaseDrag checks the released pointer's id before clearing the drag state, not after, so a mismatched event can never wipe out a still-active drag that belongs to a different pointer; and a right-click (event.button !== 0) never starts a drag at all, since some browsers suppress the matching pointerup once its context menu opens (onLostPointerCapture is wired to the same cleanup as pointercancel, as a backstop for exactly that case).

Not a BottomSheet instance. DESIGN_BRIEF calls this a "полноэкранный режим" — a different register from the bottom-sheet-with-scrim pattern every "add/edit a thing" flow in this app uses. revision-mode.tsx copies BottomSheet's focus-trap / Esc / body-scroll-lock conventions rather than importing the component, since none of that behaviour is specific to the sheet shape; useSheetOpener (src/components/use-sheet-opener.ts) is reused as-is for focus-return-on-close. Unlike BottomSheet, the cleanup reads restoreFocusTo.current fresh at unmount rather than a value captured once at mount — pantry-screen.tsx's own onClose handler redirects that ref to screenRef when the toolbar button that opened the overlay has itself unmounted mid-run (the shopper ran the pantry empty), and that redirect only has anywhere to land if the cleanup reads it live.

Accessibility: the swipe is never the only way to decide a card — two labelled buttons («Есть» / «Кончилось») sit under it, and ArrowRight/ArrowLeft mirror them. Progress and the final summary are both announced through a seq-keyed live region — mounted inside the role="dialog" aria-modal="true" subtree, not a sibling of it, since assistive tech that honors aria-modal (VoiceOver among them) prunes anything outside that subtree from the tree entirely and would otherwise never speak it — seeded once on open with the first card's own progress text (without that seed, a screen-reader user hears nothing at all until their first decision, since the visible «12 из 34» is aria-hidden), the same pattern pantry-screen.tsx's own toast uses to force a real node replacement even when consecutive announcements would otherwise be textually identical. Focus moves to the summary screen's own button the instant the run finishes, since the body branch — including whichever control was just focused — unmounts wholesale when the summary replaces it. prefers-reduced-motion skips the card's fling/spring-back animation entirely (the deck still advances immediately, nothing waits on a transition that isn't playing).

N = 0 gets its own copy, not a plural of zero. summarizeRevision returns { kind: "empty" } rather than { kind: "counted", count: 0 } specifically so the summary screen can render «Всё на месте — ничего не кончилось» instead of a grammatically-correct-but-tonally-wrong «Готово: 0 продуктов улетело в корзину». pantryRevision.summaryDone's ICU plural (one/few/many/other) only ever has to handle count >= 1. This count is taps decided «кончилось» this run, not settled outcomes — it is never revised after the fact if one of those taps later rolls back; the in-dialog error toast above is the compensating fix for that gap, not a reconciled count.

Closing a trip («Завершить закупку»)

The hinge between the cart and the pantry (VISION §3.1, §3.2), and the moment purchase history comes into existence. Task 3.2: the trip router, the S3 bottom-bar button and the S12 history block.

trip router

Procedure Boundary Notes
trip.close householdProcedure No input. → { tripId, count, productIds }, tripId null for the no-op
trip.list householdProcedure Closed trips, newest first, each with its line count (LIMIT 50, no pagination in MVP)

What close does, in one transaction:

  1. Takes the household's advisory lock (below).
  2. SELECT … FOR UPDATE over the household's active bought lines. It answers "is there anything to close" and pins the answer: with those rows locked, a partner un-ticking one blocks until this transaction commits, so the set counted is exactly the set stamped.
  3. Inserts the shopping_trips row — only if step 2 found something.
  4. Stamps trip_id on exactly those lines (re-checking household_id, trip_id IS NULL and status = 'bought' — an id never reaches a write on its own, VISION §6.7), then upserts one pantry_items row per stamped product.

The purchase is the household's, not the shopper's. Every bought line goes, whoever ticked it — VISION §3.1's «чьи бы они ни были: закупка общая на household». needed and ordered lines stay in the cart: a delivery that has not arrived is not part of the run that just ended.

The stamp is what frees the partial unique index. cart_items_productId_active_uidx is WHERE trip_id IS NULL, so stamping a line is what lets the same product be added — and bought — again next week. This is the only place trip_id is ever written.

Nothing bought is an idempotent no-op: { tripId: null, count: 0, productIds: [] }, no error and no trip row, the same shape cart.receiveOrder returns when nothing is ordered. Minting a trip for an empty run would put a permanent «0 позиций» line in the S12 history for a tap that did nothing. If the stamp somehow matches no rows after the trip row has been inserted (unreachable while step 2 holds its locks), the procedure throws rather than returning: the trip row is already in the transaction, and rolling back is the only way not to leave an empty one behind.

The pantry upsert is ON CONFLICT (product_id) DO UPDATE SET updated_at = now(), not DO NOTHING: buying something the pantry already lists is a fresher fact about the same product, not a no-op. Presence only — the insert carries the pair of ids and nothing else (VISION §3.2). The product ids are de-duplicated first: the partial unique index already guarantees one active line per product, but a multi-row ON CONFLICT DO UPDATE whose values hit one key twice is a hard Postgres error (21000, "cannot affect row a second time"), and an impossible state should not become a failed purchase.

The partner sees the closure on their next refetch (VISION §6.3) — no realtime in the MVP. The plan row's mention of "realtime-события" is legacy wording from before that decision.

Lock ordering between trip.close and pantry.ranOut

src/server/household-lock.ts, and it is a real bug fixed rather than a precaution:

  • pantry.ranOut locks pantry row → cart row: DELETE … RETURNING on pantry_items, then SELECT … FOR UPDATE (or an insert against the cart's unique index).
  • trip.close has to lock cart rows → pantry row: the stamping UPDATE … RETURNING is what authoritatively decides which products were bought, so it cannot come second.

Run both for the same product at the same instant and Postgres has a lock-order cycle: ranOut holds the pantry-row delete and waits for the cart row, close holds the cart row and waits on the pantry key's uncommitted delete. One of them is aborted with 40P01 — a 500 for a tap that did nothing wrong.

The resolution is a coarse per-household serialization: both transactions take pg_advisory_xact_lock(hashtextextended(household_id::text, 0)) as their first statement (a lock taken after the first row lock orders nothing). Transaction-scoped rather than session-scoped, so Postgres releases it on commit or rollback — there is no unlock to forget, which matters on serverless where the connection outlives the request. The cost is nothing in practice: a household is two people, these are the only two transactions that take it, and the contention window is one tap against the partner's.

The alternative was reordering close, and it does not survive its own correctness requirement. For close to touch the pantry first it would have to read the bought lines without locking them; any line un-bought in that gap would leave a pantry row for something still sitting in the cart. Hash collisions between two households are possible in principle and harmless: two unrelated households briefly serialize with each other, which is not a data problem.

Both sides are covered by a test that fails if the lock moves or disappears (trip.test.ts, pantry.test.ts) — the db stub records db.execute() statements and their position, so "first statement of the transaction" is assertable without a database.

S3 «Завершить закупку (N)»

cart-screen.tsx: a second button in the existing bottom action bar (never a second floating pill — the bar is one decision), rendered only while at least one row is bought, with N = that count. One tap, no confirmation dialog, per DESIGN_BRIEF S3.

Deliberately not optimistic, unlike everything else on this screen. The server decides which lines were bought at that instant — including the partner's ticks this client may not have refetched yet — so a client-side guess would routinely be wrong about the count and about which rows to remove. It is also not till-critical the way the checkbox is: one tap at the end of a run, with a «Завершаем…» label, is the honest shape. The double-tap guard is still a synchronous ref (isPending lands a render too late, and two closes in one tick would mint two trips), and the button uses aria-disabled, never disabled, so a keyboard user is not thrown to the top of the page mid-tap; on success focus moves deliberately to «+ Добавить», since the button is about to unmount with the rows it counted. That move waits for the refreshed list to render rather than happening in onSuccess — a trip that empties the cart swaps the action bar for the empty state, so the button to focus does not exist yet at that point — and it only ever rescues focus that was actually lost (document.activeElement is body), never steals it from a sheet the shopper has since opened.

onSettled invalidates three queries: cart.list (it loses the bought lines), pantry.list (it gains a row for each of them, so leaving it alone shows a stale «Кладовая» one segment-control tap away) and trip.list (S12's history is missing exactly the trip just closed; with a 30s staleTime and Next's client-side Back restoring a cached page without re-running its server prefetch, the block would otherwise be a tap out of date). trip.close also mutes the cart's passive refetch triggers while it is in flight, the same way cart.* mutations do (useIsMutating on trpc.trip.pathKey()).

The button is refused while offline or while a write of ours is still out — the same two conditions «Обновить» is disabled by, and here they prevent a wrong purchase rather than a wasted request. A trip.close tapped offline does not fail, it pauses, alongside the bought ticks made offline; nothing orders those on reconnect (resumePausedMutations() fires the whole set together), so the close can reach the server ahead of the ticks it counted and close a trip missing exactly them — the button said 5, the toast says 3, and two lines stay in the cart as bought. The online miniature is the same race inside one second: tick the last item, tap close immediately, and the close can be served before that setStatus. useIsMutating counts paused mutations as well as in-flight ones, so the guard also holds the button closed until the offline queue has actually drained. This is the accepted-scope answer while trip.close stays out of the IndexedDB queue; ordering the queue itself is what a proper integration would do instead.

The S3 live region's child is now keyed on the toast sequence, the solved version from pantry-screen.tsx: React skips an in-place text update when the new string is identical to the old one, so two consecutive identical toasts used to be announced once.

It is never sent offline in the first place (the guard above), so unlike pantry.ranOut it does not sit paused in memory waiting for a connection. It is also not wired into the IndexedDB queue (src/trpc/offline-queue.ts), for the reason ranOut is not: that module is hard-scoped to the cart router's path key, and widening it to a second router is its own piece of work rather than a side effect of this PR. The cost is that closing a trip is an online-only action — correct, since the set of bought lines it stamps is a server-side fact and the offline backlog is precisely what would make it wrong. A duplicate delivery cannot duplicate a trip either: the second one finds whatever is bought at that moment, which for an immediate replay is nothing at all — the no-op, no second trip row.

S12 «История закупок»

settings/trip-history-section.tsx, prefetched with the page like the kitchen profile beside it. One row per trip: the date and «N позиций». Skeleton level on purpose — DESIGN_BRIEF's «строка раскрывается в список купленного» needs a per-trip read that task 7.1 adds with the rest of the S12 assembly, which is also why trip.list returns a count rather than the lines themselves.

The date goes through next-intl's useFormatter, not toLocaleDateString: this is a client component, so it renders on the server too, and next-intl resolves the time zone once on the server and hands that same zone to the client provider. A raw Intl call would use the server's zone during SSR and the browser's after hydration — a mismatch landing exactly on dates near midnight. No global timeZone is configured (src/i18n/request.ts), so dates read in the deployment's zone (UTC on Vercel); pinning the household's own zone is a settings question for task 7.1.

Kitchen profile

A household's equipment checklist + headcount (VISION §3.3, §5) — what a recipe is checked against, and what the assistant reads for "adapt this to what we have" once it exists. Task 1.4 builds the model, the router, the S2 onboarding step and a first S12 settings section; the assistant integration is later.

kitchen_profiles (src/db/schema.ts) is keyed by householdId itself (no separate id — the row is 1:1 with the household), plus householdSize (default 2) and equipment (text[], default {}).

Presets vs. free-form. src/server/kitchen/equipment.ts exports EQUIPMENT_PRESETS — the 11 checklist slugs (oven, microwave, kettle, induction_hob, blender, grater, garlic_press, multicooker, mixer, airfryer, food_processor) DESIGN_BRIEF §5 lists, our own starting profile being the first seven. equipment stores a mix of these slugs and whatever free text someone types into the "add your own" field in the same array — the checklist just renders the preset subset as checkboxes.

Recognizing that typed text actually names a preset is a two-layer split:

  • ClientresolveEquipmentEntry() (src/lib/equipment-entry.ts, pure, unit-tested) matches an "add your own" entry against both the slug strings and their localized checklist labels (built from the same kitchenProfile.equipment.* messages the checkboxes render), case-insensitively. So typing the slug, the checklist's own label for it, or a different casing of either, all resolve to the oven box in kitchen-profile-form.tsx instead of adding a redundant chip beside it. withSlugChecked(), colocated in the same file, also drops any free-form entry that already case-insensitively equals the slug being checked (a stray "Oven" chip, say) before appending the canonical slug — otherwise normalizeEquipment's own dedup would collide with it and the checkbox would silently do nothing.
  • ServernormalizeEquipment() never sees a localized label, only slugs and free text that already agree with the checklist by the time they reach it. It cleans that up: trim, cap at 40 chars, drop empties, dedupe (exact match for a preset slug, case-insensitive for everything else). Both the client (as chips are added) and the server (on every update) run this same function, so the two always agree on the final list.

kitchenProfile router

Procedure Boundary Notes
kitchenProfile.get householdProcedure { householdSize, equipment } | null — null means never set; the client falls back to size 2, no equipment
kitchenProfile.update householdProcedure Upserts via onConflictDoUpdate on householdId — there is no client-sent id to check, the target is always ctx.household.id

Screens

Route What it is
/onboarding/kitchen S2 step, reached after a household exists — "Done" or a quiet "Skip", both land on /
/settings (S12, still growing) Page title, then HouseholdSection («Дом»: name, members, invite link, signed-in identity + sign-out — task 7.1a), the kitchen-profile section, purchase history and dish archive below it — departments drag order, AI budget and language are task 7.1

Both screens render the same src/components/kitchen-profile-form.tsx — checklist, free-form chips, a 1–10 household-size stepper — so the two can never drift. The form is a plain controlled component (no autosave): the caller owns the kitchenProfile.update mutation and passes pending/onSubmit in.

OnboardingScreen's "Continue" action and the invite-accept success path both now land on /onboarding/kitchen instead of / (ONBOARDING_KITCHEN_PATH in src/lib/auth-redirect.ts); the kitchen step itself is the one that finally lands on /.

The S2 step reads before it writes. /onboarding/kitchen's page.tsx calls kitchenProfile.get() server-side and passes the result into KitchenOnboardingScreen as initialProfile — it does not default the form to size 2 / no equipment unconditionally. The reason is who actually lands on this step: the household's creator may reach it having already filled the profile in, and the partner accepting the invite lands here right after, on the very same household. A hardcoded blank default would let the partner's first, empty "Done" tap silently overwrite whatever the creator already saved — initialProfile is what makes the step idempotent instead.

The S12 section (kitchen-profile-section.tsx) makes the equivalent guarantee against its own query state: the form only ever mounts once kitchenProfile.get has resolved to a real value (success) or null (never set) — profile.isPending shows a loading line and profile.isError shows a retry button instead, so the Save action can never fire against DEFAULT_VALUE while the actual profile failed to load.

Header avatar entry

src/components/app-header.tsx, mounted in AppShell: household name on the left, the participants' avatars (image, or an initial-letter circle) on the right, the caller's own linking to /settings — DESIGN_BRIEF §2's "tapping your own avatar opens Settings". (app)/layout.tsx passes householdName/userName/userImage/partners down from the same session/household load the gate already does. Task 2.3 completed the header with the partner avatars and the sync mark — see Cart screen.

Dishes and recipes

The household's recipe library (VISION §3.3, DESIGN_BRIEF S6/S7) and the model phase 5's week menu and phase 6's assistant are both built on. Task 4.1 ships the schema, the dish router, S6 and the read-only S7; tasks 4.2–4.7 extend the same aggregate (the S8.3 form, photo/URL/text import, portion rescaling, AI adaptation, cooking mode) and append their own ### subsections here.

The aggregate: four tables, one version token

dishesrecipes (1:1, unique(dish_id)) ⟶ recipe_ingredients / recipe_steps. All four carry a plain household_id (the settled tenant-isolation rule), and every child table carries it in its own right — DELETE FROM recipe_ingredients WHERE recipe_id = $1 has nowhere else to put the household predicate, and a predicate expressible only through a join is one refactor from vanishing.

dishes.version integer is the concurrency token, not updated_at. Every write to the dish, its recipe, its ingredients or its steps bumps it by one, and dish.update refuses a save whose expectedVersion no longer matches (CONFLICT → «Блюдо изменили — обновить?»). An integer, because every router here writes updatedAt: sql`now()` at microsecond precision, postgres.js parses timestamptz down to a millisecond Date, and superjson round-trips that — a WHERE updated_at = $clientDate guard could silently never match and turn every legitimate save into a conflict. Being an integer also makes "has the server moved on?" a comparison the S8.3 form can trust: it holds the version it opened with and raises «Блюдо изменили — обновить?» when dish.get comes back with a higher one (see Dish form). It is deliberately not used as a remount key — the form is never thrown away and re-seeded behind the user's back, which is what would wipe a half-typed recipe on the first background refetch.

recipes has its own id primary key with unique(dish_id), not PRIMARY KEY (dish_id). Children carry recipe_id; a child column named dish_id whose foreign key actually points at the recipes table would be a trap for every hand-written join and for phase 5. recipes_dishId_uidx is where the 1:1 invariant lives, so no writer can give one dish two recipes.

What that index does not do is deduplicate a double-tapped save. dish.create mints a fresh dishes.id and inserts the recipe against it, so the index can never fire on that path — a duplicate submit produces a second complete dish. Nothing on the server prevents it today: normalized_title is deliberately not unique, and input.jobId is recorded into ai_jobs.output_json but never read back, so it is not an idempotency key. The defence is the S8.3 form's synchronous ref lock (task 4.2); turning jobId into a real idempotency key (read output_json->>'consumedDishId' under FOR UPDATE, return the dish it names) is the option task 4.3 has if the import path makes retries likely. Duplicate dishes break no invariant, which is why this is a recorded gap rather than a bug — but do not read the unique index as covering it.

normalized_title is indexed but deliberately NOT unique — the asymmetry with products.normalized_name is the point. A duplicate product is the bug the catalog exists to prevent; a second «Оладьи» (mum's and the other one) is a library decision the household is allowed to make. The column exists for lookup (task 6.1's assistant resolving «сделай нам лазанью»), not for a constraint. normalizeDishTitle (src/server/dishes/normalize.ts) is an alias of normalizeProductName, not a copy: two normalizations that drift apart are worse than one that is imperfect.

Archive, never delete

dishes.archived_at plus dish.archive / dish.unarchive, and no dish.delete at all. Phase 5.1's menu_items.dish_id and 5.3's «повторить неделю» must not lose the dish a stored week names; a hard delete would either cascade that history away or start throwing 23503 at the user. dish.list filters archived_at IS NULL, dish.listArchived the opposite; the archive is reachable from S7's «…» menu (confirmation sheet, then a banner on the same screen with «Вернуть») and from Settings → «Архив блюд».

Both endpoints take expectedVersion and guard on the archive state they expect to find, so a second «В архив» on an already-archived dish matches nothing rather than bumping the version again. When the write matches nothing, one extra scoped read tells NOT_FOUND («блюда больше нет») apart from CONFLICT («его изменили») — two answers a screen can act on differently.

product_id is ON DELETE RESTRICT — task 7.1 must pre-check

recipe_ingredients.product_id is restrict, like cart_items.product_id and pantry_items.product_id. Any future product-delete endpoint (task 7.1) must pre-check

SELECT count(*) FROM recipe_ingredients WHERE product_id = $1 AND household_id = $2

and report «используется в N блюдах», or the delete fails with a raw 23503 the user cannot act on. recipe_ingredients_productId_idx exists for exactly that query, and for task 6.1's reverse lookup product → dishes.

product_id is also nullable, and that is a first-class state, not a gap: an ingredient nothing in the catalog answers to yet is stored unbound and renders as itself. Task 4.2 added the resolution step in front of the save transaction (reference catalog first, then one batched enrichProducts AI call, then savepointed product inserts) — see Dish form. The input schema carries productId per row and verifies every non-null one against the caller's own catalog before any write.

The three ingredient states, and why they must look different

qty and unit are nullable, and that nullability is the honesty rule (VISION §6.4). All three states are in DESIGN_BRIEF §5's own sample recipe, and S7 renders each differently:

stored S7 meaning
qty = null, needs_review = true amber «уточнить» chip on --null / --null-txt the parser failed — a human must look
qty = null, is_optional = true neutral grey «опционально» chip the recipe said so
qty = null, note = 'по вкусу' plain text where the number would be, no chip a deliberate absence

If they looked alike the amber chip would stop meaning anything, which is the only reason it is worth having (DESIGN_BRIEF §6: «пометки „уточнить“ жёлтые, не красные»).

needs_review is derived server-side and never carried. deriveNeedsReview (src/server/recipes/needs-review.ts, pure, unit-tested against the exact NYC-Cookies list) is recomputed by dish.create/dish.update on every save, and the AI's structured output (task 4.3) has no such field at all — a model that forgets to flag cannot produce a silently confident recipe, and typing a quantity into S8.3 clears the chip without anything else having to remember to.

Units are data, not copy

recipe_ingredients.unit stores one of RECIPE_UNITS as text and is re-validated on read, degrading to null for anything unrecognized — one row holding a retired measure must not fail a whole dish's output validation (the same rule cart.ts's FALLBACK_UNIT encodes).

RECIPE_UNITS is a superset of the cart canon, not a second canon. src/lib/units.ts keeps UNITS (the nine purchase units) untouched and adds RECIPE_ONLY_UNITS = ["ч.л.", "ст.л.", "стакан", "щепотка"] beside it. Widening UNITS itself would put «щепотка» into QtyStepper's cart unit picker and let decideCartAdd merge a teaspoon into a kilogram. The bridge is isPurchaseUnit(unit), phase 5.2's gate for turning an ingredient into a cart line.

A unit is a stored value rendered verbatim — it never enters src/messages/ru.json, exactly as the cart already renders UNITS. Same for recipes.yield_unit («печений»): it is the source's own noun, imported data like raw_text, passed into an ICU message (dish.portionsUnit) as a parameter so the words around it still come from next-intl. What is not data: «7–8 порций» and «9–11 мин» are composed on the client from two integers each (portions_min/portions_base, timer_sec/timer_max_sec) — a stored Russian label would be a user-visible string living outside next-intl.

portions_base is the upper end of a stated range («7–8 печений» → 8) and is the number every ingredient quantity is stated for; portions_min (7) is display only. Rescaling divides by portions_base (rescaleQty, src/lib/recipes/rescale.ts) and rounds with the cart's own roundQty, so phase 5.2 can sum a rescaled quantity straight into a cart row without a second rounding rule. formatRecipeQty renders «285 г», «¾ ч.л.», «1½» — and never «0»: a value that rounds below the storage floor renders «—», because «0 г» would claim the recipe asks for none of something.

dish router

Procedure Boundary Notes
dish.list householdProcedure No input. archived_at IS NULL, INNER JOIN recipes, ingredient/needs-review counts aggregated in one grouped read, ORDER BY created_at DESC, id DESC
dish.listArchived householdProcedure The opposite predicate, newest archive first — backs Settings → «Архив блюд»
dish.get householdProcedure { id } → the whole aggregate in three scoped selects; NOT_FOUND before any second query runs
dish.create householdProcedure { draft, originalDraft, jobId }saveDishOutput ({ dish, createdProducts, aiFailed })
dish.update householdProcedure { id, expectedVersion, draft } → the same saveDishOutput; CONFLICT on a stale token, raised before any AI spend
dish.archive householdProcedure { id, expectedVersion }{ id, version }
dish.unarchive householdProcedure The undo, and Settings' «Вернуть»

inPantry («· дома есть ✓») is a LEFT JOIN pantry_items inside dish.get, scoped by household on the join condition itself. pantry_items is unique on product_id, so the join cannot fan out, and a client cross-reference against the pantry.list cache would be a second cache entry that can disagree with the ✓ on screen. An unbound ingredient matches nothing and reads as false.

saveDishOutput.createdProducts / aiFailed report what the save did to the catalog: which products it minted for unbound rows, and whether any of them carry fallback values because the enrichment was refused or failed. Task 4.1 always answered [] / false; 4.2 fills them, and both create and update return the same shape.

The save path (and how 4.2+ extends it)

dish.create and dish.update run in this order, and the order is the design:

  1. Outside any transaction — normalize the draft (normalizeDraftForSave), enforce ingredients.min(1) (deliberately not in recipeDraftSchema: a parse that found steps but no ingredient list must still reach the review form), and verify every client-sent productId against this household's catalog in one scoped SELECT with a set-size check.
  2. Task 4.2's resolution step runs here, still outside the transaction — reference catalog first (free), then one batched enrichProducts call for what is left, and (on update) a version pre-check before either. A 15–40 s OpenAI round trip inside an open transaction would pin a pooled Railway connection and row locks on a Vercel function. Details in Dish form.
  3. Inside one transaction — (update only) SELECT version … FOR UPDATE scoped by household → NOT_FOUND / CONFLICT; write dishes (version = version + 1, updated_at = now(), normalized_title derived here) and recipes; DELETE both child tables by recipe_id AND household_id; bulk INSERT them again with freshly minted 0..n-1 orders. Full replace, not diff — nothing holds a recipe_ingredients.id durably, so churning child ids costs nothing and buys a save path with no reorder-or-merge logic to get wrong.
  4. After the commit — the aggregate is re-read and returned. Outside the transaction on purpose: it carries its own version, so reading a state a partner has already moved on from is not a lie, while holding the write's locks through three more round trips would be a real cost.

recipes.original_draft is written only on create, only from an import — it is the base task 4.6 diffs its adaptation against and reverts to, and an edit is exactly the thing a revert has to be able to go past. When jobId is set, the import job is marked consumed with jsonb_set(coalesce(output_json, '{}'::jsonb), '{consumedDishId}', …) scoped by household, so /dishes/import/[jobId] can redirect to the saved dish instead of re-rendering a draft the household already turned into a recipe. coalesce matters: jsonb_set on NULL returns NULL, which would erase the ledger entry rather than annotate it.

No lockHousehold() on the dish path, and the router says so in a doc comment. That advisory lock exists for exactly one reason: trip.close walks cart → pantry while pantry.ranOut walks pantry → cart, and two at once is a lock-order cycle Postgres resolves by aborting one with 40P01. A dish save touches neither table, so there is no cycle to break, and taking the lock would serialize every «Сохранить блюдо» behind every shopping action.

RecipeDraft — one contract, six producers, one consumer

src/lib/recipes/draft.ts holds recipeDraftSchema and its helpers (emptyDraft, draftFromDetail, normalizeDraftForSave). It is the single shape every producer feeds — vision parsing, JSON-LD, microdata, FireCrawl + AI, pasted text, the manual form, AI adaptation — and the one shape S8.3 consumes, whether it is creating, reviewing an import or editing.

Client-safe by construction: it imports zod, the unit canon and two pure server modules that hold no database. src/db/schema.ts takes DISH_SOURCE_TYPES from draft.ts, not the other way round — the same pattern src/lib/units.ts sets, so an output schema never drags the schema module into a browser bundle. .nullable() everywhere, never .optional(): a draft with a missing key and a draft with an explicit null would be two shapes for one thing.

photoUrl and sourceUrl are constrained to http/https explicitly. zod 4's z.url() accepts any scheme new URL() parses, javascript: and data: included — and an imported photoUrl ends up in an <img src>.

Screens

Route What it is
/dishes S6 library — search, tag chips, two-column DishCard grid, skeleton tiles, empty state, «+ Блюдо» source sheet
/dishes/[dishId] S7 card (read-only in 4.1) — photo, tags, source line, portions, ingredients, steps, actions, «…» → «В архив»
/dishes/new S8.3 with an empty draft — «✍️ Вручную» from the «+ Блюдо» sheet and from the empty state (task 4.2)
/dishes/[dishId]/edit S8.3 seeded from dish.get — S7's «Редактировать» (task 4.2)
/settings gains an «Архив блюд» block reading dish.listArchived, one «Вернуть» per row

Search and tag filtering never leave the browser. dish.list takes no input and returns the whole library, so one cache entry serves S6: every keystroke re-filters an array (filterDishes / collectTags, pure and tested), with no debounce and no request per character — and the library keeps working with a dead connection. Documented threshold for revisiting: ~200 dishes, at which point those functions become the pure half of a dish.search endpoint mirroring product.search. «все» is a UI state (tag: null), never a stored tag.

cartSyncQueryOptions is deliberately not applied here. That preset's polling and focus-refetch exist because two people race over one shopping list at the shelf; a recipe library is not that, and a 45-second poll would burn requests for nobody. The default staleTime applies and the writes invalidate what they change.

Photos are plain <img decoding="async" referrerPolicy="no-referrer"> with an emoji placeholder on missing/error, not next/image. Dish photos come from UploadThing and from arbitrary imported pages, so next/image would need a remotePatterns entry per host we have never seen and would spend Vercel's image-optimization quota on a picture the client already compressed to ~300 KB. S6's grid tiles add loading="lazy" — most of them are below the fold; S7's single photo is the first thing on the screen and stays eager on purpose.

Dish writes fail fast offline; they are never queued. dish.archive and dish.unarchive declare networkMode: "always", and the IndexedDB offline queue persists cart.* only (see Offline queue). With the default "online" mode a mutation dispatched offline pauses before its mutationFn runs — its onSettled never fires, so a synchronous "in flight" mutex is never released and the confirm button reads «Убираем…» for the whole outage, while the write itself is memory-only and dies with the tab. Failing immediately, plus a «нет сети» line rendered next to the control, is the honest behaviour for a write nobody is replaying later. dish.create and dish.update follow the same rule from 4.2, and the import mutations will in 4.3–4.4: an import costs money and a create is not idempotent, so replaying either from IndexedDB hours later is exactly wrong.

A CONFLICT refreshes the screen instead of asking for a retry. expectedVersion is read from the cache, so re-sending it after a partner's write lands would fail identically forever. Both S7 and S12's archive list invalidate on CONFLICT, close the confirmation, and say «Блюдо изменили — обновили карточку» — after which the refreshed banner (or the row's absence) usually means there is nothing left to retry.

Focus is moved deliberately wherever a write unmounts the element holding it. Three places in this feature: the S7 «…» sheet swapping its menu for the confirmation (the activated row unmounts while BottomSheet stays mounted, so its own one-shot focus effect cannot re-run — the confirmation focuses «Отмена», never the destructive «Убрать»); S7's «Вернуть» unmounting its own banner, on success and on the CONFLICT whose refresh reveals a partner already restored the dish; and S12's «Вернуть» unmounting its row, which picks the neighbour with pickNextFocusTarget while the row is still mounted and falls back to the section heading when the list empties. All are guarded on document.activeElement being body, so they rescue and never steal. S12's pending rescues are keyed by dish id and consumed only once that dish has actually left the list — the list carries Dates, so structural sharing never keeps its identity and any incidental refetch would otherwise spend a token armed for a removal that has not happened yet.

The message a screen renders is chosen in a pure module, not in the component. ingredientsForMessage (src/lib/recipes/portions.ts) and timerMessage (src/lib/recipes/timer.ts) return { key, values }, and the screen does nothing but t(message.key, message.values). vitest runs in node with no DOM harness, so a branch left inside a .tsx is unreachable from the suite: both of the bugs this shape prevents — the ranged yield losing its noun, a 30-second step reading «30 мин» — shipped green while the ternary lived in the component. src/messages/ru.test.ts renders through the same functions and sweeps the dish.* / settings.dishArchive* keys for existence, because next-intl returns the key path instead of throwing when an entry is missing.

Controls whose feature has not shipped are aria-disabled and announce «скоро», never disabled. In 4.1 that was the four source-sheet rows, S6's empty-state «📷 С фото», and S7's «В меню недели» / «Ингредиенты в корзину» / «Готовить» / «Редактировать»; task 4.2 turned «✍️ Вручную» and «Редактировать» into real links and gave the empty state a working «✍️ Заполнить вручную» beside the still-pending photo button. main deploys to production on every merge, so a button linking to a route that does not exist yet would be worse than one that is honest — and a truly disabled control cannot be focused, so a keyboard user would never learn the option exists. The hint renders inside the same container (inside the sheet's aria-modal subtree when a sheet is open), because a page-level toast is both hidden behind the scrim and pruned from the accessibility tree.

Dish form (S8.3, task 4.2)

src/components/dish-form.tsx is one component for three jobs: creating a dish by hand (/dishes/new), editing a saved one (/dishes/[dishId]/edit) and — from task 4.3 — reviewing an import. DishFormTarget is a discriminated union ({ mode: "create", originalDraft?, jobId? } / { mode: "edit", dishId, version }), and no field branches on it: the mode picks the mutation, the version guard and one caption. Anything else would be three forms that drift.

The save path, in order

dish.create and dish.update now run:

  1. Outside any transaction — normalize the draft, enforce ingredients.min(1), (update only) read dishes.version scoped by household and fail fast with NOT_FOUND/CONFLICT, then verify every client-sent productId against this household's catalog with a set-size check.
  2. Still outside any transaction — resolve the rows that carry no productId (resolveIngredientProducts): deduplicate by normalized name, then matchIngredients (the household's own catalog, then the built-in reference list — both free and deterministic), then one batched enrichProducts call for whatever is left.
  3. Inside the transaction — (update) SELECT version … FOR UPDATE; then the new products, each inside tx.transaction(...), i.e. a savepoint; then the 4.1 write path unchanged (dish, recipe, delete + re-insert both child tables).
  4. After the commit — re-read and return { dish, createdProducts, aiFailed }. dish.update returns the same shape as dish.create (saveDishOutput): the form asks the same question either way.

Why the AI call is outside the transaction. A 15–40 s OpenAI round trip inside an open transaction pins a pooled Railway connection and its row locks for the whole call, on a Vercel function with a hard duration ceiling. dish.test.ts asserts txDepth === 0 at the ai_jobs insert that fronts the call, and txDepth === 2 on the product inserts.

Why the version pre-check exists on top of the FOR UPDATE read. The lock inside the transaction is still the real guard, but by the time it runs the save may already have spent an AI call and minted products for a write that can never land. Reading the version first costs one round trip and refuses a stale editor before any of that. Between the two reads a partner's write turns into the same CONFLICT, one round trip later.

Why the savepoint. In Postgres a 23505 aborts the entire enclosing transaction: without the savepoint, a concurrent insert of the same product would leave the dish, the recipe and both child tables failing with 25P02, and the recovery read would never reach the winner's row. Same lesson cart.ts's insertActiveItem encodes. A recovered row is bound but not counted in createdProducts — «Создано N новых продуктов» counts what actually appeared in the catalog.

A failed or rate-limited enrichment never fails the save. aiRateLimitDecision (src/server/ai/rate-limit-guard.ts) returns a decision instead of throwing, and enrichProducts never throws at all; the products are created with the same fallbacks product.create uses (🛒 / «Бакалея» / шт) and aiFailed: true comes back so the form says «проверь новые продукты». Someone who has just spent a minute reviewing a recipe should not lose it to a quota over a fraction of a cent. The paying form of the guard (assertWithinRateLimit) is what product.create still uses — one AI call the user can simply ask for again.

Exactly one ai_jobs row per save, or none. One batched call for up to MAX_ENRICH_NAMES (20) names, reasoning_effort: "low", per-name validation (an unknown categoryId or a non-emoji icon costs that name its icon, not the other nine theirs), and costUsd recorded on the failure branch too. Ten sequential enrichments would burn the function's duration budget and ten rate-limit slots on one tap of «Сохранить блюдо». Zero rows when every ingredient is bound or resolves from the reference catalog — the tests prove it with unusableOpenai, which throws if anything reaches for a client.

Rows that can never be products are skipped entirely. isUsableProductName requires a letter or a digit after normalization, so «—», «...» or «•» is neither enriched nor created; the row is saved unbound, which is the honest «новый» state the nullable product_id column already has. That is the whole rule — it is not a judgement about whether the name reads like a product, so «(см. шаг 3)» passes it. A stricter test would start refusing «Мука ц/з» and «Молоко 3.2%», and a wrongly-refused ingredient is a silently unbound row nobody can explain.

Ingredient matching: which tiers may bind

src/server/catalog/search.ts gained two additive exports; searchCatalog's behaviour is unchanged (both now share one private ranker).

  • INGREDIENT_MATCH_TIERS / acceptsIngredientTier(rank) — exact, prefix and word-prefix, on names and aliases (tiers 0, 1, 2 and the same three shifted by the alias offset). A bare substring is refused (tiers 3 and 7): «масло» is a substring of «Масло сливочное» and «Масло подсолнечное», and a silent wrong bind is invisible on S8.3 and buys the wrong thing when phase 5.2 turns the recipe into a shopping list. It is a set, not a ceilingrank <= 6 would admit tier 3, which is precisely the case being rejected.
  • bestCatalogMatch(args) — the top hit and its tier, or null. A tie at the top rank returns null: «масло» reaches the prefix tier against three different fats at once, and "best" is not defined there. matchIngredients then leaves the row unbound and a human chooses.

matchIngredients tries the household's own catalog by exact name/alias first (a curated row wins even against a tie), then asks the same question through the reference list (see below), then bestCatalogMatch through the allow-set, and finally findReferenceProduct for an exact built-in name. It answers 1:1 with the input order — the caller pairs results back to draft rows by index.

A built-in staple the household already owns under another spelling binds to their row — checked as step 2, before ranking. rankCatalog drops a reference entry whose name or aliases collide with something the household has, so «Помидоры» would otherwise fall through to whatever else ranks — «Помидоры черри» at the prefix tier — or, at the last step, to minting a second row for one product. products_householdId_normalizedName_uidx covers normalized_name only, so «Картофель» beside «Картошка» inserts cleanly, and each row ends up naming the other in its aliases. The check runs before ranking, because an exact staple someone owns beats a prefix match on a different product — the rule the ranker already encodes for everything it can see. The catalog ships 85 alternate spellings across 72 of its 189 entries (pinned in reference-products.test.ts), so there is no shortage of ways to hit it.

The last step asks a different question from the ranker, and it is safe precisely because it is exact. bestCatalogMatch answers «which entry is closest?» and refuses a tie; findReferenceProduct answers «is this word the name of a staple we ship?». The second is what a whole family of everyday words needs: «сыр» is a prefix of «Сыр твёрдый» and «Сыр плавленый», so ranking declines — while «сыр» is the alias of «Сыр твёрдый» outright. «сахар», «чай», «капуста», «колбаса», «помидор» and «томат» are the same shape, and «томат» is not even the best-ranked («Томатная паста» beats it on prefix). Without this step every one of them costs a billed enrichment call and mints a bare row named with the user's own wording — which then permanently hides the curated staple from autocomplete, because rankCatalog drops a built-in the household owns. It cannot pick arbitrarily: no normalized spelling belongs to two entries, and reference-products.test.ts fails if one ever does. A genuine ambiguity still ends unbound — «масло» is a prefix of three fats and the name of none.

Where the resolution lives. src/server/dishes/resolve-products.ts, not the router: catalog lookup, reference catalog, rate limiter, a billed AI call and savepointed inserts are a pipeline of their own, and dish.ts is the largest router in the repo with dish.adapt (4.6) still to come. The statement order it produces is still asserted from dish.test.ts through the router, because order across the transaction boundary is the property that matters and only the router can show it.

The «новый» contract

AutocompleteSheet gained an additive variant?: "quantity" | "product" (default "quantity"; the cart call site is untouched) plus onPickUnbound and a title override.

Neither dish route prefetches category.list: the panel that needs it (ProductEditForm) renders only in the sheet's quantity phase, which variant="product" never reaches.

In "product" mode the sheet writes nothing. Picking a catalog row fires onAdded immediately with qty: 1 and the product's default unit — there is no quantity step, because the ingredient row already has its own numbers. Picking a reference entry, or the «Создать „…“» row, fires onPickUnbound(name) instead: no product.create, no AI call, no ai_jobs row. The row wears the neutral «новый» chip and the save creates the product (DESIGN_BRIEF S8.3: «новые продукты помечены „новый“ — при сохранении будут созданы в каталоге»). Creating them on tap would mint catalog rows for recipes people abandon, and spend an AI call each time.

Form state rules

Each one is a bug this codebase already shipped once in phases 2–3 (the paused-mutation half is spelled out under Offline queue):

  • Seeded exactly once, in useState initializers. dish.get's payload carries Dates through superjson, so TanStack's structural sharing never keeps its identity and an effect copying initial into state would wipe a half-typed recipe on the first background refetch. The form is also never given a key derived from a live version — the edit screen hands the newest aggregate down as latest, the form raises «Блюдо изменили», and the server's data replaces what is on screen only when the user taps «Обновить» (adopt).
  • savingRef is a synchronous mutex. dish.create mints a fresh id every call and has no unique index to catch a duplicate, so two taps in one tick would produce two dishes; isPending lands a render too late.
  • Pending controls are aria-disabled, never disableddisabled drops focus off the button that was just activated.
  • Deleting a row rescues focus: pickNextFocusTarget picks the neighbour while the row is still mounted, and the effect focuses that row's own delete button, guarded on document.activeElement === body so it rescues and never steals.
  • Feedback renders inside the form (and inside the sheet's aria-modal subtree when a sheet is open): a page-level toast is both hidden behind the scrim and pruned from the accessibility tree.
  • Both mutations declare networkMode: "always" and are deliberately not in the offline queue (which persists cart.* only). A paused mutation's onSettled never fires, so the mutex would stay held and the button would read «Сохраняем…» for the whole outage — for a write that dies with the tab. dish.create is not idempotent either, so replaying it from IndexedDB hours later would be exactly wrong.
  • Step reorder is a pointer drag and «Выше»/«Ниже», both ending in the same pure moveItem (src/lib/recipes/reorder.ts). HTML5 drag and drop does not work on iOS, and a drag is unusable with a keyboard, so the handle is a plain aria-hidden <span> — pointer-only by design, rather than a <button> sitting in the tab order doing nothing on Enter. The gesture filters non-primary pointers, uses setPointerCapture, cancels on pointercancel/lostpointercapture, and clears drag state before committing so a re-render cannot resurrect a finished gesture.
  • stepDropIndex takes the dragged row's index, and that is the whole subtlety. Counting midpoints yields a gap on the list as drawn, with the dragged row still in it; moveItem removes the row first and inserts into the shortened list, so every gap above the source has shifted down by one. Converting inside the pure function is what keeps the two index spaces from drifting — and its tests name the resulting array literally, because moveItem(l, 1, x) compared against moveItem(l, 1, y) agrees with itself no matter what either side computes.
  • One permanent sr-only live region carries every notice. The «Блюдо изменили» banner is raised by a background dish.get refetch with no action of the user's, and the offline line by the connection dropping — neither is announced by anything else, and a region that mounts together with its text is not reliably announced (the rule cart-screen.tsx, pantry-screen.tsx and dish-screen.tsx all document). The region exists from mount, outside the saved/editing branch, with its inner <span> keyed on a counter so two identical messages are still a real mutation.
  • A message raised behind the rebind sheet is held and re-issued when it closes. BottomSheet renders aria-modal="true" inline, with no portal, so while it is open the form's region is pruned from the accessibility tree — and because the region's <span> is keyed on a sequence number, the sheet closing is not itself a DOM mutation, so nothing would replay it. announce() therefore stashes ambient messages while a sheet is open and flushes them, with a fresh key, on close.
  • Both ambient announcements are scoped to the editing branch. Neither «нет сети» nor «Блюдо изменили» has any meaning on the saved panel — the first would tell someone their already-committed dish will not be saved.
  • A successful save advances expectedVersion to the version it just authored. Otherwise the form's own invalidation refetches dish.get, latest.version overtakes the stale guard, and the form announces «Блюдо изменили» over its own «Блюдо сохранено».
  • The banner's explanation stays in the accessibility tree, tied to «Обновить» with aria-describedby. The live region speaks it once; the banner outlives that, and the next announcement overwrites the slot. It is not itself a live region, so nothing re-speaks it.
  • The post-save invalidations are not awaited, and not held inside the save mutex. invalidateQueries awaits the refetch it triggers, and a refetch that pauses — the device drops offline mid-flight under the default networkMode: "online" — does not settle until the browser is online and focused. Awaiting it would hold savingRef, leave the button on «Сохраняем…» and swallow every further tap for the whole outage, for a write the server already committed. void, exactly as dish-screen.tsx's invalidateDish() does.
  • The tag cap says so instead of swallowing the word. normalizeTags truncates at MAX_TAGS (12) — recipeDraftSchema depends on it — so a 13th tag simply does not appear; clearing the field on top of that would take the typed word away with no explanation. Which of the three refusals happened is tagAddOutcome's job, not a condition inside the component: blank, duplicate and full all look identical from outside normalizeTags (the array comes back the same length), and only «full» is worth a message. The first inline version of that test announced a full list for an empty field. The visible notice is derived from tags.length >= MAX_TAGS && tagDraft.trim() rather than stored, because a stored one outlived its condition — it stayed on screen after a chip was removed, and after «Обновить» replaced the whole tag list; the announcement stays one-shot, since a live region re-speaking on every keystroke is unusable.
  • The text ⟷ number edges live in src/lib/recipes/form-fields.ts, not in the component: vitest runs in node with no DOM harness, so a branch left in a .tsx is unreachable from the suite. An out-of-range quantity becomes null rather than being clamped — a silently corrected amount is worse than an honestly missing one, and the amber chip is what says so.

After a save

dish.pathFilter() is invalidated, not list + get: an archived dish is still editable, and listArchived renders the title a save may have just rewritten while handing «Вернуть» the version it just spent.

The form does not navigate on its own: it replaces itself with a «Блюдо сохранено» panel that names how many products were created (and says «проверь их в каталоге» when aiFailed), with «Открыть блюдо» taking focus. The blueprint called for a toast on S7, but S7 (dish-screen.tsx) is task 4.5's file in the same wave; a cross-file toast channel would have collided, and a panel where the user already is beats a toast that a navigation immediately unmounts. Revisit when 4.5 lands.

What 4.2 deliberately did not build

Photo upload (task 4.3): the form renders a saved photo with «Удалить фото» (clearing photoUrl + photoKey) and exposes a photoUploadSlot prop for 4.3 to fill. yieldUnit is display-only — it is the source's own noun, imported data like raw_text, and nothing in a manual form should invent one.

Recipe import: photo (S8.1–S8.2, task 4.3)

The main road (VISION scenario Б): a screenshot already sitting in the gallery becomes a reviewable draft. Everything below exists to keep two promises — the household is never billed for a call the ledger does not record, and a parse that fails is a fork in the road rather than an error screen.

The pipeline, end to end.

<input type="file" accept="image/*">        no `capture` — see below
  → pickCompressionPlan()                    pure: max side 1600, quality ladder
  → canvas → JPEG ≤ ~300 KB                  src/lib/images/compress.ts
  → useUploadThing("dishPhoto")              browser → UploadThing directly
       ├ middleware → session + household + upload cap (before the presign)
       └ onUploadComplete → INSERT photo_uploads (the ownership row)
  → dishImport.fromPhoto({ fileKey })        tRPC, maxDuration = 60
       ├ ownership: photo_uploads WHERE file_key = $1 AND household_id = $2
       ├ assertWithinRateLimit(user)
       ├ INSERT ai_jobs (parse_photo, running, input_ref = fileKey)
       ├ parseRecipe({ kind: "photo", imageUrl: uploadThingUrl(fileKey) })
       ├ UPDATE ai_jobs (done|error, cost_usd, finished_at)   ← immediately
       ├ try {
       │     matchIngredients → draftFromParsed
       │     UPDATE ai_jobs (output_json = the whole importResultOutput)
       │ } catch { markJobError → rethrow }
  → replace() to /dishes/import/<jobId>      the review form (S8.3)

The ai_jobs lifecycle, and the cost-before-matching rule. The row opens before the call, because src/server/ai/rate-limit.ts counts ai_jobs rows — a burst still in flight has to count against the window already. The row closes in the statement immediately after parseRecipe returns, on both branches, before catalog matching and draft validation. Everything downstream sits in a try/catch that stamps status: 'error' on the way out. A ledger that recorded only the calls whose post-processing also succeeded would under-report exactly when things go wrong. dish-import.test.ts pins this by making the catalog SELECT reject and asserting the recorded UPDATE still carries a non-zero cost_usd.

A parse failure is an outcome, never a thrown TRPCError. S8.2's whole design is a specific fallback per failure (src/lib/recipes/import-failure.ts, exhaustive switches with no default so task 4.4's new reasons cannot ship without copy and a way out). Throwing would collapse nine outcomes into one red box and lose the jobId — and with it the cost record and the handle a reload needs. Only UNAUTHORIZED, FORBIDDEN and TOO_MANY_REQUESTS throw, the last so isRateLimitedError() keeps working unchanged.

photo_uploads — the ownership table, and why the contract is a key. fromPhoto accepts a file key and nothing else; the URL OpenAI fetches is rebuilt server-side by uploadThingUrl() from the app id decoded out of UPLOADTHING_TOKEN. So the SSRF question on this path is closed by construction rather than by a filter: there is no client-supplied string that can become the thing we fetch, hence no allowlist to get wrong and no redirect to re-validate. But a key is a short, guessable-shaped string, so ownership is a second, separate check: photo_uploads (file_key PK, household_id, user_id, url, created_at) is written from the UploadThing onUploadComplete callback — the only place the key is born, because the browser uploads directly — and both fromPhoto and discardPhoto require the row to belong to ctx.household.id. discardPhoto additionally refuses a key any dishes.photo_key still references, so «Отмена» on a review screen reopened after the save cannot delete the blob the dish is now showing.

The upload route is a second public entry point: src/middleware.ts excludes /api/**, so dishPhoto's .middleware() re-checks the Better Auth session and household membership, which is what householdProcedure does for tRPC — plus the per-user upload cap described under Rate limiting, because the tRPC limiter lives inside fromPhoto and an uploader that never reaches it would never be metered. src/server/uploadthing.test.ts drives both callbacks directly (vi.mock of @/lib/session and @/db), giving this door the coverage trpc.test.ts gives the other one.

The blob deleter is injected, not importedctx.uploadThing (src/server/uploadthing-files.ts), for the reason ctx.openai is a factory: a procedure that talks to a paid third party has to be assertable without one, and a test that forgets to supply a fake must fail loudly rather than quietly dial out. discardPhoto used to call the store directly, which left its deletion branch untestable — deleting the call, or hoisting it above the "a saved dish still references this key" guard, used to pass every test in the repo.

UPLOADTHING_TOKEN is now read at runtime (no new variable). Three places read it, all lazily and all directly off process.env rather than through env(): the route handler's factory (src/app/api/uploadthing/route.ts), uploadThingAppId() (src/server/uploadthing-url.ts), and uploadedFileStore.deleteFiles (src/server/uploadthing-files.ts), which skips the delete when the token is absent — a branch that only fires in tests and zero-env builds, since env() declares the variable required and every request builds its context through db()env(). env() validates the whole schema on its first call, so an unrelated missing variable would make a photo import fail with a message about RESEND_API_KEY; the token's own shape is validated where it is decoded. Nothing reads it at module scope — pnpm build runs in CI with zero environment variables, and this is the one file in the feature at risk of it.

The deadline and maxDuration. src/app/api/trpc/[trpc]/route.ts sets maxDuration = 60 (Vercel Hobby's ceiling) — a ceiling, not a floor; nothing else on the route runs long, and a separate route for import would fork the context and auth plumbing for one number. Deadline (src/server/recipes/deadline.ts) keeps the import itself inside 50 s so the function always has room to answer; the photo path is a single 40 s stage, and task 4.4 adds fetch and FireCrawl in front of it against the same shrinking budget. The vision call passes { timeout: 40_000, maxRetries: 0, signal } per request — the shared client's 15 s / one retry is right for an icon lookup in a sheet and wrong here, where a retry doubles both the wait someone is watching and the bill for a call whose fallback is instant.

The strict schema is primitives only, and a shape test enforces it. parsedRecipeSchema carries no .min(), .max(), .trim(), z.enum or z.uuid() at any depth: z.toJSONSchema emits minLength/maximum/format for those and OpenAI strict mode rejects them with a 400 on the first real call. parse-recipe.schema.test.ts walks the emitted document at every depth and landed before any prompt work. Bounds are not lost, only moved — recipeDraftSchema applies all of them in draftFromParsed, where an out-of-range value becomes null plus «уточнить» for that one row instead of failing the whole recipe. unit in particular is a free string, deliberately: an enum would make the model bucket «зубчик» into «шт» and hand back a confidently wrong quantity, which is the honesty failure VISION §6.4 forbids. coerceRecipeUnit maps what it can and routes the rest into the row's note.

draftFromParsed is where the honesty is mechanized. Nothing is clamped (285.4999 does not become 285.5, qty: 0 does not become MIN_QTY — an invented digit is invisible on the very screen meant to catch it); nothing is dropped silently (an unmapped measure moves into note); and no Russian string is authored there — a title the model omitted falls back to the source's own first ingredient or first step, because a placeholder we wrote would be UI copy living in dishes.title, outside next-intl. A name carrying a digit, a bare unit word or more than 40 characters is treated as a source line the model failed to reduce to a noun: the row falls back to rawText, stays unbound and wears the amber chip. The cost is real and accepted — «Молоко 3,2 %» falls back too — because a wrongly-flagged row is a chip someone clears, while a wrongly-bound row buys the wrong thing in 5.2 and nobody ever sees why.

Only a "catalog" match binds in the draft. A "reference" hit still has to create a product, and products are created on save (DESIGN_BRIEF S8.3), so those rows reach the review form as the honest «новый» state and resolveIngredientProducts resolves them again when «Сохранить блюдо» is tapped.

The draft lives in ai_jobs.output_json, and the review screen is a real URL. No recipe_drafts table: the job row and its cost are written anyway, and keeping the draft there means a reload, a Back gesture or an iOS PWA eviction while the user is in Photos cannot destroy a parse the household has already paid for. dish.create writes consumedDishId into the same document (jsonb_set over a coalesced base), so reopening /dishes/import/<jobId> after a save redirects to the dish instead of offering a second copy of the same recipe. The form's seed is frozen at mount, keyed by the job id, like every other form in this app.

getJob has a third outcome, running. S8.2's mutation normally answers first, but someone who reloaded that screen mid-parse arrives at the review route with a job in flight; it polls every 2 s. A row still running after 90 s is one whose function died before it could close its own ledger entry (maxDuration is 60), and it is reported as aiUnavailable — whose fallback is «Ещё раз» — rather than as a spinner that never stops.

Client-side compression. pickCompressionPlan is pure and tested (src/lib/images/compress-plan.ts): longest side ≤ 1600 px, quality ladder 0.82 → 0.7 → 0.6 → 0.5, at most four attempts, never below 0.5 — past that JPEG artefacts start eating the small digits the whole feature depends on, and a 400 KB readable file beats a 200 KB unreadable one. compress.ts is the thin canvas wrapper around it and is deliberately not unit-tested (there is no jsdom here, and a simulated canvas would prove nothing about Safari). No capture attribute on the input: the main path is a screenshot already in the gallery, and capture forces the camera on iOS.

HEIC is the long tail, and it degrades rather than fails. createImageBitmap decodes HEIC through the system decoder on Safari and simply fails on desktop Chrome. On any compression failure the original file is uploaded when the route would take it (4 MB), and only a file that is both undecodable and oversized becomes an error the user sees. Screenshots — the actual main path — are PNG or JPEG.

Imports are never offline-queued and never budget-gated. installOfflineQueue registers mutation defaults for cart.* only, so nothing extra was needed — but the reason matters: an import costs money and a create is not idempotent, so replaying either out of IndexedDB hours later is exactly wrong. fromPhoto, discardPhoto and the dish save all declare networkMode: "always" so they fail fast and honestly instead of pausing forever. AI_MONTHLY_BUDGET_USD caps the assistant only (see above).

Orphan blobs are hygiene, not correctness. An abandoned import leaves an UploadThing file with no dishes.photo_key pointing at it. discardPhoto is wired to «Другое фото» and to the review screen's «Отмена», and DishForm discards the keys it displaced after a save commits (never before — until the write lands, the old photo is still the one the dish has). At ~300 KB a file, the free tier absorbs several thousand orphans, so a sweep job (list UT files, delete those whose key is absent from dishes.photo_key) is a recorded post-MVP chore. Do not create draft dish rows to solve it — that trades a harmless blob for a real half-created-dish state the whole library would have to filter around.

?src=photo focuses the picker; it does not click it. Browsers only open a file dialog under transient user activation, and a tap that caused a navigation does not carry activation into the new page — an auto-click would be silently ignored while the user stared at an unchanged screen. S6's empty state and the source sheet's «📷 С фото» row both route here.

Recipe import: URL and text (S8.1–S8.2, task 4.4)

The second and third sources. Both end where the photo path ends — one ai_jobs row, one draft in output_json, the same review URL — and everything below is about the two things a link adds that a screenshot does not: a page has to be fetched, and a URL a user chose is a request the server makes on their behalf.

The cascade (VISION §6.4).

dishImport.fromUrl({ url })
  ├ classifyImportUrl(url)                    ← on the INPUT SCHEMA, not the resolver
  │    blocked → BAD_REQUEST, no ai_jobs row, no fetch
  │    instagram|facebook|tiktok → "social": skip the direct fetch entirely
  ├ assertWithinRateLimit(user)
  ├ INSERT ai_jobs (parse_url, running, input_ref = url)   ← BEFORE the fetch
  ├ deadline = new Deadline(50_000)
  ├ fetchPage(url, 8 s)     → JSON-LD Recipe?    free  → via "jsonld"
  │                         → microdata Recipe?  free  → via "microdata"
  ├ nothing structured, ≥10 s left → firecrawlScrape(url, 20 s) → via "firecrawl"
  ├ normalizeRecipe(...)    ← ALWAYS, free path included; takes what is left
  ├ UPDATE ai_jobs (done|error, cost_usd)       ← immediately after, both branches
  └ try { matchIngredients → draftFromParsed → output_json } catch { markJobError }

fromText is the same procedure with the fetch removed: ai_jobs (parse_text, input_ref = "text:" + first 80 chars), the same normalizer, the same ledger order.

Verified against the three sites VISION §6.4 names, on 2026-09-03: eda.rambler.ru is JSON-LD and free; povar.ru is microdata and free (its only ld+json block is an Organization, which is why "has JSON-LD" cannot be read as "has a recipe"); russianfood.com has nothing structured and costs one FireCrawl credit plus ~$0.007.

The normalizer runs on the free path too, ungated (decision D15). JSON-LD hands back «285 г муки», and «муки» matches «Мука» under no string ranker this app has — so a skipped normalization does not save a cent so much as fill the household's catalog with genitives that never match again. The extraction goes in as a hint the model corrects (skeleton mode of the one shared prompt; the model never sees HTML), and if that call fails the import still succeeds: every extracted line becomes { rawText, name: rawText, qty: null }, the result carries normalizationFailed, and the review screen says so. An editable draft wearing amber chips beats an error screen for a page we had already read.

The SSRF guard is two halves, and they answer different questions (src/server/recipes/url-guard.ts, fetch-page.ts).

  • classifyImportUrl reads the URL as written: scheme ∈ {http, https}, no credentials, port ∈ {∅, 80, 443}, hostname not localhost / *.localhost / *.local / *.internal / *.home.arpa, and no literal IP at all (a public literal is refused too — no recipe site is addressed by number, and allowing them would mean trusting the range list to be complete), and — for the two storage-facing callers only, via the opt-in maxHref option — the normalized href no longer than MAX_SOURCE_URL (new URL() percent-encodes every non-ASCII byte, so a Cyrillic path that fits the input bound can come back six times longer — past what recipes.source_url stores, which the draft schema would only have refused after the model was paid). fetchPage's per-hop guard deliberately passes no maxHref: a redirect target is never stored, and a site that bounces through a long tracking URL on its way to a short canonical one is still a recipe page. The URL pane mirrors the bound client-side (isUrlTooLong / isSubmittableUrl in src/lib/recipes/import-input.ts, pinned against fromUrlInput by a test) and says «слишком длинная ссылка» in the field, because the server's BAD_REQUEST can only be shown as blockedUrl — copy that blames the site, with fallbacks leading away from the field. Pure string work, so it runs as a Zod .refine on fromUrlInput: a blocked URL is a validation rejection with no ledger row (decision C.8), because ai_jobs counts calls the household could be billed for. The client maps that BAD_REQUEST onto S8.2's blockedUrl copy — the one failure that carries no jobId by design.
  • isBlockedAddress reads what the name resolved to, and fetchPage runs it on every address of every hop: 0/8, 10/8, 127/8, 100.64/10, 169.254/16, 172.16/12, 192.0.0.0/24, 192.168/16, 198.18/15, everything from 224/4 up, plus ::/96, fc00::/7, fe80::/10, ff00::/8, 2002::/16 (6to4) and 64:ff9b::/96 (NAT64) — with IPv4-mapped IPv6 unwrapped and re-checked as IPv4. This is the only check that survives wildcard DNS: 127.0.0.1.nip.io is a perfectly ordinary public hostname. Anything unparseable is blocked, not allowed.

Other limits in the same file, each for a specific failure: redirect: "manual" with at most 3 hops, every one re-classified and re-resolved (a single-hop check is defeated by a 302 to 169.254.169.254); content-length > 2 MB refuses early and the body is then streamed with a running counter that cancels the reader past MAX_HTML_BYTES (the header is optional and can lie); and the bytes are decoded in the page's own charset — russianfood.com still serves windows-1251, and Response.text() would hand the model a page of replacement characters.

Every page-controlled string is bounded, and so is every recursion. Two of these were found by review rather than by reasoning, and both had the same shape — «bounded by the document» is not a bound when the document is 2 MB. parseDurationMin caps its input at 200 characters and its numeric groups at six digits: the Russian duration patterns are global and alternating, and on a long digit run they backtracked quadratically — 128 000 digits measured at 51 seconds of synchronous CPU, which no AbortSignal can interrupt, so one hostile page burned the whole maxDuration and returned a 504 with no jobId. jsonld.ts and microdata.ts cap every value they emit at 2 000 characters (the longest ingredient line in the four fixtures is 38), and every JSON-LD value reader — not just the node search — carries the same MAX_DEPTH = 6: JSON.parse is iterative in V8, so a 4 KB block nesting recipeIngredient two thousand arrays deep parsed happily and then blew the JS stack.

And the whole read runs inside fromUrl's try. node-html-parser's selector walk recurses once per DOM level and throws a RangeError on a page of ~9 000 nested <div>s (99 KB, well under the body cap). Uncaught, that escaped as a bare 500 and left the ai_jobs row the procedure had just opened stuck on running for ever — breaking the invariant fromPhoto upholds, that a run dying before the model still closes its entry as error with costUsd = 0. The fetch-and-extract stage is a function of its own (readPage) precisely so it can return an outcome and leave every ledger write in the resolver.

Accepted residual risk (R4): TOCTOU. A hostname can resolve public in lookup and private inside fetch. Closing it needs an undici dispatcher pinned to the resolved address — real complexity for an attack that needs an attacker-controlled DNS server and a target on Vercel's function network, which has no metadata endpoint. Revisit if this app ever runs somewhere with one.

FireCrawl is called with fetch and no SDK (src/server/recipes/firecrawl.ts): POST https://api.firecrawl.dev/v2/scrape, { url, formats: ["markdown"], onlyMainContent: true }, bearer read inside the function off process.env (the reason uploadthing-url.ts does the same: env() validates the whole schema, so an unrelated missing variable would make a recipe import fail over RESEND_API_KEY). The response is Zod-parsed and any mismatch becomes pageBlocked (R7) — an API change degrades into S8.2's text/screenshot fork, never a stack trace. An absent key does the same rather than throwing a 500. Credits: this is the rare third branch, so ~1000/month is not a constraint.

The scrape is condensed before it is truncated, and this was found on a real import rather than reasoned about. russianfood.com's scrape comes back as 58 000 characters of nested table-layout markdown whose first twelve thousand are the logo, the menu, a login box and a share widget; truncating that handed the model a navigation bar and got back «на этой странице нет рецепта» for a page with a perfectly good recipe on it. condenseMarkdown strips markup only — a table row keeps its cells, a link keeps its label, an image keeps nothing — which leaves 8 000 characters starting at the dish's own heading, and costs fewer tokens besides.

The time budget, and why the last stage is not a fixed number. Deadline (src/server/recipes/deadline.ts) gives the fetch 8 s and FireCrawl 20 s — both bounded, because both have a cheap alternative — and skips FireCrawl entirely below 10 s remaining (pageBlocked, whose copy already offers text and a screenshot; starting a 20 s scrape with 6 s left would burn a credit to produce a 504). The normalizer then takes everything left less a 3 s reserve, because it is the last thing that runs and there is no one to hand the surplus to. That is not a detail: a fixed 25 s cap aborted a model still writing a twenty-step recipe on a page that had fetched in half a second, with twenty seconds of budget sitting unused. Per-request { timeout, maxRetries: 0, signal } as always.

A page's own image is stored as a remote URL with photo_key: null. Nothing was uploaded, so there is no blob of ours to discard; re-hosting somebody's photo to avoid a hotlink is a decision (and a bill) this feature does not need to make. A URL longer than recipeDraftSchema's 500-character cap is dropped rather than allowed to fail the whole draft's validation — reporting a good import as a failure over a thumbnail would be the wrong trade.

fetchPage distinguishes a refusal from a dead host. Its result carries status separately from unreachable, because blueprint §3.6 maps them to different copy: 403/429/503 is «страница не отдала рецепт» (pageBlocked) and a dead host is «не удалось прочитать страницу» (pageUnreachable). When FireCrawl also fails, the fetch's own verdict is the more specific one and survives; a login wall overrides both with loginWalled, and a scrape that came back too thin is noRecipeOnPage — the page was reachable, it simply had no recipe on it.

ctx.pageFetch() is injected like ctx.openai() and ctx.uploadThing(), and with the sharpest edge of the three: every rule above is only testable if the transport can be faked, and a router test that forgets to supply one has to fail loudly rather than have CI issue real requests to whatever host a fixture named. src/app/api/trpc/[trpc]/route.ts therefore also declares runtime = "nodejs" explicitly — node:dns does not exist on the Edge runtime, and the default should have to argue with a comment before it changes.

itemtype is a token list, not a URL. Rare but legal, and the failure was silent: a scope written itemtype="…/Recipe …/Product" ends with the second type, so testing the whole attribute missed the recipe and the page fell through to a paid scrape. Both the Recipe scope and the HowToStep filter split on whitespace now.

Fixtures, not the network (src/server/recipes/__fixtures__/, with a README recording every source URL and capture date). Each of the three real pages was fetched once, trimmed to the recipe fragment under 50 KB, and checked in as a parser test input — they pin what the parsers must survive, not what a site looks like today, so a test that starts failing is a parser regression until proven otherwise. dirty-graph.html is hand-written and carries every awkward JSON-LD shape at once: a malformed block first, an @graph wrapper, @type as an array, HowToSection with nested itemListElement, an ImageObject, and recipeYield as an array. No test touches the network.

The client refuses what the server would refuse, in the field. src/lib/recipes/import-input.ts holds both bounds and fromTextInput is built from the same constants, because a paste past MAX_IMPORT_TEXT comes back as a BAD_REQUEST the screen can only report as «сейчас не получается разобрать» — whose one action, «Ещё раз», replays the identical string forever with the textarea already unmounted. Neither textarea carries maxLength — the browser enforces that attribute on every paste, so a 25 000-character recipe would be cut to the bound silently and the fragment submitted as the whole recipe, and the «слишком длинно» rule could never fire; the rule refuses in JS instead, with aria-invalid and a role="status" line that says which rule failed. A BAD_REQUEST that still gets through is mapped per source — blockedUrl for a link, tooLarge for a paste (its fallback brings the field back), photoUnreadable for a photo (its key came from the upload callback, so trimming fixes nothing) — with the partial built from the run, so a refused photo keeps its key for «Другое фото» to discard. Exactly one control claims autoFocus when S8.1 mounts: pickImportFocusTarget (src/lib/recipes/import-focus.ts, pure and tested, like next-focus-target.ts) lets the refusal state outrank the ?src= deep link, because OR-ing them once put two autoFocus nodes on screen and the later one took focus off the field that had just been refused. A rate-limit refusal also copies a paste that came through the failure panel's inline fallback into the screen-owned textValue, so the pane it focuses holds the recipe rather than an empty field.

S8.1's panes keep their text across a failure. The values live in ImportScreen, not inside SourcePane: a rate-limit refusal returns to the source phase, which unmounts both panes, and a local useState took the typed URL or twenty thousand pasted characters with it. Focus goes back to the pane that was refused rather than to the photo picker, and the S8.2 text field is seeded from the run that failed, so «не рецепт» after a paste lands on the paste rather than on an empty box.

S8.2 renders «вставь текст» as a field, not a button — DESIGN_BRIEF's «без тупика, сразу поля» — focused when text is the primary way out (every URL failure) and sitting under the screenshot button when it is not (a login wall, where a screenshot genuinely works better). importFailureCopyKey and fallbackActions both take the import source (photo / url / text, derived by importSourceOf from what the failure salvaged), not a boolean: one reason is not one sentence. «Похоже, на фото не рецепт» after a pasted link is about something that never happened, «на этой странице» after a paste names a page that never existed — and after failed text the field the person just used must not lead, because re-pasting the same words is the one thing already known not to work.

«Ещё раз» goes back to the source it came from. The review route owns no import mutation, so all it can do is route — and it used to route every retry to ?src=photo, answering a failed page import by asking for a screenshot and dropping the URL the server had salvaged. retryImportHref picks the pane by source and hands a link back through ?src=url&url=…, which S8.1 reads once at mount into the field (prefilled, never auto-submitted — the server's guards run exactly as they did the first time).

«✍️ Вручную» keeps what the import salvaged. draftFromPartial (src/lib/recipes/import-seed.ts) derives the source from what survived instead of defaulting to manual: a dish typed in by hand after a failed page import still came from that page, and S7's «Источник» link is the difference between the URL being kept and being dropped on the floor of the screen that was supposed to rescue it. Pure and tested, for the reason import-consumption.ts was extracted before it — the screen it came from is a "use client" component and vitest runs in node.

AI budget does not gate this

AI_MONTHLY_BUDGET_USD caps the assistant only (task 6.1). Recipe import (4.3/4.4), the batched product enrichment inside a save (4.2) and the recipe adaptation (4.6) keep working at the cap: every one of them is a thing the user started and is waiting on, and losing a reviewed recipe to a quota is the wrong trade. They are still rate-limited per user like every other AI endpoint, and every call still writes its ai_jobs row with costUsd.

Migrations 0009 and 0010

0009 creates dish_source_type and the four tables; 0010 contains exactly ALTER TYPE "public"."ai_job_type" ADD VALUE 'adapt_recipe';. Two files, and the value ships in task 4.1 though task 4.6 is the first to write it, because migrate.yml and the Vercel deploy of the same commit run in parallel — shipping the value several PRs early removes the window where the function could go live before the type knows the word. adapt_recipe is appended, never inserted, so drizzle-kit emits a plain ADD VALUE rather than an ADD VALUE … BEFORE or a type recreate; only the plain form is backward-compatible with code already running. The rule that follows: never write or select a new enum value in the migration batch that adds it.

Seeding a demo library

pnpm db:seed --dishes

Inserts DESIGN_BRIEF §5's «NYC Cookies» (all three ingredient states, the 9–11 min timer) and «Шакшука» into the first household, binding ingredients to catalog products by normalized_name where one already exists and leaving the rest unbound — the honest state 4.1 ships. Idempotent by normalized_title, so a second run is a no-op. It refuses to run unless DATABASE_URL's hostname is loopback, the same footgun drizzle.config.ts documents. Without the flag pnpm db:seed stays the no-op it has always been.

Portions and equipment check (S7, task 4.5)

Zero schema change, zero router change. rescale.ts, portions.ts and timer.ts all shipped in 4.1 (S7 already routed every quantity through rescaleQty at the identity portions === portionsBase), and kitchenProfile.get already existed (task 1.4). Task 4.5 turns that constant into state and adds one comparison: src/server/recipes/coerce-equipment.ts (coerceEquipmentSlug, coerceEquipmentList) and src/server/recipes/equipment-check.ts (missingEquipment) — both pure, both new.

The slider. PortionsSlider (src/components/portions-slider.tsx) is presentational, like QtyStepper: every string arrives translated. dish-screen.tsx keeps a portionsOverride: number | null seeded null, not an effect syncing off dish.data — the bug class this repo has already paid for once (superjson Dates defeat structural sharing, so an effect keyed on the server object would fight a background refetch). While portionsOverride is null the screen shows portionsBase; a fresh mount of the route (any real "open" of a dish) starts it null again, which is the whole of "reset to portionsBase on every open, never persisted." rescale.ts gained one export, portionsRange(base) → { min: 1, max: Math.max(12, base * 2) } — always wide enough to reach double the base, floored at a 12-portion slider even for a two-portion recipe («Шакшука»).

One computed value feeds three places. ingredientsForMessage({ portionsBase: portions, portionsMin: null, yieldUnit }) (portions.ts, unchanged) is called once per render with the live slider value in place of the stored portionsBase; its t(...) result is «на 8 порций» / «на 8 печений» and is reused for both the ingredients section header and the slider's own aria-valuetext — the two can never word the same number differently because they are the same string. The ingredient list itself stays a non-live region (blueprint §4.6): aria-valuetext lives on the <input type="range"> alone, so a screen reader announces one sentence per drag tick, not ten rescaled rows.

The equipment banner (src/components/equipment-banner.tsx) is a strict function of four inputs — recipe.equipment (coerced), the household's kitchenProfile.get result, and the two loading/never-set states in between:

required.length profileEquipment Renders
0 (any) nothing — most dishes have no equipment at all
> 0 undefined (query in flight) nothing — never a flash of «профиль не заполнен» a moment before it turns out otherwise
> 0 null (never saved) «Профиль кухни не заполнен.» + a link to /settings
> 0 string[] «Нужно: ✓/✗ · …» for every required slug, green when all ✓, amber plus the aria-disabled «Адаптировать (ИИ)» button (task 4.6 wires it up) when at least one is ✗

missingEquipment(required, profile) runs the profile array through coerceEquipmentList (never required, which already arrives as EquipmentSlug[]), so a household that typed «мультиварка» into the profile's free-form field instead of checking a box still satisfies multicooker — the same reasoning resolveEquipmentEntry (src/lib/equipment-entry.ts) applies on the checklist's side of that same gap. Labels come from the S12 checklist's own kitchenProfile.equipment.<slug> messages (dish-screen.tsx builds the record with the identical Object.fromEntries(EQUIPMENT_PRESETS.map(...)) shape kitchen-profile-form.tsx uses), so the banner and the checklist can never word an appliance differently.

The «скоро» tap has its own live region, not the screen's. dish-screen.tsx's doc comment calls its hint state "the screen's one announcement slot" for the four already-disabled actions (В меню недели / Ингредиенты в корзину / Готовить / Редактировать); wiring a fifth action through it would have been a lie about what that slot means. EquipmentBanner owns a second, scoped role="status" region instead — spoken only («Адаптировать (ИИ)» — скоро), because the button next to it is already visibly aria-disabled and a static caption already explains why.

Manually verified, all four banner states, against the seeded NYC Cookies (equipment: ["oven"]) after editing the kitchen profile in Settings: covered (oven checked), missing (oven unchecked, mixer/microwave only — «скоро» announces on tap), profile never saved (kitchen_profiles empty), and nothing rendered for a recipe with equipment: [] (Шакшука, temporarily). Slider: dragging 8→16 doubled every quantity live («Мука» 285 г → 570 г, «Соль» ¾ ч.л. → 1½ ч.л.); dragging back to 8 restored exactly «285 г», not a rounded neighbour; the ± buttons disable at portionsRange(8)'s bounds (1 and 16).

AI adaptation (S7, task 4.6)

A proposal the household applies, never an auto-apply. dish.adapt reads, calls the model, and writes nothing but its own ai_jobs rows — asserted in dish.test.ts by filtering every recorded statement down to the non-select ones and expecting exactly insert ai_jobs + update ai_jobs. What it returns is a RecipeDraft; «Применить» is an ordinary dish.update carrying that draft and the version the card was opened at, so an adaptation cannot bypass draft validation, product-ownership checks, the FOR UPDATE version guard or the needsReview recompute. If the household does not like the answer, closing the sheet costs nothing but the call that was already made.

The order inside dish.adapt, and why each step is where it is:

  1. The version first, alone. A stale expectedVersion is a CONFLICT before a single token is spent — a proposal built against a recipe a partner has already rewritten is one dish.update would refuse anyway. Issued as its own small select rather than taken off the aggregate read that follows, exactly like dish.update's own pre-check: a guard has to read as a guard.
  2. nothingToAdapt before the ledger. A recipe the kitchen profile already covers, at its own portion count, has no adaptation to propose. The honest answer costs nothing, writes no ai_jobs row and returns jobId: null — the one outcome in this router with no job behind it. A targetPortions equal to the recipe's own portionsBase is not a rescale, whatever the client sent.
  3. The ai_jobs row opens before the call, because src/server/ai/rate-limit.ts counts those rows: calls still in flight have to count against the window already. Type adapt_recipe (the enum value has existed since 4.1 — no migration here), input_ref = the dish id (ai_jobs needs no dish_id column).
  4. The ledger closes immediately after adaptRecipe returns, on both branches, with costUsd on both — before the proposal is applied to anything. Everything after that is in a try/catch that stamps the reason and re-throws.
  5. An AI failure is an outcome, never a thrown error. { outcome: "failed", jobId, reason } with the recipe still on screen exactly as it was.

The portion arithmetic is ours; the model only overrides. rescaleDraft (src/server/recipes/adapt.ts) multiplies every stated quantity through the same rescaleQty S7's slider drags over, before the prompt is built, and the model is shown the already-scaled numbers. Multiplying twenty numbers is the one part of this job a language model is measurably worse at than a * operator, and every wrong product would be a wrong shopping quantity two screens later. A rescale also clears portionsMin and yieldUnit: «7–8» was the source's range for the source's own batch, and «печений» is a genitive plural that was grammatical for 8 and is not for 4 (the same rule ingredientsForMessage already applies at any count other than the recorded one). rawText is deliberately left alone — the source really did say «Мука — 285 г» — though a proposal may restate it.

Step text gets its own prompt budget. Every interpolated value goes through one cap() that trims, collapses interior whitespace and truncates — the whitespace collapse is load-bearing, because the prompt is a numbered list joined by newlines and nothing upstream (parseRecipe's bare z.string(), capped(), recipeDraftSchema's .trim()) removes an interior \n: a stored note carrying one would emit extra unindented lines inside the ingredient block, shaped like the prompt's own directives. The truncation budget, though, is 300 characters for one-line fields and MAX_STEP_TEXT (2000) for steps. Steps are the only field the one-line cap could actually shorten, and a steps[] edit is a full-text replacement by index — a model shown 300 of a 500-character step would overwrite the 200 it never read, invisibly, since the sheet renders only the new text for a changed step. The response side is bounded by MAX_OUTPUT_TOKENS; the prompt side is one recipe, and one recipe fits.

Edits are addressed by index, and a stale index is DROPPED, never clamped. The strict output carries ingredients[{index, qty, unit, note, rawText}], steps[{index, text, timerSec, timerMaxSec}], removedStepIndexes, addedSteps[{afterIndex, …}] and one summary — and no name, isOptional or productId at any depth, machine-checked by adapt-recipe.schema.test.ts. An adaptation changes how much and how, never what a recipe is made of, so catalog bindings survive it intact. Clamping index: 9 onto row 3 would put a quantity meant for one ingredient onto a different one — a wrong number that looks exactly like a right one. Step additions anchor after the original index they name (-1 prepends) and survive their anchor's removal, so «замени этот шаг на два» is expressible; an anchor outside the list is dropped like any other stale index. Out-of-range values degrade one field (a quantity becomes null and wears «уточнить»), exactly like draftFromParsed — the household paid for this call and should get the rows that worked.

No kitchen profile is not an empty kitchen profile (CodeRabbit, this PR). profile?.equipment ?? [] would make every recognized requirement "missing" for a household that never filled in the profile — and then strip all of them from recipe.equipment on apply, for appliances nobody said were absent. A missing profile row means nothing is missing, so the only thing left to adapt is the portion count; AdaptProfile.equipment is readonly string[] | null and the prompt words the two states differently («ничего из техники не указано — предлагай ручные способы» versus «про технику ничего не известно — технику не меняй»), the same null-vs-[] distinction EquipmentBanner already draws. S7 does not offer the adapt button in that state at all (the banner links to Settings instead), but the rescale entry point beside the slider does reach dish.adapt, and a direct call reaches it regardless of the screen.

Applying drops an appliance from recipe.equipment only on the model's own evidence (orchestrator review round 1). The strict output carries droppedEquipment — the appliances the model says it actually worked around, in its own Russian words — and matchDroppedEquipment intersects that with the ones the household was actually missing. Both halves of the intersection matter: a proposal cannot remove a requirement nobody asked about, and a proposal that reworked nothing (prompt rule 14 invites exactly that) cannot strip «миксер» from a recipe whose steps still say «взбить миксером». The earlier unconditional drop turned an all-empty proposal into «Больше не нужно: Миксер» over an unchanged recipe, and silenced S7's banner for that dish permanently — the one failure a proposal the household approves cannot warn them about. Nothing is ever added: the model is told to use only what the household already has, and inferring a new slug from prose would be a guess about the one field the banner reads. The removal is part of AdaptationDiff (droppedEquipment) rather than a silent side effect, so isEmptyDiff counts it and «менять ничего не пришлось» can never sit above a persisted requirement removal.

Matching the model's answer is deliberately more forgiving than coerceEquipmentSlug (round 2). That function's whole-string contract is right for its own job — missingEquipment and 4.5's banner compare two vocabularies, where «нужна духовка» must not count as «духовка» — but here the question is different: a free-text answer from a model that was handed the exact nominatives, checked against the two or three slugs this household is actually missing. Russian declines, and «убрали миксером» or «ручной миксер» are unmistakably about the mixer; refusing them left the recipe declaring an appliance it no longer uses. matchDroppedEquipment(said, candidates) (src/server/recipes/adapt.ts, exported and tested) accepts the slug, the vocabulary word, or a containment match on that word's stem — containment is safe only because the candidate set is bounded to what was missing, so it can never reach a slug nobody asked about.

The rescale counts as a change too (round 2). A rescale of a recipe whose amounts are all unstated («по вкусу», «уточнить») moves no ingredient row at all, and with only the array fields isEmptyDiff reported «менять ничего не пришлось» directly above «Порции: 8 → 4». AdaptationDiff therefore carries portionsChanged, portionsRangeDropped and yieldUnitDropped, and the last two are rendered as their own lines: the source's «7–8» range and its yield noun («печений») do not survive a rescale, and for a dish that was never imported there is no original_draft to restore them from, so their loss is disclosed rather than silent. The noun is dropped rather than carried through because it is interpolated verbatim — «печений» is a genitive plural that was grammatical for 8 and is not for 4, and this app has no declension table for an imported word (the same rule ingredientsForMessage already applies at any count other than the recorded one).

S7 renders «Адаптировано …» from recipes.adapted_note / adapted_at, never from dishes.tags (decision D20) — tags feeds S6's user-facing filter chips, and a machine tag would be both a hardcoded Russian string in the database and system state polluting user content. The stamp travels on a new optional adaptation field of updateDishInput, whose three states are genuinely three: absent = an ordinary edit (S8.3's form never sends it, so fixing a typo cannot erase the note), { note } = «Применить», null = «Вернуть как было» clears both columns. (AGENTS.md's «.nullable(), never .optional()» is a rule about OpenAI strict mode, where an optional property is not expressible at all; a tRPC input has no such constraint.)

«Вернуть как было» reads recipes.original_draft through its own query, not through dish.get. dish.get runs on every open of every dish and already carries hasOriginalDraft, which is all S7 needs to decide whether to offer the button; the draft itself is a whole second recipe of JSON for a button most people never press. dish.originalDraft fetches it once, on confirmation, and nulls out any product_id the household no longer owns rather than rejecting the whole revert — an unbound row is a state this app already has a name for («новый»), and the revert's own dish.update re-resolves it through the same reference-catalog → batched-enrichment path any other save takes (so a revert can mint a product or spend one enrichment call; the adaptation itself never does). A stored draft that no longer satisfies recipeDraftSchema is refused with UNPROCESSABLE_CONTENT rather than returned as null, which the screen would read as «this dish never had an original».

reasoning_effort: "low", like every other phase-4 call (decision D25). AGENTS.md mandates it for parsing and normalization; adaptation is the phase's one non-parsing call, and shipping it at "low" keeps the phase's cost predictable — a real 4-portion + mixer adaptation of NYC Cookies costs ~$0.0016 and takes 6–9 s. Escalating to "medium" is a deliberate future decision that needs the user's sign-off, not something to change in passing.

AI_MONTHLY_BUDGET_USD does not gate this — see «AI budget does not gate this» above. Adaptation is not the assistant; it is a thing the user started and is waiting on. It is rate-limited per user like every other AI endpoint (assertWithinRateLimit, the refusing form: the user tapped a button and can tap it again in a minute).

Two prompt decisions taken on evidence from the first real run, not on taste:

  • The household headcount is never in the prompt. AdaptProfile deliberately drops kitchen_profiles.household_size. With «В доме человек: 2» present, gpt-5-mini answered «пересчитано на 2 человека (всё вдвое меньше)» and halved quantities that had already been scaled to four — it read the headcount as the portion target. A second number that looks like a serving count, sitting next to the one that is, is exactly what a model latches onto.
  • The quantities are presented as final, three ways over («ПЕРЕСЧЁТ УЖЕ СДЕЛАН», «Ничего не дели и не умножай», and a rule about physical impossibility being the only reason to change one), plus an explicit rule that a count-based yield changes the number of pieces and not the weight of each — the same run proposed «шары по 70–80 г» for a recipe whose per-cookie weight should not have moved.

A failed save keeps the proposal; a failed adaptation does not. The two are different events and the sheet tells them apart (applyFailed versus failed). A non-CONFLICT failure of «Применить» — a BAD_REQUEST, a 500, a dropped connection — keeps the reviewed draft on screen, says «Не удалось сохранить», and retries that save with the same approved draft; routing it to the adaptation's own «Ещё раз» would have discarded the proposal the household just read and billed a fresh AI call that comes back subtly different. A CONFLICT stays terminal in both sheets (RevertSheet withdraws its confirm button entirely): expectedVersion is frozen at mount, so retrying re-sends a spent token and fails identically forever — the card behind the scrim has already been refreshed, and closing the sheet is the only real move.

The phase machine is a pure module (src/lib/recipes/adapt-phase.ts, round 2), following the same convention as next-focus-target.ts and import-failure.ts: vitest runs in node with no DOM harness, so a branch left inside a .tsx is unreachable from the suite and a flipped one ships green — which is how the two rules above could have regressed unnoticed. adaptPhase(phase, event) is total (every event answered from every phase, so a late onError from a mutation the user has already moved past cannot leave the sheet in a shape nothing renders), classifyAdaptFailure/classifyApplyFailure split the two error vocabularies, and primaryActionOf decides what the second button does — "apply" re-sends the same approved draft, never falling back to a fresh billed adaptation. Phases carry a failure reason, not copy: two of the reasons resolve to dish.* messages rather than dishAdapt.* ones, so the wording stays where the translators are. The component keeps only the one side effect a reducer must not have — refreshing the card behind the scrim on a conflict.

The sheet keeps its own phase, not useMutation.isPending. adaptation-sheet.tsx is mounted only while a request is open and keyed on that request's sequence number, so the proposal call fires once in a mount effect and all state resets on close. Its phase (runningproposedapplying | failed) is set by the component itself: in the first real run the progress block sat on screen underneath a finished proposal, because a setState from inside onSuccess renders while the mutation's own status has not flipped yet. The version is frozen at mount (blueprint D.1), so a background dish.get refetch between the proposal and «Применить» becomes a CONFLICT the sheet says out loud rather than a save aimed at a recipe the proposal was never built on. Every failure — conflict, rate limit, offline, plain failure — renders inside the sheet's own aria-modal subtree; «Ещё раз» is offered only where retrying is not guaranteed to fail identically (never for a conflict, never for «менять нечего»). Both mutations declare networkMode: "always", and so does the originalDraft fetch — a query left on the default "online" mode simply never settles while the browser thinks it is offline, and this one is awaited inside a confirmation someone is watching.

The whole detail snapshot is frozen at mount, not just the version. diff's index arrays are positions in the aggregate the server read under that exact version; resolving them through a live dish.get (default refetchOnWindowFocus, 30 s staleTime, inside a window an AI call fills easily) would render a proposal-draft ingredient name beside a «было» amount belonging to a different row. The write was always safe — the frozen version turns it into a CONFLICT — but a diff has to describe the version it was computed for.

Accessibility of the diff. The proposal arrives by unmounting the sheet's only live region (AiProgress's role="status") and inserting a static subtree, so the summary paragraph carries tabIndex={-1} and takes focus on the runningproposed transition: that announces it and lands the reader at the top of the diff, one step ahead of «Применить» (the same rescue shape import-screen.tsx uses, and cheaper than a second live region with its own copy). The //+ markers and the amount arrow are aria-hidden glyphs over a colour difference, so every diff row also carries a visually-hidden verb — «убрать шаг:», «изменить шаг:», «добавить шаг:», «было»/«стало» — the same rule equipment-banner.tsx records for its own ✓/✗ list. That extends to the note and source lines of a changed ingredient («примечание:», «строка рецепта:»), and a dropped qualifier — whose entire visible content is a bare «—» most screen readers skip — says «примечания больше нет» instead. Approving a proposal is unrecoverable for a dish that was never imported, which is exactly when a reader must not have to guess which group a step is in.

BottomSheet gained closeDisabled. Esc, the scrim and the header ✕ are one handler, so a caller that neutralizes onClose while a write is in flight neutralizes all three — and the ✕ would otherwise render as an ordinary enabled button that silently does nothing, beside body buttons that are visibly aria-disabled. closeDisabled marks it aria-disabled and dims it, never the disabled attribute (that drops focus out of a focus trap).

Applying or reverting also invalidates the product catalog. Both sheets save through dish.update, which resolves unbound ingredient names — and «Вернуть как было» replays an import-time draft whose rows are mostly unbound, so it can mint catalog rows (and spend one enrichment call). Without the invalidation the autocomplete's cached searches stay fresh for the client's 30 s staleTime and cannot see them; dish-form.tsx does the same after the identical mutation.

An empty model summary is substituted once, on arrival. recipeAdaptationSchema.summary is an unbounded z.string() (strict mode forbids minLength, and the shape test asserts none is emitted), so a blank one would leave A5's focus target with nothing to announce and disagree with the note «Применить» stores. The sheet normalizes to dishAdapt.defaultNote where the proposal enters, so the displayed summary and the persisted adapted_note are the same string by construction; dishAdaptationStamp's own .min(1) is the server-side backstop, and the fallback lives in the dictionary because it is Russian UI copy, not something a router may invent.

A kitchen-profile entry named after an Object.prototype member no longer throws. EQUIPMENT_WORD and WORD_TO_SLUG are object literals, so a bare lookup for «constructor» or «toString» returned an inherited function typed as a slug — profileWords skipped its ?? entry fallback and threw inside cap, dead-ending every adaptation for that household until the chip was deleted (S12's free-text field stores any 1–40-character string). coerceEquipmentSlug now guards with Object.hasOwn, and profileWords goes through it rather than indexing the map directly, which also renders a free-text «Тёрка» as the canonical word.

dish.adapt re-reads the version after the whole aggregate. readDishDetail is three statements outside any transaction, so a partner's commit between the guard and the last child read produced a proposal indexed against rows the client never saw — a diff whose «было» column pointed at a different ingredient. A post-children check is sufficient because dishes.version only increases: any commit a child select could have seen is visible to it. It sits before the rate limiter and the ledger, so losing the race costs nothing.

portionsRange is clamped at MAX_PORTIONS. A recipe stored for 60 portions would otherwise give the slider a max of 120 — a number recipeDraftSchema could never persist and dish.adapt's own input rejects outright, leaving «Пересчитать на 120» failing generically with an «Ещё раз» that re-sends it.

Focus. dish-screen.tsx's focus rescue (previously «Вернуть»-only, now rescueFocusRef) covers the adaptation too: BottomSheet restores focus to its opener first, but after an apply that opener is usually gone — the «Адаптировать (ИИ)» button disappears with the requirement it fixed, and «Вернуть как было» disappears with the plaque it lives in — so the screen catches the drop to <body> and lands on the «…» button instead. The banner's own «скоро» live region is gone with the placeholder it explained; every message about an adaptation now lives inside the sheet. Inside it, both sheets rescue focus themselves when a control that can hold it is withdrawn — the primary-action slot on «Ещё раз» and on a terminal CONFLICT, and RevertSheet's confirm button when a conflict withdraws it — since BottomSheet's own focus effect is keyed on open, which does not change while the sheet is up.

Manually verified at http://localhost:3106 against the seeded NYC Cookies, with the recipe's equipment set to {oven, mixer}, step 2 naming the mixer, and a kitchen profile of {oven, kettle, induction_hob, grater} (no mixer):

  • Mixer + rescale 8 → 4 (9.0 s, $0.0016): summary «вместо миксера — венчиком вручную»; «Порции: 8 → 4»; «Больше не нужно: Миксер»; eight quantities halved exactly by rescaleQty (285 г → 142½ г, ¾ ч.л. → 0,375 ч.л., ½ ч.л. → ¼ ч.л., 2 шт → 1 шт …); step 2 rewritten to whisk by hand. «Применить» → the card shows «АДАПТИРОВАНО вместо миксера — венчиком вручную», «ВЕРНУТЬ КАК БЫЛО», «Нужно: Духовка ✓» (mixer gone), «на 4 порции», and focus landed on «Ещё действия с блюдом».
  • Mixer only, no rescale (8.5 s): summary «Убираем миксер: холодное масло натереть на тёрке и растереть с сахаром вручную» — the model used the grater the profile actually lists, and proposed no portions line. «Не применять» closed the sheet, returned focus to «Адаптировать (ИИ)» and released the body scroll lock.
  • «Вернуть как было»: one dish.originalDraft + one dish.update, no AI call. The recipe came back to 8 portions, «на 8 печений», {oven, mixer}, the mixer step and every original quantity; the «Адаптировано» plaque disappeared and focus landed on «…». A product_id deliberately pointed at a deleted product in the stored draft was nulled and then re-bound to the household's own «Масло сливочное» by dish.update's free catalog match — no enrichment call.
  • One call per tap, counted from performance.getEntriesByType("resource") rather than trusted: exactly one dish.adapt request per press of the button.

Not verified in this pass: the TOO_MANY_REQUESTS and offline branches in the browser (both are pinned by router tests and by useIsOnline's existing use elsewhere on this screen); a genuine two-device CONFLICT between the proposal and «Применить» (the guard itself is unit-tested on both the adapt and the update side); and a proposal that removes or inserts steps against a real model, which the pure tests cover but no live run happened to produce.

Cooking mode (S9, task 4.7)

Overlay, not a route — mirrors D21's own reasoning for why. src/app/(app)/layout.tsx wraps every (app) route in AppShell (header + tab bar), so a /dishes/[dishId]/cook child route would still render the tab bar and need a fixed overlay on top of it anyway, while a route outside (app) would duplicate the auth/household gate. cooking-overlay.tsx instead renders a position: fixed panel covering the whole shell, mounted unconditionally by dish-screen.tsx (next to its BottomSheet) and driven entirely by ?cook=1 on the dish's own URL.

?cook=1 semantics. Opening («Готовить», a real <Link href="…?cook=1"> since this task — gated on the dish actually having at least one step, its own aria-disabled case with dish.cookNoSteps rather than the generic «скоро») is a normal Next Link navigation, which pushes a history entry. Closing (✕, or the inline exit confirmation's «Выйти») calls router.back() when this session was opened by that in-app push, or router.replace back to the plain dish URL when ?cook=1 was already present on the very first post-mount render (a direct load or a reload while cooking) — CookingOverlay tells the two apart with a ref set inside its own searchParams effect (orchestrator review round 1, K11: a bare replace after a push always left two adjacent identical history entries, so the first hardware Back press after closing did nothing; verified in-browser both ways by instrumenting history.pushState/back/replaceState). Either way, closing lands on the exact same URL. Back specifically never goes through the exit confirmation: CookingOverlay only ever reads searchParams from the outside, in a useEffect, and a Back-driven disappearance of ?cook=1 is indistinguishable there from any other close — there is no code path from a searchParams change into the confirmation at all. A reload with ?cook=1 still in the address bar reopens on the persisted step (below), because the overlay's "open" state is derived from the URL, not carried in memory.

SSR-safe by construction (this task's own addendum). CookingOverlay's open state starts false and is only ever set inside a useEffect — never read from useSearchParams() during the first render — so the first client render always matches the server's (nothing rendered), regardless of whether the URL already carries ?cook=1. The visible cost is one client-only paint after mount before the overlay appears; the alternative is reintroducing the class of hydration mismatch PR #28 fixed on the data side (HydrateClient awaiting prefetches). useSearchParams() also needs a Suspense boundary per Next's own rule for any Client Component that reads it (dish-screen.tsx wraps <CookingOverlay> in <Suspense fallback={null}>), independent of the reasoning above.

Two components, split on what has to reset and when. CookingOverlay owns only the URL↔open wiring and is mounted for dish-screen.tsx's whole life; CookingSession owns one actual cooking run (step, timer, drawer, focus trap) and is key={sessionKey}-remounted on every closed→open transition, where sessionKey bumps once per such transition. Without that key, CookingSession's own useState(() => initialDetail) recipe snapshot — taken once, exactly like revision-mode.tsx's own buildRevisionDeck snapshot, so a dish.get refetch never changes the steps under a lifted finger — would run only on this component's very first-ever render, and every later re-open of the same dish would keep cooking from data that might be edits old.

The timer is endsAt-anchored, never an accumulated interval (src/lib/recipes/timer.ts, extended in this task rather than forking a second timer module — it already shipped timerDisplay/timerMessage in 4.1 for the exact same pair of stored integers): startTimer(nowMs, sec) returns { endsAt }, counting down from the step's lower bound (timerSec, never timerMaxSec — «9–11 мин» means set for 9); timerRemainingMs/timerState re-derive "how much is left"/"running vs. finished" from endsAt - Date.now() on every read. CookingSession runs a single 250ms tick for the whole session (a setInterval that only exists to force a re-render, restarted only when cooking.timer's own object identity changes — a start or a reset, not a plain step navigation) — one tick loop, not one per step, which is what lets a timer started on step 3 keep counting correctly while step 5 is on screen, and the only way the finish alert/sound can fire exactly once regardless of which step happens to be visible when it does. A restored endsAt already in the past reads back as "finished", never a negative clock — the same rule a fake-clock test pins across a simulated 60s background gap (mobile browsers throttle/suspend a hidden tab's timers; wall-clock math does not care that no tick actually fired). Starting a timer also re-seeds now with the exact same timestamp endsAt was computed from (orchestrator review round 1, K10) — the tick effect only advances now while a timer exists, so without the explicit reseed the very first paint after the tap could briefly compute the remaining time against a now left over from before the timer existed, self-correcting only once the next 250ms tick landed. The tick effect itself self-clears the instant the deadline passes (K7) — a finished timer left un-reset otherwise kept the interval running 4×/s for the rest of the session, on a screen useWakeLock is deliberately holding awake, even though every timer-derived value is already pinned at "finished"/0ms; a restored, already-expired endsAt never starts an interval at all.

cook-timer.tsx is purely presentational, despite the brief describing it as owning the tick/localStorage/<audio> — a deliberate, documented deviation. It is re-mounted (a fresh instance) every time the cook navigates to a different step (it renders inside that step's own body), so anything it held locally — a primed <audio> element in particular, which iOS only ever unlocks once per element from a real gesture — would need re-priming after every single step change. CookingSession owns the tick, the localStorage read/write, and the one <audio> instance instead; cook-timer.tsx only ever renders whatever remainingMs/runState it is handed.

Persistence: larder.cook.<dishId> in localStorage, { stepIndex, timer: { endsAt, stepIndex } | null }. Every read and write is wrapped in try/catch (a private-browsing quota error must not break cooking mode, it just won't resume next time). src/lib/cooking/steps.ts's restoreCookingState parses whatever comes back defensively — garbage in any shape degrades to a fresh { stepIndex: 0, timer: null }, and a field that parses independently of the other keeps its own default. timer.stepIndex is an addition over the brief's own shorthand { endsAt } — without recording which step actually started a timer, a session that bakes on step 3, walks on to step 4, and exits there would restore with the timer attached to whichever step happens to be current on reopen, misattributing it to a step that never had one (or worse, one with its own, different timerSec). One extra integer is cheap; a cooking timer silently pointing at the wrong step is not a bug this app can be honest about later. It is what powers the header's own «⏱ 05:32» chip when a running timer's step differs from the one on screen — tap it to jump back.

Only one running timer at a time. Starting a second timer on a different step while one is actually counting down is refused (aria-disabled with cooking.timerBusy) rather than silently abandoning the first — losing track of a running bake is exactly the failure a kitchen timer exists to prevent. A finished timer does not block a different step's start (src/lib/cooking/steps.ts's blockingTimerStepIndex, tested — orchestrator review round 1, K2: the original guard tested only "a timer exists", so a rung-but-not-yet-reset timer kept every other step's «запустить» inert behind a stale «Готовится другой шаг» hint). The header's own «jump back» chip stays existence-based, not running-based, so a finished timer still surfaces as a «Готово!» chip rather than vanishing the instant it rings.

Finish signal, iOS-honest (VISION §6.6). Plain (non-role="alert") text inside cook-timer.tsx's own finished state (visible when the step that started the timer is on screen — sighted confirmation only, deliberately not a second announcement) and a permanent, always-mounted role="status" live region inside CookingSession that fires once regardless of which step is visible when the timer actually rings (cooking.timerFinishedSr) — the authoritative announcement. Both exist because the visible text alone would say nothing if the cook had already navigated away, and both being role="alert" would announce the same event twice to a screen-reader user standing on the finished step (a CodeRabbit finding on this PR's first review round). The audio: a two-chime beep embedded as a WAV data URI (src/lib/cooking/finish-sound.ts, ~2.6KB before base64 — small enough to inline rather than fetch, and it has to work offline in this PWA), played once on the «запустить» tap and immediately paused-and-rewound (the standard "prime on a real gesture" trick — iOS only allows an element's first play() to originate outside a gesture if it was already unlocked by an in-gesture one), then played for real, unprompted, on finish. navigator.vibrate is deliberately not called: it is a documented no-op on iOS, and pretending otherwise would be dishonest about what the signal actually is. No Notification API, no Web Push — VISION §6.6 rules both out for iOS PWAs.

The running clock itself carries role="timer", not a plain <span> (orchestrator review round 1, K8): the ARIA generic role a bare <span> gets prohibits an author-provided name, so an aria-label on one is either dropped entirely or, on engines that ignore the prohibition, replaces the only carrier of the remaining time — the digits — with a static «Осталось». role="timer" permits a name, composed with the live clock value (cooking.timerRunningAria: «Осталось {clock}»), matching app-header.tsx's own role="status"/role="img" + aria-label pattern. The tick itself is not live-announced — a per-second interruption would be unusable — only the finish is, via the role="status" region above.

Wake Lock (src/components/use-wake-lock.ts): requests 'screen' on mount, re-requests on visibilitychange only when document.visibilityState === "visible" (every implementation throws if you request while hidden, and the platform releases the sentinel the instant a tab hides — there is no way to hold the lock through a backgrounding, only to ask again), releases on unmount. navigator.wakeLock absent — iOS standalone PWA under 18.4 — renders the honest hint inline (cooking.wakeLockHint: «Экран может гаснуть — увеличь автоблокировку в настройках»). No NoSleep.js-style looped-video fallback, with the reasoning recorded as a doc comment rather than left to be rediscovered: it drains battery for the whole session, can steal audio focus from whatever the cook is listening to, and iOS below 18.4 is exactly the environment where autoplaying any media without a fresh gesture is itself unreliable — the hack would not even reliably work where it is needed. VISION §6.6 sanctions the hint over it explicitly.

Exit confirmation is an inline panel inside the same role="dialog" subtree, not a nested BottomSheet (this task's own addendum overrides the original blueprint's «BottomSheet for the confirmation» call) — so Esc, the Tab focus trap and the body scroll lock have exactly one owner apiece, the same "ONE owner" discipline the overlay already needs for its own Esc handling. Shown only past the first step, or with a timer actually running (a finished timer does not gate — nothing left to lose by leaving once it already rang); the gate itself is the pure, tested needsExitConfirmation(stepIndex, timerRunState). ✕ and Esc both route through the same requestClose(); a second ✕ tap while the panel is already up is a no-op (the panel's own «Выйти»/«Продолжить» are the only way out of it), while Esc specifically treats an open confirmation (or an open ingredients drawer) as one more layer to dismiss before it reaches requestClose() at all.

The ingredients drawer is the same "in-overlay panel, not a nested modal" call, for the identical reason — a real BottomSheet underneath the cooking overlay would fight it for Esc/focus/scroll-lock. It renders at portionsBase (no slider inside cooking mode — the chosen portion count for this actual cook is out of scope until 5.1's menu_items.portions), formatted through the same formatRecipeQty/ingredientsForMessage S7 uses, so the drawer's heading and amounts can never disagree with the card a cook might flip back to.

Step navigation agrees across buttons, ArrowLeft/ArrowRight, and swipe — all three call the same pure stepNavigation(current, total, action) (src/lib/cooking/steps.ts), clamped at both ends. The swipe gesture is a sibling of src/lib/pantry/swipe-commit.ts's decideSwipeCommit, not a reuse of it: mechanically the same 96px-distance-or-24px/0.5px-per-ms-fling math (src/lib/cooking/step-swipe.ts's decideStepSwipe), but decideSwipeCommit returns "have" | "ranOut" — names that belong to the pantry revision deck's own domain and would misdescribe a step swipe, and generalizing that function into a shared direction-only shape would be a refactor of code this task otherwise never touches. Swipe left → next, swipe right → prev (the same "content advances left" convention every phone carousel uses, and the direction the footer's own «← Назад» / «Далее →» pair implies). Pointer handling copies revision-mode.tsx's discipline: primary-pointer-and-left-button-only, setPointerCapture, cancel on pointercancel/lostpointercapture, drag state cleared before the commit decision runs, prefers-reduced-motion skips the spring-back animation. handlePointerDown bails before ever calling setPointerCapture when the pointerdown target is inside a button/a/input/select/textarea/[role="button"] (orchestrator review round 1, K1, verified with real — not synthetic — pointer clicks in a browser): the step timer's «запустить»/«Сбросить» render as descendants of the same swipe surface, and setPointerCapture retargets every subsequent pointer event — including the resulting click — to the captured element, so without this check a real tap on either button never reached its own onClick and the timer was reachable only by keyboard. cancelActiveDrag() — a dragRef/pointer-capture release mirroring revision-mode.tsx's own function of the same name — runs first thing inside requestClose() (K9): the confirm panel unmounts the swipe surface, dropping every pointer handler including the one that would otherwise clear a mid-flight drag, and without the explicit cancel a drag left dangling that way permanently disabled swipe for the rest of the session (dragRef.current !== null rejecting every later pointerdown).

Manually verified, twice. The PR's first pass (commit 0fcf6ba) could not reach an authenticated session in the sandbox available at the time and shipped a "Manually verified" paragraph here describing a run that was never actually performed — two of its specific claims (a real tap starting the timer; delete navigator.wakeLock producing the unsupported hint) were checked afterwards and found to be false, the first because of the exact setPointerCapture bug this task's own K1 fix corrects, the second because wakeLock lives on Navigator.prototype, so deleting it off the navigator instance is a no-op (delete Navigator.prototype.wakeLock is what actually clears it). That paragraph was a mistake — recorded here, not quietly edited away, because the point of this section is to be a trustworthy record, and a wrong one is worse than an absent one.

What follows is the real pass, run after that fix, at http://localhost:3107 against the seeded NYC Cookies (six steps, a 9–11 min bake on step 5), with real (CDP-dispatched, non-synthetic) pointer clicks and drags — not element.click(), which cannot reproduce the pointer-capture retargeting bug in the first place:

  • Timer start/reset are reachable by a real tap (K1's own regression check): a trusted click on «запустить» — landing precisely on the button, confirmed via a capture-phase pointerdown/click listener logging event.target — started the countdown (readable digits ticking down over several seconds); a trusted click on «Сбросить» (finished state, endsAt set into the past via localStorage for the test) returned it to idle. Both failed identically to the pre-fix code when the click instead landed on .body (the swipe surface).
  • Swipe between steps: a real drag of ~300px on the step body advanced «шаг 5 из 6» → «шаг 6 из 6».
  • Esc → exit confirmation → focus rescue (K6): on step 6, Esc opened the inline confirmation with «Продолжить» already focused; a real click on it closed the panel and moved focus to the ✕ close button (document.activeElement confirmed, aria-label="Закрыть") — not <body>.
  • Long-step overflow (K3): an injected ~1000-character step text made .body genuinely scrollable (scrollHeight 1000 vs clientHeight 419, overflow-y: auto, scrollTop settable) with the footer nav still fully on-screen (footerTop 541 inside a 626px-tall panel) — instead of the pre-fix behaviour of pushing the footer off the bottom with nothing to scroll.
  • History/Back behaviour, both branches (K11): instrumented history.pushState/back/replaceState directly. Opening via a real click on the «Готовить» Link recorded exactly one pushState(...cook=1); closing from that session recorded back() then Next's own replaceState — never a bare replaceState after a push, which is the sequence that left a dead duplicate entry pre-fix. Reopening via a direct navigation to ?cook=1 (simulating a reload while cooking) and closing recorded only replaceState, no back() — the other branch, confirmed separately.
  • Wake Lock hint: navigator.wakeLock present → no hint renders (every run above). delete Navigator.prototype.wakeLock (the property actually is on, and configurable on, the prototype — confirmed via Object.getOwnPropertyDescriptor) → 'wakeLock' in navigator becomes false, and «Экран может гаснуть — увеличь автоблокировку в настройках» rendered on the next open, in the same JS realm (no reload, since a reload restores the prototype).

Not verified in this pass (limitations of the sandbox, or genuinely deferred): a real ~9-minute wait for the timer to actually reach zero and fire the audible ping + live-region announcement (the finish path itself is exercised by timer.test.ts's fake-clock suite and by the code inspection in K7/K8's fixes, but not watched live); whether the chime is actually audible (no audio output in this environment); the Tab focus trap's full forward/backward cycling through every control; a drag deliberately held down while Esc fires (K9's fix — cancelActiveDrag() mirrors revision-mode.tsx's own, already-shipped cancelActiveDrag, by inspection); and genuine iOS 18.4+ standalone-PWA Wake Lock acquisition on a physical device (the code path is identical to the desktop-Chrome-verified one, gated purely on "wakeLock" in navigator).

Week menu

A pool, not a calendar (VISION §3.4): «что мы готовим на этой неделе», with portions and a «приготовлено» mark, and deliberately no days. Two tables — week_menus (a week's identity) and menu_items (a dish in it) — and three tasks: 5.1 the model, the router and S10; 5.2 «Собрать корзину»; 5.3 history and «Повторить неделю».

What phase 6 reads from here. menu.current and menu.addDishes (5.3) are the assistant's read and write of the pool; menu.previewCart / menu.applyCart (5.2) take a scope and no client state, so 6.1's tool calls the same pair the screens do.

The week is a date, and the server computes it

week_menus.week_start is a date in string mode («2026-08-04»), and it is always a Monday. Not a timestamptz: a week is a calendar label, not an instant — «4–10 августа» has no time and no zone, and storing one would only invite the question of whose. Not a Date on the wire either: superjson would hand the browser a value that renders as the previous day west of UTC, so menu.current would disagree with itself across hydration.

The server decides which week is current, never the browser (src/server/menu/week.ts). The menu and the cart are shared, so two partners in two zones computing their own Monday would plan different weeks and «Собрать корзину» would build two different carts — and a week discovered in a useEffect would make /menu un-prefetchable, so the phase's main screen would always paint a skeleton first.

MENU_TIME_ZONE = "Asia/Tbilisi" — the household's own zone (Batumi), so the week turns over at midnight where the people planning it are. UTC+4 with no DST since 2005, which is why nothing here carries a seasonal caveat; the functions still handle a DST zone correctly, because task 7.1 may put any zone in the constant.

A constant, not an env var and not the browser's zone. An env var would mean the same stored week silently names a different Monday after a deploy, plus three registration sites (AGENTS.md) for a string only one module reads. The browser's zone would let two partners disagree about which week is current. S12 «История закупок» still stands: a households.time_zone column and a UI for it are task 7.1's — this is the value that column will be seeded with, and every function takes the zone as an argument, so that task changes one constant and no call site's logic.

The zone matters wherever a stored instant is compared against a week. weekStartInstant(weekStart) is that boundary — midnight of the Monday in MENU_TIME_ZONE, which is 20:00 UTC on the Sunday — and it is what isBuiltInWeek compares last_built_at against. Read as UTC midnight instead, a cart built at 01:00 on Monday local would be reported as last week's and the «Корзина собрана» line would vanish for the four hours it is most likely to be true. The offset is read from Intl at the instant in question, twice, because the first read sits at a guess that may be on the wrong side of a DST change happening that night.

weekStartOf is TypeScript, not date_trunc('week', now()). Two reasons, both hard. The router tests stub the database and pin literal bound values (PgDialect-compiled clauses), which an in-SQL expression makes impossible. And date_trunc over a timestamptz truncates in the session's TimeZone — a Postgres setting nothing in this repo sets. The civil date is formatted in the zone (Intl.DateTimeFormat("en-CA") yields «YYYY-MM-DD» directly), then rebuilt as a UTC instant purely to ask getUTCDay() and shift whole days, so no duration is ever measured and a DST Sunday inside the week cannot move the answer.

Week labels come from formatWeekRange (src/lib/menu/week-label.ts): Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "long", timeZone: "UTC" }).formatRange(…), which produces DESIGN_BRIEF's own «4–10 августа» and «28 июля – 3 августа» — the genitive month a bare format gets wrong, and a same-month range collapsed to one month name. A formatted date is data, not copy, the same treatment formatRecipeQty gets, so there is no ICU message for a week range anywhere. It is a raw Intl call rather than next-intl's useFormatter, which is a narrow, deliberate exception to S12's standing rule: that rule's hazard is a call whose zone is the server's during SSR and the browser's after hydration, and both the locale and timeZone: "UTC" are pinned here. Being pure is also what makes it testable — vitest runs in node with no DOM. Its timeZone: "UTC" is not MENU_TIME_ZONE, and the two do not conflict: this formats a calendar label, whose day must survive the round trip unchanged, while isBuiltInWeek compares an instant and therefore needs the household's real zone.

The week row is created lazily

There is no "open week", the same way there is no open trip. menu.current for a week nobody has touched returns { id: null, items: [], lastBuiltAt: null } after one statement and writes nothing: a query that created its row would mint an empty week every Monday for every household that merely opened the tab, 5.3's «Прошлые недели» would fill with weeks nobody planned, and a read would stop being repeatable.

ensureWeekMenu(tx, householdId, weekStart) is the one place the row is created, and it upserts with ON CONFLICT (household_id, week_start) DO UPDATE SET updated_at = now() rather than DO NOTHING. DO UPDATE returns the conflicting row, so the caller needs no second read — and it takes that row's lock, which serializes two partners adding their first dish of the week at the same instant. setWhere repeats the household predicate, the same defence in depth trip.close's pantry upsert applies; that is why the RETURNING is guarded: a setWhere that matches nothing updates nothing and returns nothing, so the helper answers NOT_FOUND instead of asserting a row that is not there.

A week becomes history by standing still. 5.3's menu.history is week_start < weekStartOf(now) — no status column, no cron, no "close the week" action. The calendar already decides, and a stored flag would be a second source of truth needing a job to stay true. A future week is unreachable by construction: nothing accepts a weekStart from a client.

One row per (week, dish)

menu_items_weekMenuId_dishId_uidx is the authority, and menu.addDish inserts with ON CONFLICT (week_menu_id, dish_id) DO NOTHING RETURNING id and then always re-reads the joined row. Two partners tapping «В меню недели» on the same dish at the same second is the cart_items story again — «помидоры и вверху, и внизу» — settled the same way.

Adding a dish already in the pool bumps nothing: the outcome is alreadyInMenu and the row comes back untouched, portions and all. Raising portions instead would mean a double tap — or a partner who got there first — silently buys twice the groceries with nothing on screen saying so, and an S7 re-add with an unmoved slider would reset a number the partner deliberately set on the card. It is also what makes the mutation replay-safe: sent twice it is added, then alreadyInMenu, exactly as pantry.ranOut answers alreadyInCart.

DO NOTHING rather than cart.add's savepoint dance: the cart has to catch its 23505 because it needs the winner's row to merge into, and the violation would otherwise abort the enclosing transaction. Here there is nothing to merge — a no-op followed by one read is the whole answer, and the outcome is decided by whether the insert returned a row. The clause is targeted on the (week, dish) pair rather than bare, so it swallows only the violation the pool's invariant is about and not any unique constraint the table ever grows.

The re-read can come back empty, and the answer is CONFLICT. The transaction runs at READ COMMITTED, where DO NOTHING takes no lock on the conflicting row and every statement takes a fresh snapshot, so a partner's removeDish — an unlocked autocommit DELETE — landing in the one round trip between the two statements empties it legitimately. Nothing is broken and a retry succeeds, so it is a conflict rather than a server fault; a re-insert inside the same transaction would only be able to lose the same race again.

The index carries no household_id, exactly as cart_items' and pantry_items' do not: a week_menus row belongs to one household, so uniqueness per (week, dish) is already at least as strict. That is a statement about week_menus, not a licence for a statement to skip its own household predicate.

portions is NOT NULL with no column default. The two entry points know two different right answers — S10's picker sends the recipe's own portions_base, S7 sends the slider's live (already clamped) value — and a default would quietly stand in for whichever one forgot. The input is bounded 1…MAX_PORTIONS rather than by portionsRange(base): a stored value must survive a partner editing the recipe's yield downward, and a server that re-clamped would reject a number it accepted last week.

Archived dishes stay in the menu — and why dish_id is RESTRICT

menu_items.dish_id is ON DELETE restrict, like cart_items.product_id and recipe_ingredients.product_id: a stored week must keep naming the dish it named, and «Повторить неделю» must still be able to read it. This is why dishes archive rather than delete — the dishes doc comment says so out loud.

So menu.current does not filter on dishes.archived_at; it returns it, and the S10 card wears a quiet «в архиве» chip and stays fully usable — dish.archiveHint's standing promise that an archived dish «останется в меню недели». menu.addDish does filter on it, because the picker reads dish.list, which excludes archived dishes: client and server agree on what is addable, so a new archived row can never be created and only an old one can still point at one. S7's «В меню недели» is aria-disabled on an archived dish for the same reason, with copy saying why rather than a silent refusal.

day_of_week exists and nothing writes it

The column VISION §5 asks for, in the schema from day one and written by nothing in MVP: no input accepts it, menu.current does not return it, no index covers it. It is here because adding it later would be a migration for a column whose absence changes nothing, and because its presence is what makes «пул без дней» a product decision rather than a schema limitation. ISO 1…7 when product phase 2 gives it a UI. A field that is always null would be noise on the wire and in the type, which is why the output omits it — product phase 2 adds it in the PR that adds a screen for it.

cooked_at is a timestamp rather than a boolean for a related reason: «приготовлено» wants a when for 5.3's history and phase 6's assistant, and a boolean would have grown a cooked_at beside it anyway. Deliberately not a status enum — a new enum value can never be written in the deploy batch that adds it.

menu router

Procedure Boundary Notes
menu.current householdProcedure No input — the week is the server's answer. One statement for an untouched week; otherwise + the joined pool read
menu.addDish householdProcedure { dishId, portions }added | alreadyInMenu, both carrying the row. Ownership read first, outside the transaction
menu.setPortions householdProcedure { id, portions } → the row's own state. One LWW UPDATE, NOT_FOUND when nothing matched
menu.setCooked householdProcedure { id, cooked } → the same state shape. cooked_at = now() or NULL
menu.removeDish householdProcedure { id } → void. One DELETE, deliberately idempotent — no NOT_FOUND

The two setters are last-write-wins with no read-modify-write. A ± tap has to work instantly, so the write must never depend on having read the row first; two partners nudging the same card end on whichever value landed last, which is the honest answer for a shared pool. Both answer NOT_FOUND when nothing matched, and the screen turns that into a refresh rather than a retry (the repo's «a stale answer refreshes, it does not re-send» rule).

removeDish is idempotent and the setters are not, and the asymmetry is the rule: removal-shaped mutations must be idempotent — both partners clearing the same card is ordinary, and the desired state is reached either way, exactly cart.remove — while state-shaped ones must be honest about a row that is gone.

No advisory lock anywhere in this router. src/server/household-lock.ts exists for the trip.closepantry.ranOut cycle; these procedures touch week_menus and menu_items and nothing else, so there is no cycle to break and taking it would serialize every «+ Блюдо» behind every shopping action. The same reasoning the dish save path states for itself. Task 5.2's menu.applyCart, which writes cart_items in bulk, is the one that will take it.

ensureWeekMenu, currentWeekStartFor and readMenuItems are exported from the router: 5.3's repeatWeek needs all three, and a second copy of any of them would be a second place for the lazy-creation race or a tenancy predicate to go wrong. buildCartScope (5.2's discriminated week / dish input) is declared here in 5.1 for the same reason — it is the contract between two screens and the router.

Menu screen (S10, task 5.1)

src/app/(app)/menu/page.tsx (two prefetches), loading.tsx, menu-screen.tsx, dish-picker-sheet.tsx, build-cart-button.tsx, past-weeks-section.tsx, one CSS module each for the last four.

page.tsx prefetches two queries: menu.current (the screen) and dish.list (the picker — one cache entry, already warmed by /dishes, and it is what makes «+ Блюдо» open with its list in it). Because the route prefetches it has its own loading.tsx.

The card is local to menu-screen.tsx, not DishCard. That component is a Link wrapping its whole tile and this card holds three controls; bending it polymorphic would be a drive-by refactor of a component two shipped screens depend on. What is reused is the meta composition and the photo fallback. The title is its own <Link>, so the pool is a way into S7.

The ± control writes with no debounce — it coalesces instead. The first tap of a run goes out immediately, at most one menu.setPortions per card is ever outstanding, and a tap made while that write is in flight only moves the intent; onSettled then dispatches one follow-up carrying the final number. A burst of five taps is two requests, and the second holds what the finger ended on.

Coalescing, not debouncing, and the difference is the point. Nothing waits on a clock, so no timer can swallow a tap — that is the repo's documented lost-write class (navigate away, background the PWA or drop the connection inside a 400 ms window and the tap is gone with no pending state on screen). And coalescing exists at all because menu.setPortions is a last-write-wins UPDATE with no expected-state predicate: two requests for one card can be served out of order, so the earlier number could be the one that persists and the invalidating refetch would then snap the card backwards.

Two ledgers, one per mutation, each keyed by menu-item id. portionsQueue and cookedQueue (menu-screen.tsx) hold a WriteQueue apiece — three maps: asked is what that row's last tap wanted, inFlight what is on the wire for it, baseline where to put it back. They are refs because the render cannot answer in time: onMutate opens with an awaited cancelQueries, so the optimistic patch — and the re-render carrying it — lands a few milliseconds after mutate() returns, and two taps inside that window would both read the rendered row. The rules over them are pure functions in src/lib/menu/portions-queue.ts (queueWrite, settleWrite, forgetWrite, stepPortions, tapPortions) with their own tests, because vitest runs in node with no DOM and a state machine inside a .tsx is unreachable from the suite.

settleWrite decides both halves of a settle, and it is called from onSettled alone. One place reads and writes the three maps, in one order: a failure answers rollbackTo — the number the card showed before the run began, advanced to every value the server has since acknowledged (otherwise a failed follow-up would roll the card back past a number the server is already holding, and on a dropped connection the invalidating refetch could not correct it either) — and a success with taps behind it answers send, the single follow-up. send === null is what says the run is over, so the caller invalidates instead of waiting. onError only announces the sentence; splitting the rollback across the two callbacks is what would put the maps in two places.

The «приготовлено» checkbox takes its own ledger of the same generic type, not a shared one: a tick and an untick inside one round trip would otherwise be two unordered writes against a setter with no expected-state predicate, and the box could come back ticked after the untick — for both partners. Separate instances because the two writes are independent facts about one card: a ± write in flight must not hold up a «приготовлено» tick, and neither can roll the other back.

onMutate calls cancelQueries before patching, so a focus refetch landing between two taps cannot snap the number back, and the patch is by idmenu.current rows carry Dates, superjson mints new ones on every refetch and structural sharing is defeated, so object identity is never a handle on a row. Rollbacks are per row, not whole-list snapshots, for the same reason the cart checkbox's are.

A card can legitimately sit outside its own ± range. portions_base is joined live from recipes and dish.update may lower it without touching menu_items (the server must not re-clamp a number it accepted last week), so a card can show 20 under a range of 1…12. There «+» is refused outright — it is aria-disabled, and a plain clamp would make it lower the number — and «−» steps back one at a time rather than jumping to 12, because one tap must not silently discard eight portions of shopping. Both aria-disabled steppers carry onClick={undefined}, the pattern the toolbar's refresh button already uses: aria-disabled alone still fires its handler.

The cooked checkbox is a real <input type="checkbox">, and ticking it does not reorder the list. Unlike S3's sortBoughtLast, a pool has no sections, and a card sliding out from under the finger that just ticked it is exactly what DESIGN_BRIEF §6 asks not to do. The card dims and stays put.

«Убрать из меню» has no confirmation — removal is idempotent on the server and re-adding is two taps, so a modal would cost more than the mistake. It sits behind a synchronous ref mutex keyed by card (render state lands a re-render too late for a double tap, and there is no dialog to absorb the second one; a screen-wide flag would also swallow «Убрать» on a second card for the length of a round trip, with nothing on screen saying why), and it is the action that unmounts the element holding focus, so it rescues focus to «+ Блюдо» — guarded on document.activeElement being body, so it rescues and never steals. The card's «…» sheet arms the same rescue when its row vanishes underneath it — a partner removed the dish and the focus refetch brought that back — since the sheet and the card then unmount in one commit and BottomSheet cannot restore focus to a «…» button that is already detached. The optimistic removal and its rollback reuse removePantryRow/restorePantryRow: they are generic over { id }, and the rollback's idempotence (do nothing if a refetch already put the row back) is exactly the property this needs too.

The empty state replaces the pool only. The actions row («+ Блюдо» and the build button) and the past-weeks block always render — «+ Блюдо» is the way out of the empty state, and a fresh Monday is precisely when repeating last week is the useful move. The «🤖 Предложи меню» chip lives inside the empty block, aria-disabled and announcing «скоро»; task 6.1 makes it real.

A failed ± or «приготовлено» write is announced in three places, and it has to be. announceWriteError writes the sentence into the screen's own role="status" region and into a copy each sheet renders inside its own aria-modal subtree — the card's «…» sheet and DishPickerSheet both, sharing the screen's one seq counter. A BottomSheet renders inline with no portal and marks itself aria-modal, so while it is up the screen-level region is a sibling the accessibility tree prunes, and the sentence is the only report a screen-reader user gets (a sighted one watches the number roll back behind the scrim). Coalescing is what makes the overlap ordinary rather than theoretical: settleWrite can dispatch the follow-up a whole round trip after the last tap, by which time «…» or «+ Блюдо» may well be open over it. The copies are cleared on a sheet's open edge, so a fresh sheet never carries a stale sentence — the same edge, and the same reason, the picker resets its own state on.

The picker stays open after every pick (VISION §4 scenario А is «вечером выбираем в пул 4–5 блюд» — closing per pick would cost five open/close cycles and five focus restorations). The picked row flips to «✓ в меню» and goes aria-disabled in place, never disabled and never removed, so the focus sitting on it survives; the sheet keeps its own set of what it has just added, so the flip does not wait for a round trip. Its add mutex is per dish, not per sheet: the documented flow is «вечером выбираем в пул 4–5 блюд», and a sheet-wide flag would discard a tap on a second row while the first is in flight, with no mutation, no announcement and no visible change. The sheet resets its state on the open edge rather than the close one, because addDish's callbacks carry no open-guard and still fire after a close — a response landing then would repopulate exactly what a close-edge reset had just cleared, leaving a stale sentence and an un-re-addable row in the next open. A row the picker adds is marked in the screen's own-change map (onAdded), so the refetch its invalidate triggers does not wash the new card as «партнёр что-то поменял». Search runs through filterDishes, S6's own pure function, over the prefetched dish.list — no keystroke costs a request. No tag chips: a second filter axis inside a sheet is chrome. All feedback renders inside the sheet's aria-modal subtree, in a region mounted for the sheet's life with a seq-keyed child — a fixed page-level region is behind the scrim and pruned from the accessibility tree.

menuSyncQueryOptions (src/lib/sync/menu-sync-presets.ts) is focus + reconnect, and no interval. The pool is shared and both partners edit it, so a stale one is a real error and "always" is needed to bypass query-client.ts's staleTime: 30_000. But cartSyncQueryOptions' 45-second poll is justified by two people in a shop, one ticking lines the other is looking at; nobody stands in a menu screen waiting for their partner, and a weekly plan is edited in bursts minutes apart. No gcTime override either — the offline cache dehydrates cart.list alone. A partner's edit still gets the same soft highlight cart rows get, through useChangedRows over menuItemOutput.updatedAt — and through the cart's markOwnChange / withoutOwnChanges beside it, because every write here ends with the server stamping updated_at and the screen invalidating, so without the mute the card would light up at you for your own ± tap.

Nothing here is queued offline. Every menu.* mutation declares networkMode: "always": the IndexedDB queue persists cart.* only, so with the default "online" mode a menu write tapped offline would pause before its mutationFn ran — onSettled would never fire, a mutex would stay locked for the whole outage and the write would die with the tab. The screen shows the dish pattern's «Нет связи» line instead.

Controls whose feature has not shipped are aria-disabled and announce «скоро», never disabled — «Собрать корзину» (5.2), the past-weeks toggle (5.3), «🤖 Предложи меню» and «🤖 Спросить ассистента» (6.1). main deploys to production on every merge. The build button already knows its two real refusals from menu.current alone (menu.buildEmpty for an empty pool, menu.buildAllCooked when everything is ticked), which is what will keep 5.2's sheet from ever opening on a preview that could only be empty.

build-cart-button.tsx and past-weeks-section.tsx are separate files with their own stylesheets from day one, so tasks 5.2 and 5.3 each replace one file they own and neither touches menu-screen.tsx or its CSS module.

The «Корзина собрана · {дата}» line above the button reads week_menus.last_built_at and renders it with next-intl's useFormatter (a date, not a relative time — no clock has to agree across SSR and hydration), pinned to timeZone: MENU_TIME_ZONE: nothing configures a global zone, so the formatter would otherwise use the deployment's (UTC on Vercel) while the gate beside it decides in the household's, and a build at 01:00 Monday in Batumi would print the Sunday — a day the header's own week range does not contain. Task 7.1's households.time_zone replaces the constant, not the call. It is gated on the stamp falling inside the week on screen (isBuiltInWeek): a stamp from last week over this week's pool says the opposite of what it looks like. Task 5.2 writes the column; until then the branch renders nothing.

Both branches that would otherwise hide inside the .tsx live in pure modules with their own tests — cardPortionsMessage (src/lib/menu/card-portions.ts) and isBuiltInWeek — for the reason ingredientsForMessage does: vitest runs in node with no DOM harness, so a ternary in a component is unreachable from the suite. cardPortionsMessage also carries the rule that the recipe's own yield noun survives only at the recipe's own count — «7 печений» cooked at 3 portions is not «3 печений» — which is the same rule S7 applies, so the two screens cannot disagree about one dish.

S7 «В меню недели»

dish-screen.tsx's first real write from the action row: menu.addDish({ dishId, portions }) where portions is the screen's already-clamped slider binding — the number the person has been reading the ingredient list at, not the raw override, which can sit outside portionsRange(base) after a background refetch moved the recipe's yield.

The outcome is a real answer rather than a success/failure pair, so the screen says which happened: dish.toMenuAdded or dish.toMenuAlready, through the screen's existing announcement slot (there is nothing else on the page that would show for the tap). An archived dish keeps the button aria-disabled and announces dish.toMenuArchived, so the button never lies about what the server will accept. It shares the screen's pendingRef, declares networkMode: "always" like the other two writes there, and invalidates menu.current and nothing else — the dish itself did not change.

«Ингредиенты в корзину» stays the «скоро» button it is; task 5.2 lands it.

Building the cart from the menu

Task 5.2 lands this.

One preview line per product

Task 5.2 lands this.

Units at build time

Task 5.2 lands this.

The preview never promises what the cart would refuse

Task 5.2 lands this.

Preview → apply: what is re-decided and what is not

Task 5.2 lands this.

Locking in menu.applyCart

Task 5.2 lands this.

Idempotency: the client guards and the requestId stamp

Task 5.2 lands this.

Why this lives on the menu router, not on cart

Task 5.2 lands this.

MergePreview sheet (S10 + S7, and phase 6)

Task 5.2 lands this.

History and «Повторить неделю»

Task 5.3 lands this.

API (tRPC)

tRPC v11 + TanStack Query v5, superjson on the wire, Zod at every boundary. The client side uses the current @trpc/tanstack-react-query integration (option builders such as trpc.cart.list.queryOptions()), not the legacy @trpc/react-query hook proxy.

File Role
src/server/api/trpc.ts initTRPC — superjson transformer, errorFormatter, the three procedure builders
src/server/api/context.ts createTRPCContext(){ session, user, db, openai } for one request
src/server/api/root.ts appRouter, the AppRouter type, createCaller
src/server/api/routers/*.ts One router per feature, with its Zod output schemas next to it
src/app/api/trpc/[trpc]/route.ts The single HTTP endpoint (fetchRequestHandler)
src/trpc/query-client.ts makeQueryClient() — shared defaults, superjson dehydrate/hydrate
src/trpc/client.tsx TRPCReactProvider (mounted in the root layout), useTRPC(), the links
src/trpc/server.tsx caller, trpc options proxy, prefetch, HydrateClient (awaits this request's prefetches, then dehydrates) for server components
src/trpc/settle-queries.ts settleQueries() / dehydrateSettled() — awaits the request-scoped cache's in-flight queries, then snapshots it

Adding a router

  1. Create src/server/api/routers/<feature>.ts. Export the Zod output schemas from the same file — a form and an OpenAI structured output should reuse the identical contract. Nullable fields use .nullable(), never .optional().
  2. Build procedures from publicProcedure / protectedProcedure, never from a fresh t.
  3. Mount it on appRouter in src/server/api/root.ts under its own namespace.
  4. Add colocated vitest coverage with createCaller(<fabricated context>) — no database, no network.

Keep import "server-only" out of trpc.ts, root.ts and the routers: the client type-imports AppRouter, and later screens will reuse those Zod schemas. Only context.ts and src/trpc/server.tsx are marked server-only. src/trpc/settle-queries.ts is not, deliberately — it is a pure function of a QueryClient, and a vitest file importing it must not drag in server.tsx's import graph (appRouterenv()db()).

Public vs protected

src/middleware.ts excludes /api/**, so nothing gates the endpoint — authorization is per-procedure.

  • publicProcedure — reachable signed out. ctx.session and ctx.user are nullable.
  • protectedProcedure — throws UNAUTHORIZED (HTTP 401) without a session, and narrows the context so ctx.user is non-null in the resolver. No ! assertions downstream.
  • householdProcedure — additionally loads the caller's membership and throws FORBIDDEN (HTTP 403) when there is none, narrowing the context with ctx.household and ctx.membership. This is the per-request membership check VISION §6.7 asks for.

ctx.openai is a factory, not a client: building one reads OPENAI_API_KEY, and only a procedure that actually makes an AI call should need it (next build runs with no environment at all). It is on the context for the same reason db is — so an AI procedure is testable without a network.

Build every household-scoped procedure on householdProcedure, and scope its queries by ctx.household.id. A householdId arriving in the input is an authorization hole; ctx.household.id is derived from the session and cannot be forged.

FORBIDDEN rather than UNAUTHORIZED for a household-less caller is deliberate: they are authenticated, they simply have not finished onboarding. The UI gate normally redirects them long before a procedure runs; this is the backstop for direct API calls.

Tenant isolation (settled decision, 2026-08-21)

The model: tenant isolation is enforced at the application boundary, not in the database schema. Every household-scoped table carries a plain household_id foreign key (single column, ON DELETE CASCADE from households) — there is no composite tenancy key, and none is planned.

The enforcement pattern, applied uniformly across cart, category, kitchenProfile and product:

  • Every SELECT/UPDATE/DELETE on a household-scoped table carries eq(<table>.householdId, ctx.household.id) in its WHERE — even when the statement already filters by primary key. An INSERT has no WHERE to carry that in; the contract there is the values-side equivalent: householdId is always set from ctx.household.id, never taken from client input. ctx.household.id itself comes from householdProcedure's own membership lookup (src/server/api/trpc.ts), never from client input, so none of this can be forged.
  • A foreign id arriving from the client (a productId on cart.add, a buyerId on cart.updateItem, a categoryId on product.update) is verified to belong to the caller's household with its own scoped lookup before it reaches a lock or a write. The FK to the referenced table proves the id exists somewhere; it does not prove it exists in this household, so the app checks that itself. Each such check needs a test that feeds a wrong-household (or nonexistent) id and asserts the request fails before any lock or write — e.g. cart.test.ts's "refuses a product that is not in the caller's own catalog", which asserts NOT_FOUND and that nothing beyond the ownership check ran.
  • Router tests compile the recorded WHERE clause of scoped selects/updates/deletes with PgDialect and assert it mentions household_id — the expectScopedByHousehold helper, duplicated per test file (see src/server/api/routers/category.test.ts, product.test.ts, cart.test.ts, kitchen-profile.test.ts). A refactor that drops the household half of a query's WHERE fails this assertion even though the stub's queued rows would otherwise still make the test pass on values alone. For an INSERT, the equivalent assertion is on the recorded statement's values.householdId directly (see cart.add's "added" outcome).

Why composite tenancy FKs were considered and declined. CodeRabbit proposed, during PR #13 (task 2.1), adding unique (household_id, id) on every referenced table plus composite foreign keys such as (household_id, product_id) on the referencing side, so the database itself would refuse a cross-household link. The orchestrator declined it schema-wide rather than case by case:

  1. An FK constraint only guards row linking on a write. It cannot guard reads, which is where an actual tenant-isolation bug shows up — a forgotten eq(table.householdId, ...) on a SELECT leaks rows to the wrong household without tripping any constraint at all, composite or not. Per-request household scoping has to exist everywhere regardless of what the FKs look like, so it is the one enforced model; a composite key would duplicate a narrow slice of it (cross-household writes) rather than replace it.
  2. That scoping is already systematic — see the enforcement pattern above — and is the layer that actually catches the bug class composite FKs target.
  3. Composite keys would tax every future household-scoped table (phases 3–5 add pantry_items, dishes, recipes, recipe_ingredients, week_menus, menu_items): a redundant unique (household_id, id) index, migration ceremony, and subtle ON DELETE interactions with the household cascade (RESTRICT fires mid-cascade; NO ACTION defers to statement end) — a permanent cost for a bug class the app-level pattern and its tests already cover.
  4. DB-level invariants remain the right tool where concurrency, not scoping, is the risk: one household per user, one active cart row per product, normalizedName uniqueness. The distinction that guides future tables: business invariants that must survive a race live in the database; tenant isolation lives in the app boundary.

Rule for new tables: a new household-scoped table gets a plain household_id FK and the same pattern above — scope every statement, verify any client-sent foreign id before a write, add expectScopedByHousehold coverage in its router test. Reach for a DB constraint only when two concurrent requests could otherwise both succeed into an inconsistent state; do not reach for one to guard tenancy.

This decision is specifically about composite tenancy FKs, the mechanism CodeRabbit proposed. Postgres row-level security (RLS) is a different DB-level mechanism that can guard reads, unlike a FK — it was not part of what was proposed or declined here, and adopting it would be a separate, heavier call (session-scoped SET LOCAL wiring through the postgres.js pool, a policy per table, its own test strategy). Leaving RLS out of scope is a call about implementation complexity and operational cost, not about trust between households: a caller from another household is an untrusted tenant exactly like in any other multi-tenant system, and a missing scoping predicate can leak a cross-household read regardless of how well the two members inside one household get along. Cross-household isolation currently rests on the enforcement pattern above — application-level scoping — being correct, and on the tests that hold it to that; revisit RLS if that cost/benefit balance changes.

Testing routers

src/server/api/test-support.ts holds the fixtures — it is imported by tests only, never by application code:

  • unusableDb — a Proxy that throws on any property access, so a test can prove a procedure rejected before it queried.
  • unusableOpenai — the same idea for ctx.openai(). It is the default in both contexts below, so a test that unexpectedly reaches an AI call fails loudly instead of dialing a paid API.
  • createDbStub(results) — a drizzle-shaped query-builder stub. Every clause returns the builder, and awaiting one shifts the next queued result off results; an Error in the queue is thrown instead, which is how a constraint violation is simulated. stub.statements records what the resolver ran, in order — wheres, orderBys, groupBys, join conditions (joins), the fields projection, an onConflictDoUpdate config and the raw SQL of a db.execute() (query), each of which can be compiled with PgDialect to assert on the real SQL and its parameters — plus txDepth, the transaction nesting a statement was issued at (0 bare, 1 in a transaction, 2 in a savepoint). txDepth exists because some nesting is load-bearing rather than stylistic — see the cart's insert-inside-a-savepoint — and the stub has no database to reveal it any other way. The same goes for position: recording execute as an ordinary statement is what lets a test prove the advisory lock is the first thing a transaction does.
  • anonymousContext(db, openai?) / signedInContext(db, openai?) — the two contexts to hand createCaller.

Business rules that do not need a query at all (invite validity, accept decisions) live as pure functions and are tested directly. No test opens a database connection.

Calling it

  • Client component: const trpc = useTRPC(); useQuery(trpc.health.ping.queryOptions()).
  • Server component, one value: await caller.health.whoami() from @/trpc/server — in-process, no HTTP.
  • Server component, prefetch for a client child: prefetch(trpc.health.ping.queryOptions()) and wrap the child in <HydrateClient>. prefetch is fire-and-forget at the call site, so a page's queries run in parallel; HydrateClient is async and awaits them all before it dehydrates.

The prefetch/hydration contract. The cache is never dehydrated with a pending query. A pending query is serialized together with its in-flight promise, and hydrate() can resolve such a promise synchronously in the browser (it arrives as a React Flight chunk) but never during SSR — so the HTML would carry a screen's skeleton branch while the client's first render already carried the loaded one, which React reports as a hydration mismatch (fixed 2026-09-02, fix/hydration-prefetch-race). Two rules keep that from coming back: HydrateClient awaits every query still fetching in the request-scoped client (settleQueries), and shouldDehydrateQuery ships successes only. A prefetch that failed, or that somehow escaped the await, is therefore absent from the payload and the client fetches it — a skeleton on both sides, never a mismatch.

The price is that a HydrateClient subtree does not render before its data: it waits for every query prefetched on that page, including ones nothing on screen needs yet. Prefetch only what the screen renders at first paint or first tap, and give the route a loading.tsx — that is what the person looks at while the wait happens.

staleTime defaults to 30 s. It must stay above zero, or every server-prefetched query is refetched the instant the client hydrates. (It is not a promise that nothing refetches: the cart's cartSyncQueryOptions sets refetchOnWindowFocus/refetchOnReconnect to "always" precisely to bypass the staleness check — see Cart sync.)

loading.tsx (the pending state of a prefetching route)

Since HydrateClient awaits, a route that prefetches has nothing to show until its data is there — and on a client-side navigation the tab tap would otherwise sit on the old screen with no feedback at all. Every prefetching route therefore has its own loading.tsx, and each renders the same skeleton component the screen itself renders while a query is pending, under the same static chrome that sits above it in the real tree (toolbar, segment control, search field), so nothing shifts when the data lands:

Route file Fallback
src/app/(app)/(purchases)/loading.tsx segment control + cart toolbar + CartSkeleton — S3, the tab PurchasesScreen opens on
src/app/(app)/dishes/loading.tsx toolbar + search + tag row + LibrarySkeleton — S6's grid of tiles
src/app/(app)/menu/loading.tsx toolbar + MenuSkeleton — S10's pool of dish rows (task 5.1)
src/app/(app)/dishes/[dishId]/loading.tsx DishSkeleton — S7's card, which is the whole screen
src/app/(app)/dishes/[dishId]/edit/loading.tsx S8.3's heading + a form-shaped shell
src/app/(app)/settings/loading.tsx S12's shell with each section's own «Загружаем …» line
src/app/(app)/dishes/new/loading.tsx the same S8.3 shell — this route prefetches nothing, see below

The skeletons are exported from their screens for this (cart-screen.tsx, dish-library-screen.tsx, dish-screen.tsx, menu-screen.tsx) rather than copied. Nothing inside a fallback is focusable: buttons and inputs become spans and empty boxes in the same CSS-module classes, because a control that does nothing has no business taking a tab stop.

A loading.tsx covers its whole segment, so scope it. The fallback belongs to every child slot of the segment it sits in — a file directly under (app) would be what /assistant shows on a tab tap, and the cart chunk would be listed in their client-reference manifests. Hence the (purchases) route group: it gives / a boundary of its own without changing the URL, and the remaining placeholder tab keeps its pre-existing behaviour (no data to wait for, no fallback, the previous screen stays through the transition). /menu earned its own fallback the moment task 5.1 gave it two prefetches. For the same reason /dishes/new has a loading.tsx although it prefetches nothing — without one it would inherit S6's tile grid for the length of the RSC round trip. When adding a route under an existing segment, check what it inherits.

Errors

errorFormatter adds data.zodError to every error response — null for non-validation failures, { formErrors, fieldErrors } for a BAD_REQUEST caused by an input schema. Forms map fieldErrors straight onto inputs.

splitLink groundwork (post-MVP realtime)

The client already routes through splitLink({ condition: (op) => op.type === "subscription", ... }). Both branches currently point at the same httpBatchStreamLink, because the router exposes no subscriptions yet. Instant realtime moved to post-MVP (VISION §6.3, decision 2026-08-19) — MVP sync is refetch-based (plan task 2.2). If/when a realtime channel lands, the true branch becomes httpSubscriptionLink({ transformer: superjson, url: getUrl() }) and nothing else in the client has to change.

Model routing (AI-assisted development)

  • Fable — orchestration: planning, architecture, task decomposition, reviewing subagent output, plan updates.
  • Opus — tasks labeled opus in the plan (realtime infra, cart invariants, import pipeline, assistant).
  • Sonnet — tasks labeled sonnet (UI screens from the design export, CRUD, seeds, settings).

Full rules: CLAUDE.md.

Clone this wiki locally