Standards & Conventions¶
The FE mirrors selected BE conventions where the contract crosses the wire. For BE-side authority on idempotency, optimistic locking, error envelope and pagination, see grinsystem-api/docs/architecture/conventions.md. This doc covers the FE patterns that consume them.
Data Fetching¶
Standard: use Orval-generated hooks directly in components. No bespoke queryOptions wrapper layer.
import { useGetItem } from '../../generated/item-catalog'
export function ItemDetailPage() {
const { itemId } = Route.useParams()
const { data: item, isLoading, isFetching } = useGetItem(itemId)
if (isLoading) return <DetailSkeleton />
return <ItemDetailView item={item} isFetching={isFetching} />
}
Why this works. Orval generates useGetItem(id, options) and useListItems(query, options) with stable query keys derived from the OpenAPI operationId. The BE treats operationId as a stable contract — they don't churn during normal development. On the rare deliberate rename, FE call sites get a one-time manual fix in the same PR as the orval regeneration. The FE never invents its own query keys.
The axios mutator — return shape¶
orval.config.ts wires the generated client to the global axios instance (httpClient: 'axios' + override.mutator → orvalFetcher), so every generated read/write flows through apiClient in src/lib/axios.ts and inherits its interceptors: baseURL from VITE_API_BASE_URL, the Authorization stub, Idempotency-Key enforcement on unsafe methods, the 403 → /forbidden navigation, and OTel error recording. There is no fetch path and no per-call HTTP wiring.
orvalFetcher unwraps response.data, so a generated hook's data is the response body — not the { data, status, headers } envelope a raw-fetch client would return:
const { data: item } = useGetItem(itemId) // item is ItemDetailResponse, not { data, status, headers }
Because axios's default validateStatus is unchanged, a non-2xx response rejects — it surfaces as the query's error (and isError), never as a silent success. Drive error UX off error / isError; the call site routes the thrown error through handleApiError(e, opts) (see § Error Envelope). Never read a .status field off data to detect failure.
List query params — serialization¶
List endpoints flatten their query params BE-side: each FastAPI handler declares a single Query() model, so the OpenAPI spec emits one flat parameter per field (no nested pagination / filters wrapper objects) and Orval codegens a flat ListXParams type. The FE never reshapes params — it passes the flat object straight to the generated hook.
The one serialization concern the FE owns is multi-value filters. apiClient sets paramsSerializer: { indexes: null } so arrays serialize as repeat-keys — ?item_type=ingredient&item_type=packaging — which is what FastAPI's extra="forbid" Query() models expect. Axios's default brackets arrays (item_type[]=ingredient), which the BE rejects with a 422. This is a single global setting; there is no per-call paramsSerializer, no nested-object flattening helper, and no comma-joining. (See BE conventions.md § List Endpoints for the one-Query()-model rule.)
Loading states:
- isLoading — first fetch only, show a skeleton. Never on a refetch.
- isFetching — any refetch, show a subtle overlay on existing data. Never destroy existing table data during a refetch.
Cache invalidation and toasts live next to the mutation that triggers them, in features/<module>/hooks/use<X>Mutations.ts. Compose the two foundation helpers — useIdempotentMutation for the Idempotency-Key lifecycle and withSideEffects for the success-side wiring:
import {
useCreateItem,
getListItemsQueryKey,
getGetItemsStatsQueryKey,
} from '../../../generated/item-catalog'
import { useIdempotentMutation } from '@/hooks/useIdempotentMutation'
import { withSideEffects } from '@/hooks/withSideEffects'
export function useItemMutations() {
return {
create: withSideEffects(
useIdempotentMutation(useCreateItem()),
{
successToast: 'item-catalog:toast.created',
invalidates: [getListItemsQueryKey(), getGetItemsStatsQueryKey()],
},
),
}
}
Query keys come from Orval-generated helpers (getListItemsQueryKey, getGetItemQueryKey, getGetItemsStatsQueryKey) — never hand-build query-key arrays. Hand-built arrays drift the day Orval regenerates with a different shape.
withSideEffects is success-only. Error handling stays with handleApiError(e, opts) at the call site — it owns inline field errors via the form ref, the onConflict callback, and i18n-resolved toast messages.
Cache invalidation¶
When you write the invalidates list, follow this matrix by mutation kind. The defaults are conservative — the user sees fresh data without hammering the BE.
| Mutation | Invalidate |
|---|---|
| Create (POST) | List query for the resource: getListXQueryKey(). If a /stats endpoint exists: also getGetXStatsQueryKey(). |
| Update (PUT/PATCH) | Detail: getGetXQueryKey(id) and list: getListXQueryKey(). List is invalidated because row cells render fields that may have changed. |
| Delete | List (and stats if exists). Optionally call queryClient.removeQueries({ queryKey: getGetXQueryKey(id) }) for instant detail cleanup. |
| Status-change action (activate / deactivate / archive) | Same as update — detail + list. Stats if the status affects an aggregated count. |
| Bulk action | Broadest applicable: list + stats. |
Conventions:
- Always pass an Orval-generated query-key helper. Never
['items', 'list']. Hand-built arrays drift the day Orval regenerates with a different shape. - Prefix matching is the default.
invalidateQueries({ queryKey: getListItemsQueryKey() })— no params — invalidates alluseListItemsvariants regardless of filter combo. That's what we want: every list view reflects the new state. Use{ exact: true }only for the narrow "this exact filter combo only" case (rare; flag in review). - Cross-module invalidation is explicit. If a mutation in module A affects queries in module B (creating a manufacturing order ⇒ warehouse stock counts), list module B's Orval keys explicitly in
invalidates. Importing a query-key helper across modules is fine — it's a pure thin function, not a feature-to-feature dependency. - Stats invalidate only on writes.
?page=2does not invalidate stats — the count hasn't changed. - Don't invalidate what the mutation doesn't directly affect. A wildcard
queryClient.invalidateQueries()will refetch every active query in the app and tank UX. Be specific. - Optimistic updates are deferred. When a feature needs one, it uses the
effects.onSuccessescape hatch + Orval'sonMutate/onSettleddirectly. Foundation does not ship a helper for optimistic updates yet.
Filtered tables — draft vs committed state:
Split filter state into two phases: 1. Draft state — local React state, updates on every keystroke, does NOT trigger a fetch. 2. Committed state — URL search params, updated on "Apply" / "Search" click, triggers refetch.
const [draft, setDraft] = useState(initialFilters)
const search = Route.useSearch() // committed — source of truth for fetch
const { data } = useListItems(search)
const handleApply = () => navigate({ search: draft }) // commits → triggers refetch
const handleReset = () => navigate({ search: defaultFilters })
URL is the source of truth — filters survive page refresh and are shareable.
Loading & error states for reads¶
Every query-backed region renders exactly one of four states — never a bare empty box, never silent:
- Loading → a content-shaped skeleton. Drive off
isLoadingonly (first fetch), never on a refetch. Components whose cell count is data-driven (e.g.StatsBar) take a fixedskeletonCountso the skeleton shows before any data arrives. - Error-without-data →
<DataLoadError>+ Retry. Only whenisError && !data. - Empty →
<EmptyState>. - Data → rendered with a subtle
isFetchingoverlay; never destroy existing data on a refetch (stale-while-revalidate).
Stale-while-error. A failed background refetch keeps the stale data on screen (optionally a quiet toast); the error state appears only when there is no data to show. Always gate <DataLoadError> on isError && !data, never on isError alone.
Primary vs auxiliary regions:
- Primary (the list table, a detail body) →
<DataLoadError variant="page" | "section">replacing the region. - Auxiliary (a
StatsBar, an option picker, a side card) →<DataLoadError variant="inline">inside the region's own footprint. Treat a region as auxiliary when its failure does not block the user — e.g. the item-listStatsBar's filter shortcuts are duplicated by the FilterBar Status control, so a stats failure is non-blocking. Auxiliary regions are not fused with the primary one: independent endpoint, independent cache, independent error.
Error categorisation (handled inside <DataLoadError> via parseApiError):
| Error | Category | UX |
|---|---|---|
404 |
terminal not-found | PackageX icon, no Retry (retrying the same id won't help); caller may pass a nav action via children |
no response (isAxiosError(e) && !e.response) |
network/CORS/timeout | WifiOff, network copy, Retry |
5xx / unknown |
server-side | ServerCrash, load-failed copy, Retry |
403 |
— | never handled inline — the axios interceptor navigates to /forbidden |
409 / 422 |
— | mutation concerns, not reads — see § Error Envelope |
Retry = refetch the failed queries in scope — never a blanket refetch of healthy queries (an explicit refetch always hits the network; the cache only prevents UI flicker):
- Region retry → that query's own
query.refetch(). - Primary-region retry →
queryClient.refetchQueries({ type: 'active', predicate: (q) => q.state.status === 'error' }). One click recovers everything broken on the page (the usual outage case) while leaving successful queries untouched.
Auto-retry. Reads inherit the QueryClient default retry: 1 (src/lib/queryClient.ts) so transient blips self-heal before the user ever sees an error; manual Retry is the backstop. Don't set per-call retry: false on reads — it removes that self-heal. (Mutations stay retry: 0.)
Reads-only. This standard governs read queries. Mutations are unchanged: handleApiError(e, opts) → inline field errors / conflict callback / toast remains the single write-feedback path (§ Error Envelope).
Idempotency¶
Every POST/PUT/PATCH/DELETE must send an Idempotency-Key: <uuid-v4> header. The BE caches (tenant_id, key) → response in Redis for 24 hours; retrying with the same key replays the cached response without re-executing the handler. BE detail: grinsystem-api/docs/architecture/conventions.md § Idempotency Keys.
The rule: one key per logical user action. A retry — whether by TanStack Query's automatic retry, the user clicking "Try again", or a network blip — must reuse the same key so the BE can dedupe. A fresh key on retry defeats the entire mechanism: BE sees two distinct requests, and the side effect runs twice.
A naïve axios interceptor that generates uuidv4() per outgoing request is wrong — TanStack Query retries the underlying request, the interceptor fires again, the key is different, and the BE has no way to recognise the retry. Don't write that interceptor.
Correct pattern — useIdempotentMutation hook (added in Foundation M4):
// src/hooks/useIdempotentMutation.ts (pseudo — implementation lands in M4)
export function useIdempotentMutation<TData, TVariables>(
useOrvalMutation: () => UseMutationResult<TData, unknown, TVariables>,
) {
const keyRef = useRef(uuidv4())
const mutation = useOrvalMutation()
return {
...mutation,
mutateAsync: (vars: TVariables) =>
mutation.mutateAsync(vars, {
// axios request config — header travels with every retry of THIS call
headers: { 'Idempotency-Key': keyRef.current },
}),
reset: () => {
keyRef.current = uuidv4() // explicit "fresh attempt after user edit"
mutation.reset()
},
}
}
Usage:
const { mutateAsync, reset } = useIdempotentMutation(useCreateItemMutation)
// Submit — the same key is reused across automatic retries
await mutateAsync({ data: values })
// User edited the form after a 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD —
// call reset() to mint a new key before resubmitting:
reset()
await mutateAsync({ data: newValues })
Until Foundation M4 lands this hook, mutation hooks pass a key explicitly via the Orval mutation.headers config, generated with useMemo(() => uuidv4(), []) at the form's mount. Do not ship a global per-request-fresh-key interceptor as a stopgap — it would mask the bug class above.
BE-side error codes you may see:
| HTTP | code | Meaning |
|---|---|---|
| 422 | IDEMPOTENCY_KEY_REQUIRED |
The header was not sent. Bug in the mutation wiring — file it. |
| 409 | IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD |
Same key, different body. Usually a stale key reused after the user edited the form. Resolution: call reset() to mint a fresh key, then resubmit. |
Optimistic Concurrency¶
Every editable aggregate carries a version: int field. BE detail: grinsystem-api/docs/architecture/conventions.md § Optimistic Concurrency Control.
FE rules:
- Read responses include
version. Detail pages, edit forms, and tables all preserve it. - PUT/PATCH request bodies must include the last-seen
version. The generated Orval types require this — there's no way to omit it accidentally. - A 409
CONCURRENT_MODIFICATIONmeans another user saved a newer version while the current user was editing.
RHF pattern — version is a hidden field carried through the form:
const itemFormSchema = z.object({
name: z.string().min(1).max(200),
// ... other editable fields
version: z.number().int(), // hidden, sourced from the read response
})
const form = useForm<ItemFormValues>({
resolver: zodResolver(itemFormSchema),
defaultValues: { ...item, version: item.version },
})
409 handling — refresh-and-retry (pseudo-code; helper names are illustrative — the real isConflictError and the Orval-generated query-key helper are implemented in Foundation M4):
const { mutateAsync } = useUpdateItemMutation()
const onSubmit = form.handleSubmit(async (values) => {
try {
await mutateAsync({ id: itemId, data: values })
toast.success(t('item.updateSuccess'))
} catch (e) {
if (isConflictError(e)) {
toast.error(t('item.conflictRefresh'))
// Refetch the item and reset the form to the new values + new version.
// The user re-applies their edit on top.
queryClient.invalidateQueries({ queryKey: getGetItemQueryKey(itemId) })
return
}
throw e
}
})
A future refinement: diff the user's draft against the new server values and show a merge UI. Not in scope until the first 409 is encountered in practice.
Error Envelope¶
Every non-2xx response (other than transport errors) has this shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "code", "message": "Code is required" }
]
}
}
details is always an array (BE defaults to [] for non-validation errors). The BE message field is always populated (English today) — it is the always-works fallback when an unknown code has no FE translation.
Interceptor vs call-site split¶
Error handling is intentionally split across two layers. The axios response interceptor handles global side effects only and never toasts. handleApiError(e, opts) at the call site always produces visible feedback. No silent paths.
Every write/action failure raises a toast — that is the baseline "it failed" signal, fired in all cases. Field-scoped and form-level errors then layer an additional surface (inline field error, or the form-level <FormError> banner) so the user sees both the toast and where to act. The rule is one toast + at most one extra surface per error — never the same message duplicated across surfaces (so a field-mapped error toasts the generic "check the highlighted fields" copy, with the specific message inline).
| Layer | Responsibility |
|---|---|
Axios response interceptor (src/lib/axios.ts) |
401 stub (identity slot), 403 FORBIDDEN_INSUFFICIENT_PERMISSION → navigate to /forbidden, OTel recordException on the active span (wired in M5). Always rethrows. |
handleApiError(e, opts) (src/lib/errors.ts) |
Always toasts. Field-scoped (fieldErrorMap code → field, then details[]) → also inline on the field (generic toast). 409 CONCURRENT_MODIFICATION → toast + caller's onConflict banner. Other form-level errors → toast + pinned to the form's root.serverError banner. Transport / non-envelope → toast only. |
This split exists because the previous draft tried to make the interceptor decide what gets toasted (5xx-only toast, all other errors silent). That produced inconsistent UX — a 422 with no details[] would vanish unless every call site remembered to handle it. The new shape makes a single helper at the call site responsible for every category, and the interceptor responsible only for the cross-cutting side effects.
handleApiError contract¶
type HandleApiErrorOpts<T extends FieldValues = FieldValues> = {
form?: UseFormReturn<T> // present → field/root errors render on the form
toastNs?: string // 'item-catalog', etc. — checked first in i18n
onConflict?: () => void // 409 CONCURRENT_MODIFICATION → caller's reload banner
fieldErrorMap?: Record<string, Path<T>> // BE code → field, for domain errors with empty details[]
}
function handleApiError(e: unknown, opts: HandleApiErrorOpts): void {
const err = parseApiError(e)
if (!err) { // transport / non-envelope → toast only
const reachedServer = isAxiosError(e) && Boolean(e.response)
return toast.error(t(reachedServer ? 'common:errors.serverError' : 'common:errors.networkError'))
}
// Field-scoped → inline on the field + a generic toast (specifics stay inline).
const mappedField = opts.form && opts.fieldErrorMap?.[err.code]
if (mappedField && opts.form) {
opts.form.setError(mappedField, { type: 'server', message: resolveErrorMessage(err, opts.toastNs) })
return toast.error(t('common:errors.formInvalid'))
}
if (err.details.length > 0 && opts.form) {
applyFieldErrorsToForm(opts.form, err)
return toast.error(t('common:errors.formInvalid'))
}
// Conflict → toast + caller's reload banner.
if (err.code === 'CONCURRENT_MODIFICATION') {
toast.error(t('common:errors.CONCURRENT_MODIFICATION'))
return opts.onConflict?.()
}
// Form-level domain error → toast + pin to the <FormError> banner (root.serverError).
const message = resolveErrorMessage(err, opts.toastNs)
toast.error(message)
opts.form?.setError('root.serverError', { type: 'server', message })
}
i18n resolution order¶
resolveErrorMessage(err, ns?) tries in order:
${ns}:errors.${err.code}(only ifnsprovided and the key exists)common:errors.${err.code}err.message(BE-supplied human message — English today, but always present)common:errors.unexpected
Because BE always populates message, unknown codes are never silent — they render the BE wording. Modules add <module>:errors.<CODE> keys opportunistically when UX testing surfaces a code that deserves polished Polish copy or domain-specific phrasing.
Per-module mapping is opportunistic, not exhaustive¶
No FE ticket needs to pre-translate every code BE could emit. Each feature module adds <module>:errors.<CODE> entries as needed. Until a key is added, the resolution chain falls through to common, then the BE message. common:errors.* covers every shared code emitted by grinsystem-api/src/shared/exceptions.py. Domain-specific codes (e.g. EAN_ALREADY_IN_USE, ITEM_NOT_FOUND) live in module namespaces.
The BE declares per-route possible errors in OpenAPI via the errors_for() helper. Once a module's OpenAPI spec carries those declarations, the planner decides whether the FE adds a typed-per-endpoint code accessor — until then, ApiError.code: string is sufficient and the runtime handleApiError switch covers every case.
Common error codes¶
See grinsystem-api/src/shared/exceptions.py for the authoritative list.
| HTTP | code | Source |
|---|---|---|
| 422 | VALIDATION_ERROR |
Domain rule / Pydantic-mapped rejection. details[] populated when field-scoped. |
| 422 | IDEMPOTENCY_KEY_REQUIRED |
Missing Idempotency-Key header on a non-GET. Programmer error. |
| 422 | INVALID_SORT_FIELD |
?sort=... field not in BE allowlist. |
| 400 | IMMUTABLE_FIELD_CANNOT_BE_CHANGED |
PATCH attempted to mutate a read-only field. |
| 409 | CONCURRENT_MODIFICATION |
Optimistic-lock conflict. See § Optimistic Concurrency. |
| 409 | IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD |
See § Idempotency. |
| 403 | FORBIDDEN_INSUFFICIENT_PERMISSION |
Caller lacks the required permission. Interceptor navigates to /forbidden. |
| 403 | FORBIDDEN |
Generic forbidden (rare base case). |
| 401 | UNAUTHORIZED |
Missing / invalid credentials. Identity wires the redirect-to-login. |
| 404 | NOT_FOUND |
Resource doesn't exist or RLS hides it. |
| 409 | CONFLICT |
Generic resource conflict (e.g. uniqueness). |
| 500 | REPOSITORY_ERROR |
Persistence-layer failure (SQLAlchemy catch-all). |
| 400 | DOMAIN_ERROR |
Base class — emitted only if no subclass matched. |
Pagination¶
These rules apply to every GET /api/v1/{resource} collection — paginated list, search, filter, sort. Section order below mirrors BE grinsystem-api/docs/architecture/conventions.md § List Endpoints so cross-referencing is trivial. Worked example throughout: BE GET /items (api-contract.md).
Request shape: ?page=<n>&page_size=<n>. 1-indexed page; default page_size=20; BE-enforced max page_size=100. Boundary violations come back as 422 with code: VALIDATION_ERROR — programmer error, not a user-facing case.
Response envelope:
type Paginated<T> = {
items: T[]
total: number // count after filters
page: number
page_size: number
pages: number
snapshot_at: string // ISO 8601 — BE-issued, see § snapshot_at below
}
TanStack Table is wired against this shape. Pagination controls (next/prev, page-size selector) live on a shared DataTable component that lifts out of the first list-page consumer in item_catalog — Foundation does not pre-build it.
snapshot_at — stable pagination across page changes¶
The BE pins each list response to a server-side timestamp (echoed in snapshot_at) so the row set doesn't shift under the user's feet when paging. The FE roundtrips it on every navigation that should keep the same set:
| User action | snapshot_at handling |
|---|---|
| Next / prev page | Echo in URL — BE returns the same row set, total stays stable. |
Change page_size |
Echo — same conceptual list, different window. |
Change a filter, sort, or q |
Drop from URL — BE re-snapshots with the new criteria. |
| Click an explicit Refresh | Drop — fresh data set, fresh snapshot. |
Open URL with no snapshot_at |
BE picks now, returns it; FE writes it back via navigate({ replace: true }) so subsequent paging reuses it. |
The snapshot constrains created_at only — live updates (status flips, name edits, reactivations) still flow through across pages. Don't lean on snapshot_at for point-in-time reporting; for genuine audit views the BE will ship a dedicated history endpoint.
Worked example. GET /api/v1/items?page=2&page_size=20&snapshot_at=2026-05-18T14:32:00Z — page 2 of the same row set the user saw on page 1, regardless of new items created between the two clicks.
Sorting¶
Single sort param, JSON:API-style: ?sort=field for ascending, ?sort=-field for descending. Leading - means descending; no + prefix.
Allowed fields are a per-endpoint enum published in OpenAPI — Orval generates a typed Zod enum for each list endpoint's sort token. An unknown field gets rejected by Pydantic with 422 INVALID_SORT_FIELD — that's a programmer error (FE sent a value not in the enum), never a user-facing case.
// Generated by Orval from the OpenAPI sort enum — never hand-write this union:
type ItemSortToken =
| 'name' | '-name'
| 'code' | '-code'
| 'created_at' | '-created_at'
| 'updated_at' | '-updated_at'
| 'item_type' | '-item_type'
| 'status' | '-status'
Default sort is per endpoint and lives on the BE. The FE doesn't write the default to the URL — when ?sort= is absent the BE applies its own default (e.g. -created_at for items). The validateSearch Zod schema treats sort as optional.
UI convention: TanStack Table column-header click is the only sort UI. Clicking a column header toggles asc → desc → unsorted (back to BE default). No separate "Sort by" dropdown anywhere. Multi-sort (shift-click) is BE-deferred — FE wires single sort only.
Worked example. GET /api/v1/items?sort=-created_at is the BE default; the FE never writes that to the URL. The user clicking the Name column header writes ?sort=name, a second click writes ?sort=-name, a third click clears the param.
Filtering¶
Flat typed scalar query params. No filter[x]= nesting. No RSQL. No JSON-encoded filter blobs. This is a hard constraint, not a preference — filters must survive Orval codegen as a typed useListXxx(params) argument, and a custom DSL string doesn't.
| Concept | Convention | Example |
|---|---|---|
| Single-value filter | Plain scalar param | ?item_group_id=11111111-1111-1111-1111-111111111111 |
| Multi-value filter | Repeated scalar params | ?item_type=ingredient&item_type=packaging |
| Status / lifecycle | Tri-state literal (see § Status filter below) | ?status=active / ?status=inactive / ?status=all |
| Date range | Two separate inclusive bounds, either side optional | ?created_after=2026-01-01&created_before=2026-05-01 |
| Combining | AND across params — no OR, no nested groups | n/a |
| Operators | eq, in (via repeated params), date-range pair |
no >, <, contains_any yet |
Only write a filter to the URL when the user picks a non-default value. The validateSearch Zod schema knows each filter's default (and mirrors the BE default), so widgets render correctly when the URL is empty. Writing redundant defaults makes URLs unscannable and breaks the "shareable URL = current state" invariant.
Status filter — tri-state, default = working set¶
Every list endpoint with an active/archived/closed distinction uses a tri-state literal, not a boolean. The unset case for a boolean is ambiguous (omit → "all" or "default"?) and there's no clean way to express "all" without null. The tri-state makes all three cases explicit and produces a clean Zod enum on the FE.
// Items
status: 'active' | 'inactive' | 'all' // default: 'active'
// Orders (future)
status: 'open' | 'closed' | 'all' // default: 'open'
// Recipes (future)
status: 'active' | 'archived' | 'all' // default: 'active'
Default is "working set" — aligns with the domain's ubiquitous language ("show me the items" means "the items I work with", not "the full historical archive"). The FE's validateSearch schema mirrors that default; URL stays clean at the default state.
UI widget: a tri-state <SelectField> variant — lifts out of item_catalog's first list page, not pre-built in foundation.
Draft vs committed — search debounces, everything else commits on Apply¶
Filter UIs maintain a draft state locally (no URL writes, no refetch) until the user clicks Apply, at which point the draft is committed to the URL via navigate({ search }) — which triggers the refetch. Avoids one-letter-at-a-time refetches when composing complex filter combinations. Full pattern in § Data Fetching (lines above).
Search (?q=...) is the one exception — it commits on debounce, not on a button click. See § Search.
Worked examples.
- Multi-value:
GET /api/v1/items?item_type=ingredient&item_type=packaging— list of repeating scalars. - Status:
GET /api/v1/items?status=inactive— the defaultactiveis never written; toggling the tri-state to "inactive" writes the param; clicking "All" writesstatus=all. - Date range:
GET /api/v1/items?created_after=2026-01-01— partial range; the absentcreated_beforemeans open-ended.
Search¶
Single ?q=... query param, max 200 chars (BE-enforced). Case + diacritic-insensitive on the BE via Postgres unaccent + ILIKE — the FE just sends q, the server decides which columns to match against the per-resource allowlist.
FE convention: debounce the input at 300ms. Baseline value; tune per page if a slow endpoint or specific UX needs it. Treat search as a filter — it lives in the URL alongside everything else and goes through the same draft→committed lifecycle, just committed on debounce instead of on an Apply click.
Worked example. Typing mąka (Polish for "flour") in the search input writes ?q=mąka to the URL 300ms after the last keystroke. The BE's unaccent + ILIKE matches maka, mąka, MAKA, and substring hits like mąka pszenna across the per-resource search-column allowlist (for items: name, code, description, group name, tags).
Stats endpoints¶
Some list views need a small set of tenant-wide aggregate counts that are stable across filter changes ("you have 124 items in your catalog — 118 active, 6 inactive") to give the user context. The BE convention is a separate, dedicated endpoint per resource that needs it — not a generic stats framework:
GET /api/v1/items/stats
→
{
"total": 124,
"active": 118,
"inactive": 6,
"by_type": { "ingredient": 80, "product": 38, "packaging": 6 }
}
Tenant-wide, not filter-aware. Banner numbers shouldn't shift every time the user changes a filter — they're a stable "what's in my catalog" indicator. Cached independently of the list query; invalidates on create / deactivate / delete, never on browsing.
Two different totals on the page:
- List view's "showing X of Y" indicator reads from
paginated.total(the post-filter count for the current query). - Page-level chrome ("124 items in your catalog") reads from
/stats.total.
Not every resource gets a /stats endpoint — only the ones where filter-independent counts add value. Response shape is per resource (items have by_type; future entities may have by_status or by_warehouse).
FE consumption. A generated useGetItemsStats() hook lives in src/generated/<module>.ts alongside useListItems. No special wiring — just don't tie its invalidation to filter changes.
Options endpoints¶
Picker UIs (<Combobox>, <SelectField>) ask a different question than list pages or stats banners: "what are the choices?" The answer is neither a paginated list (no need for pagination, sort, search metadata) nor a stats aggregate (we want rows, not counts). The BE convention is a third dedicated endpoint shape — GET /api/v1/<resource>/options — returning a bare array of { id, label }:
GET /api/v1/item-groups/options?item_type=ingredient
→
[
{ "id": "8d2d…", "label": "Drożdże" },
{ "id": "4a91…", "label": "Mąki" },
{ "id": "0c11…", "label": "Sole" },
{ "id": "1f70…", "label": "Słodziki" }
]
BE-authoritative convention: grinsystem-api/docs/architecture/conventions.md § List Resource Options Endpoints.
When to use options vs. list-with-?q= — bounded vs. unbounded cardinality:
- Bounded (< ~500 rows per tenant, e.g. item groups, warehouses, suppliers, BOM templates) →
/optionsendpoint, full filtered list returned in one fetch, client-side filtering inside the<Combobox>. - Unbounded (items, customers, sales orders) → reuse the list endpoint with
?q=ab&page_size=20for server-side typeahead — the picker fires debounced fetches as the user types. No/optionsendpoint. - Static enums (e.g.
item_type,storage_unit) → no endpoint at all — the values come from the generated Zod schema as union literals; translate by value viat('itemType.${value}').
Shape contract (mirrors BE). Bare JSON array. Each row is exactly { id: UUID, label: string }. Sorted alphabetically by label server-side. Archived / deactivated rows excluded by default. Optional flat scope filters (e.g. ?item_type=ingredient) — never q, never sort, never page. Same Permission as the list endpoint (e.g. Permission.ITEM_READ for /item-groups/options).
FE consumption — naming. Orval generates useList<Resource>Options(scope?) per options endpoint, e.g. useListItemGroupOptions({ item_type }). The hook lives in src/generated/<module>.ts alongside useListItemGroups and useGetItemGroupsStats — three distinct hooks, three distinct query keys, three distinct invalidation surfaces.
Cache invalidation. The options query key is independent from the list query key, even though both target the same resource. Edits via the list page (rename a group, archive a group) MUST invalidate both keys — see the per-mutation feature hooks in § Cache invalidation. The reverse is not true: an item create that assigns a group does not touch the groups options cache.
Loading-state policy. Same as list endpoints — isLoading on first fetch gates a skeleton inside the popover; isFetching on a scope-param change shows a subtle spinner without unmounting the existing options.
Don't bend the list endpoint to do this job. Setting page_size=1000 on the list endpoint to "fake" an options call defeats the whole pattern — payload bloat, coupled cache invalidation, BE can't optimise the query shape. If a resource needs picker semantics, the BE owes a /options endpoint; if BE hasn't shipped one yet, raise it via be-feedback.md and document the workaround under § FE workarounds.
URL as Source of Truth (lists)¶
Every list-state value lives in TanStack Router search params, validated with a per-page Zod schema via validateSearch:
page,page_sizesort- All filters (multi-value, tri-state status, date-range pairs)
qsnapshot_at
// Sketch — per-page list search schema; the shared helper lifts out of item_catalog's first list page.
const itemListSearchSchema = z.object({
page: z.number().int().positive().default(1),
page_size: z.number().int().positive().max(100).default(20),
sort: ItemSortToken.optional(),
status: z.enum(['active', 'inactive', 'all']).default('active'),
item_type: z.array(ItemType).optional(),
item_group_id: z.string().uuid().optional(),
created_after: z.string().datetime().optional(),
created_before: z.string().datetime().optional(),
q: z.string().max(200).optional(),
snapshot_at: z.string().datetime().optional(),
})
export const Route = createFileRoute('/_auth/catalog/items/')({
validateSearch: itemListSearchSchema,
// ...
})
Component state is derived from URL state, never the reverse. The list page reads Route.useSearch() → passes to useListItems(search) → renders. Filter widgets read the same useSearch() for their initial committed value; the draft buffer is local React state until the user clicks Apply (then navigate({ search: draft })).
Why. Deep-linking works for any page state — a teammate can paste a URL and see the same filtered/sorted/paged view. Browser back/forward and refresh preserve everything. Default values stay implicit via the Zod schema, so the URL stays scannable.
Foundation does not ship a shared useListSearchParams<T>(schema) hook yet. First list-page consumer in item_catalog is where it lifts out of feature code into components/common/. Until then, each list page composes its own validateSearch schema following these conventions.
Forms¶
Zod schema is the single source of truth for types, validation and constraints.
4-step pattern:
The form schema is derived from the generated Zod schema — never written from scratch. Pick the editable fields, optionally extend with FE-only ones (e.g. confirmation fields, computed display values). Never hand-redeclare BE-owned enums (item_type, storage_unit, etc.) — they come from generated/.
// Step 1: Schema in features/<module>/types.ts.
// Start from the generated request schema; pick what the form edits.
import { CreateItemRequest } from '../../generated/schemas/item-catalog'
const itemCreateFormSchema = CreateItemRequest.pick({
code: true,
name: true,
item_type: true,
storage_unit: true,
description: true,
tags: true,
shelf_life_days: true,
ean: true,
item_group_id: true,
})
type ItemCreateFormValues = z.infer<typeof itemCreateFormSchema>
// Step 2: Wire form with zodResolver
const form = useForm<CreateItemFormValues>({
resolver: zodResolver(createItemSchema),
defaultValues: { code: '', name: '', item_type: 'ingredient', storage_unit: 'kg' },
})
// Step 3: shadcn Form components — validation errors display automatically via zod-i18n-map
<Form {...form}>
<FormField control={form.control} name="code" render={({ field }) => (
<FormItem>
<FormLabel>{t('item.code')}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormActions
helperSlot={<span className="text-xs text-fg-muted">{t('item-catalog:page.itemCreate.helperImmutable')}</span>}
actionsSlot={<><CancelButton onClick={handleCancel} /><SaveButton isLoading={mutation.isPending} /></>}
/>
</Form>
// Step 4: Submit
const onSubmit = form.handleSubmit(async (data) => {
await mutation.mutateAsync({ data })
})
Validation mode: onBlur default. onChange only when instant feedback is critical (password strength, quantity that affects downstream calculations).
Feature forms (ItemForm, future RecipeForm) work both standalone (full page) and inside FormDialog — same component, onSuccess callback handles navigation (standalone) or dialog close (modal). Never duplicate form logic for the two contexts.
Error & loading states (forms)¶
A form has two failure phases — the reads that feed it, and the submit. They reuse existing primitives; don't invent per-form handling.
Reads that feed the form follow § Data Fetching → Loading & error states for reads:
- Prefill (edit forms,
useGet<Aggregate>) is primary — onisError && !datarender<DataLoadError variant="page" | "section">and don't mount the form. - Option pickers (
useList<Resource>Optionsbehind a<Combobox>) are auxiliary — passisError+ anerrorSlot={<DataLoadError variant="inline" … onRetry={refetch} />}to the<Combobox>. Never show a silently empty list (indistinguishable from "no options"). A failed optional picker must not block submit; only guard Save when a required picker can't load.
Submit routes through handleApiError(e, { form, toastNs, fieldErrorMap, onConflict }) (§ Error Envelope). Every failure toasts; on top of that, one extra surface by scope:
| Failure | Toast | Extra surface |
|---|---|---|
Field-scoped (details[] or fieldErrorMap code) |
generic formInvalid |
inline error on the field(s) |
409 CONCURRENT_MODIFICATION (edit) |
conflict copy | onConflict reload banner |
| Other form-level domain error | resolved message | <FormError> banner (root.serverError) |
| Transport / 5xx-without-envelope | category message | — (transient; resubmit) |
<FormError>(components/common/FormError.tsx) is the form-level banner. Render it above<FormActions>, driven byform.formState.errors.root?.serverError?.message—handleApiErrorpopulates it. Clear it at the start of each submit:form.clearErrors('root').fieldErrorMapis per-call, typed to that form'sPath<T>— never a global registry (the same BE code may map to different fields, or to a toast, in different forms). When create + edit of one aggregate share a map, extract a module-local constant — not a global one.- Save is
isPending-disabled during submit; entered data is never cleared on error.
Anti-patterns: mirroring BE validation client-side beyond the generated schema; auto-retrying mutations (breaks idempotency / OCC); the same message on two surfaces; a silently-empty picker on fetch failure; per-field error boundaries.
Composite Edits (aggregates with sub-resource endpoints)¶
Some aggregates expose related state via REST sub-resources rather than columns on the main resource. The user sees one form; the API is multiple endpoints. Treat this as a first-class pattern.
Canonical example — Item:
| What changes | Endpoint |
|---|---|
| Editable fields (name, description, tags, shelf_life_days, ean) | PATCH /api/v1/items/{id} |
| Group assignment | PUT /api/v1/items/{id}/group |
| Group removal | DELETE /api/v1/items/{id}/group |
The split is intentional on the BE — each sub-resource carries its own permission (e.g. ITEM_UPDATE vs ITEM_GROUP_ASSIGN), its own audit semantics (a no-op group reassignment still bumps the audit row, a no-op field PATCH does not), and a clean REST shape (the group is a relationship, not a column). Do not lobby BE to merge them.
Pattern¶
Collapse the API split into one user-facing form. Wrap the multi-call save in a per-aggregate helper under features/<module>/hooks/save<Aggregate>.ts:
- Diff the form state against the prefill snapshot to decide which sub-resources are dirty.
- Dispatch the per-sub-resource mutations in parallel via
Promise.allSettled. Typically 1–3 calls, frequently just one. Parallel only — never sequential in the happy path. - Idempotency-Key per sub-action — one UUID per logical sub-operation (the field-PATCH, the group-PUT). Not one per Save click. If the group call fails and the user retries, only that sub-resource replays with its key — the field-PATCH does not refire.
- Map to a typed
SaveOutcomeper sub-resource:unchanged | saved | { error }. The caller (the page) decides UX based on the outcome.
// features/item-catalog/hooks/saveItem.ts
export type ItemSaveOutcome = {
fields: 'unchanged' | 'saved' | { error: ApiError }
group: 'unchanged' | 'saved' | { error: ApiError }
}
export async function saveItem(
itemId: string,
initial: ItemFormSnapshot,
next: ItemFormValues,
mutators: { editItem: …; assignGroup: …; removeGroup: … },
): Promise<ItemSaveOutcome> {
const fieldDiff = diffEditableFields(initial, next) // undefined if no fields dirty
const groupChange = next.group_id !== initial.group_id
const fieldsCall = fieldDiff
? mutators.editItem({ itemId, data: { ...fieldDiff, version: initial.version } })
: null
const groupCall = groupChange
? next.group_id
? mutators.assignGroup({ itemId, data: { group_id: next.group_id } })
: mutators.removeGroup({ itemId })
: null
const [fieldsResult, groupResult] = await Promise.allSettled([
fieldsCall ?? Promise.resolve(null),
groupCall ?? Promise.resolve(null),
])
return {
fields: fieldsCall === null ? 'unchanged'
: fieldsResult.status === 'fulfilled' ? 'saved'
: { error: parseApiError(fieldsResult.reason) },
group: groupCall === null ? 'unchanged'
: groupResult.status === 'fulfilled' ? 'saved'
: { error: parseApiError(groupResult.reason) },
}
}
UX consequences (the planner of every screen that uses this pattern must spec these)¶
- All fulfilled → success toast + navigate / close. Invalidate the detail + list queries once at the end.
- Partial failure → stay on the form. Surface per-sub-resource errors next to the section that owns them — field-level errors inside the relevant fieldset (e.g. an
EAN_ALREADY_IN_USEhighlights the EAN input) and section-level error banners on fieldsets that own a sub-resource (e.g. a 403 on group assignment renders a banner on the Classification fieldset). Do not roll back the successful sub-resources — the BE state has already changed; the user retries only the failed parts, and the form re-diffs from the now-updated baseline. - Toast copy distinguishes three cases: "Saved", "Saved with errors — review highlighted sections", "Failed to save".
Permission gating per sub-resource¶
- If the user lacks the parent-resource update permission, disable the editable fieldsets — the diff will be empty for those, no call fires.
- If the user lacks a sub-resource permission, disable only the relevant control (e.g. the Group select). Save still fires for the sub-resources the user is allowed to modify.
- Hide the Save button only when all sub-resources are denied. Otherwise it submits the calls the user is allowed to make.
Forbidden¶
- Never collapse the diff into a single PATCH that bundles sub-resource fields. The BE rejects unknown fields (
extra="forbid"); even if it didn't, merging sub-resources erases the per-permission and per-audit semantics the BE deliberately split. - Never run the calls sequentially in the happy path. Sequential ordering matters only when sub-resource B depends on sub-resource A having succeeded —
Itemhas no such dependency, so dispatch in parallel.
When this pattern applies¶
Item— fields + group (this module).- Any future aggregate where REST exposes related state behind sub-resource endpoints (
/{id}/<relation>).
A single-resource update (e.g. ItemGroup — fields only, no sub-resource) does not need this pattern. Use the generated mutation hook directly.
State Management Layers¶
| Layer | Tool | What lives here |
|---|---|---|
| Server state | TanStack Query | All API data: lists, details, lookups |
| Client/UI state | Zustand | Sidebar open state, toast queue, theme preference |
| Form state | React Hook Form | Field values, errors, dirty state |
| URL state | TanStack Router search params | Pagination, active filters, active tab |
Rule: if it can live in the URL, it should. URL state gives shareability and back-button support for free.
Zustand is used sparingly. Before reaching for Zustand, ask: can this live in the URL? Can it be local component state? Zustand is for genuinely global state that doesn't belong in the URL (ephemeral UI interactions like which sidebar panel is open).
i18n¶
Three always-loaded namespaces today; per-module namespaces are added when modules ship.
| Namespace | When loaded | Covers |
|---|---|---|
common |
Always | Nav labels, shared action labels, empty states, confirm dialog text, date/unit formatting strings |
identity |
Always | Login, account, role labels (when identity module ships) |
validation |
Always | Zod error messages — consumed by zod-i18n-map, never written manually |
item-catalog |
When item_catalog module ships | Items, item groups, item types |
manufacturing |
When manufacturing module ships | Recipes, BOM, allergens, production orders, kanban |
warehouse |
When warehouse module ships | Stock, goods receipt/issue, LOT codes, FEFO, expiry |
traceability |
When traceability module ships | Genealogy, recall, LOT history |
crm |
When crm module ships | Customers, sales orders, pricing |
invoicing |
When invoicing module ships | Invoices, credit notes |
Key patterns:
// Status / enum values — translate by value key, never by id
t(`itemType.${item.item_type}`)
// Unit names — translate by name with raw string fallback
t(unit.name, { defaultValue: unit.name })
// Semantic action buttons own their translations internally
// Never pass button label text from a parent component
<SaveButton /> // renders "Zapisz" in pl
<CancelButton /> // renders "Anuluj" in pl
<DeleteButton /> // renders "Usuń" in pl
// Page-level text lives in the feature namespace
const { t } = useTranslation('item-catalog')
t('itemList.title') // "Katalog pozycji"
t('itemList.emptyState') // "Brak pozycji"
All Zod validation error messages are translated automatically via zod-i18n-map. Never write validation message strings manually in schema definitions.
Custom Hooks — When to Extract¶
Extract to a hook when: - Logic is reused across 2+ components. - Mutation has side effects worth isolating (cache invalidation, toast notifications, analytics). - Business rules or permissions logic that should be independently testable. - Complex async coordination or derived state. - Form logic with DTO↔form value mapping.
Leave inline when:
- A single useState used only in that component.
- Standard hook calls that are just wiring (useParams, useTranslation, useNavigate).
- Logic that only moves code without adding reusability or testability.
// Good extraction: mutation with side effects
export function useCreateItemMutation() {
const queryClient = useQueryClient()
const { t } = useTranslation('item-catalog')
return useCreateItem({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListItemsQueryKey() })
toast.success(t('item.createSuccess'))
},
},
})
}
// Bad extraction: this doesn't need a hook
// function useDialogState() { return useState(false) } ← just use useState inline
Types — Where They Live¶
| Location | What goes here |
|---|---|
src/generated/ |
All API shapes — always use these, never redefine |
features/<module>/types.ts |
Form shapes (z.infer<>), UI state unions, view models that differ from DTOs |
| Same file as component | Single-component prop types |
Never:
- Redefine types that already exist in generated/.
- Create a types.ts just to move prop types out of their component file.
- Write types manually for anything that can be derived with z.infer<>.
// features/item-catalog/types.ts
export * from '../../generated/schemas/item-catalog' // re-export generated Zod schemas
// FE-only types below:
export const itemFormSchema = z.object({ /* ... */ })
export type ItemFormValues = z.infer<typeof itemFormSchema>
// View model — differs from DTO (adds computed display fields)
export type ItemRowViewModel = {
id: string
code: string
name: string
typeLabel: string // pre-translated for table display
stockSummary: string // computed from cross-module read (Warehouse)
}
Naming Conventions¶
Hooks: Named by what they do, not what they touch.
useItemMutations // ✓ — clear purpose
useItemPermissions // ✓
useItem // ✗ — vague
useData // ✗ — meaningless
Components: Named after the domain concept they represent.
Status/config maps: Module-level constants, not recreated inside components.
// ✓ Module level — created once
const ITEM_TYPE_BADGE_VARIANTS: Record<ItemType, BadgeVariant> = {
ingredient: 'info',
product: 'success',
intermediate: 'warning',
packaging: 'secondary',
consumable: 'secondary',
service: 'outline',
resale: 'info',
}
// ✗ Recreated on every render
function ItemTypeBadge({ type }) {
const map = { ingredient: 'info', ... } // don't
}
Files: PascalCase for components, camelCase for hooks/utils/stores. kebab-case for filenames that map to URL segments (route files mirror URL casing — TanStack convention).
Component Extraction Rules¶
Extract a section of a page when:
- Used in 2+ places.
- Has its own data fetching (its own useQuery).
- Has its own loading/error state.
- Represents a named domain concept (e.g. BOMTree, AllergenMatrix).
Leave inline when: - Single-use layout divs with no logic. - Simple, straightforward markup that would only add indirection.
A page component should read like a table of contents:
export function ItemDetailPage() {
const { itemId } = Route.useParams()
const { data: item, isLoading } = useGetItem(itemId)
if (isLoading) return <DetailSkeleton />
return (
<PageContainer maxW="detail">
<PageHeader title={item.name} />
<div className="grid grid-cols-[1fr_var(--sidebar-col)] gap-6">
<div>
<ItemBasicInfo item={item} />
<ItemGroupInfo item={item} />
<ItemHistoryPanel itemId={itemId} />
</div>
<ItemSidePanel item={item} />
</div>
</PageContainer>
)
}
Do not apply architectural patterns mechanically. Extract logic to hooks when there is a real reason — not because a rule says to. A useState(false) for a local dialog does not need a custom hook.
Environment Configuration¶
Three sources, validated once at startup:
- Build-time env vars — Vite reads
import.meta.env.VITE_*from.env,.env.local, and.env.<mode>. OnlyVITE_*-prefixed vars are exposed to the bundle. - Runtime env (future) — for container deployments where the same build serves multiple environments, a
window.__APP_CONFIG__object injected by the server is the planned escape hatch. Not implemented yet. - Implicit constants — committed to the repo (e.g. supported locales, max-upload-size).
Zod-validate the env at the entry point so a missing or malformed value fails the app boot with a clear error, not a silent undefined later:
// src/lib/env.ts (added in Foundation M0)
import { z } from 'zod'
const envSchema = z.object({
VITE_API_BASE_URL: z.string().default('/api/v1'),
VITE_APP_ENV: z.enum(['development', 'staging', 'production']).default('development'),
VITE_APP_VERSION: z.string().default('0.0.0'),
VITE_OTEL_EXPORTER_OTLP_URL: z.string().default('/otel/v1/traces'),
})
export const env = envSchema.parse(import.meta.env)
VITE_APP_VERSION is injected by CI at build time (from git describe --tags or package.json). Used as the OTel service.version resource attribute and as the release tag for source-map upload — see § Observability.
VITE_OTEL_EXPORTER_OTLP_URL points at the SigNoz collector's OTLP-HTTP endpoint. In dev, defaults to /otel/v1/traces and Vite proxies it; in prod, the env supplies the full URL.
Dev API access — Vite dev server proxies /api/* to the backend's local port (8000 per the grinsystem-api compose config) and /otel/* to the local SigNoz collector (4318), so browser-side URLs are the same in dev and prod:
// vite.config.ts (added in Foundation M0)
export default defineConfig({
server: {
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/otel': { target: 'http://localhost:4318', changeOrigin: true, rewrite: (p) => p.replace(/^\/otel/, '') },
},
},
build: {
sourcemap: 'hidden', // emits maps next to JS without the //# sourceMappingURL comment;
// CI uploads them to SigNoz, browsers don't fetch them.
},
// ...
})
apiClient in src/lib/queryClient.ts uses baseURL: env.VITE_API_BASE_URL (defaults to /api/v1). No need for separate .env.development and .env.production files to switch between absolute and relative URLs.
Never put secrets in VITE_* vars — they ship in the bundle and are visible to anyone with the page open. If a secret is needed (e.g. an analytics write-key), it goes via the BE.
Date, Time and Number Formatting¶
All formatting is locale-aware via Intl.* APIs. Polish locale (pl-PL) uses , as decimal separator and dd.MM.yyyy for dates by default; English (en-US) uses . and MM/dd/yyyy. Never hardcode formats.
Display rules:
| Value type | Storage (BE → wire) | Display (FE) | Component |
|---|---|---|---|
| Date only (e.g. expiry, planned date) | ISO date string "2026-06-15" |
Locale long format: 15 cze 2026 / Jun 15, 2026 |
<DateDisplay> |
| Date + time (e.g. created_at) | ISO 8601 with timezone "2026-06-15T10:00:00Z" |
Local timezone, locale format: 15.06.2026 12:00 (in pl, +2h UTC offset) |
<DateTimeDisplay> |
| Relative (e.g. "x days until expiry") | ISO date | za 73 dni / in 73 days via Intl.RelativeTimeFormat |
<DateDisplay format="relative"> |
| Quantity with unit | Decimal number + unit string | 125,5 kg (pl) / 125.5 kg (en) |
<QuantityDisplay value unit> |
| Plain number (no unit) | Number | Locale-formatted decimal | <NumberDisplay> |
| Currency | Decimal + currency code | 123,45 zł / $123.45 via Intl.NumberFormat({ style: 'currency' }) |
<MoneyDisplay value currency> |
All timestamps display in the user's local timezone, not in UTC. BE returns UTC; FE renders local. On detail pages that need provenance (audit logs, LOT traceability) show a tooltip with the UTC value for debugging.
No raw new Date().toLocaleString() calls in components — always go through the display primitive. This is what keeps the format consistent and makes a future timezone-override or 24h/12h preference a single-file change.
Routes & Navigation¶
Two narrow files, two clear concerns:
src/lib/routes.ts— every URL path in the app, organised by module. Knows about paths and their parameters, nothing else.src/lib/navigation.ts— the curated subset of routes that appear in the AppShell sidebar, with label, icon, group and (later) permission gate.
String-literal route paths in components are forbidden. Always read from routes. The sidebar reads from navigation. Breadcrumb labels are page-driven (set on the route file itself when AppShell lands in GRF-17), not derived from either file.
src/lib/routes.ts — path source of truth¶
One constant per BE module plus sharedRoutes for non-module routes. Static routes are plain strings; parameterised routes are arrow functions.
export const sharedRoutes = {
dashboard: '/',
forbidden: '/forbidden',
} as const
// Example shape a future module ticket will land:
export const itemCatalogRoutes = {
items: '/catalog/items',
itemDetail: (id: string) => `/catalog/items/${id}`,
itemEdit: (id: string) => `/catalog/items/${id}/edit`,
} as const
export const routes = {
shared: sharedRoutes,
identity: identityRoutes,
itemCatalog: itemCatalogRoutes,
// …seven modules
} as const
Paths are not internationalised — URLs stay canonical English. Foundation seeds only sharedRoutes; each module's first ticket fills in its own module constant in place. Module tickets never edit the aggregating routes object or another module's constant.
Access pattern¶
import { Link } from '@tanstack/react-router'
import { routes } from '@/lib/routes'
<Link to={routes.shared.dashboard} />
<Link to={routes.itemCatalog.itemEdit(item.id)} />
navigate({ to: routes.shared.forbidden })
// ❌ don't — string-literal paths drift from the registry
<Link to="/" />
navigate({ to: `/catalog/items/${id}/edit` })
An ESLint rule to mechanically forbid string-literal route paths is deferred. Until it lands this is a doc-enforced rule — call it out in PR review.
src/lib/navigation.ts — sidebar tree¶
A flat NavItem[] array. Order in the array is the order the sidebar renders.
export enum NavGroup {
IDENTITY = 'identity',
ITEM_CATALOG = 'item_catalog',
MANUFACTURING = 'manufacturing',
WAREHOUSE = 'warehouse',
TRACEABILITY = 'traceability',
CRM = 'crm',
INVOICING = 'invoicing',
}
export type NavItem = {
path: string // from routes.*
i18nKey: string // sidebar label
icon: LucideIcon
group?: NavGroup // omitted => ungrouped, rendered at the top
requiredPermission?: Permission // controls sidebar visibility (M4+)
}
export const navigation: NavItem[] = [
{ path: routes.shared.dashboard, i18nKey: 'common:nav.dashboard', icon: Home },
// …module tickets add their entries here
]
| Field | Semantics |
|---|---|
path |
A path from routes — never a literal. |
i18nKey |
i18next key for the link label. Namespaced (common:…, <module>:…). |
icon |
Required LucideIcon — every sidebar entry has one. |
group |
Optional. Omitted ⇒ the item renders ungrouped above the first group header. |
requiredPermission |
Optional sidebar-visibility gate. Cheap UX hide; the hard access gate lives on the route file via <RequirePermission> (lands with identity in M4). |
NavGroup values mirror BE snake_case module identifiers so they round-trip cleanly with backend payloads (e.g. for permission strings). They are independent from the camelCase JS keys used on the routes aggregator.
Not every route belongs in navigation. Detail / edit / parameterised pages (routes.itemCatalog.itemEdit(id)), and auxiliary routes like /forbidden, are reachable via routes but never live in the sidebar.
Registering a new route¶
When a route should be in the sidebar:
- Add the path to the relevant module constant in
routes.ts(e.g.itemCatalogRoutes). - Add a
NavItementry to thenavigationarray innavigation.ts, referencing the path viaroutes.*. - Add the
i18nKeyto the relevant locale namespace (bothen/andpl/). - Wire the route file under
src/routes/to the same path.
When a route should not be in the sidebar (detail page, edit page, modal-only route):
- Add the path / builder to the relevant module constant in
routes.ts. - Wire the route file. Skip
navigation.tsentirely.
Permissions¶
NavItem.requiredPermission is the future hook for hiding sidebar entries the user can't reach (cheap UX). The hard access gate is <RequirePermission> on the route file itself (see § Permission-Gated UI). The two are different concerns and can coexist. Until the identity module ships (M4), Permission is a local placeholder type Permission = string in navigation.ts carrying a TODO(GRF M4 / identity) to swap it for the real import from src/auth/permissions.ts.
Back navigation¶
Any "Back" or "Back to X" button on a child page MUST hardcode the navigation to its hierarchical parent via routes.* — navigate({ to: routes.itemCatalog.itemList }) for a detail page's back, navigate({ to: routes.itemCatalog.itemDetail(itemId) }) for an edit page's back, and so on.
// ✅ predictable — always lands on the parent, independent of how the user arrived
const handleBack = () => {
void navigate({ to: routes.itemCatalog.itemList })
}
// ❌ don't — `router.history.back()` is unreliable
const handleBack = () => {
if (router.history.length > 1) {
router.history.back()
} else {
router.navigate({ to: routes.itemCatalog.itemList })
}
}
Why hardcoded, not router.history.back():
- Browser pre-existing history.
router.history.lengthreflects the browser session's full stack — entries from before the user entered the app count, so alength > 1fallback can send the user toabout:blankor an unrelated tab origin. replace: truerewrites the current entry. Sibling guards (e.g. the system-item redirect onItemEditView) replace/editwith/detail. After that,back()skips the entry the user thinks they came from — feeling like a bounce or a loop.- Direct URL / deep links / bookmarks. When the user pastes a detail URL, there is no in-app prior entry.
back()either does nothing or escapes the app entirely. - Predictable parent-child semantics. A "Back to list" button that goes to the list every time is what the user expects from breadcrumbs. Preserving the user's previous list scroll / filters is the URL's job, not the history stack's — module list pages already keep filters / page in the URL (see § URL as Source of Truth (lists)), so navigating to
/catalogrebuilds the same view.
Convention for every page:
| Page type | Back target |
|---|---|
Detail page (.../items/$itemId) |
Module list (routes.itemCatalog.itemList) |
Edit page (.../items/$itemId/edit) |
Detail page (routes.itemCatalog.itemDetail(itemId)) |
Create page (.../items/new) |
Module list |
| Nested child page | Its hierarchical parent |
Use useNavigate() from @tanstack/react-router (preferred for onClick handlers) or a <Link to={routes.*}> for a true link (preserves middle-click / ⌘-click / preload behaviour). Don't import useRouter just for back navigation.
Code-Splitting and Lazy Routes¶
Each module's route subtree is lazy-loaded so the initial bundle stays small. TanStack Router supports per-route lazy imports natively — use them by default for any non-trivial route.
// src/routes/_auth/catalog/items/index.tsx
import { createFileRoute, lazyRouteComponent } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/catalog/items/')({
validateSearch: itemListSearchSchema,
component: lazyRouteComponent(() => import('../../../../features/item-catalog/pages/ItemListPage')),
})
Default split granularity: per route, not per module. Each list/detail/edit page is its own chunk. Vite handles the rest.
Exceptions (eager-load):
- __root.tsx, _auth.tsx, _auth/index.tsx (dashboard) — visible on first navigation.
- login.tsx — unauthenticated entry point.
File Uploads¶
The first uploader to ship is the Item Catalog Excel import (IC-UC-06). Pattern is shared across future modules.
Use Orval-generated mutation hooks for multipart/form-data endpoints — Orval emits typed wrappers for FormData inputs given the right OpenAPI annotations. No bespoke fetch wrapper.
// usage of generated useImportItems mutation
const file: File = ...
const form = new FormData()
form.append('file', file)
await mutateAsync({ data: form })
Validation rules belong on the BE; the FE pre-rejects only what's trivially client-determinable (file extension, size limit) before posting. BE returns the full row-level error list per IC-UC-06's all-or-nothing contract; the FE renders that list in a dedicated error panel — never silently truncate.
Progress indication — TanStack Query's mutation.isPending is sufficient for the import case (the user waits on a single submit). For large-file or streaming uploads (not currently in scope), an onUploadProgress callback via axios is the escape hatch.
Observability¶
Stack: OpenTelemetry JS SDK → OTLP-HTTP → self-hosted SigNoz. Same backend as the BE. The OTel fetch instrumentation propagates W3C trace context (traceparent header) on every API call, so a user click → API call → BE handler → DB query appears as one continuous trace in SigNoz.
What gets captured¶
| Signal | How |
|---|---|
| Traces — page loads | @opentelemetry/instrumentation-document-load (auto). |
| Traces — API calls | @opentelemetry/instrumentation-fetch (auto). Spans every fetch with method, URL, status code; propagates trace context to BE. |
| Traces — user interactions | @opentelemetry/instrumentation-user-interaction (auto). Spans click / submit, with target element attributes. TODO: PII scrubber for target attributes — Polish national IDs (pesel, nip), customer names etc. can appear in aria-labels or button text. Land a scrubber in Foundation M4 before any non-dev environment. |
| Traces — custom | tracer.startActiveSpan(...) for in-app work worth measuring (BOM traversal, large form validation). |
| Metrics — Web Vitals | web-vitals library bridged → OTel histograms: LCP, INP, CLS, FCP, TTFB. |
| Metrics — custom | OTel counters / histograms declared as module-level singletons. Use sparingly — most things belong in spans. |
| Errors | Top-level React error boundary + window.onerror + unhandledrejection → span.recordException(err) on the active span, with a synthetic span created if none active. Source-mapped in SigNoz. |
| Logs | Not emitted from the FE. OTel JS logs API is still experimental, and "logs" on the FE are better modelled as span events (span.addEvent('...', { ... })) so they stay attached to the user's action. |
Setup (Foundation M4)¶
One file, run once at app entry before any other init:
// src/lib/observability.ts — pseudo-code; lands in Foundation M4
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'
import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction'
import { Resource } from '@opentelemetry/resources'
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'
import { env } from './env'
const provider = new WebTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'grinsystem-front',
[SemanticResourceAttributes.SERVICE_VERSION]: env.VITE_APP_VERSION,
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: env.VITE_APP_ENV,
}),
})
provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({
url: env.VITE_OTEL_EXPORTER_OTLP_URL, // e.g. http://localhost:4318/v1/traces in dev
})))
provider.register()
registerInstrumentations({
instrumentations: [
new DocumentLoadInstrumentation(),
new FetchInstrumentation({
// Attach W3C trace context to API calls so BE picks up the same trace.
propagateTraceHeaderCorsUrls: [/.*\/api\/.*/],
}),
new UserInteractionInstrumentation({
// TODO (PII): allowlist attributes; for now defaults include id/class/aria-label.
}),
],
})
A parallel MeterProvider + OTLPMetricExporter is wired the same way for the Web Vitals bridge.
Recording errors¶
// src/app/ErrorBoundary.tsx — pseudo-code
import { trace } from '@opentelemetry/api'
class AppErrorBoundary extends React.Component {
componentDidCatch(error: Error, info: React.ErrorInfo) {
const span = trace.getActiveSpan() ?? trace.getTracer('grinsystem-front').startSpan('react.error')
span.recordException(error)
span.setAttribute('react.componentStack', info.componentStack ?? '')
span.setStatus({ code: 2 /* ERROR */ })
if (!trace.getActiveSpan()) span.end()
}
// ...
}
Same pattern in main.tsx for window.addEventListener('error', ...) and 'unhandledrejection'.
Emitting a custom span¶
import { trace } from '@opentelemetry/api'
const tracer = trace.getTracer('grinsystem-front')
export async function calculateBomTotals(bom: BOMNode) {
return tracer.startActiveSpan('manufacturing.bom.calculate', async (span) => {
try {
span.setAttribute('bom.depth', getDepth(bom))
const result = await doWork(bom)
span.setAttribute('bom.lines', result.lineCount)
return result
} catch (e) {
span.recordException(e)
span.setStatus({ code: 2 })
throw e
} finally {
span.end()
}
})
}
Span naming: <module>.<noun>.<verb> — mirrors BE conventions (e.g. manufacturing.calculate_allergens).
Sampling¶
- Dev (
VITE_APP_ENV=development): 100% sample. All traces forwarded. Pair with a small dashboard for "watch what the SDK is sending" while developing. - Staging / production: TBD when those environments exist. Strategy: head-based 10–20% baseline with always-sample for errors and 4xx/5xx — implemented at the SigNoz collector via tail sampling, not at the SDK, so the client doesn't decide.
Source maps¶
Vite is configured with build.sourcemap: 'hidden' for production builds:
- Maps are emitted next to JS files (dist/assets/*.js.map).
- The //# sourceMappingURL= comment is omitted from the JS so browsers don't try to fetch them.
- A CI step uploads each release's maps to SigNoz, tagged with VITE_APP_VERSION. SigNoz uses them server-side to symbolicate stack traces in error spans.
Dev environment doesn't need this — Vite's HMR shows real source in the browser console and SigNoz isn't in the loop.
The CI upload step is deferred until the first non-dev environment exists (no point uploading maps when no one's looking at SigNoz for prod errors yet).
Collector reachability¶
SigNoz's OTLP-HTTP endpoint (/v1/traces, /v1/metrics) is exposed publicly with CORS allowlisted to the FE origin so the browser can post directly. No auth on the dev endpoint; future non-dev endpoints add a token via OTLPTraceExporter({ headers: { ... } }).
In dev, Vite proxies /otel/* → http://localhost:4318/* (the local SigNoz collector) so the FE never needs to know the collector's host name.
What NOT to put in spans / events¶
Same deny-list as BE — never as span attributes, event attributes, or exception messages:
- Passwords, tokens, JWTs, cookies, session IDs.
- Polish national IDs: pesel, nip, regon.
- Customer names, addresses, phone numbers, emails (unless the test environment).
- Full request / response bodies.
Until the PII scrubber lands, be deliberate about what you put in setAttribute calls.
Responsive¶
Source of truth: responsive-strategy.md. Visual tokens (breakpoints, touch targets, tooltip-on-touch) in design-system.md § Responsive design. Per-primitive contracts (AppShell, Sidebar, Dialog, PageHeader, FilterBar, DataTable↔DataCardList) in architecture.md § Responsive contracts. This section is the standards-level contract — what every module plan and every page ticket must declare and uphold.
Three-bucket rubric¶
Every page falls into exactly one bucket. The bucket is declared in pages.md (per-module plan), inherited into the implementing Linear ticket's AC, and verified in the PR.
| Bucket | Definition | Examples |
|---|---|---|
| Excellent on phone | First-class at <sm. Same data, same actions as desktop — only the layout adapts. |
Read views, dashboards, list pages (rendered as cards at <md), search, single-record detail, quick-create forms with ≤ 8 fields. |
| Workable on phone | Functions at <sm, but slower and more scroll-heavy than desktop. Not optimised — owners can complete the task if they have to. |
Long forms (9–30 fields), table-heavy admin pages, bulk operations. |
| Soft-gated on phone | Renders <DesktopOnly> at <sm — polite "use a larger screen" card with a "continue anyway" link that flips a route-scoped flag. |
Drag-and-drop builders, side-by-side comparisons, screens that need ≥ 2 columns of structured content. |
Tablet rule¶
Every screen at ≥ md (768px+) renders with full feature parity. There is no "tablet mode" — <DesktopOnly> soft-gates apply only at <sm. If a screen needs adjustment at the md–lg band, that's layout (sidebar drawer vs rail, multi-column detail collapsing to single), never feature gating.
Module-plan contract¶
Every module's pages.md must include a per-page bucket table directly after the existing screen list. frontend-module-planner refuses to produce a complete pages.md if this table is empty.
## Responsive buckets
| Page | Phone bucket | Tablet bucket | Notes |
| ----------------- | --------------------- | ------------- | ---------------------------------------------------- |
| ItemListPage | Excellent on phone | Excellent | DataTable swaps to DataCardList at < md. |
| ItemDetailPage | Excellent on phone | Excellent | Right rail (stock summary) collapses to a section. |
| ItemCreatePage | Workable on phone | Excellent | 12-field form; phone scroll length acknowledged. |
| ItemImportPage | Soft-gated on phone | Excellent | Excel-grid preview needs ≥ 2 columns of context. |
Notes column is required when the bucket is Soft-gated or Workable — it documents why the page can't reach Excellent. For Excellent buckets, Notes captures any non-obvious swap (e.g. "rail collapses to section").
Implementation contract¶
Every page-implementing Linear ticket carries:
- Its bucket in the acceptance criteria. Verbatim:
- [ ] Responsive bucket: {Excellent | Workable | Soft-gated} on phone. - Use of the responsive primitives named in
architecture.md§ Responsive contracts — no hand-rolled breakpoint logic in feature code. Pages that swapDataTable↔DataCardListconsumeuseBreakpoint(); everything else adapts via Tailwindmd:/lg:prefixes inside the primitives themselves. - Tested at
<smand≥ mdduring PR self-review. ForWorkable/Soft-gatedbuckets, the PR Notes-for-reviewer call out which compromises were accepted.
PRs that introduce a new page without a declared bucket are blocked at review.
Authentication / Authorization (deferred)¶
The BE currently ships a dev-stub get_current_user that returns a fixed user. All routes are open in dev; no FE auth wiring exists yet and none is added speculatively.
When the BE identity module ships, the FE will add:
- Axios request interceptor attaching Authorization: Bearer <jwt> (in src/lib/queryClient.ts).
- usePermission(Permission.X) hook reading permission claims from the decoded JWT.
- <RequirePermission permission={Permission.X}> route gate (see § Permission-Gated UI).
- <PermissionGate permission={Permission.X}> control-level wrapper (see § Permission-Gated UI).
- Mapping 403 FORBIDDEN_INSUFFICIENT_PERMISSION to a forbidden UI.
Authorization is permission-based on the BE — never role-based. When FE gating arrives it will gate on Permission.X values, never on raw role names. BE convention reference: grinsystem-api/docs/architecture/conventions.md § Authorization.
Permission-Gated UI¶
Two layers of permission gating; both consume the same usePermission(Permission.X) hook (deferred — ships with identity).
| Scope | Mechanism | If the user lacks permission |
|---|---|---|
| Whole route / page | <RequirePermission permission={Permission.X}> wrapping the route element |
Navigate away or render a forbidden screen. The user never sees the page. |
| Control inside a viewable page (button, menu item, row action) | <PermissionGate permission={Permission.X}> wrapper, or requiredPermission prop on a semantic button |
Default: render the control disabled + tooltip "You don't have permission for this action." |
Default is disabled + tooltip, not hidden. Rationale: - Users discover what the system can do and ask their admin for access, instead of believing the feature doesn't exist. - UI is consistent across roles — no "where did that button go?" tickets when permissions change. - Defence in depth: the 403 envelope still surfaces if a permission flips server-side between page-load and click.
Escape hatch — hideWhenDenied. A small set of admin-only destructive actions (e.g. "force-delete tenant", "promote user to admin") are pure noise to non-admins. Opt into hiding with the prop:
<PermissionGate permission={Permission.TENANT_FORCE_DELETE} hideWhenDenied>
<DeleteButton onClick={onForceDelete} />
</PermissionGate>
This is the exception, not the rule. Every use of hideWhenDenied should be justifiable in a code-review one-liner.
Component contract (lands with identity module)¶
<PermissionGate> sits in components/common/ (Layer 2 — app-aware, no domain specifics):
// src/components/common/PermissionGate.tsx — pseudo, ships with identity
type Props = {
permission: Permission | Permission[] // array = all required (AND)
hideWhenDenied?: boolean // default false → disabled + tooltip
children: React.ReactElement // single clickable child
}
export function PermissionGate({ permission, hideWhenDenied, children }: Props) {
const allowed = usePermission(permission)
if (allowed) return children
if (hideWhenDenied) return null
return (
<Tooltip>
<TooltipTrigger asChild>
{React.cloneElement(children, {
disabled: true,
'aria-disabled': true,
onClick: undefined, // belt-and-braces — stop click client-side
})}
</TooltipTrigger>
<TooltipContent>{t('common:errors.insufficientPermission')}</TooltipContent>
</Tooltip>
)
}
Semantic buttons (SaveButton, DeleteButton, …) accept an optional requiredPermission?: Permission prop and delegate to <PermissionGate> internally, so call sites stay one-liners:
The _loading and _denied states compose — a button can be both saving and denied; the denied tooltip wins (the user can't fire it anyway).
Disabled visuals — pointer-events stay on¶
The shadcn default of disabled:pointer-events-none does not work for the denied state — without pointer events the tooltip never fires. The disabled-by-permission styling is:
opacity-50 cursor-not-allowed
/* pointer-events: auto (default) — needed so the tooltip can hover-trigger */
disabled:pointer-events-none stays on for plain disabled (e.g. form not yet valid); the gate replaces it for the permission-denied case. See docs/design-system.md § Disabled states.
Row-level actions¶
Disabled + tooltip per row, same rule. Don't hide individual rows' actions — a jagged table is worse than a few greyed-out icons. If an entire row should be unactionable, disable all its actions; if entire rows shouldn't be in the list, filter them out server-side.
Worked example — list page with row actions¶
// features/item-catalog/components/ItemListRowActions.tsx
export function ItemListRowActions({ item }: { item: Item }) {
return (
<div className="flex gap-1 justify-end">
<EditButton
requiredPermission={Permission.ITEM_UPDATE}
onClick={() => navigate({ to: '/catalog/items/$itemId/edit', params: { itemId: item.id } })}
/>
<DeleteButton
requiredPermission={Permission.ITEM_DELETE}
onClick={() => confirmDelete(item)}
/>
</div>
)
}
If the user has ITEM_UPDATE but not ITEM_DELETE, the Edit icon is live and the Delete icon is greyed with a tooltip on hover. No bespoke logic per page.
i18n key¶
One key, owned by common, reused everywhere:
| Key | en | pl |
|---|---|---|
common:errors.insufficientPermission |
"You don't have permission for this action." | "Brak uprawnień do tej akcji." |
Don't write module-specific variants.
Don't do this¶
- ❌
if (!canDelete) return nullad-hoc in a page component — bypasses the contract and creates inconsistency. - ❌ Letting the click hit BE and surfacing a 403 toast as the only feedback — wastes a round-trip and surprises the user.
- ❌ Hiding controls because "it's cleaner" — opt-in only via
hideWhenDenied, justified per use. - ❌ Gating on role names (
user.role === 'admin') — authorization is permission-based, always.