424 lines
35 KiB
Markdown
424 lines
35 KiB
Markdown
# Phase 6: Turn-by-Turn Guidance (Live Driving) - Pattern Map
|
||
|
||
**Mapped:** 2026-10-24
|
||
**Files analyzed:** 25 new/modified files across `routing/`, `web/src/guidance/`, `web/src/location|map|route|telegram/`, `bot/src/`, `bot/wrangler.jsonc`, and the three test workspaces
|
||
**Analogs found:** 22 / 25 (3 partial — the CSR/A\* data plane, the glyph rasterizer script, and the Worker `scheduled()` handler are repo firsts; RESEARCH.md Patterns 1–2 + Code Examples are the fallback)
|
||
|
||
## File Classification
|
||
|
||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||
|-------------------|------|-----------|----------------|---------------|
|
||
| `routing/src/graph.ts` (new) | model (binary decode) | transform (ArrayBuffer → typed arrays) | `routing/src/polyline.ts` codec + `fixture-source.ts` lazy index | role-match |
|
||
| `routing/src/snap.ts` (new) | utility (geo math) | transform | `routing/src/geo.ts` | exact |
|
||
| `routing/src/astar.ts` (new) | service (path computation) | batch | none — pure-math posture of `routing/src/geo.ts`; layout per RESEARCH Patterns 1–2 | partial |
|
||
| `routing/src/http-source.ts` (new) | service (RoutingSource backend #2) | request-response | `routing/src/fixture-source.ts` | exact |
|
||
| `routing/scripts/build-road-graph.mjs` (new) | build tool (maintainer) | batch + file-I/O | `routing/scripts/fetch-route-fixtures.mjs` | exact |
|
||
| `routing/scripts/build-glyphs.mjs` (new) | build tool (maintainer, sharp) | file-I/O | `fetch-route-fixtures.mjs` shell (collect-then-write, exit codes) | role-match |
|
||
| `bot/src/routing/worker-router.ts` (new) | service (R2 graph load + route handler) | request-response | `fixture-source.ts` lazy-memoize posture (LAW 1) | role-match |
|
||
| `bot/src/guidance/cron.ts` (new) | service (scheduled projection + push) | event-driven (cron tick) | `webhook.ts` handler shape + `bot/src/bot/bot.ts` `createBot(env, {baseFetch})` | role-match (first `scheduled()` in repo) |
|
||
| `bot/src/kv/guidance-sessions.ts` (MODIFY: types → service) | model + service | CRUD (KV) | `bot/src/kv/location-pin.ts` + `bot/src/kv/sessions.ts` | exact |
|
||
| `bot/src/webhook.ts` (MODIFY: +`scheduled()`, +`/api/route`, +`/api/guidance`) | controller (Worker routes) | request-response + event | itself (its own fetch-handler pattern) | exact |
|
||
| `bot/src/config.ts` (MODIFY: +`ROAD_GRAPH: R2Bucket`) | config | — | itself (typed Env contract) | exact |
|
||
| `bot/wrangler.jsonc` (MODIFY: +`r2_buckets`, +`triggers.crons`) | config | — | itself | exact |
|
||
| `bot/package.json` (MODIFY: +`@araharah/routing` dep) | config | — | itself (`@araharah/search` workspace-dep precedent) | exact |
|
||
| `web/src/guidance/guidance.ts` (new) | service (pure state machine) | event-driven (fix stream reducer) | `web/src/route/route.ts` controller shape + `web/src/location/chain.ts` pure-injected-clock posture | role-match |
|
||
| `web/src/guidance/eta.ts` (new) | utility (EMA + deadband) | transform | `routing/src/geo.ts` (pure, named constants) | exact |
|
||
| `web/src/guidance/banner.ts` (+ list + guidance-card builders) (new) | component builders | request-response (DOM compose) | `web/src/route/route-card.ts` | exact |
|
||
| `web/src/guidance/watch.ts` (new) | hook (watchPosition + ≥10 m/≥5 s throttle) | streaming | `web/src/location/webview.ts` + `telegram.ts` Fix normalization | exact |
|
||
| `web/src/guidance/` capability guards (haptics, `disableVerticalSwipes`, closing confirmation) | middleware (guards) | request-response | `web/src/telegram/webapp.ts` optional members + `safe-area.ts` version gate | exact |
|
||
| `web/src/telegram/backbutton.ts` (MODIFY: 3-state graph) | middleware (BackButton wire) | request-response | itself (`wireBackButton` — predicate+handler-as-one-change) | exact |
|
||
| guidance camera locked-follow (new, in `web/src/guidance/` or `map/`) | component | request-response | `web/src/map/follow.ts` + `web/src/ui/recenter.ts` | exact |
|
||
| `web/src/route/route.ts` (MODIFY: evolves into guidance chrome) | controller | request-response | itself | exact |
|
||
| `routing/test/{graph,astar,snap}.test.mjs` + mini-graph fixture (new) | test | batch | `routing/test/fixture-source.test.mjs` + `contract.test.mjs` | exact |
|
||
| `web/test/guidance-surface.test.mjs` (new) | test (behavioral + structural gates) | — | `web/test/route-surface.test.mjs` | exact |
|
||
| `web/test/guidance-replay.test.mjs` + `web/test/fixtures/drives/*.json` (new) | test + fixture corpus | streaming replay | `web/test/location-chain.test.mjs` stubs + `routing/test/fixtures/golden-pairs.json` corpus | role-match |
|
||
| `web/test/guidance-eta.test.mjs` (new) | test | — | replay-fixture style of the two above | role-match |
|
||
| `bot/test/guidance-cron.test.mjs` (new) | test | — | `bot/test/webhook.test.mjs` (undici offline) + KV-fake style of `bot/test/sessions.test.mjs` | role-match |
|
||
| `bot/test/guidance-envelope.test.mjs` (MODIFY: GE4–GE7 re-pin), `bot/test/route-privacy.test.mjs` (MODIFY: RP1/RP2 re-pin), `bot/test/privacy.test.mjs` (MODIFY: PG1 module list) | test gates | — | themselves (each gate's doc-comment names Phase 6 as the re-pinning owner) | exact |
|
||
|
||
## Pattern Assignments
|
||
|
||
### `routing/src/http-source.ts` + `bot/src/routing/worker-router.ts` (service, request-response)
|
||
|
||
**Analog:** `routing/src/fixture-source.ts` — the backend-ladder construct every new RoutingSource implements.
|
||
|
||
**The seam being implemented** (`routing/src/types.ts` lines 89–100):
|
||
```typescript
|
||
// THE NULL CONTRACT IS LOAD-BEARING (D-03): route() resolving to NULL is
|
||
// the honest "router pending" state — an unknown destination, or an origin
|
||
// outside the fixture corpus's tolerance. Never a blank map, never a fake
|
||
// route, never a thrown error for a normal miss.
|
||
export interface RoutingSource {
|
||
readonly name: string;
|
||
route(req: RouteRequest): Promise<RouteResult | null>;
|
||
}
|
||
```
|
||
|
||
**Backend factory shape — copy this skeleton** (`fixture-source.ts` lines 156–189):
|
||
```typescript
|
||
export function createFixtureRoutingSource(data?: RouteFixturesFile): RoutingSource {
|
||
let index: PairIndex | null = null;
|
||
const load = (): PairIndex => {
|
||
if (index === null) {
|
||
index = data === undefined ? loadDefaultIndex() : buildPairIndex(data);
|
||
}
|
||
return index;
|
||
};
|
||
return {
|
||
name: "fixture",
|
||
async route(req: RouteRequest): Promise<RouteResult | null> {
|
||
// Slug guard FIRST — a hostile shape never reaches the index (T-05-03).
|
||
if (!ID_PATTERN.test(req.destinationId)) return null;
|
||
const idx = load();
|
||
const candidates = idx.byDestination.get(req.destinationId);
|
||
if (candidates === undefined) return null; // D-03: honest pending
|
||
// ... match → RouteResult; else return null
|
||
return null;
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
**Lazy memoize at module scope (LAW 1 — pay nothing at startup)** (`fixture-source.ts` lines 134–141, mirrored in the doc header lines 7–10):
|
||
```typescript
|
||
let defaultIndex: PairIndex | null = null;
|
||
function loadDefaultIndex(): PairIndex {
|
||
if (defaultIndex === null) {
|
||
defaultIndex = buildPairIndex(fixturesData as RouteFixturesFile);
|
||
}
|
||
return defaultIndex;
|
||
}
|
||
```
|
||
`worker-router.ts` transposes this to the R2 graph — RESEARCH.md "R2 graph load + isolate memoization" gives the exact promise-memo form:
|
||
```typescript
|
||
let graphP: Promise<Graph> | null = null; // module scope = per-isolate memo
|
||
function loadGraph(env: Env): Promise<Graph> {
|
||
graphP ??= env.ROAD_GRAPH.get(GRAPH_KEY)
|
||
.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()));
|
||
return graphP;
|
||
}
|
||
```
|
||
Absence of the graph object → `createHttpRoutingSource` resolves `null` (D-03's designed degradation), never throws.
|
||
|
||
**Defensive validation posture** (`fixture-source.ts` lines 66–95, `isUsablePair`): every field type-checked, non-finite numbers refused, a decode that throws marks the entry unusable (`catch { return false; }` lines 88–92) — the malformed entry renders as an honest null downstream, never a throw in the render path. `http-source.ts` applies the same shape to the Worker's JSON response; `graph.ts` applies it to the binary header (magic + version + size check, fail-honest per RESEARCH §Security).
|
||
|
||
**Imports convention** (`fixture-source.ts` lines 35–46): static import for data (`import fixturesData from "./data/route-fixtures.json" with { type: "json" }`), relative imports WITH `.ts` extension, types via `import type`. The package has no `exports`/`main` — consumers import the explicit subpath: `import { … } from "@araharah/routing/src/index.ts"` (`routing/src/index.ts` lines 5–11). New exports (`decodeGraph`, `snapToGraph`, `routeAstar`, `createHttpRoutingSource`) are added to `index.ts` as named exports only — no default export.
|
||
|
||
### `routing/src/snap.ts`, `routing/src/astar.ts`, `routing/src/graph.ts` (pure modules)
|
||
|
||
**Analog:** `routing/src/geo.ts` — the pure-module law.
|
||
|
||
**Header contract** (`geo.ts` lines 1–17): spherical math with named constants (`EARTH_RADIUS_M = 6_371_000`), "Pure: no I/O, no logging, no storage (the D-15 analog — nothing about a request outlives the caller's frame)". `haversineM` (lines 25–34) is the admissibility heuristic's distance oracle; `deriveCumulativeDistanceM` (lines 38–49) is the progress backbone the client snaps against:
|
||
```typescript
|
||
export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[] {
|
||
if (points.length === 0) return [];
|
||
const cumulative: number[] = [0];
|
||
for (let i = 1; i < points.length; i++) {
|
||
// cumulative.push(prevMeters + haversineM(prev, curr));
|
||
}
|
||
return cumulative;
|
||
}
|
||
```
|
||
Equirectangular point-to-segment math for `snap.ts` must scale lng by `cos(lat)` (RESEARCH Pitfall 8) and route all displayed distances through `cumulativeDistanceM`/`haversineM` — never raw Δdegrees.
|
||
|
||
**No analog exists for CSR layout / binary heap A\*** — RESEARCH.md Pattern 1 (CSR binary layout: header/nodes/offsets/edges/weights, zero-copy `DataView` views) and Pattern 2 (typed-array A\*, binary heap of indices, haversine÷maxSpeed heuristic, bidirectional variant) are the authoritative references. Graph **decode** (`graph.ts`) follows the codec posture of `routing/src/polyline.ts` (pure functions, decode/encode pair, header contract at `polyline.ts`'s doc block; round-trip proven by tests).
|
||
|
||
### `routing/scripts/build-road-graph.mjs` + `build-glyphs.mjs` (maintainer build tools)
|
||
|
||
**Analog:** `routing/scripts/fetch-route-fixtures.mjs` — the exact maintainer-tool pattern (D-12 boundary).
|
||
|
||
**Header/usage contract** (lines 1–47): `#!/usr/bin/env node`, a dense header stating "A MAINTAINER-TIME REPO TOOL … NOT a runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports it", plus Usage / Exit codes sections.
|
||
|
||
**Named constants up top** (lines 57–64): `USER_AGENT`, `INTER_REQUEST_DELAY_MS = 1200`, `REQUEST_TIMEOUT_MS = 30_000`, tolerance values — never inline magic numbers.
|
||
|
||
**Pre-flight validation before any request leaves the machine** (lines 110–142): validate every input against committed data, `fail()` with the offending id named on the first violation.
|
||
```typescript
|
||
function fail(message) {
|
||
console.error(`fetch-route-fixtures: ${message}`);
|
||
process.exit(1);
|
||
}
|
||
```
|
||
|
||
**Collect-then-write** (lines 185–242): results accumulate in memory; the output file is written ONLY after every unit succeeds; a hard failure exits 1 with the committed file UNTOUCHED. `build-road-graph.mjs` copies this verbatim: Overpass query → validate → CSR encode → stats print (node/edge counts vs the D-01 30–80 MB envelope — a stats gate, like line 244–248's post-write summary + "next:" hint) → write `kl-roadgraph.v1.bin`. `build-glyphs.mjs` keeps the same shell around sharp's SVG→PNG (sharp is a NEW devDependency: `npm install -D -w routing sharp` — zero runtime deps added, per RESEARCH §Installation).
|
||
|
||
### `web/src/guidance/guidance.ts` (service — pure state machine, event-driven)
|
||
|
||
**Analog:** `web/src/route/route.ts` for the state-machine/controller shape; `web/src/location/chain.ts` for the pure-injected-clock posture.
|
||
|
||
**Injected-deps state machine with a total walk-back** (`route.ts` lines 55–82 + 174–187): every DOM/map concern is injected so tests run in Node ("All DOM/map concerns are INJECTED so the RS gates run in Node … the module itself touches no DOM and no maplibre value import"). The state transitions funnel through one `setState` that fires `onStateChange` (lines 88–91) — main.ts derives the BackButton predicate from it. `stepGuidance(state, fix, routeModel, nowMs) → {state, effects[]}` (RESEARCH Pattern 3) is the same idea, purer: no DOM concerns at all, injected clock.
|
||
|
||
**Fail-honest error posture** (`route.ts` lines 95–104):
|
||
```typescript
|
||
const result: RouteResult | null = await deps.source
|
||
.route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id })
|
||
.catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line
|
||
```
|
||
REROUTING failures resolve to a calm on-route-holds / pending posture, never a thrown error in the fix loop.
|
||
|
||
**Console discipline** (`route.ts` lines 30–31 + RS8 gate at `route-surface.test.mjs` lines 498–513): the ONLY console lines are state tokens (`ARAHARAH-ROUTE state=drawn`), never template interpolation, never coordinates. Guidance adds its own `ARAHARAH-GUIDANCE state=…` family under the same law (D-14 category-only extended client-side).
|
||
|
||
**Named constants exported at top** (`route.ts` lines 46–52): `ROUTE_CAMERA_PADDING`, `ROUTE_FIT_DURATION_MS`, `ROUTE_CAMERA_PITCH` — guidance's `OFF_ROUTE_THRESHOLD_M`, `OFF_ROUTE_CONSECUTIVE_FIXES`, `OFF_ROUTE_GRACE_MS`, `STORM_GUARD_MS`, `MISSED_TURN_PAST_MANEUVER_M`, `ARRIVAL_THRESHOLD_M` (RESEARCH Pattern 3 table) follow this export-and-pin convention; a test asserts the exported values (RS4 camera constants, `route-surface.test.mjs` lines 563–568).
|
||
|
||
**Fix shape consumed** (`web/src/location/types.ts` lines 20–33) — replay fixtures are Fix-shaped JSON (same six fields):
|
||
```typescript
|
||
export type Fix = {
|
||
lat: number; lng: number;
|
||
accuracyM: number | null;
|
||
headingDeg: number | null;
|
||
ts: number;
|
||
source: FixSource;
|
||
};
|
||
```
|
||
|
||
### `web/src/guidance/watch.ts` (hook, streaming)
|
||
|
||
**Analog:** `web/src/location/webview.ts` + `web/src/location/telegram.ts`.
|
||
|
||
**The wrapper pattern** (`webview.ts` lines 18–44): promise-wrapped callback, `settled` latch, hard timeout (a wedged client can never wedge the caller), errors resolve `null` — "denial / unavailability = null; the chain's normal signal (no error object escapes)". Options: `{ enableHighAccuracy: true, timeout, maximumAge: 0 }`. The guidance watch extends this to `watchPosition` (same `maximumAge: 0`, high-accuracy) with the D-15 distance throttle applied app-side:
|
||
```typescript
|
||
import { haversineM } from "@araharah/routing/src/geo.ts";
|
||
const moved = last === null ? Infinity : haversineM(last, fix);
|
||
if (moved >= 10 || fix.ts - lastAcceptedTs >= 5000) accept(fix); // ≥10 m OR ≥5 s
|
||
```
|
||
**Fix normalization precedent** (`telegram.ts` lines 48–56): map the platform payload into the six-field Fix with `source: "…"`, `accuracyM ?? null`, `headingDeg ?? null`. `chain.ts` lines 10–15 record the cadence contract this phase finally flips: "a Fix-shaped abstraction with a PLUGGABLE cadence and hard-codes NO continuous-mode interval … Phase 6 chooses between bounded getLocation() polling and watchPosition() without a rewrite."
|
||
|
||
### `web/src/guidance/banner.ts` + list + guidance-card (component builders)
|
||
|
||
**Analog:** `web/src/route/route-card.ts` — the builder law.
|
||
|
||
**createElement + textContent ONLY** (`route-card.ts` lines 11–15 header, enforced by RS5 at `route-surface.test.mjs` lines 424–434): "a name-derived string never reaches innerHTML". Structure: actions interface first (lines 26–31), then a pure builder returning an `HTMLElement`:
|
||
```typescript
|
||
export interface RouteCardActions {
|
||
onSelect(index: number): void;
|
||
onSearchAgain(): void;
|
||
}
|
||
export function buildRouteCard(locale, destinationName, options, selectedIndex, actions): HTMLElement {
|
||
const loc = strings[locale];
|
||
const card = document.createElement("div");
|
||
card.className = "card";
|
||
// ... header / headline / telemetry / diff appended via createElement+textContent
|
||
return card;
|
||
}
|
||
```
|
||
**Theme classes only, no local restyle** (`cards.ts` lines 2–8): `.card, .card-header, .card-ref, .btn-primary, .btn-secondary, .text-headline-md, .text-body-md, .text-body-sm` from the theme/shell — never restyled locally. Telemetry numbers use `.text-telemetry-num` (line 69), the first-consumer precedent for the banner's distance/ETA digits.
|
||
|
||
**Copy lives ONLY in `web/src/i18n/strings.ts`** (`strings.ts` lines 17–63 interface + COPY5 gate; RS9 at `route-surface.test.mjs` lines 517–542 greps the route family for inline English/Malay). New banner/confirm/trip-end copy keys are added to `ShellStrings` in both `en` and `ms`; clinical-honest tone. **Instrument formats are composed in code, not locale strings** — `fillTemplate` (lines 193–195) + `chip.textContent = \`${String(index + 1).padStart(2, "0")} // ${minutes} MIN · ${km} KM\`` (line 172). The banner's "480 m → JALAN AMPANG" format and the turn-card caption follow this.
|
||
|
||
**aria attributes** on interactive groups (`route-card.ts` lines 88–89): `role="group"` + `aria-label` from the strings module.
|
||
|
||
### Guidance camera (locked follow + recenter return)
|
||
|
||
**Analog:** `web/src/map/follow.ts` + `web/src/ui/recenter.ts`.
|
||
|
||
**Mode machine with a programmatic-move lock** (`follow.ts` lines 14–43):
|
||
```typescript
|
||
export const CAMERA_PADDING_PX = 96; // 12 x space-sm
|
||
export const RECENTER_DURATION_MS = 400;
|
||
export type FollowMode = "following" | "free";
|
||
export function createFollowController(map, onModeChange) {
|
||
map.on("movestart", (e) => {
|
||
if (lock) return;
|
||
if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move
|
||
});
|
||
return { follow(fix) { lock = true; map.flyTo({...}); set("following"); lock = false; } };
|
||
}
|
||
```
|
||
Locked-follow (D-10: center ≈65% down-screen via MapLibre padding, pitch 35°) extends `follow(fix)` to ease along the snapped line; user pan still unlocks via `originalEvent` (T-03-16) → the Phase 3 recenter control returns. **Control state is data-attributes + CSS** (`recenter.ts` lines 27–47): `button.dataset.follow = mode; button.dataset.fix = state;` — instant swaps, zero easing; the click handler is `addEventListener`, never inline onclick. `route.ts` lines 8–14 document the camera law the guidance camera must keep: pitch/bearing passed EXPLICITLY; fit stays center+zoom only.
|
||
|
||
### Capability guards (haptics, vertical swipes, closing confirmation, BackButton)
|
||
|
||
**Analog:** `web/src/telegram/webapp.ts` + `web/src/telegram/safe-area.ts` + `web/src/telegram/backbutton.ts`.
|
||
|
||
**The contract grows by OPTIONAL members only** (`webapp.ts` lines 31–65 — the hand-rolled `TelegramWebAppLike` IS the contract; `@types/telegram-web-app` stays deliberately out). RESEARCH.md "Telegram capability guards" names the exact additions:
|
||
```typescript
|
||
HapticFeedback?: { impactOccurred(style): unknown; notificationOccurred(type): unknown; selectionChanged(): unknown };
|
||
disableVerticalSwipes?: () => void; // Bot API 7.7+
|
||
enableVerticalSwipes?: () => void;
|
||
enableClosingConfirmation?: () => void; // Bot API 6.2+
|
||
isActive?: boolean; // 8.0+
|
||
// "activated" | "deactivated" via the existing onEvent?(event, cb)
|
||
```
|
||
|
||
**Version-gate BEFORE touching the API** (`safe-area.ts` lines 35–43, the established shape; `telegram.ts` lines 23–26 shows the null-safe variant):
|
||
```typescript
|
||
const tg =
|
||
wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0")
|
||
? wa.safeAreaInset
|
||
: null;
|
||
if (!tg) return base; // old-client fallback
|
||
```
|
||
Haptics are graceful no-ops where unsupported (D-08) — `?.` chains on the optional member.
|
||
|
||
**BackButton graph extension** (`backbutton.ts` lines 1–27): "its predicate and its handler ship as ONE change so Phase 6 extends the state graph rather than reinventing the wire". The 3-state walk (search → routes → guidance → search) makes `walkBack` context-aware; guidance-exit routes through the D-09 confirm card; never `close()` from a pushed state (RS7 proves the wire with a recording fake, `route-surface.test.mjs` lines 449–494).
|
||
|
||
**Safe areas during guidance:** `resolveSafeInsets` + `applySafeArea` (`safe-area.ts` lines 35–58) already write `--safe-top/bottom/left/right` custom properties on `:root` — the banner's top register padding consumes the existing tokens.
|
||
|
||
### `bot/src/kv/guidance-sessions.ts` (MODIFY: frozen types → TTL-only service)
|
||
|
||
**Analog:** `bot/src/kv/location-pin.ts` (the exact write/read/delete trio) + `bot/src/kv/sessions.ts` (multi-key rotation).
|
||
|
||
**The frozen contract being implemented** (`guidance-sessions.ts` lines 15–17, 49–58):
|
||
```typescript
|
||
export const GUIDANCE_SESSION_TTL_SECONDS = 7200;
|
||
export interface GuidanceSessionEnvelope {
|
||
id: string;
|
||
chat_id: number;
|
||
route_snapshot: GuidanceRouteSnapshot;
|
||
created_at: number;
|
||
}
|
||
```
|
||
Key scheme `guidance:<id>`; EXACTLY four fields — "a 5th property means drift (the SessionClaim law)".
|
||
|
||
**TTL-on-the-same-line write** (`location-pin.ts` lines 42–46):
|
||
```typescript
|
||
export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise<void> {
|
||
await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS });
|
||
}
|
||
```
|
||
PG1 (`privacy.test.mjs` lines 32–49) mechanically enforces: every `.put(` line carries `expirationTtl`, referencing an ALL-CAPS named constant — never an inline literal. The new service's writes land in `privacy.test.mjs`'s `storageModules` list.
|
||
|
||
**Injected KvLike seam** (`sessions.ts` lines 53–57): `interface KvLike { get/put({expirationTtl})/delete }` — the service takes `kv: GuidanceSessionKv extends KvLike`, so tests inject a Map-backed fake (the `bot/test/sessions.test.mjs` pattern). `crypto.randomUUID()` ids (line 74, T-02-05).
|
||
|
||
**Defensive read with exact-field + range validation** (`location-pin.ts` lines 48–73): parse in try/catch, check every field's presence and type, range-check numerics, return `null` on any mismatch — "a corrupt or tampered record can never fly the client camera to null island." `getGuidanceSession` mirrors this against the frozen envelope shape (the cron must never push a card from a malformed record).
|
||
|
||
**Explicit delete for trip end (D-14):** `location-pin.ts` lines 75–79 `clearPin` is the model — one `await kv.delete(\`loc:${chatId}\`)`; guidance adds `deleteGuidanceSession(kv, id)` called on BOTH arrival and abandon paths.
|
||
|
||
### `bot/src/webhook.ts` (MODIFY: +`scheduled()`, +`/api/route`, +`/api/guidance`)
|
||
|
||
**Analog:** its own established handler pattern.
|
||
|
||
**Route-dispatch skeleton** (lines 54–84): pathname match → method check (405) → handler; `satisfies ExportedHandler<Env>` (line 84). RESEARCH.md's "Cron config + scheduled handler" example extends exactly this object:
|
||
```typescript
|
||
export default {
|
||
async fetch(req, env, ctx): Promise<Response> { /* existing + /api/route + /api/guidance */ },
|
||
async scheduled(controller: ScheduledController, env: Env, ctx): Promise<void> {
|
||
let cursor: string | undefined;
|
||
do {
|
||
const page = await env.chat_sessions.list({ prefix: "guidance:", cursor });
|
||
for (const key of page.keys) { /* get → projectTurnCards → maybe sendPhoto */ }
|
||
cursor = page.list_complete ? undefined : page.cursor;
|
||
} while (cursor !== undefined);
|
||
},
|
||
} satisfies ExportedHandler<Env>;
|
||
```
|
||
|
||
**Bearer-claim derivation — NEVER client-supplied chat ids** (`webhook.ts` lines 143–161, `handleLocation` — the T-03-01 precedent both new endpoints must copy):
|
||
```typescript
|
||
const header = req.headers.get("authorization") ?? "";
|
||
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
||
const token = match !== null && match[1] ? match[1] : null;
|
||
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
||
if (claim === null) {
|
||
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
|
||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, ... });
|
||
}
|
||
```
|
||
`/api/guidance` mints the envelope with `chat_id: claim.chat_id`; `/api/route` is gated identically. Body-size caps follow `MAX_INITDATA_CHARS` + the pre/post-read double check (lines 91–119, WR-01) for request-body validation; lat/lng validation is hand-rolled (no zod — RESEARCH §V5).
|
||
|
||
**Category-only logging** (lines 37–43 law + call sites): `console.log("[initdata] rejected: malformed")` — never payload, never ids, never timestamps. Cron and routing paths extend this (D-14): `[guidance-cron] …` categories only.
|
||
|
||
**Fail-closed secret posture** (lines 58–71): reject with a constant response BEFORE any framework/library path runs when a required binding is missing — the shape to mirror if the R2 binding or graph key is absent (honest degradation, D-03).
|
||
|
||
### `bot/src/bot/bot.ts` (MODIFY-adjacent: cron-side sends)
|
||
|
||
**Analog:** `createBot(env, opts)` (lines 41–44) — the env-bound factory with an injectable `baseFetch` for offline tests:
|
||
```typescript
|
||
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
|
||
const bot = opts.baseFetch
|
||
? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } })
|
||
: new Bot(env.BOT_TOKEN);
|
||
```
|
||
`cron.ts` calls `createBot(env)` (works identically inside `scheduled()`); `guidance-cron.test.mjs` injects `baseFetch` to record `sendPhoto` calls without network — the turn-card send is `bot.api.sendPhoto(chat_id, url, { caption, parse_mode: "HTML" })` (RESEARCH "Turn-card send").
|
||
|
||
### `bot/src/config.ts` + `bot/wrangler.jsonc` (config MODIFY)
|
||
|
||
**Analog:** themselves — the typed-Env / config-comment style.
|
||
|
||
`config.ts` (lines 4–17): each field one line, trailing `//` comment naming its law (e.g. `chat_sessions: KVNamespace; // TTL-only namespace`). Add `ROAD_GRAPH: R2Bucket; // D-01 graph binary — R2 binding, first in the repo` plus any cron constants as named exports, NOT env reads. `wrangler.jsonc` (lines 1–24): JSONC with law-bearing comments; add:
|
||
```jsonc
|
||
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
|
||
"triggers": { "crons": ["*/1 * * * *"] }
|
||
```
|
||
(60 s tick; app-side logic decides whether a card is due — D-11's 60–120 s discretion.) KV namespace stays EXACTLY ONE (`chat_sessions`, line 23) — GE5 re-pins to "exactly one" still; the R2 bucket is NOT a KV namespace.
|
||
|
||
### `bot/package.json` (MODIFY)
|
||
|
||
**Analog:** itself — `bot/package.json` already carries the workspace-dep precedent: `"dependencies": { "grammy": "^1.46.0", "@araharah/search": "*" }`. Add `"@araharah/routing": "*"` (the research-named first routing import). Import form inside bot/src follows the subpath rule: `import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"`.
|
||
|
||
## Shared Patterns
|
||
|
||
### TTL-only KV (every write carries `expirationTtl`)
|
||
**Source:** `bot/src/kv/location-pin.ts` lines 42–46, `bot/src/kv/sessions.ts` lines 73–90; enforced by `bot/test/privacy.test.mjs` PG1 (lines 32–49)
|
||
**Apply to:** `bot/src/kv/guidance-sessions.ts` service functions; extend PG1's `storageModules` list in the same change.
|
||
```typescript
|
||
await kv.put(`guidance:${id}`, JSON.stringify(envelope), { expirationTtl: GUIDANCE_SESSION_TTL_SECONDS });
|
||
```
|
||
|
||
### Bearer-claim → server-derived key (V4 access control)
|
||
**Source:** `bot/src/webhook.ts` lines 143–161 (`handleLocation`)
|
||
**Apply to:** `/api/route`, `POST /api/guidance`, `DELETE /api/guidance`; the cron reads `envelope.chat_id` from KV only — never a client-supplied chat id anywhere.
|
||
|
||
### Category-only logging
|
||
**Source:** `bot/src/webhook.ts` lines 37–43, 112, 150, 196; client-side twin: `route.ts` state tokens + RS8 gate
|
||
**Apply to:** all cron + routing paths (bot) and all `web/src/guidance/` modules (state tokens only, no `${…}` interpolation — coordinates and chat ids never appear in any log line).
|
||
|
||
### Lazy memoize at module scope (LAW 1)
|
||
**Source:** `routing/src/fixture-source.ts` lines 134–141, 57–63
|
||
**Apply to:** `worker-router.ts` graph load (1 R2 GET per cold isolate), `graph.ts` decode cache, and any snap-grid index (built once per isolate, never per request).
|
||
|
||
### Fail-honest nulls (never fabricate, never throw for a normal miss)
|
||
**Source:** `routing/src/types.ts` lines 13–16 + `fixture-source.ts` lines 175–186; client twin `route.ts` lines 97–104
|
||
**Apply to:** `http-source.ts` (network/absent-graph → null → router-pending), `graph.ts` header check, `getGuidanceSession` (malformed → null), reroute failure inside the state machine.
|
||
|
||
### Pure module + injected deps (Node-testable without a browser)
|
||
**Source:** `web/src/location/chain.ts` lines 27–36 (injected `nowMs`, injected source functions); `route.ts` lines 33–36 (injected DOM/map); `geo.ts` (no I/O at all)
|
||
**Apply to:** `guidance.ts` (injected clock + RoutingSource), `eta.ts`, `cron.ts`'s `projectTurnCards()` (pure projection, injected now), `graph/astar/snap.ts`. Module header comments state the law (the `chain.ts` lines 1–20 style).
|
||
|
||
### Capability-guarded Telegram WebApp APIs
|
||
**Source:** `web/src/telegram/webapp.ts` lines 31–65 (optional members only) + `safe-area.ts` lines 35–50 (`isVersionAtLeast` gate) + `telegram.ts` lines 23–26 (null-safe support probe)
|
||
**Apply to:** haptics, `disableVerticalSwipes`, `enableClosingConfirmation`, `isActive`/`activated`/`deactivated` events, envelope POST-on-minimize.
|
||
|
||
### createElement + textContent builders; strings-module copy; theme classes only
|
||
**Source:** `web/src/route/route-card.ts` lines 11–15, 37–103, 193–195; `web/src/i18n/strings.ts` lines 17–63; gated by RS5/RS9 + COPY5
|
||
**Apply to:** banner, maneuver list, guidance card, "abandon route?" confirm card, "trip ended, data deleted" messaging — all copy keys land in `ShellStrings` (en + ms), all instrument formats composed in code via `fillTemplate`.
|
||
|
||
### Version-gated constants tables (camera + thresholds exported and pinned)
|
||
**Source:** `route.ts` lines 46–52 (`ROUTE_CAMERA_PADDING/…`), pinned by `route-surface.test.mjs` lines 563–568
|
||
**Apply to:** the D-04/D-07 constants (`OFF_ROUTE_THRESHOLD_M` … `ARRIVAL_THRESHOLD_M`, EMA α, deadband) — named exports, asserted by the replay/eta suites, replay-tuned without renaming.
|
||
|
||
### Hand-rolled test harness family
|
||
**Source:** `routing/test/harness.mjs` (10 lines: `test()` + registry), `routing/test/runner.mjs` (auto-discovers `*.test.mjs`, sorted; load-failure becomes one failing case; PASS/FAIL + exit code). `bot/test/` and `web/test/` are the same shape verbatim (confirmed by diff).
|
||
**Apply to:** every new suite registers via `import { test } from "./harness.mjs"`; new suites are dropped into the existing `test/` dirs — runner discovery picks them up with zero config.
|
||
|
||
### Behavioral fakes + structural gates (the two test styles)
|
||
**Source (behavioral):** `web/test/route-surface.test.mjs` — `fakeMap()` recording fake (lines 77–104), `fakeDeps()` full dep set capturing actions (lines 266–306), `withConsole()` capture helper (lines 309–318), Node-24 type-stripping dynamic imports of `.ts` sources (lines 37–46).
|
||
**Source (behavioral, DOM-free):** `web/test/location-chain.test.mjs` — `stubNavigator`/`stubFetch` install+restore-in-finally (lines 30–62), `fakeWebApp` with real `isVersionAtLeast` comparison (lines 68–89).
|
||
**Source (structural):** comment-stripped source introspection — `stripComments` + banned-token scans (`route-surface.test.mjs` lines 50–58; `guidance-envelope.test.mjs` lines 54–70; `route-privacy.test.mjs` lines 42–50).
|
||
**Apply to:** `guidance-surface.test.mjs` (copy of the RS style), `guidance-replay.test.mjs` (Fix-shaped JSON streams through `stepGuidance` — the chain-test stub style, no DOM at all), `guidance-cron.test.mjs` (undici Request/Response offline pattern of `bot/test/webhook.test.mjs`; KV Map-fake; `baseFetch`-captured `sendPhoto`).
|
||
|
||
### Gate re-pins ship WITH the change that invalidates them
|
||
**Source (the handoff notes):** `bot/test/guidance-envelope.test.mjs` lines 36–40 ("when the turn-card service legitimately adds the kv namespace binding, the Env field, and the consumer, updating GE4–GE7 is THAT plan's responsibility"); `bot/test/route-privacy.test.mjs` lines 1–24 (RP1 bans `/api/route` tokens, RP2 bans `@araharah/routing` in bot/src).
|
||
**Apply to:** the plans that add the envelope service re-pin GE4 (types-only → service), GE6 (Env gains `ROAD_GRAPH`), GE7 (bot/src may import guidance-sessions), and keep GE1 (TTL 7200), GE2 (field freeze), GE3 (inline snapshot) UNCHANGED — the frozen shape stays frozen. The plan adding `/api/route` + the routing import re-pins RP1 (ban *unauthenticated/proxy-shaped* routing) and RP2 (routing import now allowed, listed explicitly). GE5 stays: exactly ONE KV namespace (R2 is a separate binding class).
|
||
|
||
## No Analog Found
|
||
|
||
| File | Role | Data Flow | Reason | Fallback |
|
||
|------|------|-----------|--------|----------|
|
||
| `routing/src/astar.ts` + CSR layout in `graph.ts` | service | batch | No graph-search code exists anywhere in the repo | RESEARCH.md Pattern 1 (CSR binary layout) + Pattern 2 (typed-array A\*, binary heap, bidirectional); correctness gate = A\* equals brute-force Dijkstra on a committed mini-graph (`routing/test/` fixture, Wave 0) |
|
||
| `routing/scripts/build-glyphs.mjs` (sharp SVG→PNG) | build tool | file-I/O | No rasterization code exists; sharp is a new devDependency | Keep `fetch-route-fixtures.mjs`'s maintainer-tool shell (header/usage/exit codes/collect-then-write); rasterizer choice per RESEARCH Standard Stack (sharp preferred, `@resvg/resvg-js` fallback) |
|
||
| `bot/src/webhook.ts` `scheduled()` + `bot/src/guidance/cron.ts` | service | event-driven | First Cron Trigger in the repo — no scheduled-handler precedent | RESEARCH.md "Cron config + scheduled handler" code example (KV `list({prefix, cursor})` + `list_complete` pagination); local test via `curl "http://localhost:8787/cdn-cgi/local/scheduled"`; send shape from `bot.ts` `createBot(env)` |
|
||
| `web/test/fixtures/drives/*.json` corpus | fixture corpus | — | No position-stream fixtures exist (phase 3/4 used capture profiles, not recorded drives) | Shape = the six-field `Fix` (`web/src/location/types.ts` lines 22–33) as JSON streams; corpus-commit conventions from `routing/test/fixtures/golden-pairs.json` (committed data + header/attribution fields where licensing applies) |
|
||
|
||
## Metadata
|
||
|
||
**Analog search scope:** `routing/{src,test,scripts}/`, `web/src/{route,map,location,telegram,ui,i18n}/`, `web/test/`, `bot/src/{kv,bot}/`, `bot/test/`, `bot/wrangler.jsonc`, workspace `package.json`s
|
||
**Files read in full:** 24 source/test/config files (~2,700 lines); runners trio diff-confirmed identical in shape
|
||
**Pattern extraction date:** 2026-10-24
|
||
|
||
**Cross-cutting cautions for the planner (each pinned by an existing gate):**
|
||
1. **GE5 stays green:** the R2 bucket is a `r2_buckets` binding, NOT a second KV namespace — `guidance-envelope.test.mjs` GE5 still asserts exactly one `kv_namespaces` entry.
|
||
2. **PG1 scope grows:** `privacy.test.mjs` `storageModules` (line 25) must list `kv/guidance-sessions.ts` the moment it gains a write path.
|
||
3. **RS6 inversion:** `route-surface.test.mjs` RS6 (lines 438–445) currently BANS the tokens `progress`/`maneuver` in `web/src/route/` — the plans that move guidance chrome into `web/src/route/` must re-pin RS6 in the same change (the phase-5 header comment reserved this: `route.ts` D-10 "no start-guidance button … Phase 6" note).
|
||
4. **RS10 floor bump:** the no-storage scan floor (≥16 files, lines 546–559) grows with `web/src/guidance/` — bump it in the plan that creates the family.
|
||
5. **Workspace-import rule:** subpath-with-extension only (`@araharah/routing/src/index.ts`); a bare `@araharah/routing` specifier resolves to nothing (`routing/src/index.ts` lines 5–11) — and bot/package.json needs the dep added before bot/src imports it.
|