# Frontend Decisions & Gotchas Non-obvious choices in the web app and the pitfalls behind them, one short section each. Grep for the symbol you are touching. The bar for a new entry is in `AGENTS.md`. ## Dive/trip/dive-site API calls take `uuid` strings, not `username`/numeric ids `divesAPI`/`tripsAPI`/`diveSitesAPI` in `lib/api/*.ts` identify resources by string `uuid`, never by `username` or a numeric `id`, matching the API's flat routes (`/dive`, `/dives`, `/dive/{id}`). - No request names its owner. A create call (`createDive`/`createTrip`/`createDiveSite`) takes the full request object as a single argument and puts no `user_uuid` in it; a list call (`getDives`/`getTrips`/`getDiveSites`) takes its page, size and filters and sends no `user_uuid` query param. The bearer token scopes both, so there is no owner id to pass — and none to thread down a prop chain to reach a request with. - Single-resource calls (`getDive`/`updateDive`/`deleteDive` and the trip/dive-site equivalents) take only the resource `uuid`; the backend authorizes by comparing the fetched object's owner to the caller. `authAPI.updateProfile` and `diveStatsAPI.getDiveStats` are the same rule on the current-user routes — see "Current-user endpoints live on a bare `/user`, not `/user/me`/`/user/{uuid}`" below. ## Never use `z.preprocess()`/`.transform()` on fields feeding `z.input<>`-derived types Form pages derive their form-data type from Zod via `z.input` (`DiveCreateInput`, `DiveUpdateInput`). `.preprocess()`/`.transform()` collapse the input type to `unknown` or to the output type, which breaks `useForm()`'s binding to `onSubmit` with "two different types with this name exist, but they are unrelated" errors — and only during a full `next build`, not under `diagnostics`/editor type-checking. Keep each schema's input and output types identical (`z.union([z.literal(""), z.number()])`, no `.transform()`) and do real conversion (`"" -> undefined`) in a plain TS helper called right before the API call (`normalizeMixtures`, `normalizeTripParts` in `lib/validations/*.ts`). Always run `npm run build` after touching a form-bound Zod schema; `diagnostics` alone misses this class of bug. ## The "cleared field resets to default" React Hook Form quirk Setting a live RHF field value to `undefined` mid-edit makes RHF display that field's default value; it cannot distinguish "explicitly cleared" from "never set". This affects every optional numeric/date field with a non-empty-string default. Use the empty string `""` as the live "cleared" sentinel, never `undefined`, and convert `"" -> undefined` only in a normalize helper called right before the API call. `diveMixtureSchema.start_pressure/end_pressure` (numeric) and a trip part's `start_date`/`end_date` in `tripFormSchema` (date-string) are the reference call sites. ## Explicit field construction beats spread-then-override for generics `normalizeMixtures()` lists every output field explicitly rather than `{ ...mixture, start_pressure: ... }` over a generic `mixture` parameter. With a generic source type TypeScript does not reliably narrow the overridden property away from the original wider union, so the `""` placeholder type leaks into the inferred return type and breaks assignability to the API's `DiveMixture[]`. No spread and no generics is the default for any "normalize before submit" helper. ## Bare `YYYY-MM-DD` dates must not go through `new Date(dateString)` `new Date("2024-06-01")` parses as UTC midnight, which a negative-UTC-offset timezone (most of the Americas) displays as the previous day. Every date-only field (a trip part's `start_date`/`end_date`, a course's) is formatted via `formatDateOnly()`/`formatTripDateRange()` in `lib/date-time.ts`, which split the string and construct a local `Date(year, month-1, day)`. `Dive.start_time` is usually a full ISO datetime and has no off-by-one-day problem, but displaying and editing it still cannot go through `new Date(dateString)` and local getters — see the next section. A bare date there is its own state — see "A bare-date `start_time` is a fourth state, and never midnight". ## A dive's `start_time` displays/edits in its own timezone, never the browser's `Dive.start_time` is an ISO 8601 string whose offset, if any, is the dive's own timezone, not the viewer's (an imported dive may carry none — see "An unknown UTC offset is a third state, and `new Date()` never sees an offset-less string"). `new Date(start_time)` with `.getHours()`/`toLocaleString()` converts to the browser's zone. `lib/date-time.ts` uses the embedded offset: - `parseUtcOffsetMinutes()` extracts it, or `null` for a naive string. - `formatDiveDateTime()`/`formatDiveTimeOnly()` shift the instant by that offset and format with `timeZone: "UTC"`; a `null` offset parses the string as UTC. - `splitStartTime()`/`combineStartTime()` convert between the string and a `"YYYY-MM-DD HH:mm:ss"` string plus offset minutes or `null`. `formatDateTime()` shows browser-local time: for `created_at`, never `start_time`. The form has one `start_time` field; `DiveStartTimeField` (`components/dives/dive-start-time-field.tsx`) alone calls `splitStartTime()`/`combineStartTime()`, around `DateTimePicker` + `UtcOffsetSelect`. New dives use `nowStartTime()` (`getBrowserUtcOffsetMinutes()`); `normalizeParsedStartTime()` keeps an imported file's offset and falls back to the browser's for naive ones. ## Date fields are typed into, and settle only when the field is left Where a pointer hovers, every date field is a text box with a calendar button in it (`ui/date-picker.tsx`, `ui/date-time-picker.tsx`); `lib/date-input.ts` reads it. Separators are loose (`2024/6/1`, `20240601`), the order always year-first: `01/06/2024` is two different days depending on who typed it. `parseDateTimeInput()` refuses a trailing `Z` or `+02:00`, since the offset belongs to `UtcOffsetSelect` and dropping a pasted one moves the dive in silence. The box holds a draft and commits on blur or Enter, never per keystroke, because a half-typed date passes through other real ones. Unparseable text is discarded; a date typed without a time takes the time the popover holds, as a picked one does. Focus opens the calendar, which follows the draft as typed; `onClick` reopens it, since `focus` does not fire on a focused input. Only the icon button hands the grid focus, or the first keystroke lands on a day cell. ## Touch devices get the OS date pickers, chosen by pointer `DatePicker` and `DateTimePicker` render native ``/`"time"` where `useCoarsePointer` - `(hover: none) and (pointer: coarse)` - says a finger is driving, and the text box and calendar elsewhere. Pointer, not viewport: a tablet at desktop width is still a finger. Its server snapshot is `false`, so the desktop branch renders server-side and a phone switches after hydration; rendering both would put two controls under one `FormControl` id. `DatePicker` keeps its clear button: the iOS picker has none. The date-time field is two inputs, not `datetime-local`, which Android chains two dialogs for and iOS strips the seconds from when only the date changes. A date change rewrites the date alone, so an imported dive keeps its seconds; a time change writes `HH:mm:00`, since no wheel offers seconds. ## On touch, every control is 44px to a finger The `touch:` variant in `globals.css` asks the same query as `useCoarsePointer`. Under it a control takes 44×44 CSS px, in one of two ways: - Buttons, fields and menu rows grow. `Button`, `Input` and `NativeSelect` take `touch:min-h-11`, a floor, so a caller's `h-7` shrinks a button for a mouse only; menu rows take `touch:py-3`. `--header-height` and `--card-title-lift` follow the 44px button. - A compact control in a larger shape, or a link standing on its own line, keeps its look and takes `touch:tap-target`, a centred 44px `::after`. The element must be positioned and its area must stay off its neighbours: a positioned pseudo-element paints over an unpositioned field. A link inside running text keeps its size. The unit toggle's area stops at its field, 34px tall. Rejected: a width breakpoint, which misses a tablet, and growing compact controls, which breaks the shapes they sit in. ## A finger types numbers on a keypad, with either separator Under `useCoarsePointer`, `UnitNumberInput` and `DecimalInput` put a value that cannot go below zero - depth, pressure, visibility, a gas percentage - in `type="text"` on `inputMode="decimal"`, or `"numeric"` for a whole number. A signed value (temperature, altitude, a coordinate) and a duration keep the full keyboard: iOS's keypads have no "-" or ":" and no way to switch. Text, not `type="number"`: the decimal keypad types the region's separator, and WebKit's number input drops a comma, so "1,5" arrives as 15. `parseDecimal` reads either separator, never both. Text has no `min`/`max`, so `decimalEntryMessage` sets the bounds as custom validity, which the submit refusal reads. Plain whole-number fields keep `type="number"` with `inputMode="numeric"`: no separator to lose. A mouse keeps `type="number"`: a desktop keyboard has a point key. Contacts and people set `autoComplete="off"`: autofill would offer the diver's own details. Rejected: `inputMode="decimal"` on `type="number"`, which shows the comma and still drops it. ## FastAPI 422 errors can be an array, not a string - never render `detail` directly Pydantic validation errors return `detail` as an array of `{type, loc, msg, input}` objects; other errors (duplicate name, etc.) return a plain string. Rendering the array in JSX crashes React ("objects are not valid as a React child"). Every error-handling call site goes through `getApiErrorMessage()` (`lib/api/error.ts`), which normalizes both shapes into a displayable string; never read `error.response?.data?.detail` directly. ## The generic `CreatableCombobox` pattern `components/ui/creatable-combobox.tsx` is a generic "pick existing or create on the fly" combobox. `TripCombobox` is a single-select wrapper supplying the fetch/create calls. `GearItemMultiSelect` wraps it for several items per dive: `CreatableCombobox` is the "add an item" input, always rendered with `value={undefined}` so it clears after each pick, above a reorderable list of added items. `SpeciesMultiSelect` follows the same shape. Wrap `CreatableCombobox` this way for any further "pick or create" entity rather than copying the interaction logic (filtering, commit-on-blur/Enter, mouse-down-prevents-blur for option clicks). Read "The species picker resolves a pick into a catalog row before form state sees it" before writing the next wrapper: it is the only one whose pick needs a round trip before it has a value, and what follows from that is not visible here. ## Every picker of the diver's records searches its lookup Pickers of trips, contacts, people, courses and gear search `//lookup` through `CreatableCombobox`'s `onSearch`: thin rows ranked by last use on a dive at or before `until`, the edited record's date as its form holds it, or none. Remote rows are never re-filtered or re-sorted. The dive form's site field opens the site finder, which searches `GET /dive-sites/search`: the sites nearest the camera or, with none, the diver's by last use at or before `until`, as the lookup orders them. Its one client-side drop is a site the dive already names. The delete dialog's reassign picker and the review screen's matches still search `/dive-sites/lookup`. A label comes from a `known*` prop, a lookup row, or a per-uuid read fired once with no `cancelled` flag (`useLookupLabels`): under StrictMode the request in flight is the discarded mount's. A host needing the whole record reads it on pick. The dive form lists its trip's and course's people, and the course's contact, first and unlabelled: a heading would teach the combobox unselectable rows. A pin the lookup matched by username or city stays where the lookup put it. ## Dive time is a free-typed, regex-validated "MM" or "MM:SS" string in the form `Dive.submerged_time` is seconds on the API types (`Dive`/`DiveCreate`/`DiveUpdate`); the form field holds a plain string (`"45"`, `"45:30"`) — see `dateTimeField()`/`durationField()` in `lib/validations/dive.ts` (no `.transform()`). `durationField()`'s regex (`^\d{1,3}(?::[0-5]\d)?$`) is the only validation, via ``, with no live reformatting. The seconds half is optional: dive computers report whole minutes. A colonless value is minutes (`"130"` is 130 minutes, never 1:30), so the minutes half is capped at three digits and a mistyped `"1030"` is refused, not stored as a 17-hour dive. Seconds convert right before submit and after fetch via `parseFormDuration()`/`formatDurationForForm()` in `lib/date-time.ts`; a missing seconds half parses as zero, and the formatter always writes `"MM:SS"`. Reuse them elsewhere; a "total minutes" number input loses sub-minute precision. The `` is a bare `Dive time`, the word on the diver's wrist; the key stays `submerged_time`, which nobody reads as end minus start. Two prose strings move with `DURATION_REGEX`: the placeholder in `dive-form-fields.tsx` and `durationField()`'s default message; a test pins the message, the placeholder has no guard. ## Occasional `.next` cache corruption during builds `npm run build` intermittently fails with errors unrelated to the diff (`ENOENT` on `.nft.json` trace files, `Cannot find module for page: /some-route`) while TypeScript and lint pass. Remedy: `rm -rf .next && npm run build`. Try it before assuming the code is broken whenever a build failure does not match the change just made. A running dev server is not a cause and does not have to be stopped: `next dev` writes its output under `.next/dev/` and a build writes beside it, so the two share no cache. The remedy clears both, and a dev server recompiles on the next request. ## Soft navigations are timed against a production build, on its own port `scripts/measure-navigations.mjs` times a click — when the RSC round trip ends, when `
` first changes, when the destination's data lands — and needs `npm run build` rather than `npm run dev`. Prefetching is a production-only behaviour, so a dev measurement describes a navigation the app never performs. The build serves itself on `:3002`, clear of the map renderer's `:3001`, and talks to the API same-origin: `NEXT_PUBLIC_API_URL= npm run build`, then `API_INTERNAL_URL=http://localhost:8000 npx next start -p 3002`. _Rejected:_ the default split-origin build, whose baked API base sends every request from `:3002` at an origin the API's CORS allowlist does not name; and copying a `.env` into a worktree, which bakes that base back in. The commands, the columns and the method for timing the same hop against the flagship are in the script's header. ## Layout width convention Every page inside the shared chrome puts its content in one `max-w-6xl` column - `HERO_BODY` in `ui/map-hero.tsx` under a hero, `PAGE_COLUMN` where there is none - and the header and footer share that column, so their edges line up with the content's. The landing page (`/`), built from full-bleed alternating sections, is the exemption, and so are the dive "new"/"edit" forms, whose hero and body share one centred `max-w-2xl` column (`FORM_COLUMN`). This is a content-width choice only; `Header`/`Footer` come from `AppShell` (next section). ## `Header`/`Footer` live once in `AppShell`, not per-page `components/layout/app-shell.tsx`, mounted near the root of `app/layout.tsx`, renders `Header`/`Footer` around `children` for every route except the hardcoded `NO_CHROME_ROUTES` list. Rendering them per page duplicates the chrome JSX and unmounts/remounts `Header`/`Footer` on every navigation (visible jank, header-local state reset), so pages never import them. `Header` takes no `currentPage`/`showDashboardActions` props; it calls `usePathname()` and derives the active nav item by matching the path against each `href` in `NAV_ITEMS` in `header.tsx`. A new top-level destination is an entry there, with its tier. A route that needs no chrome goes into `NO_CHROME_ROUTES` in `app-shell.tsx` — there is no per-page opt-out — and renders `layout/standalone-shell.tsx` rather than a hand-rolled centered card: `AppShell` holds the app's `
`, and that component holds the chrome-free half's. See "The chrome-free routes share `standalone-shell.tsx`, which carries their one `
`". ## The header sorts its destinations by use The bar carries Home, then the most used record pages in usage order. `More ▾`, last in the row, carries the other record pages, then Admin for a superuser. The account menu carries the account, with Check-in, Import and Export; "+" carries creation, import included. Below `md` the burger lists everything in the same order, ruled into Home, the logbook, the other record pages and Admin. Tiers are breakpoints, not measurement: an `lg` item is `hidden lg:inline-flex` in the bar and `lg:hidden` in More, so the row needs no `ResizeObserver` and never reflows after paint. More stays at every width rather than giving way to a tier where everything fits, so the bar's set changes only at `lg`. More rather than the burger is the overflow at `md` and up because a burger beside a visible row hides which pages are missing from it; a labelled dropdown in the row says so. ## Shared list-page pattern: `useAuthGuard` + `useInfiniteResource` + `useDeleteResource` The dives/trips/dive-sites list pages (`components/dives/dives-page-content.tsx`, `components/trips/trips-page-content.tsx`, `components/sites/sites-page-content.tsx`) share three hooks and two components; any new deletable-resource list reuses them: - `hooks/useAuthGuard.ts` redirects to `/signin` once the auth check settles signed-out (`useRedirectIfAuthenticated` mirrors it for public-only pages). - `hooks/useInfiniteResource.ts` takes a `(page, perPage) => Promise<{data, total_count, has_more}>` fetcher and a `keyOf`, returning `items`/`isLoading`/`isLoadingMore`/`totalCount`/`hasMore`/`loadFailed`/`loadMore`/`reload`/`removeItem`/`applySaved`. Pages accumulate. Pair with `components/ui/load-more-trigger.tsx` (``), whose button doubles as the scroll sentinel and hides on a single page. - `hooks/useDeleteResource.ts` takes a `(id) => Promise<...>` delete function and returns `deletingId`/`pendingId`/`requestDelete`/`cancelDelete`/`confirmDelete`. Pair with `components/ui/confirm-dialog.tsx` (``), not the native `confirm()`, which freezes the tab. Wire `useInfiniteResource`'s `removeItem` as `useDeleteResource`'s `onDeleted` so the row vanishes without the list collapsing to page one; `reload` is for a delete that changes other rows. Why `removeItem` rather than a refetch, and why the cursor moves with it, is under "The lists load on scroll, and a delete leaves the one you are reading in place". ## Access token lives in memory only, never in `localStorage` Anything running on the page (XSS, a compromised dependency, a browser extension, an error-reporting SDK serializing storage) can read `localStorage`, so `lib/api/client.ts` holds `access_token` in a module-scoped variable exposed via `getAccessToken`/`setAccessToken`/`clearAccessToken` — not React state, since the request interceptor needs the latest value at request time, not a re-render. The refresh token is already an `httponly`, `secure`, `samesite=lax` cookie set by the API (`login.py`). The token does not survive a reload, so `AuthContext`'s bootstrap effect calls `refreshAccessToken()` (POST `/auth/refresh`, re-deriving a token from the cookie) on every mount. `authAPI.isAuthenticated()` only says whether this tab holds a token right now; use `refreshAccessToken()` to learn whether a session exists. This defeats passive exfiltration only. A live XSS payload can call `/auth/refresh` itself while the page is open; escaping and the CSP (below) close that gap, not storage choice. ## `NEXT_PUBLIC_API_URL` is the full base, `/api/v1` included - not an origin The variable goes straight to axios as `baseURL` in `lib/api/client.ts`; `lib/api/*.ts` modules pass route-relative paths on top. The API mounts everything under `/api/v1` (`APIRouter(prefix="/api")` + `APIRouter(prefix="/v1")`, not configurable), so the prefix belongs in the variable; an origin-only value 404s every request. Appending the prefix in `client.ts` instead is rejected: a reverse proxy mounting the API under a subpath has nowhere else to say so, and `src/proxy.ts` is written around a value carrying a path. Two other readers: `src/proxy.ts` needs an origin for the CSP's `connect-src` (a path there matches only itself), so `apiCspSource` in `lib/api-base.ts` takes `new URL(...).origin`, guarded against a relative value. `scripts/screenshots.mjs` appends only the route; running in Node with no page origin, it needs an absolute value, hence `.env.example`'s absolute dev URL. The variable is an optional build-time override for split-origin deployments; unset, the base is the relative `/api/v1`, proxied by this app (next section). ## The web app proxies `/api/v1` to the API, and that is the shipped topology With `NEXT_PUBLIC_API_URL` unset, axios' `baseURL` is the relative `/api/v1` (`DEFAULT_API_BASE_URL`, `lib/api-base.ts`) and the catch-all `app/api/v1/[...path]/route.ts` streams to `API_INTERNAL_URL` (default `http://api:8000`; `http://localhost:8000` under `next dev`). `NEXT_PUBLIC_*` values are inlined at build, tying a baked-in address to its builder; a route handler reads `process.env` per request, whereas `rewrites()` in `next.config.js` freeze into the `output: "standalone"` build. Load-bearing in `lib/api-proxy.ts`: `Expect` is dropped (`undici`: `UND_ERR_NOT_SUPPORTED`); `Accept-Encoding` is not forwarded nor `Content-Encoding`/`content-length` passed back (`fetch` decodes its hop); `Set-Cookie` is re-added via `getSetCookie()` (comma-joining breaks `Expires`); no body for `HEAD`/204/205/304; `request.body` streams (`duplex: "half"`) only while `src/proxy.ts`'s matcher excludes `api/v1/`, as middleware buffers, truncating at `experimental.proxyClientMaxBodySize`; a `Location` on the internal origin is cut to its path (Starlette builds redirects from the `Host` it was dialled on); the unreachable-API log omits the `?token=` query; `X-Forwarded-For` passes through unappended (a handler cannot see the socket peer). The API trusts it only from `TRUSTED_PROXY_IPS`: omitted, callers share one rate-limit bucket; naming a directly exposed container makes every per-IP limit forgeable (`.env.example` warns). ## Strict, nonce-based CSP via `src/proxy.ts` - Node server only `src/proxy.ts` (export `proxy`, not `middleware`) sets a per-request `script-src 'nonce-...' 'strict-dynamic'` CSP. `app/layout.tsx` reads it from the `x-nonce` header via `headers()` for `next-themes`' `ThemeProvider`; Next propagates it elsewhere. All routes go dynamic (`ƒ`); accepted. Node server only (`output: "standalone"`): a static export (`output: "export"`) cannot carry a per-response nonce; that needs a hash-based CSP. - `eslint.config.mjs` sets `"react/no-danger": "error"`; `dangerouslySetInnerHTML` needs a sanitizer plus disable. - `style-src-attr` is plain `'unsafe-inline'`: third-party inline `style` attributes carry no nonce and execute no script. `style-src` is the nonce plus the two Scalar stylesheet hashes in `lib/api-reference-csp.ts` in production; dev needs `'unsafe-inline'` for Fast Refresh. - Radix scroll locks (`react-remove-scroll` -> `react-style-singleton`) inject a raw-DOM `