Files
arah2/.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-PLAN.md
T

20 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-turn-by-turn-guidance-live-driving 02 execute 1
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
true
GUID-02
GUID-03
GUID-04
GUID-06
truths artifacts key_links
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
path provides exports
web/src/guidance/guidance.ts THE pure D-04 state machine + route projection + named constants
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 provides exports
web/src/guidance/eta.ts EMA + deadband honest ETA (D-07)
updateEta
EMA_ETA_ALPHA
DEADBAND_MIN_S
DEADBAND_REMAINING_PCT
path provides exports
web/src/guidance/watch.ts watchPosition wrapper + ≥10 m / ≥5 s distance throttle (D-15)
shouldAcceptFix
createGuidanceWatch
THROTTLE_MIN_MOVE_M
THROTTLE_MIN_ELAPSED_MS
path provides
web/test/fixtures/drives/corridor-follow.json D-16 drive replay: clean corridor follow to arrival
from to via pattern
web/src/guidance/guidance.ts routing/src/geo.ts haversineM for deviation distances (never raw Δdegrees) haversineM
from to via pattern
web/test/guidance-replay.test.mjs web/src/guidance/guidance.ts drive fixtures replayed through stepGuidance stepGuidance
from to via pattern
web/test/guidance-replay.test.mjs routing/test/fixtures/golden-pairs.json drives derived from a real committed route line 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.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.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

<threat_model>

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.
</threat_model>
- `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/

<success_criteria> 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. </success_criteria>

After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-SUMMARY.md`