- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
returning Map<deviceId, Device>. deviceLabel() formats human-readable
titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
Two GeoJSON sources (clustered non-selected + unclustered selected).
Five layers: non-selected symbol + direction + cluster-bubble,
selected symbol + direction. Click handlers: marker -> selectDevice,
cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
<MapView>.
Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
(devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
filters on array-like values; non-arrays collapse to never which
broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
RestClient<Schema> & AuthenticationClient<Schema> — without the
explicit annotation Schema didn't flow through .with(...) chain to
request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.
useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.
Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
callback-style); used .then().
Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
- src/auth/logout.ts: performLogout({ queryClient, navigate? })
orchestration. Calls store.logout(), clears query cache, navigates
to /login. Server-call failures swallowed; local state still flips
to anonymous.
- src/auth/cross-tab-sync.ts: startCrossTabSync(). Subscribes to the
auth store and bumps trm-auth-version in localStorage on status
transitions; listens for storage events and re-runs initialize()
when another tab bumps. Idempotent via module-level guard. No-ops
if localStorage is unavailable.
- src/ui/components/logout-button.tsx: <LogoutButton> wraps shadcn
Button. Pass-through props but locks onClick / disabled / children.
Local isLoggingOut for "Signing out..." indicator + double-click
guard.
- src/auth/bootstrap.tsx: <AuthBootstrap> now calls startCrossTabSync()
alongside the existing init steps. Single mount point.
- src/auth/index.ts: re-export performLogout + startCrossTabSync.
- src/routes/_authed/index.tsx: replaced the inline sign-out button
with <LogoutButton />. The existing useEffect on 'anonymous' status
handles navigation when cross-tab sync fires.
Bonus: tsconfig.app.json now has "strict": true. TanStack Router emits
a conditional-type error ("strictNullChecks must be enabled") in
editors when strict is off; tsc -b was lenient. Caught in 1.7's
review. Typecheck remained green after enabling.
'cookie' mode keeps the session alive via an in-memory access token
that's refreshed from a refresh cookie when the SDK is alive. After a
hard reload the SDK has no access token in memory, so /users/me 401s
before autoRefresh can kick in (or the refresh cookie's surface doesn't
cover plain reads cleanly). Net effect: every reload bounces back to
login even when the cookie is still valid.
'session' mode puts the actual session in the cookie. Browser sends it
automatically on every request, the SDK doesn't need to manage tokens,
and reload survives cleanly because /users/me with the session cookie
just works.
Reordered .with() calls to match the working pattern from a prior
project: rest() before authentication() (cosmetic; SDK accepts either
order).
The runtime config supports relative paths like /api so the SPA can run
same-origin in dev (Vite proxy) and stage/prod (Traefik). createDirectus
from @directus/sdk calls new URL(...) internally, which throws on
relative URLs. Resolve in toAbsoluteUrl() before handing to the SDK:
'/api' -> 'http://localhost:5173/api' (dev)
'https://api.example.com' -> passes through unchanged
Caught at runtime; typecheck/build don't surface this since the SDK
treats its url arg as a string.
Six files under src/auth/:
- client.ts: createDirectus<Schema>().with(authentication('cookie', ...))
.with(rest(...)). Lazy singleton via initDirectusClient(url) +
getDirectus() + useDirectus() (React-side helper). DirectusUser type;
empty Schema (grows in Phase 2+).
- store.ts: Zustand store with discriminated AuthState
(unknown / anonymous / authenticating / authenticated). Actions:
initialize, login, logout, setUser. humanizeAuthError maps Directus
error codes (INVALID_CREDENTIALS / INVALID_OTP / USER_SUSPENDED) to
user-facing strings.
- guard.ts: useRequireAuth (redirect to /login on anonymous) and
useRequireRole (bounce to / on role mismatch). Uses TanStack Router's
useNavigate; effective once 1.7 wires the router.
- bootstrap.tsx: <AuthBootstrap> component runs initDirectusClient +
initialize() once after the runtime config is loaded.
- index.ts: barrel re-exports.
main.tsx wraps App in <RuntimeConfigProvider><AuthBootstrap>...
App.tsx shows the auth status as a smoke indicator before 1.6/1.7.
Deviations:
1. Split getDirectus into initDirectusClient(url) + getDirectus()
because the runtime config is a React context (per 1.4), not a
Zustand store. <AuthBootstrap> bridges from React land to the non-
React getter.
2. Added <AuthBootstrap> as a discrete component rather than putting
the bootstrap effect in App.tsx — App.tsx gets replaced by the
router in 1.7, but bootstrap needs to keep running.