Patterns & Conventions
The idioms that repeat across the codebase — the "how we do things here" reference for anyone writing new features.
URL & routing patterns (apps/web)
- Org routes are always
/o/[slug]/<area>; dynamic ids are[id]. - Filter state lives in the URL when it should survive reload: visits/orders use
?status=; components readinguseSearchParamsare wrapped in<Suspense>(required by the Next build). - Prefill via query params:
/visits/new?client=&pet=,/pets/new?client=. - Impersonation:
/portal?as={clientId}. - The
meconvention:staff/[id]andstaff/[id]/hoursaccept the literal idme, resolved via the shell query — this is how "My hours" works for every role. - Ephemeral tab state (calendar view, settings tab) is plain
useState; only reload-worthy state goes in the URL.
Data-fetching patterns (apps/web)
- Everything is client-side reactive Convex — no server components fetch data.
useOrgQuery(fn, args | "skip")injects{sessionToken, orgSlug}and auto-skips until the session is ready.undefinedresult = loading.useOrgPaginatedQueryfor cursor pagination (clients, pets — 50/page,LoadingFirstPage/CanLoadMorestates).useOrgMutation/useOrgActionso call sites pass only domain args.- Owner-only queries are mounted after the role check resolves, to avoid throwing FORBIDDEN into an error boundary during render.
Form patterns
- Local
formstate object +setForm({...form, field});<Field label>auto-wires label↔control. - Empty strings coerce to
undefinedbefore mutations (form.email || undefined). - Money enters in dollars, converts with
Math.round(parseFloat(x) * 100), renders withformatMoney— integer cents everywhere in between. - A
busyflag gates submit buttons ("Saving…", "Creating…"). - Creates get dedicated
/newpages and redirect to the created record; edits happen in dialogs on the detail page.
Feedback patterns
- Every mutation success toasts (
sonner), often with an emoji (🎉 💊 👋 💰 ✍️). - Errors:
toast.error(e instanceof Error ? e.message : "…failed"). Permission failures pattern-match the message to role-specific copy: "Managers edit schedules", "Owners manage staff", "Managers and up can post notes". - Typed backend errors (
ConvexError({code})) surface through error boundaries as full screens;nulldetail results renderNotFoundScreen.
File uploads
One pattern everywhere (documents, message attachments, org logo, portal records):
generateUploadUrl() mutation
→ fetch(url, { method: "POST", headers: { "Content-Type": file.type }, body: file })
→ { storageId }
→ attach/set mutation with the storageId
Reads render Convex signed URLs in plain <img> tags.
Backend function conventions
- New org-scoped functions use
orgQuery/orgMutation— never rawquery/mutationwith a manualorgIdfilter. - Guard writes with
requirePolicy(ctx, "area.action"); shape reads withhasPolicywhen data should be masked instead of denied. - Detail reads return
nullfor missing/foreign ids — never throw, never leak existence. - Call
logAuditin mutations that change business state. - State changes go through their state machine (
transition()for visits) — no direct status patches. - New enums/business tables belong in
packages/domain, not inline — schema validators, backend checks, and frontend labels must share one source.
Formatting helpers
src/lib/utils.ts (web): formatMoney, formatDate, formatTime, formatDateTime ("date · time"), timeAgo, initials, ageFromBirthDate. Domain: formatCents, fullName, plus label maps (CARE_EVENT_LABELS — potty="Potty break"; PET_STATUS_LABELS — deceased="Passed away").
Reports
All range reports share RangePresets (7d/14d/31d/MTD/3mo/6mo/YTD/1yr) and client-side downloadCsv + ExportCsvButton from report-tools.tsx.
Reactivity idioms
- Dialogs that stay open over live data re-look up their subject by id each render (the board's care-log dialog reads fresh
board.inHouseso mid-dialog updates flow through). - Clocks tick with interval +
forceTickreducer (the header clock widget, 30s).
Admin console conventions (this app)
- Nav derives from the app registry; adding a page = one entry in
src/lib/apps.tsx+ a markdown file (docs) or route. - Every route ships a shape-matched
loading.tsxskeleton. - Unwired surfaces render
NotYetWiredBanner— no mock data, ever. - Accent styling always uses
blue-*utilities so the theme's remapped ramp applies.