--- phase: 06-turn-by-turn-guidance-live-driving plan: 02 type: execute wave: 1 depends_on: [] files_modified: - web/src/guidance/guidance.ts - web/src/guidance/eta.ts - web/src/guidance/watch.ts - web/test/guidance-replay.test.mjs - web/test/guidance-eta.test.mjs - web/test/fixtures/drives/corridor-follow.json - web/test/fixtures/drives/off-route-excursion.json - web/test/fixtures/drives/missed-turn-excursion.json - web/test/fixtures/drives/gps-jitter.json autonomous: true requirements: [GUID-02, GUID-03, GUID-04, GUID-06] must_haves: truths: - "A recorded drive replays through the state machine asserting every D-04 transition, the storm guard, and arrival (D-16)" - "A missed turn silently triggers exactly ONE reroute request — storm-guard holds under excursion storms (GUID-02)" - "GPS jitter never leaves ON_ROUTE — the accuracy-aware threshold + 3-consecutive-fixes + 8s grace absorb it" - "ETA is EMA-smoothed with the ±1 min / ±5% deadband — no visible whiplash across a jitter stream (D-07)" - "Haptic effects fire once per edge: approach at 300 m, impact at the maneuver, success at arrival (D-08)" - "Guidance work runs only on ≥10 m moved or ≥5 s elapsed — the D-15 distance throttle is pure and tested" artifacts: - path: "web/src/guidance/guidance.ts" provides: "THE pure D-04 state machine + route projection + named constants" exports: ["createGuidanceSession", "stepGuidance", "projectAlongRoute", "OFF_ROUTE_THRESHOLD_M", "OFF_ROUTE_CONSECUTIVE_FIXES", "OFF_ROUTE_GRACE_MS", "STORM_GUARD_MS", "MISSED_TURN_PAST_MANEUVER_M", "ARRIVAL_THRESHOLD_M", "APPROACH_HAPTIC_M", "GUIDANCE_START_ALONG_M"] - path: "web/src/guidance/eta.ts" provides: "EMA + deadband honest ETA (D-07)" exports: ["updateEta", "EMA_ETA_ALPHA", "DEADBAND_MIN_S", "DEADBAND_REMAINING_PCT"] - path: "web/src/guidance/watch.ts" provides: "watchPosition wrapper + ≥10 m / ≥5 s distance throttle (D-15)" exports: ["shouldAcceptFix", "createGuidanceWatch", "THROTTLE_MIN_MOVE_M", "THROTTLE_MIN_ELAPSED_MS"] - path: "web/test/fixtures/drives/corridor-follow.json" provides: "D-16 drive replay: clean corridor follow to arrival" key_links: - from: "web/src/guidance/guidance.ts" to: "routing/src/geo.ts" via: "haversineM for deviation distances (never raw Δdegrees)" pattern: "haversineM" - from: "web/test/guidance-replay.test.mjs" to: "web/src/guidance/guidance.ts" via: "drive fixtures replayed through stepGuidance" pattern: "stepGuidance" - from: "web/test/guidance-replay.test.mjs" to: "routing/test/fixtures/golden-pairs.json" via: "drives derived from a real committed route line" pattern: "golden-pairs" --- The guidance brain: THE pure, Node-testable D-04 reroute state machine with named replay-tuned constants, the D-07 honest ETA (EMA + deadband), the D-15 distance throttle, and the four D-16 drive fixtures — proven by replay tests before any DOM, map, or Worker surface exists. Purpose: every guidance behavior the user experiences (silent self-heal, honest ETA, haptics, seamless resume) is decided HERE, replay-verifiable in Node in seconds. Plans 05/06 only render and wire what this plan decides. Output: `web/src/guidance/{guidance,eta,watch}.ts` + 4 committed drive fixtures + replay/eta suites green. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md @.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md export type Fix = { lat: number; lng: number; accuracyM: number | null; headingDeg: number | null; ts: number; source: FixSource; }; // RoutePoint {lat,lng}; RouteStep {distanceM?, durationS?, name?, geometry?, kind?} // deriveCumulativeDistanceM(points) -> number[] (meters along the line, [0]===0) // haversineM(a, b) -> meters | OFF_ROUTE_THRESHOLD_M | 50 | accuracy-aware: max(35, 2 × accuracyM), capped 100 | | OFF_ROUTE_CONSECUTIVE_FIXES | 3 | GPS-jitter rejection | | OFF_ROUTE_GRACE_MS | 8000 | grace window before acting (D-04) | | STORM_GUARD_MS | 20000 | min time between recalculations (D-04) | | MISSED_TURN_PAST_MANEUVER_M | 40 | off-route past the maneuver shortens grace (D-04) | | ARRIVAL_THRESHOLD_M | 25 | snapped progress ≥ end-of-route (D-14) | | APPROACH_HAPTIC_M | 300 | maneuver-approach haptic (D-08 / UI-SPEC) | | GUIDANCE_START_ALONG_M | 30 | route drawn + first fix ≥ 30 m along → guidance starts | | EMA_ETA_ALPHA (plan task 2) | 0.25 | per-fix smoothing (D-07) | | DEADBAND_MIN_S | 60 | ±1 min display deadband (D-07) | | DEADBAND_REMAINING_PCT | 0.05 | ±5% remaining deadband (D-07) | | THROTTLE_MIN_MOVE_M | 10 | D-15 distance throttle | | THROTTLE_MIN_ELAPSED_MS | 5000 | D-15 time floor | **Test style:** `web/test/location-chain.test.mjs`'s stub-install + restore-in-finally and the harness registry (`import { test } from "./harness.mjs"`). Node-24 type-stripping dynamic imports of `.ts` sources. NO DOM, NO maplibre, NO console in the three modules (the route.ts law — console state tokens are the CONTROLLER's job, plan 06). task 1: Drive fixtures + THE D-04 state machine (guidance.ts) — RED→GREEN web/test/fixtures/drives/*.json, web/src/guidance/guidance.ts, web/test/guidance-replay.test.mjs - web/src/location/types.ts (the six-field Fix — fixtures are Fix-shaped JSON streams, PATTERNS law) - routing/test/fixtures/golden-pairs.json (the committed real KL route lines drives are derived from) - web/src/route/route.ts lines 30–52 (the pure-injected-deps + named-constants posture this module follows) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 3 (the state machine + constants table) and Pitfall 6 (reroute storm: after ON_ROUTE, deviation is measured against the NEW route only) - corridor-follow.json replays: state stays "on_route" throughout, ZERO reroute-requested effects, haptic approach fires once ~300 m before the final maneuver, arrival effect fires at the end (D-16) - off-route-excursion.json: 3 consecutive off-threshold fixes spanning ≥8 s → state "off_route" → reroute-requested effect; after reroute-applied → "on_route"; a SECOND excursion injected immediately → NO second reroute-requested within STORM_GUARD_MS (Pitfall 6) - missed-turn-excursion.json: off-route ≥ MISSED_TURN_PAST_MANEUVER_M past the maneuver point → grace shortens → reroute-requested fires earlier than the standard grace - gps-jitter.json: ±15 m alternating fixes with accuracyM 20 (threshold max(35, 2×20)=40 m) → state NEVER leaves "on_route", zero reroute requests - guidance start gate: a stationary first fix (< GUIDANCE_START_ALONG_M along) → not started; a fix ≥ 30 m along → started (PX-03 protections may bind) - reroute-failed while rerouting → state "no_reroute" (the honest degradation, D-03) 1. RED: write `web/test/guidance-replay.test.mjs` — a `replayDrive(fixtureName, overrides?)` helper: loads the fixture JSON, builds the GuidanceRouteModel from the SAME golden-pair route the drive was derived from (`decodePolyline6` → points → `deriveCumulativeDistanceM` → steps synthesized at the fixture's recorded maneuver indices with the fixture's turn kinds), then folds each Fix through `stepGuidance` and records `{snap, effects}` frames. Assertions per behavior list. The initial run fails on import (module absent) — the RED state. 2. Build the four fixtures FIRST as committed JSON, each `{ "_notice": "synthetic drive derived from golden-pairs.json ; D-16 replay fixture — Fix-shaped", "pairId": "...", "maneuverIdx": [i0, i1, ...], "fixes": [ {lat,lng,accuracyM,headingDeg,ts,source}, ... ] }`: - Decode the pair's polyline6, sample along the line at ~8 m intervals (~1 Hz at 30 km/h) for corridor-follow (accuracyM 8, headingDeg from bearing, source "webview"). - off-route-excursion: corridor prefix, then a perpendicular offset growing to ~80 m (accuracyM 10 — inside the honest threshold), held ≥ 12 s / 4 fixes, then rejoin. - missed-turn-excursion: follow to one recorded maneuver index, then continue straight past it ≥ 60 m off the new leg, then rejoin. - gps-jitter: corridor with alternating ±15 m perpendicular noise, accuracyM 20, timestamps 1 s apart. Derive coordinates programmatically (a small throwaway generator inline in the test run or a scratch node -e) and COMMIT the resulting JSON — golden-pairs data is committed public ODbL, so derived drives carry the same `_notice` attribution line. 3. GREEN: `web/src/guidance/guidance.ts` — pure, no DOM/map/console imports: - `GuidanceState = "idle" | "on_route" | "off_route" | "rerouting" | "resuming" | "no_reroute"`; `GuidanceRouteModel = { points: RoutePoint[]; cumulative: number[]; steps: RouteStep[]; destinationId: string; destinationName: string }`. - `projectAlongRoute(points, cumulative, fix, fromIdx): { idx: number; snapped: RoutePoint; alongM: number; remainingM: number }` — walk FORWARD from fromIdx only (the anti-pattern law: never global nearest-point per fix), point-to-segment in cos(lat)-scaled equirectangular space, remainingM from cumulative difference (never raw fix-to-destination distance, D-07). - `createGuidanceSession(model, firstFix, nowMs): GuidanceSnapshot` + `stepGuidance(snap: GuidanceSnapshot, event: GuidanceEvent, nowMs): { snap: GuidanceSnapshot; effects: GuidanceEffect[] }` — a pure reducer over `GuidanceEvent = { type: "fix"; fix: Fix } | { type: "reroute-started" } | { type: "reroute-failed" } | { type: "reroute-applied"; model: GuidanceRouteModel }`. `GuidanceEffect = { type: "haptic"; cue: "approach" | "maneuver" | "arrive" } | { type: "reroute-requested"; origin: RoutePoint } | { type: "arrival" }`. All constants from the context table as named exports. After reroute-applied, deviation is measured against the NEW model only (Pitfall 6). Effects are once-per-edge: approach fires once per maneuver (reset on reroute-apply), maneuver haptic when progress crosses a step boundary, arrive once. 4. Structural assertions in the same suite: `grep`-style source scan — guidance.ts contains no `console.`, no `document`, no `maplibre`, no `localStorage` (the pure-module + no-storage laws, RS10-analog). npm test -w web - all four JSON fixtures exist under web/test/fixtures/drives/ and each carries a `_notice` attribution line and Fix-shaped `fixes` - guidance-replay.test.mjs asserts: corridor (on_route, 0 reroute, arrival), excursion (exactly 1 reroute-requested, storm guard holds), missed-turn (shortened grace), jitter (never off_route) - grep `console\.|document\.|maplibre|localStorage` → 0 matches in web/src/guidance/guidance.ts - named constants exported: OFF_ROUTE_THRESHOLD_M=50, OFF_ROUTE_CONSECUTIVE_FIXES=3, OFF_ROUTE_GRACE_MS=8000, STORM_GUARD_MS=20000, MISSED_TURN_PAST_MANEUVER_M=40, ARRIVAL_THRESHOLD_M=25, APPROACH_HAPTIC_M=300, GUIDANCE_START_ALONG_M=30 (suite asserts the exported values, RS4 style) - grep `haversineM` matches web/src/guidance/guidance.ts (one distance authority) Every D-04 transition, the storm guard, missed-turn shortening, jitter absorption, arrival, and the D-08 effect stream are replay-proven in Node; the machine is pure with zero browser imports task 2: Honest ETA — EMA + deadband (eta.ts) — RED→GREEN web/src/guidance/eta.ts, web/test/guidance-eta.test.mjs - web/src/guidance/guidance.ts (task 1 — remainingM/remainingS come from projectAlongRoute, never from fix-to-destination distance) - routing/src/geo.ts (the named-constants pure-module posture) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md D-07 + Assumption A5 (α ≈ 0.25/fix, ±1 min / ±5% deadband) - first update seeds the EMA directly (no smoothing toward zero) - a raw remaining-time sequence with ±40 s jitter produces a displayed sequence whose successive DISPLAYED values never differ by less than the deadband allows — updates only when |new − displayed| ≥ 60 s AND ≥ 5% of remaining (either bound passes → update) - a genuine 5-minute drop updates the display within 2 fixes - remaining distance follows the same deadband (±5%) - no input ever reads a raw fix→destination distance (the function consumes remainingS/remainingM computed by the caller from the route model — signature enforces D-07) 1. RED: `web/test/guidance-eta.test.mjs` — feed synthetic remaining-S/M sequences (jitter, drop, plateau) through `updateEta`; assert deadband behavior + the EMA trajectory (α = 0.25 convergence: after n fixes, value ≈ within 10% of target). 2. GREEN: `web/src/guidance/eta.ts` — `updateEta(prev: EtaState | null, remainingS: number, remainingM: number): EtaState` where `EtaState = { etaS: number; remainingM: number; displayedEtaS: number; displayedRemainingM: number }`. EMA: `smoothed = α × raw + (1 − α) × smoothed` per axis; display update only past BOTH deadbands? NO — past EITHER deadband (±1 min on time OR ±5% on distance triggers that axis's refresh; axes independent). Constants named + exported. Also export `composeEtaClock(etaS, nowMs): string` — 24-hour `HH:MM` = now + displayed etaS (the UI-SPEC instrument format; en/ms identical). npm test -w web - web/src/guidance/eta.ts exports updateEta, composeEtaClock, EMA_ETA_ALPHA=0.25, DEADBAND_MIN_S=60, DEADBAND_REMAINING_PCT=0.05 - guidance-eta.test.mjs asserts: seed, jitter-holds-display, real-drop-updates, independent axes - grep `console\.|Fix` → updateEta's signature takes numbers, not a Fix (D-07's "never raw fix-to-destination" is structural) - composeEtaClock("…", fixedMs) returns exactly `HH:MM` 24-hour format for known inputs ETA smoothing is proven whiplash-free under jitter and responsive to real changes; the deadband constants are exported and pinned by test task 3: watchPosition + distance throttle (watch.ts) web/src/guidance/watch.ts, web/test/guidance-replay.test.mjs - web/src/location/webview.ts (the promise-wrapped callback + settled latch + hard-timeout + errors-resolve-null posture watch.ts extends) - web/src/location/chain.ts lines 1–20 (the D-05 cadence note this phase finally flips to watchPosition) - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Distance throttle (D-15)" code example (the exact accept rule) - shouldAcceptFix(null, fix, 0) → true (first fix always accepted) - 9 m moved, 1 s elapsed → false; 10 m moved → true; 11 m at 1 s → true; 3 m at 5001 ms → true; 3 m at 4999 ms → false - createGuidanceWatch starts watchPosition with { enableHighAccuracy: true, maximumAge: 0 } (structural assertion on the recorded options) - stopWatch() clears the watcher id (the privacy-minimal stop — D-14's "the watch stops") 1. Extend `web/test/guidance-replay.test.mjs` with a throttle block (the pure rules are replay-adjacent; keep the suite count at the VALIDATION map's three): assert the shouldAcceptFix truth table and, with a stubbed `geolocation.watchPosition` (the location-chain stub style), that createGuidanceWatch wires high-accuracy options and stopWatch calls clearWatch with the issued id. 2. `web/src/guidance/watch.ts`: `shouldAcceptFix(last: Fix | null, fix: Fix, lastAcceptedTs: number): boolean` — accept when `last === null || haversineM(last, fix) >= THROTTLE_MIN_MOVE_M || fix.ts - lastAcceptedTs >= THROTTLE_MIN_ELAPSED_MS` (≥10 m moved OR ≥5 s elapsed, whichever first — D-15); `createGuidanceWatch(geo: Geolocation, onFix: (fix: Fix) => void): { stop(): void }` — watchPosition high-accuracy, normalizes the payload into the six-field Fix (`accuracyM ?? null, headingDeg ?? null, source: "webview"` — the telegram.ts normalization precedent), feeds ONLY accepted fixes to onFix. npm test -w web - web/src/guidance/watch.ts exports shouldAcceptFix, createGuidanceWatch, THROTTLE_MIN_MOVE_M=10, THROTTLE_MIN_ELAPSED_MS=5000 - the replay suite's throttle block asserts the full accept/reject truth table - structural assertion: watchPosition called with enableHighAccuracy true and maximumAge 0 - grep `haversineM` matches web/src/guidance/watch.ts The D-15 gated-streaming law is flipped ON: a high-accuracy watch whose feed is distance-throttled, fully Node-tested, with a clean stop for trip end ## Trust Boundaries | Boundary | Description | |----------|-------------| | GPS stream → state machine | Position fixes are device-local sensor input; the machine is pure so nothing about a fix outlives the caller's frame | | reroute-requested effect → network (plan 06) | The ONLY in-drive position ever transmitted is a reroute origin | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-06-05 | Information Disclosure | guidance.ts / watch.ts | mitigate-by-design | On-route fixes NEVER leave the device: the machine computes deviation/progress/ETA locally; the only network-bound position is the reroute-requested effect's origin (one point, on a real excursion). Structural gate: no fetch/console/localStorage in any of the three modules. | | T-06-05b | Information Disclosure | watch.ts | mitigate | The watch STOPS at trip end (`stop()` exported + wired in plan 06) — no location flow after the trip; replay/structural tests pin the stop path's existence. | | — | DoS (resource) | stepGuidance hot loop | accept | Pure reducer with a forward-walking projection window (O(local)); no unbounded memory; replay fixtures prove bounded work per fix. | - `npm test -w web` — replay + eta suites green (all four drives), existing web suites untouched-green - All D-04/D-07/D-08/D-14/D-15 constants exported with pinned values - Zero browser/DOM/network imports in web/src/guidance/ The guidance brain is done and replay-proven: the four recorded drives exercise every transition (corridor → arrival, excursion → exactly one reroute + storm guard, missed turn → shortened grace, jitter → absorbed), ETA never whiplashes, haptics fire once per edge, and the watch throttles at ≥10 m / ≥5 s. Plans 05/06/08 render, wire, and capture this — they never re-decide it. After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-SUMMARY.md`