Skip to main content

Frontend Coding Standards

Last updated: 2026-04-30 Stack: React 19, TypeScript, Vite, Tailwind v4, TanStack Query v5, Zustand, MSAL, Phosphor Icons

This document is the authoritative reference for coding standards, architectural patterns, and component conventions used in the RoundTripWeb repository. All new code — and all AI-assisted code — must follow these patterns. When in doubt, look at an existing file that does the same thing and match its structure.


1. Project Structure

src/
├── components/
│ └── common/ # Shared UI primitives (SurfacePanel, MetaRow, etc.)
├── features/ # Feature-based folders — one per domain
│ ├── dispatch/ # DispatchBoardPage.tsx
│ ├── tickets/ # TicketListPage.tsx, TicketDetailPage.tsx, CreateTicketModal.tsx
│ ├── clients/ # ClientListPage.tsx, ClientDetailPage.tsx
│ ├── inventory/ # InventoryListPage.tsx, InventoryDetailPage.tsx
│ ├── billing/ # InvoiceListPage.tsx, InvoiceDetailPage.tsx
│ └── tech/ # TodayPage.tsx, TechTicketDetailPage.tsx, RecordPartsPage.tsx, TechProfilePage.tsx
├── layouts/
│ ├── DispatchLayout.tsx # Shell for /dispatch/* routes
│ └── TechnicianLayout.tsx # Shell for /tech/* routes
├── lib/
│ ├── api/ # One file per domain
│ │ ├── axiosInstance.ts
│ │ ├── types.ts # Shared cross-domain types (DispatchBoardData, PagedResult)
│ │ ├── ticketApi.ts
│ │ ├── ticketDetailApi.ts
│ │ ├── createTicketApi.ts
│ │ ├── dispatchApi.ts
│ │ ├── clientApi.ts
│ │ ├── inventoryApi.ts
│ │ ├── billingApi.ts
│ │ └── techApi.ts
│ ├── auth/
│ │ ├── msalConfig.ts # MSAL configuration + loginRequest + apiScopes
│ │ └── msalInstance.ts # Single PublicClientApplication instance
│ ├── offlineQueue.ts # IndexedDB mutation queue for PWA
│ └── queryClient.ts # TanStack QueryClient + IndexedDB persister
├── stores/
│ └── authStore.ts # Zustand store — auth state, role, tenantId
├── features/*/hooks/ # TanStack Query hooks, co-located with their feature
├── AuthProvider.tsx # MSAL initialization + AuthSync
├── RoleRedirect.tsx # Post-login role-based routing
├── router.tsx # createBrowserRouter — all routes
└── sw.ts # Custom Workbox service worker

Key structural rules

  • Pages and components live in features/, not pages/
  • Each feature folder has its own hooks/ subfolder
  • Auth infrastructure lives in lib/auth/ — never inline MSAL config in a component
  • The single msalInstance is created in msalInstance.ts and imported wherever needed outside React context (e.g. axiosInstance.ts)
  • Shared cross-domain types (e.g. DispatchBoardData, PagedResult) live in lib/api/types.ts

2. Separation of Concerns — The Golden Rule

Every file has exactly one responsibility.

LayerWhat lives hereExample
src/lib/api/API calls + response types. No React, no hooks.ticketDetailApi.ts
src/features/*/hooks/TanStack Query hooks wrapping the API. No JSX.useTicketActions.ts
src/features/*.tsxPure UI. No useMutation, no apiClient calls.TicketDetailPage.tsx
src/components/common/Reusable UI primitives. No business logic, no API calls.SurfacePanel.tsx
src/stores/Zustand global state. No JSX, no API calls.authStore.ts
src/lib/auth/MSAL config and instance only.msalConfig.ts

Never put API calls directly in a page or component. Never put useMutation or useQuery directly in a page — they belong in a hook file.


3. API Layer (src/lib/api/)

One file per domain

axiosInstance.ts — configured Axios instance with auth interceptor
types.ts — shared cross-domain types (DispatchBoardData, PagedResult)
ticketApi.ts — ticket list + TicketSummary types
ticketDetailApi.ts — ticket detail + all action methods (assign, start, complete, hold, cancel, addNote)
createTicketApi.ts — client search, address selection, create ticket
dispatchApi.ts — dispatch board data
clientApi.ts — client list, detail, create, update, addresses, service history
inventoryApi.ts — inventory list, detail, stock adjustment
billingApi.ts — invoice list, detail, generate, send, record payment, cancel
techApi.ts — technician-specific endpoints (me, tickets, status transitions, location, notes, parts)

File structure

import { apiClient } from './axiosInstance'

// ── Types ──────────────────────────────────────────────────────────────────

export interface TicketDetail { ... }
export interface AssignTicketResponse { ... }

// ── API ────────────────────────────────────────────────────────────────────

export const ticketDetailApi = {
getDetail: async (id: string): Promise<TicketDetail> => {
const { data } = await apiClient.get<TicketDetail>(`/v1/tickets/${id}`)
return data
},

assign: async (ticketId: string, technicianId: string): Promise<AssignTicketResponse> => {
const { data } = await apiClient.patch<AssignTicketResponse>(
`/v1/tickets/${ticketId}/assign`,
{ technicianId }
)
return data
},
}

Rules

  • Always use apiClient from ./axiosInstance — never import axiosInstance directly
  • Export all types from the API file — hooks and pages import types from the API layer
  • Keep methods thin — no business logic, just the HTTP call and return
  • No console.log statements in production code

4. Hook Layer (src/features/*/hooks/)

Hooks live in a hooks/ subfolder co-located with their feature, not in a global hooks folder.

Query hook pattern

import { useQuery } from '@tanstack/react-query'
import { ticketDetailApi } from '@/lib/api/ticketDetailApi'

export const TICKET_DETAIL_KEY = (id: string) =>
['tickets', 'detail', id] as const

export function useTicketDetail(id: string) {
return useQuery({
queryKey: TICKET_DETAIL_KEY(id),
queryFn: () => ticketDetailApi.getDetail(id),
enabled: !!id,
staleTime: 30_000,
})
}

Mutation hook pattern

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useMsal } from '@azure/msal-react'
import { ticketDetailApi } from '@/lib/api/ticketDetailApi'
import { TICKET_DETAIL_KEY } from './useTicketDetail'

export function useTicketActions(ticketId: string) {
const queryClient = useQueryClient()
const { accounts } = useMsal()
const account = accounts[0]
const authorUserId = account?.localAccountId ?? '00000000-0000-0000-0000-000000000000'
const authorName = account?.name ?? account?.username ?? 'Dispatcher'

const invalidate = () =>
queryClient.invalidateQueries({ queryKey: TICKET_DETAIL_KEY(ticketId) })

const assignMutation = useMutation({
mutationFn: (technicianId: string) =>
ticketDetailApi.assign(ticketId, technicianId),
onSuccess: invalidate,
})

return { assignMutation, ... }
}

Rules

  • Always export query keys as named constants (TICKET_DETAIL_KEY, TICKET_LIST_KEY)
  • Mutation hooks own onSuccess invalidation — pages never call queryClient.invalidateQueries directly
  • MSAL account extraction lives in the hook, not the page

5. Page / Feature Layer (src/features/*.tsx)

Pages are pure UI. They import hooks and common components only. Pages do not import apiClient, call useMutation/useQuery directly, or import msalInstance.


6. Common Components (src/components/common/)

Always use shared components — never recreate inline.

ComponentUse for
SurfacePanelEvery card/panel surface
SectionHeaderTitled header row inside a SurfacePanel
MetaRowLabelled metadata rows in detail sidebars
StatusBadgeTicket status pill
PriorityBadgeTicket priority pill
PageHeaderPage title + subtitle + optional action slot

StatusBadge and PriorityBadge — spread ...rest props

StatusBadge and PriorityBadge extend React.HTMLAttributes<HTMLSpanElement> and spread ...rest. This means they forward arbitrary HTML attributes including data-testid:

// ✅ Correct — data-testid is forwarded to the underlying <span>
<StatusBadge status={ticket.status} data-testid="status-badge" />

Any new shared component must follow the same pattern — accept and spread ...rest so testids and ARIA attributes can be passed without modifying the component.


7. Authentication Architecture

Role and TenantId come from GET /v1/me — NOT from JWT claims

⚠️ Critical. The JWT tid claim is the Entra External ID directory GUID — it is NOT the RoundTrip TenantId. Role and TenantId are resolved by calling GET /v1/me after login, which does an OID lookup in the TenantUsers table.

App load
└── AuthProvider initializes msalInstance
└── handleRedirectPromise() processes redirect
└── setMsalInstance() wires MSAL into axiosInstance
└── AuthSync: LOGIN_SUCCESS → setAccount() called
└── setAccount() is ASYNC — calls GET /v1/me
└── /v1/me returns { role, tenantId, displayName, ... }
└── authStore updated with real role + tenantId
└── RoleRedirect reads authStore.role → navigates to /dispatch or /tech

setAccount() in authStore.ts is an async function. It calls GET /v1/me to get the role and RoundTrip TenantId from the database — not from JWT claims. Role-dependent UI must not render before this resolves.

MSAL cache — localStorage

MSAL is configured to use localStorage (not sessionStorage). This was changed from the initial implementation to support Playwright E2E auth state persistence across browser contexts.

// msalConfig.ts
cache: {
cacheLocation: 'localStorage', // ← not sessionStorage
}

MSAL transient errors — do not log out (TRA-181)

⚠️ Critical. When acquireTokenSilent throws BrowserAuthError with codes like block_iframe_reload, timed_out, or monitor_window_timeout, this is a transient error caused by stale MSAL interaction state in localStorage (e.g. after a deployment while a user was mid-auth-flow). Do NOT clear auth on these errors.

authStore.ts uses a typed FetchResult discriminated union to handle this:

type FetchResult =
| { status: 'ok'; user: CurrentUserResponse }
| { status: 'token_error' } // transient — don't log out
| { status: 'not_found' } // user not provisioned — do log out
| { status: 'api_error' } // network failure — don't log out

Key files

FilePurpose
lib/auth/msalConfig.tsMSAL config, apiScopes, loginRequest. Cache is localStorage.
lib/auth/msalInstance.tsSingle PublicClientApplication instance
AuthProvider.tsxInitializes MSAL, handles redirect, wraps app in MsalProvider
RoleRedirect.tsxPost-login: reads role from authStore, redirects to the right shell
stores/authStore.tsZustand store: isAuthenticated, role, userId, tenantId, displayName

MSAL instance rule

msalInstance is created once in msalInstance.ts. Import it wherever you need MSAL outside React context. Never create a second PublicClientApplication.


8. Design Tokens

All colors use CSS variables. Never hardcode hex values except in RoundTripLogo.

Brand colors

var(--rt-navy) /* #0F2D44 */
var(--rt-blue) /* #1D6FA4 */
var(--rt-blue-muted) /* icon tiles, selected states */
var(--rt-blue-hover) /* button hover */
var(--rt-sky) /* #7EC8E3 — accent */

Semantic colors

var(--color-text) var(--color-text-muted)
var(--color-surface) var(--color-surface-2)
var(--color-border) var(--color-border-strong)
var(--color-red) var(--color-red-muted) var(--color-red-text)
var(--color-amber) var(--color-amber-muted) var(--color-amber-text)
var(--color-green) var(--color-green-muted) var(--color-green-text)
var(--color-steel) var(--color-steel-muted)

Typography

var(--font-display) /* Barlow Condensed 700 — page titles */
var(--font-ui) /* Inter — labels, buttons, badges, nav */
var(--font-body) /* Inter — body text, descriptions */
var(--font-mono) /* JetBrains Mono — ticket numbers, dates, IDs */

9. Service Worker (src/sw.ts)

Built via vite-plugin-pwa in injectManifest mode.

Critical routing rules

// MUST check url.origin BEFORE url.pathname — prevents intercepting cross-origin API calls
registerRoute(
({ url }) => url.origin === 'https://api.roundtrips.app',
new NetworkOnly()
)

registerRoute(
({ request, url }) =>
request.method === 'GET' &&
url.origin === self.location.origin && // ← origin guard mandatory
url.pathname.startsWith('/v1/'),
new NetworkFirst({ cacheName: 'api-responses', networkTimeoutSeconds: 10 })
)

Without the origin guard, Workbox silently returns no-response for cross-origin fetches.

Deploying SW changes — stale service worker warning

After deploying a new build:

  1. DevTools → Application → Storage → Clear site data
  2. Close the tab completely — a page reload is NOT sufficient
  3. Open a fresh tab

10. Real-Time (SignalR)

WebSockets are disabled by default on Azure App Service Linux. Always enable via CLI:

az webapp config set \
--name app-roundtrip-prod \
--resource-group rg-roundtrip-prod \
--web-sockets-enabled true

This is not available in the Azure Portal UI for Linux App Service — CLI only.


11. E2E Test Patterns (src/e2e/)

State sharing between tests

Use src/e2e/fixtures/e2e-state.ts for cross-test state. Never use process.env directly — it doesn't survive across CI pipeline steps.

import { setTicketUrl, getTicketUrl, setTicketNumber, getTicketNumber } from '../fixtures/e2e-state'

// After creating a ticket (test 02)
setTicketUrl(page.url())
setTicketNumber(ticketNum)

// In downstream tests (03-06)
const ticketUrl = getTicketUrl()

State is persisted to src/e2e/test-results/.e2e-state.json (gitignored).

Direct page.goto(deepUrl) triggers MSAL re-auth. Always load from the dispatch board first:

await dispatchBoard.goto() // loads from app root — MSAL state already in localStorage
await page.goto(ticketUrl) // now navigate to deep link

Hold reason values must match the DB CHECK constraint

// ✅ Correct
await ticketDetail.putOnHold('AwaitingParts')

// ❌ Wrong — doesn't match DB constraint
await ticketDetail.putOnHold('WaitingForParts')

12. Naming Conventions

ThingConventionExample
Feature folderslowercasefeatures/tickets/
API filescamelCase + Api suffixticketDetailApi.ts
Hook filescamelCase + use prefixuseTicketActions.ts
Query key exportsUPPER_SNAKE + _KEYTICKET_DETAIL_KEY
Page componentsPascalCase + PageTicketDetailPage
Modal componentsPascalCase + ModalCreateTicketModal
Layout componentsPascalCase + LayoutDispatchLayout
Common componentsPascalCase, descriptive nounSurfacePanel, MetaRow
Store filescamelCase + StoreauthStore.ts
API object exportscamelCase + ApiticketDetailApi

13. Known Issues & TODOs

LocationIssueTicket
lib/api/PagedResult<T> duplicated across API files — consolidate into types.ts

14. Things to Never Do

NeverWhy
Derive role or tenantId from JWT claimsRole/tenantId come from GET /v1/me — JWT tid is Entra directory GUID
Import axiosInstance directly in a page or componentAll API access goes through a domain API file
Call useMutation or useQuery directly in a pageWrap them in a hooks/ file
Use sessionStorage for MSAL cacheCache is localStorage — changing this breaks Playwright E2E
Clear auth on MSAL transient errorsblock_iframe_reload, timed_out are transient — use FetchResult pattern in authStore
Use url.pathname alone in SW route matchersAlways check url.origin first
Navigate directly to a deep URL in E2E testsLoad from dispatch board root first to avoid MSAL re-auth
Use hold reason values not in the DB constraintAwaitingParts | AwaitingApproval | Rescheduled | CustomerNotHome only
Leave console.log statements in production codeRemove all before beta
Create a second PublicClientApplicationOne instance only, in msalInstance.ts
Use Lucide or any other icon libraryPhosphor only
Use hardcoded hex colorsUse CSS variables
Use the offline queue in dispatcher featuresTechnician PWA only