Skip to content

Frontend

Sergio Hernandez edited this page Jul 24, 2026 · 1 revision

Frontend

The TrackHub portal is a React 19 + TypeScript 7 single-page application built with Vite 8 and tested with Vitest 4. It is served as static files by nginx and talks to every backend service directly.

npm run typecheck && npm test && npm run build is the gate. tsc is the TypeScript lint gate — typescript-eslint is disabled until it supports TypeScript 7.

Related pages: Technology · Inter-Service Communication


Architecture

graph TB
    subgraph App["TrackHub Web"]
        router["React Router<br/><i>routes.tsx</i>"]
        auth_mod["Auth Context<br/><i>PKCE + token refresh mutex</i>"]

        subgraph Pages["Screens"]
            dash["Dashboard<br/><i>Live map + positions</i>"]
            trips["Trip Manager<br/><i>Dispatch board</i>"]
            geofence["Geofence Manager<br/><i>Polygons + circles</i>"]
            reports["Reports<br/><i>Preview + Excel/PDF</i>"]
            admin["Account Management<br/><i>Users, units, documents, drivers, alerts</i>"]
            sysadmin["System Administration<br/><i>Accounts, clients, features</i>"]
            status["Status<br/><i>public</i>"]
            profile["Profile"]
        end

        help["Contextual Help<br/><i>Help button / F1, EN + ES</i>"]

        subgraph Data["Data access"]
            queries["src/queries/<br/><i>TanStack Query hooks</i>"]
            api["src/api/<backend>/<br/><i>typed functions</i>"]
            core["src/api/core/<br/><i>graphqlClient, restClient,<br/>tokenStore, endpoints, errors</i>"]
        end
    end

    router --> Pages
    Pages --> queries --> api --> core
    Pages --- help
    auth_mod -->|Bearer token| core
    core -->|GraphQL / REST over HTTPS| backend["Backend Services"]
Loading

Data-access layering

Components never touch the network. Every call goes through three layers, in this order:

Layer File Responsibility
1 src/api/<backend>/<domain>Operations.ts GraphQL documents, written with the generated graphql() tag
2 src/api/<backend>/<domain>.ts Plain typed async functions that throw ApiError
3 src/queries/<domain>.ts TanStack Query hooks owning cache keys and invalidation-after-mutation

The shared core lives in src/api/core/:

File Role
endpoints.ts All endpoint environment reads — nowhere else
tokenStore.ts Framework-agnostic token holder with a single refresh mutex; AuthContext is the writer
graphqlClient.ts executeGraphQL(backend, document, variables)
restClient.ts Authenticated multipart uploads and downloads, same mutex
errors.ts ApiError, and notifyApiError → an 'app-error' CustomEvent → the toast

Rules that matter:

  • Values travel as GraphQL variables ONLY. The old formatValue interpolation and escaping is gone; never build a document by string concatenation.
  • Partial success is tolerated — errors alongside usable data do not discard the data.
  • Global QueryCache / MutationCache onError routes failures to the toast; mutations are never retried.
  • A query may set meta: { silent: true } to opt out of the global error toast. Used by surfaces that display failure as their content — so the status page does not fire a toast every 60 s while calmly reporting an outage.

Two sanctioned exceptions

Both live in src/api/, are reached only through src/queries/platformStatus.ts, and are documented in their own file headers. The public status page must work with no token at all:

  • api/core/healthProbe.ts — anonymous fetch of each service's /health
  • getVisibleAnnouncements — anonymous axios GET that returns [] instead of throwing, so a Manager outage removes the banner rather than the page

Schema contract and codegen

The contract test suite exports each producer's SDL to TrackHub/schemas/<service>.graphql. Then:

npm run codegen

runs graphql-codegen (client preset, per backend; scalars UUID/DateTimestring, Longnumber) and validates every portal operation against those SDLs. Backend drift becomes a compile error rather than a runtime surprise.

The workflow after any backend GraphQL change is therefore:

  1. run the contract tests (which re-export the SDLs);
  2. run npm run codegen;
  3. run npm run typecheck && npm test && npm run build.

Reporting is REST-only (api/BasicReports), and the Manager documents REST base is ~/documentsno api/ prefix.


TypeScript hardening

src/ is 100% TypeScript. allowJs is off and an ESLint guard errors on any new .js or .jsx file under src/.

  • The vendored Argon layer (components, controls, theme) exports real prop types. Import and use them directly — never re-introduce local prop-slice interfaces or as unknown as boundary casts at call sites. If a control genuinely lacks a prop you must pass, widen the control's exported prop type.
  • Argon theme extensions are a single MUI module augmentation in src/types/mui-theme.d.ts (boxShadows, borders, functions, palette groups, typography.size, text.main), consumed directly by the styled *Root files — not by structural helper types.
  • No as unknown as boundary casts remain in hand-written code. The survivors are codegen output, dynamic palette and i18n-key lookups, and documented data-shape casts.
  • Evaluated and deferred: noUncheckedIndexedAccess (139 errors, mostly in vendored interiors) and exactOptionalPropertyTypes (73).

The env define shim is permanent

vite.config.ts defines process.env.REACT_APP_*. This is a decision, not debt: all reads centralize in api/core/endpoints.ts, and keeping the CRA environment-variable convention means existing .env files and deployment documentation stay valid. It is disabled in test mode, because suites assign env at runtime.


Internationalization

English and Spanish, through i18next. Keys are compile-checked (src/types/i18next.d.ts, strictKeyChecks against en.json), so a key must be added to both locales/en.json and locales/es.json. Dynamic keys cast at the key expression only.


Contextual help

User documentation lives in this repository, not in the wiki:

TrackHub/public/help/{en,es}/<topic-id>.md

One GitHub-flavoured Markdown topic per screen or feature, with YAML frontmatter carrying screens: (route-key mapping), category, related and an optional featureKey. It ships as static assets with each portal build, so what a user reads always matches the screens in front of them.

Build-time validation

scripts/build-help.mjs runs on predev and prebuild (and standalone as npm run help:check). It validates the authoring contract:

  • language parity between en and es
  • topic id equals filename
  • screens:routes.tsx in both directions
  • topic: link targets resolve
  • no raw HTML
  • referenced assets exist

It emits the gitignored public/help/manifest.json, whose per-topic content hashes act as fetch cache-busters — the SPA never parses frontmatter itself.

Runtime

HelpProvider (src/context/help/, mounted in App, F1 and Shift+? shortcuts) plus the navbar Help button and HelpDialog (src/controls/Help/, react-markdown + remark-gfm, skipHtml, with topic: links navigating in-modal behind a back stack).

TanStack queries fetch topics lazily with staleTime: Infinity, detect the nginx SPA index.html fallback by content (so fetched HTML is never rendered), and fall back to English, then to the index view.

Index and search filtering by role and feature reuses routeAllowed and the account's features. That is UX only — help content is a public static asset by decision.

The nginx.frontend.conf /help/ location (max-age 300, try_files $uri =404) is optional hardening; the client-side fallback detection must stay regardless.


Status page

/status is a public route in the SPA, not a separate site. It renders without authentication and outside the signed-in shell, because the scenario it exists for is "nobody can sign in — is it us or the platform?"

Three things make that work:

  1. the SPA is static files served by nginx with no auth;
  2. every service maps an anonymous UseHealthChecks("/health");
  3. UseCors runs before it, so the portal origin can probe each service cross-origin with no token.

That CORS-before-health ordering is load-bearing. AuthorityServer originally had it inverted, which made the Sign-in tile permanently unreadable.

If nginx or the frontend itself is down, nothing can self-report. That is accepted.

Route gating

RouteDefinition.public marks a route reachable by every principal type. Omitting principalTypes defaults to [User], so "public" had to be explicit — without the flag, a Driver or public-link principal was redirected to /dashboard, which is itself User-only, and the two bounced forever. App.tsx's routeAllowed and the Sidenav filter both honour it.

Three tiers on one page

Tier Sees Gate
Anonymous Service tiles + active announcements
Manager or Administrator Additionally the GPS-synchronisation (SyncWorker) tile OperatorSyncRuns/Read
Administrator Additionally the background-jobs table and announcement CRUD Administrative/Read, Administrative/Write

The page resolves its own isAdmin / isManager state (both silent) rather than taking props, because it is rendered for signed-out visitors too.

Probing

Service state is probed from the browser, not from a server rollup: GET {base}/health per backend, with a 6 s AbortController budget, using URLs built only from HEALTH_ENDPOINTS in api/core/endpoints.ts (lazy getters, so tests can stub the env). Probes never reject — a total outage still renders the page, degraded per tile.


Other hardening

  • ErrorBoundary and NotificationProvider at the app root
  • 30 s request timeout (60 s for file transfers)
  • Console output suppressed in production
  • Security meta headers in index.html
  • Map popups escape their input. Leaflet bindPopup/bindTooltip and the Google InfoWindow assign their argument via innerHTML, so every dynamic value passes through escapeHtml (src/utils/htmlUtils.ts). Transporter names are account-editable free text and addresses come from a third-party geocoder.
  • datetime-local ⇄ UTC goes through toDateTimeLocalInput / fromDateTimeLocalInput (src/utils/dateUtils.ts). The control holds local wall time, so the instant shifts by the viewer's offset in both directions. A helper that skips the shift round-trips correctly only under TZ=UTC — which is what dev boxes and CI run. Assert the round-trip property, never a literal.

Environment variables

All reads go through src/api/core/endpoints.ts. The CRA REACT_APP_ convention is retained deliberately.

Variable Purpose
REACT_APP_CLIENT_ID OAuth client id (web_client)
REACT_APP_AUTHORIZATION_ENDPOINT /Identity/authorize
REACT_APP_TOKEN_ENDPOINT /Identity/token
REACT_APP_CALLBACK_ENDPOINT OAuth redirect target
REACT_APP_REVOKE_TOKEN_ENDPOINT /Identity/revoke
REACT_APP_LOGOUT_ENDPOINT /Identity/logout
REACT_APP_MANAGER_ENDPOINT Manager GraphQL
REACT_APP_SECURITY_ENDPOINT Security GraphQL
REACT_APP_ROUTER_ENDPOINT Router GraphQL
REACT_APP_GEOFENCING_ENDPOINT Geofencing GraphQL
REACT_APP_TELEMETRY_ENDPOINT Telemetry GraphQL
REACT_APP_TRIPMANAGEMENT_ENDPOINT TripManagement GraphQL
REACT_APP_REPORTING_ENDPOINT Reporting REST base
REACT_APP_DEFAULT_LAT / REACT_APP_DEFAULT_LNG Map centre when the browser denies location permission

Clone this wiki locally