TBT — Transition & Development Plan

TBT / JourneyHelper — Knockout → Angular Transition & Development Plan

Status: Fable 5 draft, tightened by Opus (final v1). Deltas from the draft are in “Opus tightening notes” immediately below; the frozen original is at transition-plan-draft-fable-v1.md. Inputs (source of truth): docs/ideas.md (feature backlog + [DECISION]s), docs/org-angular-standards.md (org patterns), review/style-and-architecture-themes.md (code DNA, risks R1–R10, constants inventory, custom.js triage), and current/ (Knockout code). Overriding value: simplicity — reusable components/services, minimal boilerplate, a well-written happy medium. No gold-plating.


Opus tightening notes (what changed from the draft)

The Fable 5 draft is strong and adopted almost wholesale. Five substantive tightenings:

  1. Web Worker demoted to a measured contingency, not the default (§4.4). The draft (and ideas.md §4) justified the worker with “241k-point Turf scans” — but 241k is the whole-feed shape total (a sync/enrichment concern), not a per-fix cost. Per-fix nearestPointOnLine / lineSliceAlong run over a single route’s shape (hundreds–low-thousands of points) at ~1 Hz, comfortably a main-thread workload. Plan now: port the engine as pure functions on the main thread first; add the worker only if a real tablet measurement demands it (criteria in §4.4). This removes upfront worker-protocol boilerplate from Phase 1. → reaffirm as Q10.
  2. Added an explicit parity checklist (§9.a) — cutover safety hinges on it, so it’s enumerated, not implied.
  3. Phase 3 sliced into vertical, PR-sized deliverables (§10) rather than one monolithic UI phase.
  4. Surfaced two ordering decisions for you (§11 Q10–Q11): the worker default above, and whether to pull nav-pill clamping (F6) forward into the Immediate horizon — it directly fixes the “looks like the wrong side of the road” complaint and is nearly free (reuses the proximity the engine already computes).
  5. Minor: corrected the per-fix cost framing wherever “241k” appeared; everything else stands.

All ideas.md [DECISION]s are honoured; no hard constraint is violated.


1. Executive summary & goals

TBT (“JourneyHelper”) is an offline-capable, in-vehicle, landscape-tablet PWA giving bus drivers turn-by-turn guidance over pre-authorised route lines. Its special sauce is chainage / linear-referencing routing: GPS is projected onto the route shape; going off-route deliberately does not auto-reroute — the driver radios the OCC. We are rewriting the Knockout.js app in Angular to org standards, and using the rewrite to (a) pay down real defects (offline that doesn’t actually work, data-loss risks, leaks, hardcoded secrets) and (b) lay foundations the backlog needs (the position pipeline, config service, telemetry client, ScheduleSource).

Goals, in priority order:

  1. Behavioural parity of the special sauce — chainage advance, forward-only look-ahead, off-route detection/halt, next-stop/next-turn selection — proven by golden regression tests written before the port.
  2. A genuinely working offline PWA — today the service worker caches nothing (R3); Angular’s service worker delivers real offline for the first time. This is an upgrade, not a carry-over.
  3. Org-standard Angular — components, Router + guards, Reactive Forms where forms exist, HttpClient (when the API arrives), RxJS, BehaviorSubject services for shared state, Angular Material — deviating only where simplicity dictates, with the deviation documented (§2.3).
  4. Foundations for the backlog — the position pipeline, config service, Web Worker geo engine, telemetry outbox, and ScheduleSource interface are designed once so the Immediate/ Medium/Long features snap on as stages/implementations rather than rework.
  5. Massive net simplification — delete ~1,200 lines of dead theme boilerplate (custom.js), the homegrown listener system, the runtime template loader, and the webpack setup.

Non-goals: pixel parity with the sticky-ui theme; NgRx or any state library; the /telemetry backend; custom indoor positioning; keeping Knockout alive in any hybrid form.


2. Guiding constraints

2.1 Principles (from ideas.md §0 — restated as tests we apply to every task)

2.2 Hard constraints (must-not-break)

2.3 Org standards — adoption map and documented deviations

Org standard Adoption in TBT Deviation & reason
Component-based architecture Full — standalone components
Angular Router + guards Light — a small route table (/, /gps-test, /diagnostics); enrollment guard if enrollment is kept (Q2). TBT is a single-screen, map-first app; panels (shift, on-time, route selection) are shell state, not routes. Deep routing would add boilerplate with no user value. The back-button stack maps to a small BackActionService, not router history (driver flow is modal, not navigational).
Reactive Forms & validation Where forms exist: route-selection search field; pinpad stays a bespoke component (a pinpad is not a form). Minimal form surface in this app.
HttpClient + JWT Adopted when the schedule API and telemetry endpoints arrive (Medium horizon); interceptor ready in the scaffold but inert until then. Today the app has no backend; wiring JWT against nothing is gold-plating.
RxJS observables Full — the position pipeline and services are RxJS-first.
BehaviorSubject state Hybrid per ideas.md [DECISION]: BehaviorSubject services for global/shared state; signals for local component state and derived view state (toSignal at the boundary). Sanctioned by ideas.md §4; lowest-boilerplate mapping from Knockout observables.
Angular Material Full — replaces Bootstrap 5 / sticky-ui. Custom components only where Material has no fit (pinpad, nav overlays on the map). Map overlays are bespoke by nature.

2.4 Engineering baseline


3.1 Recommendation: greenfield port behind golden tests, feature-by-feature to parity, then hard cutover

Knockout and Angular cannot cleanly coexist in one page (both want to own the DOM/binding; a hybrid bridge would be pure throwaway plumbing in an app this size — ~3,900 lines of templates+VMs total). The app is small enough, and its core valuable enough, that the right shape is:

  1. Scaffold a fresh Angular workspace (next/) with the engineering baseline above. Nothing from current/ is imported wholesale; libraries carry over as npm deps (MapLibre GL, Turf, Dexie, PapaParse).
  2. Lock the special sauce first: build the golden-trace harness against the legacy JS engine, record fixtures, then port the chainage core to pure TypeScript until the goldens pass (§5). No UI work on navigation until this gate is green.
  3. Port the headless core next (position pipeline, data layer, config) — it’s already framework-agnostic and ports near-1:1 into @Injectables.
  4. Rebuild the UI feature-by-feature to parity in the new app (shell → map → sign-on/shift → navigation → admin/diagnostics), verifying each against the running legacy app side-by-side.
  5. Hard cutover once the parity checklist (§10, M4) passes on a real tablet: the new build replaces the old at the production URL.

Why not strangler-fig / incremental in-page? The interop bridge (Knockout observables ↔︎ Angular inputs, two binding roots, double-loaded vendor bundles) would cost more than the entire UI port, be shipped to drivers as transitional risk, and then be deleted. Greenfield-with-golden-tests gives the same safety (behavioural lock on the part that matters) without the throwaway layer.

Why not big-bang without parity staging? The feature-by-feature parity sequence is the protection: each slice is verified against the live legacy app, and the legacy app remains the production system until the final gate.

3.2 Execution model

3.3 Rollback stance


4. Target architecture

4.1 App shell, structure & routing

src/app/
  core/                        # singleton services, no components
    config/    config.service.ts, app-config.ts (typed defaults + constants inventory)
    position/  position.service.ts, sources/{geolocation,synthetic}.source.ts,
               stages/{accuracy-filter,bearing,interpolate,clamp}.ts, position.types.ts
    routing/   routing.service.ts            # Angular wrapper (thin)
    data/      db.service.ts (Dexie), gtfs-sync.service.ts,
               schedule/{schedule-source.ts, static-file.source.ts, api.source.ts(later)}
    map/       map.service.ts                # imperative MapLibre wrapper
    telemetry/ telemetry.service.ts, outbox.ts
    shell/     back-action.service.ts, theme.service.ts, connectivity.service.ts,
               interaction-lock.service.ts (Immediate horizon)
    error/     app-error-handler.ts, error-log.service.ts
  domain/                      # PURE TypeScript — no Angular imports (worker + test shared)
    chainage/  chainage-engine.ts, poi.ts, distance-format.ts
    geo.worker.ts              # worker entry wrapping the engine
  features/
    map/           map.component.ts (hosts the GL container + nav overlays)
    navigation/    nav-panel/, next-direction/, poi-list/, on-time-panel/ (Phase 5)
    sign-on/       pinpad/, shift-panel/, activity-list/, route-selection/
    admin/         admin-menu/, gtfs-sync-dialog/
    diagnostics/   diagnostic-panel/, error-log-dialog/, gps-test/
    shell/         app-shell.component.ts, header-bar/, settings-menu/, connectivity-banner/
  app.config.ts                # composition root (providers) — replaces app.js wiring
  app.routes.ts

4.2 State management (where BehaviorSubject vs signals)

Rule of thumb: shared, written-by-services state → BehaviorSubject in an injectable (org standard); component-local and derived view state → signals; bridge with toSignal() at the component boundary. No NgRx, no component-to-component subjects.

State Home Type
Broadcast position (displayPosition$), true position (truePosition$) PositionService BehaviorSubject<Position \| null>
Routing outputs: chainage, proximity, isOffRoute, nextStop, nextTurn, upcoming POIs RoutingService one BehaviorSubject<RoutingState> (single object — atomic updates from the worker)
Current shift, selected activity, navigating flag ShiftService BehaviorSubjects
Header title, active panel, admin unlocked ShellStateService BehaviorSubjects
Theme (light/dark) ThemeService BehaviorSubject<'light'\|'dark'>
Online/offline ConnectivityService BehaviorSubject<boolean>
Config (after load) ConfigService resolved once at APP_INITIALIZER; plain readonly object + a flags signal for remote-overridable flags
Component view state (search term, expanded accordion, pinpad code, list slices) components signals / computed

Knockout → Angular mapping is mechanical: ko.observableBehaviorSubject (shared) or signal (local); ko.computedcomputed() or RxJS map/combineLatest; subscribetoSignal/effect/async-free takeUntilDestroyed() subscriptions. valueHasMutated pokes disappear — immutable updates only.

4.3 The Position Pipeline (foundation ⭐ — unifies smoothing, clamping, depot, gps-test)

One explicit, ordered pipeline. Sources are swappable; stages are pure and individually testable; there are two outputs because honesty matters here:

                ┌──────────────────────── sources (one active) ───────────────────────┐
                │ GeolocationSource (watchPosition, high-accuracy)                     │
                │ SyntheticSource   (gps-test tool, demo mode, golden-test replay)     │
                └──────────────────────────────┬───────────────────────────────────────┘
                                        RawFix │
   stage 1  accuracyFilter   — reject Δdist/accuracy < 0.2 (config)                    
   stage 2  history+derive   — ring buffer (10, config); derive bearing, speed (m/s)   
                                               │
                             ══ truePosition$ ══╪══► RoutingService (worker), telemetry,
                                               │      diagnostics, geofences (depot, later)
   stage 3  interpolate      — dead-reckoning along route line between fixes (Phase 6; 
            (worker tick)      identity pass-through until then)                       
   stage 4  clampToRoute     — snap to route line **only when navigating AND on-route**;
                               release to true position when isOffRoute (Phase 6;      
                               identity until then)                                    
                                               │
                             ══ displayPosition$ ═══► MapService (nav disc), UI         

Interfaces (the contract to build against):

interface RawFix {
  timestamp: number; latitude: number; longitude: number;
  accuracy?: number; altitude?: number; heading?: number; speed?: number;
  source: 'gps' | 'synthetic';
}
interface Position extends RawFix {
  bearing: number | null;       // derived from history
  speedKmh: number;             // derived (GPS speed preferred, delta fallback)
  clamped: boolean;             // true only on displayPosition$ when snapped
}
interface PositionSource {
  readonly fixes$: Observable<RawFix>;
  readonly errors$: Observable<GeolocationPositionError>;
  start(): void; stop(): void;
}

Design points: - Routing always consumes truePosition$ (post-filter, pre-clamp). Clamping is presentation only ([DECISION]) and consumes RoutingState.isOffRoute — no cycle, because clamp output never feeds routing. - The legacy override() becomes PositionService.setSource(source) — the gps-test tool, demo mode, and golden-trace replay are all just SyntheticSource instances. The map-click override (mouse only) is kept as a dev affordance behind a feature flag. - The fixed 500 ms re-broadcast interval is dropped: emit on new fix now; the Phase 6 interpolator re-introduces a tick (in the worker) as the smoothing mechanism, which is what the interval was groping toward. - Depot mode is not a source — it’s an app mode triggered by a geofence detector on truePosition$ (Long horizon). The pipeline needs no change for it; that’s the point. - speedKmh on Position is what the on-time trigger (2 km/h) and interaction lock (10 km/h) consume — both become trivial map+debounce observers of the pipeline. - Fixes R8: sources/stages live in a PositionService with RxJS teardown; consumers use takeUntilDestroyed(); stop() cancels the watch. No listener arrays, no orphan intervals.

4.4 Routing engine placement (main thread; Web Worker as contingency)

4.5 Data layer

4.6 Config service

4.7 Map (imperative MapLibre service)

4.8 Service worker / offline (a real feature this time — R3)

4.9 Telemetry client (Medium horizon; interface defined now)

4.10 Feature flags

4.11 Error handling


5. Domain port + golden regression tests (sequenced FIRST)

The gate: no navigation UI work starts until these pass. This is the enforcement mechanism for “preserve the special sauce”.

5.1 What the engine is (extraction boundary)

From routing-view-model.js + location-manager.js, the pure functions:

5.2 Golden test harness — [O]

  1. Fixture format: JSONL traces of RawFix + a shape GeoJSON + POI CSV per scenario; goldens are JSONL of per-fix RoutingState (chainage to 0.1 m, proximity, isOffRoute, nextStop id, nextTurn id, formatted distance string).
  2. Record goldens from the legacy engine: run the legacy JS (it’s module.exports-clean; stub ko.observable with a 10-line shim, import the same Turf version) in Node over each trace. Human spot-checks each golden once (the legacy engine is the spec, but eyes on the output catches “faithfully ported a typo that matters”).
  3. Scenario set (minimum):
  4. Port domain/chainage/ in TS; run the same traces; diff against goldens with tight tolerances (float noise only). CI-gated forever — these tests also protect the Phase 6 interpolation work and any future OCC-corridor loading (same engine, new shape input).
  5. Property adds (cheap, high value): chainage is monotonically non-decreasing while on-route and navigating; 0 ≤ chainage ≤ totalLength; POI list is always a suffix of the original.

5.3 Tasks

# Task Who Size
D1 Trace/golden fixture format + Node runner for legacy engine (ko shim) [O] M
D2 Synthetic trace generator (route walker + noise + off-route excursion injectors) [O] M
D3 Record real drive traces via current gps-test export human/field S
D4 Generate + review goldens for scenario set [O]+human S
D5 Port domain/chainage/ (pure TS) until goldens pass [O] M
D6 Property tests + CI wiring [S] S
D7 Worker host (geo.worker.ts) + typed protocol + RoutingService wrapper; goldens re-run through the worker path [O] M

6. UI component inventory (Knockout → Angular Material)

Current (template / VM) Angular component Material / notes
index.html shell: #page, #header-bar, back button, preloader AppShellComponent, HeaderBarComponent MatToolbar; back button drains BackActionService; preloader → simple splash div removed on bootstrap
#menu-settings (only live menu from custom.js) SettingsMenuComponent MatSidenav (end position); items: dark mode MatSlideToggle, times toggle, diagnostics toggle, GPS test, demo mode, admin (PIN-gated)
Admin #collapse-1 section AdminMenuComponent MatExpansionPanel inside settings sidenav; gated by PinpadComponent per Q1
pinpad-template / PinpadViewModel (shift + admin, custom button, glass effect) PinpadComponent (one, reused — keep the good parameterisation: prompt, digits, custom button, async validate callback) Bespoke buttons grid (Material buttons); glass effect = CSS
shift-panel-template / ShiftViewModel ShiftPanelComponent + ActivityListComponent MatAccordion; activity rows via @switch (activity.kind)inheritance dropped: Activity becomes a discriminated union (trip \| transfer \| other), Transfer is just data (review verdict)
trip-template, card-template / TripViewModel, ActivityViewModel, TransferViewModel TripActivityComponent, ActivityCardComponent Trip enrichment (headsign, route names, stops, shape, POIs) moves to a TripFacade/ShiftService method — async service call, not constructor-callback (review verdict); memoized per trip_id
single-route-selection-template, select-route(-type)-template / SingleRouteSelectionViewModel + shift custom-route mode RouteSelectionComponent MatFormField search (Reactive Forms — the org-standard checkbox), CDK virtual-scroll list (thousands of enriched shapes), lazy trip hydration on select (getEnrichedShapes reuse fixes R9); RAIL mode = same component with prefix filter input
map-template / MapViewModel MapComponent (container + overlays) + MapService (§4.7) Nav overlays are bespoke: NextDirectionComponent (big banner: icon + distance), PoiListComponent (upcoming 4)
diagnostic-panel-template / DiagnosticViewModel DiagnosticPanelComponent (lazy route/flag) Simple Material cards; consumes truePosition$ + routingState$ + build info (build info via Angular environment + CI-injected version — replaces webpack DefinePlugin globals)
error-log-template / ErrorLogViewModel ErrorLogDialogComponent MatDialog + list; persisted log (§4.11); export kept
gps-test-template / GPSTestViewModel (419 lines) GpsTestComponent (lazy route, dev flag) Rebuilt on the pipeline: it becomes a SyntheticSource controller + truePosition$/log viewer + trace record/export (feeds golden fixtures — D3). Port functionally, not line-by-line
Demo mode (in app.js) DemoDriveService (dev flag) The turf.along walker as a canned SyntheticSource — shares code with D2’s trace generator; monkey-patched stop_navigation cleanup becomes ordinary teardown
SweetAlert2 confirms/progress (GTFS sync, unenroll, GPS error) MatDialogs + GtfsSyncDialogComponent (per-table MatProgressBars) Keep confirm-before-destructive pattern
custom.js survivors ThemeService (body class + map style — the MutationObserver bridge dies; ThemeService calls MapService.setTheme directly), ConnectivityBannerComponent, SW registration (via @angular/pwa), card sizing → CSS 100dvh, OS body classes only if CSS audit (Phase 3 task) shows safe-area use ~1,200 lines deleted
Enrollment screen + QR scanner (enroll_now, qr-scanner lib) EnrollmentComponent + guard only if Q2 = keep; otherwise deleted along with the qr-scanner dep Decision needed before Phase 3 ends
Wake lock (app.js) WakeLockService (re-acquire on visibilitychange — current code silently loses it) S

Deleted, not ported: ~81% of custom.js (sliders, share, OTP, ads, Swup, …), runtime import_templates(), vendor.js window-globals (→ ES imports), webpack config + cache-busting plugin, Bootstrap/sticky-ui CSS, ObjectViewModel, getShapesByPrefix (R10), the 15 highlight_*.css theme files (pending the custom.js CSS-injection check flagged by the review).


7. Feature roadmap by horizon

Every feature: flag-gated, config-driven thresholds, honours the [DECISION]s verbatim.

7.1 Immediate (next release after parity)

F1. On-time running infoonTimePanel - Tasks: (a) speedKmh observer: < 2 km/h sustained ~2 s (debounced) and at/near a stop → open left panel; auto-collapse on movement; driver-dismissable [O-design, S-build]; (b) schedule model: scheduled arrival per stop from stop_times vs shift start; “should-be” position = interpolate scheduled position at now [O]; (c) OnTimePanelComponent: full stop list, mm:ss ahead/behind, timepoints visually distinct, current-position marker + a should-be horizontal-rule marker in the list (not a map object) [S]; (d) gentle colour tokens only (no red/flash) [S]. - Depends: pipeline speed (Phase 1), shift schedule data. Only for shift-planned services — never in manual/RAIL route mode (gate on ShiftService.isPlannedShift). - Must-not-break: non-distractive rule; offline. Open: Q4 (timepoint field in data).

F2. Audible next-stop / turn calloutsaudioCallouts - AudioGuidanceService (speechSynthesis): announce on next-stop/next-turn change + distance-band re-announcements; reuse formatDistancespokenDistance. Settings toggle (off switchable) per [DECISION]. Serialise utterances; nothing queues up at a stop. [S]

F3. Audible off-route alertoffRouteAudio - Single very subtle cue on isOffRoute rising edge (short soft chime, not speech, quiet, no repeat spam — re-arm only after on-route recovery + hold-down). Pairs with existing visual flag; no behaviour change to routing. [S] Audio asset choice: human sign-off (non-distractive).

F4. Interaction lock while movinginteractionLock - InteractionLockService: speedKmh > 10 (config, not UI-editable) with hysteresis → shell signal; components/CSS disable fiddly targets (route selection, settings, admin, search keyboard); navigation-critical glances stay. Must never trap the driver — dismiss/back and the off-route display always work. [S-build, O-review of what’s locked]

7.2 Medium-term

F5. Animation smoothingpositionSmoothing (pipeline stage 3) - Dead-reckoning along the route line ([DECISION], not map easing): worker tick (~100–250 ms, config) projects forward from last fix using speed/accel along the known shape when navigating & on-route; converge (don’t jump) on each real fix; fall back to raw pass-through off-route or when not navigating. Golden tests must be unaffected (they consume truePosition$); add interpolator-specific tests (convergence, overshoot at stops). [O]

F6. Nav pill / arrow clampingnavClamping (pipeline stage 4) - Snap displayPosition$ to nearest-point-on-line only when navigating and on-route; release to true position when isOffRoute ([DECISION] — never hide reality during an excursion). Reuses the proximity intercept already computed in the worker (zero extra Turf cost). Bearing from line tangent when clamped. [O for stage, S for map hookup] - F5+F6 land together sensibly (both are “presented position” quality; one worker changeset).

F7. Telemetry monitoring (client)telemetry — build §4.9; instrument: auth/run lifecycle, navigation (incl. off-route enter/exit + duration), stop-dwell (chainage window + <2 km/h dwell timer — shares F1’s stop-proximity logic), UI opens, service-performance. [O service, S emission points] Depends: Q2 (deviceID), Q5 (endpoint).

F8. GTFS serving (shifts → API)scheduleApi — implement ApiSource (DriverID → activities; HttpClient + JWT interceptor — org standard activates here), base_url from config, cache-then-network with last-known allocation persisted (offline sign-on must-not-break); sign-on flow gains DriverID entry per API contract (Q3). [S once contract known]

7.3 Long-term (directional — design allowances only, no build now)


8. Risk mitigations (R1–R10 → concrete tasks)

Risk Mitigation task Phase
R1 Dexie migration data-loss §4.5 version chain anchored at v2; additive v3; CI upgrade test opening a seeded v2 DB; policy: schema change ⇒ upgrade() + test 2
R2 Non-atomic GTFS sync §4.5 GtfsSyncService: parse-all-first, single rw transaction, version-flag-last, abort-safe, honest failure dialog 2
R3 Offline gap §4.8 @angular/pwa app-shell prefetch; airplane-mode e2e gate in the cutover checklist 4
R4 MapTiler key hardcoded Key → config.json; rotate old key; referrer-restrict new key (Phase 0 ops task — do it before any of this ships) 0
R5 Google key (dead code) Dies with custom.js; rotate anyway (ops task) 0
R6 Admin PIN [REDACTED-PIN] in source Decision Q1; interim: PIN moves to config.json (out of repo/source), acknowledged as obfuscation-not-security; admin functions stay non-safety-critical 0 (decide) / 3 (build)
R7 Enrollment gate disabled (&& false) Decision Q2: real enrollment (guard + QR flow + deviceID) or delete the dead gate + scanner dep. No shipping a zombie 0 (decide) / 3 (build)
R8 Listener/interval leak Dissolved by §4.3: RxJS pipeline, takeUntilDestroyed(), source stop(); no listener arrays or orphan intervals survive the port 1
R9 N+1 route queries RouteSelectionComponent reuses memoized getEnrichedShapes 3
R10 Dead getShapesByPrefix Not ported

9. Testing & verification strategy

Right-sized: heavy where behaviour is safety-adjacent (engine, data), light where it’s chrome.

  1. Golden regression tests (§5) — the centrepiece. CI gate from Phase 1 forever; re-run through the worker path (D7) and after F5/F6.
  2. Unit tests (Vitest) — pipeline stages (accuracy gate, bearing, clamp release-on-off-route), GtfsSyncService atomicity (fake-indexeddb: inject failure mid-sync → assert old data intact; R1 upgrade test), ScheduleSource cache-then-network fallback, telemetry outbox (enqueue-offline → flush-online → dedupe-safe retry), ConfigService overlay/fallback, BackActionService, distance/spoken formatting.
  3. Component tests — selective: PinpadComponent (validation/async callback), OnTimePanel trigger logic (fake timers), activity list @switch. No snapshot theatre.
  4. E2E smoke (Playwright, ~5 flows) — boots the real app with a SyntheticSource seeded via the dev flag: sign-on (valid/invalid) → shift list → select trip → start navigation → drive a canned trace → next-stop updates → off-route flag raises → back-stack unwinds. Plus GTFS sync dialog happy path.
  5. Offline/PWA gate — installability audit + scripted SW checks + manual airplane-mode cold-start on the target tablet (in the M4 checklist; this is the R3 payoff and doesn’t get waved through).
  6. Performance checkpoints — on real tablet hardware: worker round-trip latency per fix on the biggest shape (budget: < 50 ms p95), map frame rate while navigating, sync duration on the full dataset, memory after an hour of simulated driving (leak check — the R8 regression test in spirit).
  7. Parity verification (Phase 3–4) — side-by-side checklist against the legacy app per feature slice (same data, same synthetic drive): a human eyeballs behavioural equivalence; goldens cover the math, this covers the UX.
  8. Field pilot before cutover — ≥1 device on real routes for ≥1 week, gps-test trace recording on, error log reviewed. Cheap, and it’s a bus app — reality is the test bench.

9.a Parity checklist (the M3/M4 cutover sign-off)

A feature slice is “at parity” only when, against the same GTFS data and the same synthetic drive, the new app matches the legacy app on:


10. Sequenced phased roadmap

Dependencies flow downward; phases 5+ overlap freely once 4 ships. Sizes: S ≤ 1 day, M ≈ 2–4 days, L ≈ 1–2 weeks of focused agent+review time.

Phase 0 — Foundations & guardrails (no app code yet) (S–M total) - Scaffold workspace (next/): Angular latest, standalone, strict, Material, ESLint/Prettier, Vitest, CI (lint+test+build). [S] - Ops: rotate + referrer-restrict MapTiler key (R4); rotate Google key (R5). [human] - Decisions locked: Q1 admin PIN, Q2 enrollment, Q3 API contract sketch, Q4 timepoint data. [human] - D1–D4: golden harness, trace generator, real-trace capture kickoff, goldens recorded. [O] - ✅ M0: CI green on empty app; goldens exist and legacy engine replays them deterministically.

Phase 1 — Domain core & position pipeline (L) - D5–D7: chainage engine port → goldens pass → worker host + RoutingService. [O] - PositionService: sources (geolocation, synthetic), stages 1–2, truePosition$/ displayPosition$ (stages 3–4 identity), teardown-correct (R8). [O] - ConfigService + app-config.ts seeded from §4.6 inventory. [S] - ✅ M1: goldens pass through the full worker path; pipeline unit tests green.

Phase 2 — Data layer (M–L) - DbService (version chain, R1 test), read API port + memoized getEnrichedShapes. [S] - GtfsSyncService atomic sync (R2) + version-check flow + sync dialog. [O sync core, S dialog] - ScheduleSource interface + StaticFileSource. [S] - ✅ M2: full sync of production GTFS data passes atomically; injected mid-sync failure leaves old data intact; sign-on query works offline.

Phase 3 — UI parity build (L; the bulk of [S] work) > Slice into vertical, PR-sized deliverables — one flow per PR (shell → map → sign-on/shift → > navigation wiring → diagnostics), each independently reviewable and parity-checkable. Avoid one > monolithic UI PR. - Shell: AppShell, header, back-action, settings sidenav, theme, connectivity banner, wake lock. [S] - Map: MapService + MapComponent + overlays + padding directive. [O service, S overlays] - Sign-on/shift: Pinpad, shift panel, activity list (@switch union), trip facade, route selection (virtual scroll, R9 fix), RAIL mode. [S] - Navigation loop wired end-to-end: select → overview → start nav → guidance → stop. [O wiring] - Diagnostics, error handler + persisted log + dialog, GPS-test rebuild (+trace export), demo service. [S, gps-test O-assist] - Q1/Q2 outcomes built (admin gate; enrollment or deletion). [S] - ✅ M3 “parity build”: every legacy user flow works in the new app against the same data; side-by-side checklist signed off; perf checkpoints met (worker-deferral decision taken here).

Phase 4 — PWA, hardening, cutover (M) - @angular/pwa + ngsw config + update flow (§4.8); install/offline gates. [S config, O review] - E2E smoke suite; field pilot (≥1 week); bug burn-down. [S + human] - Cutover checklist: staging→prod swap, legacy at fallback URL, SW-release stub in fallback, localStorage/Dexie compatibility verified on an upgraded real device. - ✅ M4 CUTOVER. Rollback = URL repoint (§3.3), available for a full release cycle.

Phase 5 — Immediate horizon (M–L) — F1 on-time panel [O+S], F2 callouts [S], F3 off-route audio [S], F4 interaction lock [S+O review]. Flag-gated; staged to the pilot device first. ✅ M5: Immediate features live; non-distractive review sign-off (human, on a moving vehicle).

Phase 6 — Medium horizon (L) — F5+F6 smoothing & clamping (one worker changeset, goldens untouched) [O]; F7 telemetry client [O+S]; F8 ApiSource when contract lands [S]. ✅ M6: smoothed+clamped nav in field use; outbox proven over real connectivity gaps.

Phase 7 — Long horizon (design-first) — depot geofence spike, OCC channel spike (Q6), corridor-loading proof on the golden harness. Build only with a backend commitment.


11. Open questions / decisions needed

ideas.md currently contains no unresolved [OPEN] tags — all its calls are [DECISION]s (honoured above). The following need answers; Q1–Q4 before Phase 3 ends, and none block Phases 0–2 (except the decisions themselves being Phase 0 tasks):


12. Explicitly out of scope


Opus tightening applied to Fable 5 draft v1. The draft’s suggested review focus is addressed: (a) Phase 3 sliced into PR-sized vertical deliverables (§10); (b) worker demoted to a measured contingency with an explicit checkpoint (§4.4, Q10); (c) Q1/Q2 recommendations stand (§11); (d) F6 clamping flagged to pull forward into Immediate (§11 Q11). Remaining decisions for Sandy: Q1–Q11 (§11).