Documentation
IntroductionArchitectureMonorepo & ToolingRunning Locally
Screen Flow MapUser JourneysPersonas & RolesWeb ScreensClient Portal
Convex OverviewData ModelFunctions ReferenceAuth & SessionsRoles & PermissionsJobs & Providers
Web Design SystemAdmin Console DesignPatterns & Conventions
Web AppiOS AppMarketing Website
Testing & HarnessCI Pipeline

Patterns & Conventions

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 reading useSearchParams are wrapped in <Suspense> (required by the Next build).
  • Prefill via query params: /visits/new?client=&pet=, /pets/new?client=.
  • Impersonation: /portal?as={clientId}.
  • The me convention: staff/[id] and staff/[id]/hours accept the literal id me, 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. undefined result = loading.
  • useOrgPaginatedQuery for cursor pagination (clients, pets — 50/page, LoadingFirstPage / CanLoadMore states).
  • useOrgMutation / useOrgAction so 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 form state object + setForm({...form, field}); <Field label> auto-wires label↔control.
  • Empty strings coerce to undefined before mutations (form.email || undefined).
  • Money enters in dollars, converts with Math.round(parseFloat(x) * 100), renders with formatMoney — integer cents everywhere in between.
  • A busy flag gates submit buttons ("Saving…", "Creating…").
  • Creates get dedicated /new pages 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; null detail results render NotFoundScreen.

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 raw query/mutation with a manual orgId filter.
  • Guard writes with requirePolicy(ctx, "area.action"); shape reads with hasPolicy when data should be masked instead of denied.
  • Detail reads return null for missing/foreign ids — never throw, never leak existence.
  • Call logAudit in 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.inHouse so mid-dialog updates flow through).
  • Clocks tick with interval + forceTick reducer (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.tsx skeleton.
  • Unwired surfaces render NotYetWiredBanner — no mock data, ever.
  • Accent styling always uses blue-* utilities so the theme's remapped ramp applies.