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.jstriage), andcurrent/(Knockout code). Overriding value: simplicity — reusable components/services, minimal boilerplate, a well-written happy medium. No gold-plating.
The Fable 5 draft is strong and adopted almost wholesale. Five substantive tightenings:
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.All ideas.md [DECISION]s are honoured; no
hard constraint is violated.
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:
ScheduleSource interface are designed once so the
Immediate/ Medium/Long features snap on as stages/implementations rather
than rework.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.
shifts
moves to API; core GTFS stays on-device).| 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. |
@if/@for/@switch), Angular CLI
(esbuild) — webpack is dropped.accordian_clicked → onActivitySelected,
activites/ → activities/).tbt/next/
(working name) alongside tbt/current/; single Angular CLI
workspace, no Nx (one app, no monorepo need).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:
next/) with the engineering baseline above. Nothing from
current/ is imported wholesale; libraries carry over as npm
deps (MapLibre GL, Turf, Dexie, PapaParse).@Injectables.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.
tbt-next.apps.sandytulloch.com) for tablet
testing.tbt) at the same schema version
(2), adding only additive versions (§4.5) — so a rollback finds
its data intact. The GTFS payload is re-syncable by design, so even a
worst-case wipe self-heals via the existing empty-DB detection → sync
flow (kept in the new app). localStorage keys
(JourneyHelper-gtfs-version,
JourneyHelper-device-token) are reused unchanged.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
'' →
AppShellComponent (map + panels); gps-test and
diagnostics as lazy-loaded routes (they’re dev/ops tools —
routing them keeps them out of the main bundle via
loadComponent). An enrollmentGuard on the root
only if Q2 resolves to “keep enrollment”.BackActionService — a
small stack service (push(action), pop()), the
Angular-shaped version of the current appVM.back_actions.
The header back button and the hardware/browser back (via a
popstate hook) both drain it. Concept kept,
appVM smell removed.BehaviorSubject/signals in small services), rendered with
@if/Material sidenav/bottom-sheet.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.observable
→ BehaviorSubject (shared) or signal (local);
ko.computed → computed() or RxJS
map/combineLatest; subscribe →
toSignal/effect/async-free
takeUntilDestroyed() subscriptions.
valueHasMutated pokes disappear — immutable updates
only.
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.
domain/chainage/ is pure TS, zero Angular
imports — shared verbatim by the golden tests, the main thread,
and (if built) the worker.nearestPointOnLine / lineSliceAlong run over
the selected route’s shape (hundreds–low-thousands of
points), not the 241k whole-feed total (that figure is
a sync/enrichment cost, not a per-fix one). At ~1 Hz this is comfortably
a main-thread workload. RoutingService feeds
truePosition$ into the pure engine and exposes
routingState$ (BehaviorSubject) —
findProximity + findChainage (forward
lineSliceAlong window) + POI-list advance → one
RoutingState per fix.geo.worker.ts only if the Phase 3 perf
checkpoint shows p95 per-fix cost > ~16 ms (one frame) on the real
tablet, or the F5 interpolation tick measurably janks.
RoutingService is written so relocating the engine into a
worker later is an internal change (same pure functions, same
routingState$ contract, structured-clone the shape once on
activity change). If built: a typed postMessage protocol
(SetActivity, PositionInput,
Reset → RoutingState) — no Comlink for three
message types. Reaffirm as Q10.DbService — the Dexie wrapper. Same
DB name tbt; version chain:
version(2) — current schema verbatim (compound indices
preserved: [trip_id+stop_id],
[trip_id+stop_sequence],
[shape_id+poi_sequence],
[activity_id+shift_id]). This is the baseline so existing
installs open cleanly (R1).version(3) — additive:
telemetry_outbox
(&eventId,timestamp,eventType), error_log
(++id,timestamp), meta
(&key). No upgrade() needed for additive
tables, but the version bump is written explicitly and an
upgrade test (open a seeded v2 DB, assert GTFS rows
survive) goes in CI. Any future schema change must ship an
upgrade() + test — policy, not hope.Read API — port DataManager’s
methods near-verbatim (they’re good): getEnrichedShapes
(batch-then-join, now memoized per DB generation —
invalidated on sync), getShiftActivities,
getTrip/Route/Stop/StopTimes/Shape/Pois. Drop
getShapesByPrefix (R10, dead/broken) — the RAIL prefix
filter stays an in-memory filter over enriched shapes, as today. Fix R9
(N+1 in single-route-selection) by reusing
getEnrichedShapes.
GtfsSyncService — atomic sync
(R2):
typeExceptions coercion map
— today’s only validation layer), into memory. No DB writes yet.rw transaction across all
tables: clear + chunked bulkPut (1000 rows,
config) + write meta.gtfs_version last, inside the
transaction.dataGeneration$ tick — no
location.reload(); consumers re-query reactively
(review’s verdict).version.txt + localStorage,
cache-busted, SW-bypassed) and its trigger points (app init, sign-on)
carry over as-is; keep confirm-before-destructive Material dialog.ScheduleSource (ideas.md
[DECISION]) — the seam for GTFS-serving evolution:
interface ScheduleSource {
getActivities(id: string): Promise<Activity[]>; // shiftID now; driverID later
}StaticFileSource (now): reads the synced
shifts table — pure carry-over.ApiSource (Medium): HttpClient + JWT
against config.base_url,
cache-then-network — persist the last-known allocation
in Dexie so offline sign-on always works (must-not-break).ConfigService: typed defaults
compiled in (app-config.ts), overlaid by an optional
assets/config.json fetched at APP_INITIALIZER (deploy-time
editable without rebuild — preserves static-file deployability),
overlaid by an optional remote config URL later (Medium, same shape).
Missing/failed fetch → defaults; never block startup on
config (offline-first).
Seed inventory (from the review, all with these defaults):
| Key | Default | Notes |
|---|---|---|
routing.offRouteThresholdM |
50 | not UI-editable |
routing.lookAheadWindowM |
50 | forward chainage search window |
position.accuracyGateRatio |
0.2 | noise filter |
position.historySize |
10 | ring buffer |
position.interpolationTickMs |
500 | Phase 6 (replaces broadcast interval) |
ui.onTimeTriggerKmh /
.onTimeDebounceMs |
2 / 2000 | Immediate |
ui.interactionLockKmh |
10 | Immediate |
ui.distanceRoundingTiers |
5 m / 50 m / 0.1 km | display rounding |
ui.errorLogMax |
100 | |
ui.gpsNotStartedTimeoutMs |
2000 | diagnostics |
map.defaultCenter |
−27.528, 153.197 | Brisbane |
map.themeStyles.{light,dark} |
MapTiler UUIDs | |
map.turnHighlight |
0.05 km slice / 0.003 km buffer | |
map.maptilerKey |
out of source → config.json | R4: rotate + referrer-restrict |
gtfs.baseUrl |
./gtfs_data/ |
becomes remote base_url (Medium) |
gtfs.syncChunkSize |
1000 | |
api.baseUrl, telemetry.endpoint,
telemetry.flushIntervalMs |
unset / off | Medium |
flags.* |
see §4.10 |
Honesty note on keys: a static PWA cannot truly
hide a key; the mitigation is rotation + MapTiler referrer/origin
restriction (task in Phase 0) and keeping it out of the repo. The Google
key (R5) dies with custom.js; rotate it anyway. Admin PIN
(R6) → §11.
MapService owns the
maplibregl.Map instance and all GL objects (nav disc
marker, POI marker, route_line layer,
upcoming-turn extrusion). Angular owns the chrome;
nothing binds into the canvas
([DECISION]).MapComponent provides the container div
(ngAfterViewInit → mapService.attach(el)) and
hosts the HTML overlays (next-direction banner, POI list) as ordinary
components.displayPosition$ (disc + camera),
routingState$ (next-stop marker, turn highlight),
theme$ (style swap), and a padding signal —
the jQuery $('#shift-panel-container').width() measurements
become a small MapPaddingDirective using
ResizeObserver on the panels (review: never DOM in
services… except this service, whose whole job is the GL DOM).turf.buffer over a 100 m
slice is cheap — main thread is fine; only whole-shape scans go to the
worker.ng add @angular/pwa; ngsw-config.json:
prefetch (installs
the whole UI for offline).assets/config.json: freshness, short
timeout, cached fallback.gtfs_data/** + version.txt:
bypass/network-only — GTFS lives in IndexedDB via
explicit sync; letting the SW cache 100 MB of CSVs would be double
storage. Version checks must see the network truth (they already no-op
gracefully offline).api.maptiler.com):
network-only. Known caveat: map imagery is not
available offline; navigation data (route line, POIs, chainage,
guidance) is. This matches today’s reality; offline tile packs are a
possible Long-horizon item, explicitly not now.SwUpdate → non-distractive “update
available” affordance (apply on next idle at 0 km/h or on restart —
never mid-drive).TelemetryService.emit(eventType, payload)
— fire-and-forget, synchronous enqueue, never throws to callers (must
never block/crash driver UX).telemetry_outbox Dexie
table (survives restarts/offline). Envelope per [DECISION]:
{ deviceID, timestamp, eventId (UUID), eventType, schemaVersion } + payload
(ad-hoc payload, fixed envelope). deviceID from
JourneyHelper-device-token or a generated persisted UUID
(ties to Q2 enrollment).[DECISION]):
periodic batch fetch (config interval) + immediate flush on
key events (start/end run, sign-on/off) +
navigator.sendBeacon on
pagehide/visibilitychange. Delete rows only on
2xx; retry with capped exponential backoff; at-least-once with
server-side dedupe on eventId ([DECISION]).
Sampling knob for high-frequency UI events (config).[DECISION] list):
auth, run-lifecycle, navigation
(incl. off-route enter/exit), stop-dwell, ui,
service-performance, error.config.flags: Record<string, boolean>
in ConfigService (defaults compiled in, overridable via config.json /
remote config), read via config.flag('onTimePanel').onTimePanel, audioCallouts,
offRouteAudio, interactionLock,
positionSmoothing, navClamping,
telemetry, scheduleApi,
mapClickOverride (dev), gpsTest (dev). Each
horizon feature ships dark behind its flag; flags removed once bedded in
(flags are for rollout, not permanent config — anti-gold-plating
rule).ErrorHandler (+ the
unhandledrejection listener Angular’s handler misses) →
ErrorLogService: persisted ring buffer in
Dexie (error_log, max 100 from config) — the
current in-memory log dies on the crash-reload it exists to explain;
persistence fixes that.eventType: 'error' — one pipeline, two sinks.PositionService.errors$ subscriber in the shell.The gate: no navigation UI work starts until these pass. This is the enforcement mechanism for “preserve the special sauce”.
From routing-view-model.js +
location-manager.js, the pure functions:
findProximity(shape, point) — nearest-point distance in
metres (off-route input).findChainage(shape, point, currentChainage, lookAheadM)
— forward-only window scan:
lineSliceAlong(current, current+50 m) when navigating,
whole-line otherwise; per-segment nearestPointOnLine with
local→global chainage conversion. Port bug-for-bug —
including the from-zero full-line search when
currentChainage === 0 and the behaviour that chainage
halts (does not regress or advance) while off-route
(update_chainage gates on !isOffRoute).isOffRoute(proximity, thresholdM, navigating) —
navigating && proximity > 50.buildPoiList(pois) (icon mapping,
cumulative→incremental chainage, display strings) and
advancePois(pois, chainage) (filter
chainage_meters >= chainage; nextDirection = head;
nextStop = first Stop; nextTurn = first Turn; upcoming =
slice(1,5)).formatDistance(m) — the 5 m / 50 m / 0.1 km rounding
tiers (drivers see these strings; lock them).accuracyGate(prev, fix, ratio) and
deriveBearing(history) from LocationManager.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).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”).turf.along walker — it already exists as a
synthetic driver).isOffRoute
transitions exactly where the legacy does, and recovery re-acquires
without jumping backwards.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).0 ≤ chainage ≤ totalLength; POI list is always a suffix of
the original.| # | 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 |
| 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).
Every feature: flag-gated, config-driven thresholds, honours the
[DECISION]s verbatim.
F1. On-time running info — onTimePanel
- 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 callouts —
audioCallouts - AudioGuidanceService
(speechSynthesis): announce on next-stop/next-turn change +
distance-band re-announcements; reuse formatDistance →
spokenDistance. Settings toggle (off
switchable) per [DECISION]. Serialise utterances;
nothing queues up at a stop. [S]
F3. Audible off-route alert —
offRouteAudio - 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 moving —
interactionLock - 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]
F5. Animation smoothing —
positionSmoothing (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 clamping —
navClamping (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]
truePosition$ (polygon in config/data) → top-down zoom mode
in MapService; spot polygons as a data-driven layer; spot
allocation + confirm-spot = first OCC push channel use
case. Allowance made: pipeline mode switching + MapService layer API
already fit.SetActivity with a new shape — protocol already supports
it). No auto-reroute, ever. Golden harness reused to
validate corridor loading.OccChannelService abstraction (transport TBD — Q6);
notification UI honouring non-distractive rules.| 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 | — |
Right-sized: heavy where behaviour is safety-adjacent (engine, data), light where it’s chrome.
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.@switch. No snapshot theatre.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.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:
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.
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):
config.json (out of source;
obfuscation-not-security — probably fine, the menu only triggers
re-syncs);
deviceID provenance (enrollment token vs generated
UUID). Recommend: decide by intent — if fleet device management is
coming, keep + finish it; otherwise delete and use a generated persisted
deviceID for telemetry.stop_times feed carry a timepoint flag (GTFS
standard field) or another marker? The on-time panel’s “timepoints
distinctively marked” needs it in the data or a rule for deriving
it.[DECISION], no in-app consent UI).100dvh, wake lock,
sendBeacon, worker perf budgets) — one-line answer, Phase
0.ideas.md §4 “always
worker” call for “measure first.”/telemetry backend (client-side
enablement only — per ideas.md §5).[DECISION]).custom.js,
getShapesByPrefix, ObjectViewModel, webpack
tooling.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).