Skip to content

Latest commit

 

History

History
159 lines (131 loc) · 40.6 KB

File metadata and controls

159 lines (131 loc) · 40.6 KB

Wingover — Working Plan

Living execution doc. Direction lives in STEERING.md; this file tracks where the code actually is, what was learned, what's next, and questions queued for Alex.

Last session: 2026-07-09 (initial scaffold).

Current state — what exists and works

Ring 1 (browser) is fully working and verified:

  • Stack: Vite 8 (Rolldown — prod build ~400 ms) + Ionic React + TypeScript 7 + vitest 4, plain CSS, PouchDB, prettier with Voyager's config. pnpm; pnpm-workspace.yaml pins react-router/react-router-dom out of updates (v5 required by @ionic/react-router). Vite 8 needs the full dep graph in optimizeDeps.include + holdUntilCrawlEnd or Ionic's lazy Stencil chunks 504 ("Outdated Optimize Dep") on cold starts.
  • Recording engine seam (src/engine/types.ts): RecordingEngine interface with getSnapshot / start / stop / on("fix"|"status"|"error") (typed nanoevents emitter, on returns unsubscribe). UI is fully stateless against it — it rehydrates from getSnapshot() on every mount, exactly as the future native plugin will require.
  • State machine: idle → acquiring (GPS accuracy gate) → armed (waiting for takeoff) → recording. Takeoff auto-detected (sustained ≥5 m/s for 5 fixes), start backdated to the beginning of contiguous movement (≥1.5 m/s). Accuracy gate: ≤10 m horizontal, ≤15 m vertical, 3 sustained fixes; inaccurate fixes are excluded from takeoff detection and backdating.
  • Simulator source (src/engine/simulatorSource.ts, replaced the separate mock ENGINE 2026-07-10 per Alex — replay/detection must be source-agnostic): the deterministic FlightSimulator as a PositionSource feeding the real engine through withWebCore — mock and real GPS share the entire engine (WAL, replay, takeoff/landing/ended, waypoints). The source owns its session persistence (fresh watch = new flight; a since watch resumes the same deterministic flight for reload drills). Time compression via ?mock-speed=N (default 60×); the simulated pilot stops in place after 2 h (SIM_FLIGHT_END_S), so flights land and finalize hands-free — e2e proves arm→logbook with zero interaction at 6000×.
  • Flight simulator (src/flight/simulator.ts): seeded/deterministic, incremental; phases: GPS acquiring → standing → launch run → climbout → cruise wander. This is the universal test fixture.
  • Pure flight modules (src/flight/): takeoff.ts (portable reference for the native port), stats.ts, format.ts (imperial/metric), gpx.ts + download.ts (export, wired to the flight detail page).
  • UI: Fly (idle / acquiring / armed / recording instrument tiles — AGL "above launch" (launch-relative, from the backdated first fix) + MSL like PPG Flyer, hold-to-stop guard, "To launch" tile: arrow rotated by relative bearing + distance home via pure nav.ts — waypoint nav later reuses the same math), Logbook (list, slide-to-delete, lifetime totals), Flight detail (stats card, editable name/notes, GPX export download, guarded delete), Plan (stub), Settings (units). PPG Flyer-style: dark, huge color-coded numerals, giant buttons. Ionic forced to mode: "ios" everywhere (iOS-first, WYSIWYG in browser dev). Tab bar hides during acquiring/armed/recording (KISS in flight) — App subscribes to engine status; engine start()/stop() emit status events so the hide survives reloads.
  • Storage: PouchDB (pouchdb-browser, adopted 2026-07-09 after brainstorm with Alex — Apache incubation sealed it): flight metadata as flight:{uuid} docs, immutable track as gzipped attachment (track.json.gz, CompressionStream), settings as setting:{key} docs, auto_compaction: true, revs_limit: 25. Helpers in src/storage/db.ts (saveFlight/listFlights/getFlight/getTrack/updateFlight/deleteFlight/getSetting/setSetting) — UI never touches PouchDB directly. Tests run on fake-indexeddb with self/FileReader shims (src/test-setup.ts). Future sync = plain CouchDB replication (self-host free, paid hosted later); iCloud/Dexie Cloud rejected (not FOSS-compatible). Dexie was removed.
  • Tests: 37 unit tests (vitest) incl. simulator determinism, takeoff golden cases, stats, GPX, PouchDB storage round-trips; 7 Playwright e2e incl. reload kill drill, armed-cancel, hold-interrupt, logbook→detail→export→delete journey, pin long-press flow, and two slow-style map-race regressions (delayed locally-fulfilled style + pageerror watchdog — the pattern that catches "Style is not done loading" ordering bugs; written failing-first per Alex). Simulator home field: Dane County WI (43.075, −89.55, 300 m). All green.
  • src-tauri: config scaffold with identifier app.wingover.wingover, bundle disabled, not yet compiled (ring 2).
  • Repo: git initialized on main, all files staged, no commit yet (waiting for Alex's OK). AGPL-3.0 LICENSE, README.

Mapping plan (decided with Alex 2026-07-09 — Option B, no backend)

Stack: MapLibre GL JS renderer. Street = OpenFreeMap (keyless). Satellite = MapTiler satellite-v2 raster + OpenFreeMap labels composited (hybrid). Toggle persisted in settings. MapTiler key: VITE_MAPTILER_KEY build-time env (origin-restricted; self-builders use their own free key; documented in README) + settings override; no key → satellite toggle hidden. All style/tile URLs are config (settings-overridable) so self-hosted tiles and future PMTiles offline slot in without code changes. maplibre-gl loaded via dynamic import (own chunk). E2e blocks tile network (page.route abort) — tests assert UI/markers, never imagery.

  • M1 — Map foundation (done 2026-07-09): src/ui/map/MapView (React wrapper, maplibre dynamically imported, ?map-style=blank test seam), config.ts (street/satellite/hybrid style resolution, key resolution: settings → VITE_MAPTILER_KEY → built-in restricted key), ViewToggle. Tauri window UA set to WingoverApp/1.0 (matches MapTiler key restriction; PWA origin wingover.app). Note: satellite 403s from localhost unless Alex allowlists localhost:5173 in the MapTiler dashboard.
  • M2 — Plan tab (done 2026-07-09, simplified per Alex): full-screen map, long-press (500 ms, move/pinch-cancelled, implemented in MapView — MapLibre has no native long-press) = drop pin, tap pin = delete it — no edit sheet, no names/notes UI (fields remain in the Pin model for possible future use). Plain tap does nothing (no accidental pins). Center-on-me, fit-to-pins on open, view toggle persisted. Pin helpers in db.ts with unit tests; e2e covers long-press×2 → tap-noop → reload-persist → tap-delete×2 with tile traffic blocked.
  • M3 map — Flight detail redesign (done 2026-07-09): full-screen map with track polyline, launch (green) / landing (red) markers, fit-bounds under a translucent 6-tile stats overlay (shared src/ui/components/Tile), view toggle; name/notes editing moved to the (...) sheet → "Edit name & notes" alert. Old list layout gone.
  • M3 replay — scrubber + graphs (done 2026-07-21): integrated replay pane on the host maps (zoomable barogram scrubber, speeds, draw-along default) plus destructive clip tools — trim (rewrites the track, cut fixes gone) and split into two flights — entered from the (…) sheet on both hosts
  • M4 core — In-flight moving map (done 2026-07-09, then refined per Alex): full-screen map with everything overlaid at opacity — compact instrument tiles (drag-through via pointer-events: none), global flight-controls stack right side (follow / track-up / view / compact red hold-to-stop, all .map-button sized). Live track line beneath labels, aircraft arrow glides between fixes (rAF interpolation, duration adaptive to fix cadence so it never trails at high mock speeds), follow centers in the visible region via camera padding.top measured from the tile grid. Attribution auto-collapses after 6 s once per app launch. Distances everywhere show .xx precision. Rehydrates on reload; e2e asserts map + marker during recording.
  • Logbook composite map (done 2026-07-09): Logbook (...) menu → "View all flights on map" (/logbook-map, AllFlightsMapPage) — every track in one GeoJSON source, per-feature color ramp oldest→newest (hsl 290→175), gradient legend, fit-to-all, view toggle. Layer adds have a triple fallback (style.load event / isStyleLoaded() / once("idle")) — the first two alone raced and drew nothing; same hardening applied to flight detail. Attribution now re-collapses on styledata (style switches re-expanded it after the one-shot linger was consumed). Detail fit-bounds top padding is now measured from the tile grid (+28 px), not guessed. Saved toast presents from top.
  • M4b — Waypoint routes + in-flight waypoint UX (direction from Alex 2026-07-10): pins form an ORDERED route (createdAt order). Plan-view route DONE (2026-07-10): dashed blue line (#4cc2ff, plan-route layer, eventually-consistent ensure pattern) through pins in creation order; pins use the logbook endpoint colors (first green, last red, middles blue) and the tail pin is enlarged + haloed (.pin-tail) to show what a tap deletes and where the next long-press extends from; data-route-coords e2e seam, covered in plan.spec. Note: MapView modifier classes now go through classList — a React className write was clobbering maplibre's own container classes (.maplibregl-map didn't exist in the DOM). Still to do: lines between the flight's waypoints on the live map. In-flight UX must be barebones: long-press = add next waypoint, one button = remove next waypoint, nothing else; the Plan tab can use normal iOS controls. Model already in place: flight owns its waypoints (seeded from plan at start, snapshot.waypoints); wire additions through engine.addWaypoints + annunciator.setWaypoints. Target tiles ("Distance/Direction to launch", nav.ts math ready) swap to "…to waypoint" while one is set. Reached-markers + next-waypoint stats: derive as a fold over session fixes (golden-pinned semantics, design discussed 2026-07-10). Open: should announcements become sequence-aware (announce only the NEXT waypoint; skip semantics for the remove button)?

Next tasks (in rough order)

  • GPX import (done 2026-07-09): Logbook (...) menu → "Import GPX files" (multi-select). src/flight/gpxImport.ts parses GPX (built against Alex's real PPG Flyer exports: trk>name launch-site names, 1 Hz, <rte> ignored), dedupes burst duplicates (<500 ms apart — real files contain 44 ms doubles that would fake 2000 m/s), derives speed/course/climb. Provenance for future undo: flights get source: "gpx-import", sourceFilename, importBatchId (one uuid per import operation), importedAt. Validated by importing all 309 real exports through the UI in Chromium: 16.9 s, zero errors. Unit tests use happy-dom (per-file pragma; note: happy-dom leaks on hundreds of parses — bulk validation belongs in a real browser). E2e drives the filechooser with a fixture file.

  • Folder structure = abstraction layers (2026-07-10, per Alex): src/ui/ is now the ONLY React world (App, pages/, components/, map/, settings/, useFlightActions, download); src/flight/ is pure flight math only (fixes in, decisions out — no IO, no React); src/engine/ is the headless runtime (engine, WAL, core.rs twin, sources, platform, simulator, and session.ts — the startFlight action); src/storage/ is PouchDB + importGpx.ts. The doctrine boundary is now a directory boundary: React never appears outside src/ui/, and nothing in src/ui/ holds business logic. Also deleted: tauriSource.ts + its tests and the stock tauri-plugin-geolocation (Cargo dep, lib.rs registration, capabilities) — fully superseded by the in-repo wingover plugin; gen/schemas regenerated without it.

  • Landing is engine lifecycle, not events (2026-07-10, per background-parity doctrine — see STEERING.md "Background parity"): EngineStatus gained landed (touchdown detected, recording continues, dismissible) and ended (grace expired — flight of record is FINAL). Both are PURELY DERIVED from WAL data (takeoffIndex + landingIndex + fix timestamps; grace = LANDING_GRACE_MS of fix time, never wall clock) — rehydration or burst replay lands in exactly the state live delivery would. ended is durable: the finalized flight waits in the WAL until collected — FlyPage persists FIRST (deterministic id recorded-<startedAt>, conflict-idempotent) THEN stop() clears; a crash between the two repeats collection harmlessly on next launch. This closed a real hole: the earlier onFlightEnd-event design cleared the WAL before the consumer saved, so a webview death in that window lost the whole flight. stop() doubles as collect; trim = slice(takeoff..touchdown), stationary tail discarded. FlyPage prompt = projection of status === "landed" with countdown from latest.timestamp − landingAt (no Date.now, no interval). Route note: /logbook/:id has a regex guard (protects /logbook/map) — extended for recorded-\d+ ids; new id families must be added there. Test note: engines leave fire-and-forget WAL writes — tests need per-test IDBFactory + afterEach stop() drain or writes bleed across tests. Burst-replay e2e ("backgrounded landing") emits flight+landing+tail in one stream and asserts hands-free retroactive finalization — burst-replay IS the backgrounding drill.

  • Landing auto-detection (done 2026-07-09, superseded by the engine-owned version above): src/flight/landing.ts — ≤1.0 m/s sustained 15 fixes (track exists only post-takeoff, so no taxi false-positive; stationary wind-hover is why it prompts instead of auto-stopping). FlyPage shows a gloves-sized prompt "Looks like you landed" [Still flying / Stop & save] with a 30 s countdown that auto-stops (?land-timeout-ms seam). Dismiss re-arms only after movement resumes. Also: real GPS engine landed (src/engine/engine.ts + IndexedDB WAL in wal.ts, kill-drill e2e via stubbed watchPosition in e2e/real-engine.spec.ts — CDP setGeolocation can't supply altitude/speed). Engine choice (revised 2026-07-10): real GPS everywhere by default — native queue under Tauri, navigator.geolocation in any browser; the simulator is strictly opt-in via ?mock-speed=N (?engine=real seam deleted).

  • Reload-while-armed e2e kill drill (done 2026-07-09, in real-engine.spec.ts — armed session survives reload and still auto-takes-off)

  • GPS error surfacing (done 2026-07-09): onError channel on the engine seam; real engine classifies permission-denied vs unavailable; armed screen shows an actionable banner, cleared by the next fix. Also .tool-versions (Voyager pattern) now drives CI node version; pnpm pinned via packageManager.

  • Ring 2 gate cleared (2026-07-09): cargo check green on Linux (webkit2gtk-4.1/jscore/soup3 all present on this machine). @tauri-apps/cli added; placeholder icon set generated from app-icon.png (dark field + cyan aircraft arrow — replace with real branding later). tauri dev window launch still untried: its beforeDevCommand fights the running Vite server on strict port 5173 — run when the dev server is down.

  • eslint + CI (done 2026-07-09): flat config modeled on Voyager (tseslint, prettier, react-hooks, perfectionist import sorting). sortSideEffects: false — REQUIRED, not style: the autofix once alphabetized side-effect CSS imports, putting theme.css before Ionic's css and MapView.css before maplibre's (attribution bg + 1px scrollbar regressions; cascade order is load-bearing, now e2e-guarded in record.spec). React Compiler adopted 2026-07-09 (babel-plugin-react-compiler via @rolldown/plugin-babel + reactCompilerPreset — @vitejs/plugin-react v6 is oxc-based, no babel option). Full react-hooks compiler strictness ON, and @eslint-community/eslint-comments/no-use bans ALL eslint directive comments (Alex directive: never disable, fix the code). The prop-mirror-ref idiom is GONE: every entry point invoked from outside React (rAF playhead tick, MapLibre listeners, engine subscriptions) is a useEffectEvent — old captured wrappers dispatch to the latest render's body, which is what the mirrors hand-rolled. Rules that shaped the refactor: effect events cannot be passed to children (map handles flow through mapContext state + a [mapContext] effect calling a setup effect event) and listener registration racing an already-loaded style needs the idempotent-ensure guard (isStyleLoaded() check next to the style.load listener). React pinned ≥19.2 for stable useEffectEvent. TypeScript pinned to ^6.0.3 (matching Voyager): typescript-eslint 8.x cannot parse against TS 7's API — same compat-pin class as react-router; in pnpm-workspace.yaml ignoreDependencies. CI: .github/workflows/ci.yml — lint, vitest, build (includes tsc), Playwright e2e with report artifact on failure.

  • Native seam switch (done 2026-07-09): PositionSource injected into Engine (navigator adapter + tauriSource.ts over tauri-plugin-geolocation, permission flow unit-tested); engine auto-selects the Tauri source under __TAURI_INTERNALS__. iOS config + Info.ios.plist + capabilities prepped; see MAC-SESSION.md for the on-Mac runbook and the background-delivery risk (plugin may need vendoring for allowsBackgroundLocationUpdates).

Learnings (2026-07-09 afternoon batch)

  • The aircraft is a custom WebGL layer — this is load-bearing. Three rendering approaches failed before this: (1) DOM Marker — positioned by a separate mechanism from the canvas, desyncs under per-frame jumpTo/zoom (upstream maplibre #6494, #2190); (2) symbol layer + per-frame setData — point data routes through the GeoJSON worker, rendering 1-2 frames behind the synchronous camera transform (lags while following) and jittering even with a static camera. (3 = final) CustomLayerInterface: render(gl, matrix) is called synchronously inside the map's render pass with the current frame's projection matrix — the triangle (12 vertices, CPU-transformed, two draw calls for outline+fill) draws with the same transform and same frame as tiles and track line. Desync has no mechanism. renderFrame just updates displayRef and calls jumpTo (following) or triggerRepaint (unpinned); the layer reads the ref at render time. Re-added on every style.load like other runtime layers. The track line's LAST SEGMENT is also drawn by the custom layer (the "live tail"): the GeoJSON line body re-uploads with backpressure (never setData until the worker confirmed the previous upload via isSourceLoaded, plus one frame of hysteresis), and the custom layer draws from the last confirmed vertex to the aircraft every frame. Without this the line visibly leads the arrow early in a flight (arrow glide lags newest fix) and trails it on long flights (full-track worker rebuild grows with length — measured 165px of divergence at z15, worker saturated 72% of frames). Backpressure bounds the uncommitted tail to ~10-30 fixes even at 100 fixes/s with 14k points. Custom-layer vertices are anchor-relative (anchor = aircraft position) with the anchor translation folded into the projection matrix in float64 — world-space Float32 quantizes at ~2.4m, which is many pixels at z17+. The aircraft is a PLAYHEAD that travels along the recorded track polyline, chasing the newest fix with an exponential rubber-band (steady-state lag ≈ CHASE_MS real-time regardless of fix rate); the line — committed body AND tail — is derived from the fixes the playhead has passed, never from raw fix count. Playback is constant-velocity, not proportional pursuit (Alex report at 1 Hz: speed "maxes and wanes every second"): exponential chasing makes velocity proportional to remaining distance — 2.3x speed oscillation per fix cycle at 1 Hz (measured; invisible at 40x mock because the fix interval is far below the time constant — probe at real rates, not just compressed). Each arriving fix starts a leg toward its timestamp at a fixed track-time RATE (rate-based, not wall-clock, so starved frames advance proportionally instead of stalling and teleporting — wall-clock legs failed the hunter at low fps), with duration = max(learned arrival interval x 1.15 pad, backlog / 1.5x max catch-up) and a real-time (backlog/production-rate) snap threshold. Measured at 1 Hz: speed variation p95/p5 down from 2.28 to 1.17 (cv 0.26 -> 0.05). The rendered heading is a separate exponential low-pass (COURSE_SMOOTH_MS) over the playhead's segment-lerped course — the lerp alone is only C0-continuous and its rate kinks at every fix boundary, which reads as heading snaps, worst in track-up where the whole map rotates; smoothing once and feeding both the arrow rotation and the camera bearing keeps the arrow locked screen-up in track-up. This makes line-ahead-of-arrow and line/tail forking impossible by construction (the earlier glide-to-latest design let the line data race ahead of the animating arrow, and its corner-cutting chords forked visibly from the committed line). Commits are also time-gated (COMMIT_INTERVAL_MS) to reduce whole-source re-tile churn. There is deliberately NO distance-based snap guard: at mock time compression the playhead legitimately trails hundreds of simulated meters, and a meters guard made the aircraft teleport once per second; frozen-tab/reload catch-up happens via the dt clamp (a >=CHASE_MS frame gap advances the playhead fully in one step). Known scaling limit: each commit re-tiles the entire line in the worker (O(n) every 2s) — fine at 10k+ points, chunk into multiple frozen sources if it ever shows on old phones. Per-frame jumpTo skips while map.isZooming()/isRotating() so it doesn't cancel wheel-zoom or our own eases (jumpTo calls stop()). DOM markers remain only for static/interactive points (pins, endpoints).

  • Flight geometry (track line, tail, aircraft) renders ABOVE everything, including labels — user decision 2026-07-09; layers are appended with no beforeId. Historical trap that led here: layers were once inserted "before the first symbol layer", but OpenFreeMap dark places the water_name symbol at index 8, directly before the building fill — that put the track UNDER every building and road, which read as flicker/desync near the aircraft and was a major cause of the live-map glitch reports. If flight geometry ever needs to sit below labels again, anchor at the first layer of the trailing all-symbol block, never at the first symbol.

  • While following, the follow loop owns the camera COMPLETELY — including wheel zoom. map.on("wheel") + preventDefault intercepts scroll while following; the loop glides zoom exponentially (ZOOM_SMOOTH_MS) in the same per-frame jumpTo as center/bearing/padding. Rationale: scrollZoom's smooth ease and per-frame jumpTo fight (jumpTo calls stop(), killing the ease → slow choppy wheel zoom), and yielding to the ease instead accumulates drift-then-snap at mock compression. Single-authority also erased the long-standing one-frame wheel bob. Unpinned zoom stays native (cursor-anchored). Touch pinch is untouched: interactingRef pauses the loop and around:'center' anchors the gesture.

  • Layer creation must be eventually consistent, everywhere: style.load can fire before listeners register, and isStyleLoaded() stays false until sprites/glyphs/sources finish loading AFTER style.load. LiveTrackMap now uses styledata + idle + a direct attempt with an isStyleLoaded-guarded idempotent ensure (same as the other map pages); e2e pins it with a delayed-sprite style ("track/triangle doesn't render until toggling the map" regression).

  • Verify live-map rendering with the frame-locked pixel probe, not screenshots. scratchpad/glitch-hunter.mjs pattern: hook map.on("render"), copy the GL canvas region around the aircraft to a 2d canvas (clearRect first — a stale/clipped drawImage silently keeps old pixels), and pixel-check line continuity behind the arrow plus overshoot ahead of it AND a temporal smoothness metric (per-frame geographic step rate vs rolling median — geometry-only checks are blind to teleports because a snap moves arrow, line, and camera coherently), per rendered frame, across follow/zoom/track-up/unpinned phases. Test seams: __display, __tail (committed/total/tailCoords) on the map container. Static screenshots and data-side probes repeatedly passed while users saw glitches; only per-frame pixel checks caught the real artifacts.

  • The //fly redirect strips query params — round two: page.reload() after the redirect reloads a bare URL, silently dropping every test seam param (hold-ms fell back to 1500 ms while tests held 800 ms — a "flake" that was actually deterministic truth). Kill-drill reloads in e2e must goto(originalUrl) instead of reload(). Any new query-param seam will hit this; consider a proper seam registry someday.

  • Live-view state (src/ui/map/liveViewState.ts, localStorage): mapView/trackUp/follow/zoom/center persist so reload mid-flight is visually transparent (doctrine extended to UI state). Zoom saved on zoomend, center on dragend (only matters when unpinned). hold-ms query seam controls the stop-hold duration for tests.

  • Tile overscan: live map renders into a container 256 px larger per side (clipped by wrapper) with camera padding compensating — tiles are always loaded one tile beyond every visible edge. Attribution control re-inset via CSS.

  • Two-layer track line: base line updated once per fix + 2-point tip segment updated per rAF — O(1) per-frame cost (full-track setData per frame at 60 fps was the laggy culprit at scale).

  • Ionic controlled overlays (IonLoading etc.) must never fast-toggle isOpen (<~500 ms): present/dismiss overlap leaves the page aria-hidden → every ARIA role vanishes app-wide (found when a single-file GPX import made getByRole("heading") return nothing). Guard: only show for real batches + enforce minimum display time. E2e pageerror watchdogs don't catch this — role-based assertions do.

  • Frame-locked live map: marker, camera (per-frame jumpTo), and the track line's tip are all driven by ONE interpolated position per rAF (renderFrame) — nothing can lag anything at any zoom. Ease-restart chasing (camera easing to stale targets on every fix) and line-ahead-of-arrow are structurally impossible now. Snap rules (stale >2.5 s / far >250 m) apply to the whole frame.

  • Logbook list virtualized with virtua (Voyager's library) wired to IonContent's scroll element — 309 flights scroll smooth.

  • Flight detail = collapsible right-floating overlay card (inline-editable title/notes committed on blur, compact stat rows) — instrument tiles are for flying only, per Alex. Overlays cap at 400 px and float right on ≥768 px viewports.

Learnings

  • Playwright getByText() is substring + case-insensitive: "Logbook" tab collided with the "Flight saved to logbook" toast (IonToast stays in the DOM when hidden). Use { exact: true } or role-based locators around Ionic overlays.
  • The mock WAL stores only { armedAt, seed, compression, takeoffIndex } — the track is derived deterministically from elapsed wall-clock, so reload-recovery is exact with no serialization. The native WAL will store real fixes instead; same contract, different substrate.
  • Simulator determinism + ?mock-speed makes e2e timing tractable: speed 40 gets through acquire+standing+takeoff in ~1.5 s wall; speed 2 holds the acquiring phase long enough to test Cancel.
  • Voyager conventions (checked ~/voyager): prettier {tabWidth 2, trailingComma all} defaults (double quotes/semicolons), folder-by-feature src/features/, flat eslint config, pnpm, Playwright e2e in e2e/. Wingover follows; consider migrating src/ toward feature folders as it grows.
  • @types/node needed for playwright.config.ts (process.env) with types: ["vite/client", "node"].
  • GPX export in the browser ring is a plain blob download (src/ui/download.ts); on mobile this helper becomes a Tauri share-sheet/fs call — same call site, different implementation.
  • Playwright warns this OS isn't officially supported (Pop!_OS → ubuntu24.04 fallback build) — works fine.
  • MapTiler key facts (learned 2026-07-09): restriction types are OR-ed (origin OR user-agent — one key serves PWA via wingover.app/localhost:5173 origins AND native via WingoverApp/1.0 UA); origin entries need the explicit port for dev; dashboard changes take ~20 s to propagate (don't conclude from probes too fast); the tileset for this plan is satellite-v2 (satellite-v4 404s). App preflights the tile manifest and falls back to street with a console warning when the key is rejected.
  • MapLibre's compact AttributionControl re-opens itself whenever attribution text updates (e.g., MapTiler TileJSON arriving async after a style switch) — event-based collapsing loses that race. Solution: MutationObserver on the open attribute that re-collapses programmatic opens after the one-time launch linger, with a summary-click listener whitelisting user taps. Also: map canvas stays opacity: 0 until the map load event (fade-in kills the white flash from light style backgrounds painting before tiles); street style is OpenFreeMap dark (hosted; fiord also exists) matching the app theme.
  • Dynamically-imported deps (maplibre-gl) must be listed in Vite optimizeDeps.include, or dev discovers them mid-session and re-optimizes → "error loading dynamically imported module" with a stale ?v= hash.
  • PouchDB + Vite needs the events npm package installed (Vite stubs node builtins to empty objects → "Class extends value [object Object]" at runtime, dev and prod alike). PouchDB in vitest/node needs self and FileReader shims plus fake-indexeddb (src/test-setup.ts). Restart the dev server after installing deps — Playwright's reuseExistingServer happily reuses a stale one.
  • Walkthrough-driven bug find: the //fly redirect drops the query string, so ?mock-speed was silently ignored (everything ran at the default 60×, including e2e where the speed params were placebos). Fixed by caching location.search at module load in mock.ts. Lesson: actually driving the app catches what green tests miss — worth screenshotting flows after UI changes.

Native queue decision (2026-07-10, decided with Alex on the Mac)

The native layer is a dumb capture+buffer; JS keeps all flight semantics. Replaces the vendor-and-patch plan entirely. STEERING §Architecture layer 3 ("owns the GPS/baro pipeline and the active flight, end to end") is amended in spirit: native owns capture and durable buffering; the JS engine owns the flight. Rationale: the e2e-proven JS engine stays the only engine; the Swift surface is ~200 lines; the Kotlin port later is a foreground service feeding the same contract.

  • Plugin: src-tauri/plugins/wingover (crate tauri-plugin-wingover, in-repo path dep). CLLocationManager with allowsBackgroundLocationUpdates, pausesLocationUpdatesAutomatically=false, .airborne, best accuracy — the flags the community plugin never set. Fixes normalized at the source (CoreLocation -1 sentinels → absent keys → JS null) and buffered in memory
    • an append-only JSONL session file (Application Support) that survives app crash/jetsam; torn tail = couple of points, accepted budget.
  • Delivery is pull, not push: JS polls fixes_since(cursor) at 1 Hz. One code path serves live delivery AND post-reload catch-up, so the recovery path runs every second of every flight; no channels to orphan on reload (the old plugin spammed "Couldn't find callback id" after every HMR reload). stop_watch (only from engine.stop → flight finalization) stops capture and deletes the file. Page reloads never touch native state.
  • Engine seam: PositionSource.watch gained { since } — engine passes its newest WAL fix timestamp; buffering sources replay exactly the backlog.
  • Load-bearing discovery: WKWebView does NOT reload after iOS kills its content process and tauri does nothing by default — on_web_content_process_terminate → reload() is registered in lib.rs. Without it, "webview killed → rehydrate" can never happen: the app stays a dead white view.
  • Sim drills passed (iPhone 17 / iOS 26 sim, Freeway Drive): (1) WebContent process kill mid-recording → auto-reload → WAL rehydrate → native-queue catch-up: duration continuous, track line continuous through the dead window. (2) Full app terminate + relaunch → same, via session-file hydration. Screen-off/jetsam remain device drills.
  • Also fixed on the Mac session: iOS webview was sized to the safe area (96pt dead strip at the bottom) — tao inner_size() reports safe-area size; setup hook resizes the WKWebView to superview bounds + contentInsetAdjustmentBehavior=.never. And Info.plist location keys now live in gen/apple/project.yml info.properties (xcodegen regenerates Info.plist, wiping direct edits).
  • Open (question #11 below): simulator scenario fixes have NO altitude/verticalAccuracy (verified from the session file — keys absent). The old plugin's -1 passed the <=15 vertical gate (loophole, now closed), which is the only reason sim flights ever armed. Fresh arming in the sim now sticks at "acquiring". Real devices provide valid vAcc and are unaffected.

Questions for Alex (answer when back online)

  1. Initial commit + GitHub repo Resolved 2026-07-09: aeharding/wingover created by Alex, main pushed, CI live. Reminder: DEFAULT_MAPTILER_KEY in src/ui/map/config.ts is public by design (origin/UA-restricted).
  2. Takeoff thresholds Resolved 2026-07-10 (device testing): lowered to ≥4.5 m/s (≈10 mph — ">10 mph should trigger" per Alex) sustained 5 fixes; backdate threshold unchanged at ≥1.5 m/s. Real root cause of the ground-test no-trigger: takeoff detection demanded the strict two-axis accuracy check per fix, and accuracy degrades in motion — one bad fix per five resets the sustain forever while the armed screen shows only speed. detectTakeoff/backdate now use hAcc ≤ 35 m only (credible doppler speed; still rejects wifi junk); the strict gate remains for arming.
  3. GPS accuracy gate: ≤10 m horizontal / ≤15 m vertical sustained 3 fixes before "waiting for takeoff". Too strict/loose? Device evidence 2026-07-10: gate passed outdoors on hardware, so plausibly right for arming; takeoff detection no longer uses it (see #2).
  4. Climb rate units: ft/s like PPG Flyer, or fpm? (Currently ft/s.)
  5. Landing auto-detection: auto-stop vs prompt Default shipped 2026-07-09: prompt with 30 s countdown → auto-stop (steering-doc guarded-action posture). Sanity-check the thresholds: ≤1.0 m/s sustained 15 s. OK? Also: should the countdown be longer on-device?
  6. Map tiles: OpenFreeMap as default provider OK?
  7. Min iOS version target?
  8. Flight naming: currently Flight {locale datetime}. Good enough for v1?
  9. To-launch tile: shows rotated arrow + distance home (steer by arrow, fuel-plan by number). PPG Flyer showed relative degrees instead — want the degrees added/back?
  10. "Flights on a computer" Resolved 2026-07-09: PWA only — no native desktop app. Native mobile wrappers exist solely for recording reliability. Desktop = installable web app syncing via CouchDB (E2EE decryption client-side works in-browser). Static hosting for the PWA (e.g. wingover.app) keeps the zero-dynamic-backend posture.
  11. Sim can't arm the accuracy gate (2026-07-10): simulator fixes have no vertical accuracy, so fresh arming sticks at "acquiring" on the desk (real devices unaffected). Options: a dev-only gate-relax seam (?vacc-gate=off, consistent with existing seams) so sim-ring drills can run the full arm→takeoff flow; or accept browser-ring-only coverage for arming. Recommend the seam.
  • Attribution stays as the compact on-map control — moving it to Settings was tried and rejected by Alex 2026-07-09 (commit c1ba625, reverted). Don't propose again without new reasoning.

  • Camera pixel-snap (commit cbbb071, reverted): tried for zoomed-out shimmer; Alex still saw shimmer in the dev env afterward, so reverted pending a real-iPhone check. If shimmer reproduces on device, the snap technique + probes are in that commit's history (probe showed motion itself is uniform — any fix is rendering-side).

  • TestFlight pipeline is LIVE (first upload 2026-07-10, run 29078748225, 7 attempts). The failure ladder, so nobody re-climbs it: (1) MATCH_GIT_BASIC_AUTHORIZATION must be base64 -w0 — default Linux base64 wraps at 76 chars and a wrapped PAT makes GitHub return 400 on clone; (2) gym CANNOT drive the Tauri-generated project directly — its Rust build phase (tauri ios xcode-script) SIGABRTs without the context tauri ios build sets up (frontend build, env); (3) the Tauri template archives with the Development identity — irrelevant now but CODE_SIGN_IDENTITY='Apple Distribution' was the fix in the gym era; (4) Tauri's own EXPORT step regenerates export options and insists on cloud signing, which needs an Admin-role ASC API key (ours is App Manager) — and it ignores gen/apple/ExportOptions.plist; the working shape is: run tauri ios build tolerating its export failure, assert the xcarchive exists, then xcodebuild -exportArchive manually with fastlane/export-options.plist (manual signing, match profile). Optional cleanup someday: an Admin API key would let cloud signing work and delete the manual-export workaround.

  • TestFlight pipeline (added 2026-07-10, Voyager pattern): .github/workflows/testflight.yml on every main push (macos runner, environment: deploy, gracefully skips until secrets exist) → fastlane ios deploy: match (certs from the shared voyager-match repo), build_app on the Tauri-generated project (its Build Rust Code phase drives pnpm+cargo), CURRENT_PROJECT_VERSION = run number, upload_to_testflight internal-only (flip distribute_external + groups once an external beta group exists). One-time setup on Alex: (1) register app.wingover.wingover in App Store Connect, (2) bundle exec fastlane match appstore once from the Mac to mint the profile into voyager-match, (3) repo secrets APP_STORE_CONNECT_KEY (authkey.json contents), MATCH_PASSWORD, MATCH_GIT_BASIC_AUTHORIZATION (same values as Voyager's), (4) create the deploy environment. Version bumped to 0.1.0 (ASC rejects 0.0.0); Settings version string is hardcoded — wire to a build define someday.

  • Realtime core implemented per ARCHITECTURE.md (2026-07-10, uncommitted pending review): Rust (tauri-plugin-wingover crate) now owns the durable session fix log (store.rs — hydration, torn-tail tolerance, ordering guard against CoreLocation relaunch redelivery), the core lifecycle (core.rs — persist-then-announce, waypoints persisted beside the session), and the waypoint announcer (announcer.rs). Swift dieted to capture/drain/permissions/speak (AVSpeechSynthesizer with .duckOthers). Wire contract to JS unchanged (fixes_since now answered by Rust). New: the web reimplements the plugin surface — src/engine/core.ts is core.rs's TS twin (start/stop/setWaypoints/ingest, same functions by name), the watch carries the lifecycle on BOTH platforms (webCore wraps the browser source; native = start_watch/stop_watch), engine pushes set_waypoints at watch-establish + additions only, src/flight/waypoints.ts TS twin, shared golden vectors (src/flight/golden.json) executed by BOTH vitest and cargo — cross-language conformance in CI. Waypoints are FLIGHT-SCOPED (Alex 2026-07-10): startFlight() (src/engine/session.ts, headless) copies plan pins → session waypoints (200 m radius) via engine start options, persisted in the WAL and exposed on snapshots; the plan is never read mid-flight; mid-flight additions join only that flight via engine.addWaypoints (UI pending, see M4b). No lifecycle asymmetry between platforms (Alex): the engine runs one code path everywhere; announce lifecycle lives in core.rs and its TS twin only — no director, no boot wiring, no component effects; FlyPage has exactly ONE engine effect (all subscriptions + teardown, incl. ended-flight collection). Detection state resets per flight in both languages (fresh web tracker on start; Rust announcer resets on a fresh session). Tests: 9 cargo + 79 vitest + 16 e2e incl. pin→spoken-utterance flow with stubbed speechSynthesis. Mac must verify: Swift compile, sim drills (Rust log replaces Swift session file), background speech + ducking on device.

Later / parked

  • Code splitting — rejected by Alex 2026-07-09: mobile app, bundle ships locally, not a concern

  • M3 replay (scrubber + graphs) — belayed by Alex 2026-07-09 in favor of the real-engine critical path

  • Native iOS recording plugin (M0 kill drills on hardware) — the takeoff.ts + types.ts contracts are the porting reference

  • Sync spike: replicate against a real CouchDB (per-user DBs, db.replicate/db.sync is now one call away); E2EE question for the hosted tier

  • App icon / branding, wingover.app landing page

  • Offline tiles, airspace, engine-hours log (steering doc parking lot)