Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
now cross the bridge in a form Android supports, and an export that fails
says so with the reason instead of silently stopping. Requires the matching
LapWing build.
- **A drag log could overwrite a course's lap snapshot.** Opening a drag (or
waypoint) session right after a circuit session kept the previous course
selected, so assigning an engine or saving a snapshot could replace that
course's real fastest lap with a 10-second drag run — and cloud-sync it.
Course-less sessions now clear the selection, and drag sessions can never
save a course snapshot.
- **Incomplete drag runs no longer count as "fastest"** in the video overlay's
best-lap readout, the Overlays menu trophy, the external-reference picker,
lap snapshots, or the course outline generated from a session.
- **Video overlay gauges kept the old range after a unit toggle.** Switching
MPH/KPH (or metric distance) left analog, bar, graph and bubble overlays
scaling the new values against the previous unit's range, in both the
preview and exported videos.
- **Android app: an export could use the wrong session's video.** If the
app's background copy of a video finished after you had switched sessions,
the next export burned the new session's overlays onto the old session's
footage. A copy now only attaches to the session (and video) it started
for.
- **Android app: Settings said "not cached yet" forever.** The offline
readiness row looked for a web service worker the app never uses. It now
shows that everything is built into the app and works with no signal.
- **Android app: deleting a session now frees its video copy.** The app's
stored copy of a session's video (often gigabytes) was left behind in app
storage when the session was deleted.
- **Offline extras now update.** The bundled sample logs and logger photos
that download in the background for offline use were cached once and never
refreshed, so a device kept the old copy forever after an update. Each one
is now checked against the current release and re-downloaded when it
changes, and files a release no longer ships are cleaned up.

## [4.1.0] - 2026-08-24

Expand Down
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ src/
│ ├── buildInfo.ts # Build version/hash/branch stamp + isPreviewBuild()
│ ├── analytics.ts # ★ Anonymous usage stats (plan 0030): PostHog, cookieless, dynamic-imported only when the gate passes (key baked in + web + not iframe/`?nosw=1` + no DNT/GPC + `sendUsageStats` on) — keyless builds drop the import entirely. Pageview/pageleave only; `scrubEvent` (before_send) strips query/hash and placeholders `/s/:token` + `/driver/:username` from every URL property. Privacy.tsx wording mirrors its config — change both together
│ ├── versionCheck.ts # ★ "Update available" signal: compares buildInfo vs the build-emitted, uncached /version.json (independent of the SW's own update detection) → main.tsx update toast
│ ├── offlineWarmup.ts / offlineReadiness.ts # ★ Two-stage offline cache (plan 0027): the precache install is all-or-nothing, so `vite.config.ts` holds the heavy public dirs (DEFERRED_ASSET_DIRS = samples/, loggers/) OUT of it and emits `offline-assets.json`; offlineWarmup writes them into the `app-deferred-assets` cache afterwards via `cache.add` (NOT fetch — an uncontrolled first-visit page bypasses the SW), where a failure costs only that asset. offlineReadiness is the pure state → useOfflineReadiness → SettingsModal row
│ ├── offlineWarmup.ts / offlineReadiness.ts # ★ Two-stage offline cache (plan 0027): the precache install is all-or-nothing, so `vite.config.ts` holds the heavy public dirs (DEFERRED_ASSET_DIRS = samples/, loggers/) OUT of it and emits `offline-assets.json` (url + content-hash revision per file); offlineWarmup writes them into the `app-deferred-assets` cache afterwards with `cache.put` (NOT a plain fetch — an uncontrolled first-visit page bypasses the SW), stamping each with its revision so an updated asset is re-fetched (the CacheFirst route alone never would) and pruning files no longer shipped; a failure costs only that asset. offlineReadiness is the pure state (incl. `native` for the Tauri shell) → useOfflineReadiness → SettingsModal row
│ ├── nativeVideoExport.ts / nativeVideoStore.ts / nativeBytes.ts # ★ Native-shell video bridge (plan 0024, → docs/subsystems.md): nativeVideoExport stages a hardware-transcoded overlay export over `video_export_*` IPC (null = shell can't, caller falls back to the WebView exporter); nativeVideoStore copies a session's picked video into the shell's app-data store (`video_store_*`) so it replays/exports without re-picking — deleted with its session by fileStorage.deleteFile, listed/pruned by plugins/native-storage; nativeBytes = the base64-chunk upload encoding (Android's WebView has no raw IPC bodies)
│ ├── pwaInstall.ts / persistentStorage.ts # Offline durability (plan 0026): pwaInstall decides the install nudge (iOS never fires beforeinstallprompt → hand-held Home Screen hint, snoozable) for InstallPrompt; persistentStorage asks navigator.storage.persist() once at boot (main.tsx) so the precache + IndexedDB stop being evictable
│ ├── debugConsole.ts # ★ On-screen debug console (`?dbg=true`) — mobile/PWA has no dev tools
│ ├── units.ts # ★ Pure unit conversions for the 3 imperial/metric toggles
│ ├── i18n/ # ★ i18next config/init/format (→ docs/i18n.md)
Expand All @@ -161,6 +163,7 @@ src/
│ ├── (framework) # types, registry, index, panels, mounts, fileSources, storage + hosts
│ ├── cloud-sync/ # ★ First-party plugin: Supabase file + garage sync (→ docs/backend.md)
│ ├── tools/ # ★ First-party plugin: Tools tab (kart seat-position viz; phone Lap Timer; pill alignment calculator — plan 0011)
│ ├── native-storage/ # First-party plugin, native shell only: Profile-tab "Videos on this device" panel over lib/nativeVideoStore (plan 0024) — also what gives a native build without cloud a Profile tab
│ └── coaching/ # Gitignored slot for the AI coach (npm pkg in production)
├── types/racing.ts # ★ Core types: GpsSample, ParsedData, Lap, Course, Track, …
├── contexts/ # SettingsContext, SessionContext, PlaybackContext, DeviceContext, AuthContext
Expand Down Expand Up @@ -284,9 +287,11 @@ A plugin default-exports `{ id, name, version?, priority?, setup?(ctx) }`. In
- **File sources** (`fileSources.ts`, `FILE_SOURCES_POINT`): feed *remote* files
into the host browser as inline `cloud` rows without coupling the host to cloud.

First-party plugins: **cloud-sync** (Supabase file + garage sync → `docs/backend.md`)
and **tools** (Tools tab: kart seat-position visualizer + phone Lap Timer built on
`lib/gps/`). New slots/points are just new strings — no framework change.
First-party plugins: **cloud-sync** (Supabase file + garage sync → `docs/backend.md`),
**tools** (Tools tab: kart seat-position visualizer + phone Lap Timer built on
`lib/gps/`) and **native-storage** (native shell only — the Profile tab's "Videos
on this device" card over `lib/nativeVideoStore`; its `setup` returns early off
native, so the web build contributes nothing). New slots/points are just new strings — no framework change.

> ## ⚠️ SUPER IMPORTANT — coach source differs by branch (DO NOT MERGE BLINDLY)
>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,7 @@ Built on the shoulders of these incredible open-source projects and free service

- [React](https://react.dev) · [Vite](https://vite.dev) · [TypeScript](https://www.typescriptlang.org) · [Tauri](https://tauri.app) (native shell IPC, native-only)
- [Tailwind CSS](https://tailwindcss.com) · [shadcn/ui](https://ui.shadcn.com) · [Radix UI](https://www.radix-ui.com) · [Lucide Icons](https://lucide.dev)
- [Archivo](https://github.com/Omnibus-Type/Archivo) (LapWing brand display face) · [Inter](https://rsms.me/inter/) · [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL fonts) · [Fontsource](https://fontsource.org) (self-hosted, offline-ready)
- [Archivo](https://github.com/Omnibus-Type/Archivo) (LapWing brand display face, self-hosted from `src/assets/fonts/` with its licence in `OFL.txt`) · [Inter](https://rsms.me/inter/) · [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL fonts) · [Fontsource](https://fontsource.org) (self-hosted, offline-ready)
- [Leaflet](https://leafletjs.com) · [CARTO basemaps](https://carto.com) · [Esri World Imagery & Wayback](https://livingatlas.arcgis.com/wayback/) (satellite + historical imagery dates)
- [TanStack Query](https://tanstack.com/query) · [Sonner](https://sonner.emilkowal.dev) · [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) · [dnd kit](https://dndkit.com) (sector list drag-to-reorder)
- [i18next](https://www.i18next.com) · [react-i18next](https://react.i18next.com) (internationalization)
Expand Down
9 changes: 9 additions & 0 deletions docs/plans/0022-drag-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ convention) re-runs the detector and re-maps silently; a real track/course
restore shadows a stale drag field; assigning a course clears the field.
Distance switching is a pure re-map of the held `DragRun[]` — no re-detection.

Course-less pre-application (drag runs, and the waypoint fallback) goes
through `applyCourselessLaps`, which **clears the course selection** first. The
loader carries the previous file's selection into a new load, and a drag
session left holding it kept `canSnapshot` live — a 10-second run could replace
that course's snapshot and cloud-sync it (4.2.0 release review A1).
`useLapSnapshots` also refuses drag sessions outright (`snapshotEligible`), and
every "fastest" pick in the app goes through `fastestRankedLap`, never a bare
min-reduce (A2).

### UI

`TrackPromptDialog` precedence: course-step > drag > waypoint > no-track (title
Expand Down
16 changes: 15 additions & 1 deletion docs/plans/0024-native-export-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ seamlessly; web and desktop behavior is byte-identical to before.
The per-frame invariant work found while profiling the export path is now
cached per session via `WeakMap` on array identity (no invalidation, no
leaks): per-source ranges (`memoRange`, validated against the pace/braking
arrays for the special sources), map bounds (`memoMapBounds`), the pace bar's
arrays for the special sources and against the data-source defs, which close
over the unit toggles — a MPH/KPH switch must re-range), map bounds (`memoMapBounds`), the pace bar's
scale (`memoPaceMax`), and the digital widget no longer resolves its value
twice per draw (`digitalBox`). This speeds up preview, the WebView exporter,
and native layer generation alike — the draws' output is bit-identical.
Expand Down Expand Up @@ -141,3 +142,16 @@ export shortcut goes away. The pure parts — ordering, totals, labels,
applying a removal, byte formatting — live in `deviceVideos.ts` with tests;
the panel is a thin view.

## Follow-up 4 (landed): release-review hardening (4.2.0)

- **Stale copy, wrong session.** `loadRecording`'s background copy set
`nativeStoredKey` whenever it resolved, so a copy that finished after a
session switch attached session A's key to session B and the export paired
A's footage with B's overlays. `useVideoSync` now adopts a key only through a
latest-wins gate (`lib/latestGate.ts`) invalidated on every session/video
change; the async restore lookup is gated the same way.
- **Deleted sessions leaked their copy.** `deleteNativeStoredVideo` had no
callers. `fileStorage.deleteFile` now drops the session's stored copy,
best-effort, next to its weather cache.
- **Tests.** `nativeVideoExport.test.ts` pins the IPC sequence, the stale-key
retry and the fallback-vs-error classifier.
26 changes: 25 additions & 1 deletion docs/plans/0027-precache-resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,37 @@ The route deliberately has no `ExpirationPlugin`: entries written by the client
aren't in the plugin's own index, and a fixed set of build assets has nothing to
expire.

### Revision the deferred cache (follow-up, 4.2.0 release review PERF-1)

Moving `samples/` and `loggers/` out of the precache also moved them out of
Workbox's per-file revisioning, and the runtime route is `CacheFirst` with no
expiry — so an updated sample log or logger photo would never reach a device
that had already cached the old bytes. The fix keeps the cache key a plain URL
(the app requests plain URLs and the route serves them) and moves the revision
into the data:

- `offline-assets.json` lists `{ url, revision }`, where `revision` is the first
16 hex chars of the file's SHA-256, computed by `deferredAssetManifest`.
- The warm-up stores each response with an `X-Asset-Revision` header
(`fetch(…, { cache: "reload" })` + `cache.put`, so a refresh never gets the
old bytes from the HTTP cache) and treats an entry whose stamp doesn't match
the manifest as missing. Entries the service worker's own route cached on a
miss carry no stamp, so they are replaced once.
- After a run it prunes entries whose path the manifest no longer lists — but
never against an empty list, which is what an unreadable manifest degrades to.
- The readiness count uses the same revision check, so a stale cache reads as
"preparing" rather than "ready".

### Make readiness visible

The deeper failure was that none of this was observable — you found out the cache
was incomplete by losing signal and refreshing. `lib/offlineReadiness.ts` (pure)
plus `hooks/useOfflineReadiness` and a Settings row report one of
`unsupported` / `not-ready` / `preparing` / `ready`, with a *Finish download*
button. `controlled` alone decides whether the app loads offline — the shell is
button. A later `native` state (checked first, via `isNativeApp()`) covers the
Tauri shell: its WebView exposes `navigator.serviceWorker` but the shell never
registers one, so it used to read `not-ready` forever; native is offline by
construction and the row says so. `controlled` alone decides whether the app loads offline — the shell is
precached as a unit, so a controlling worker means install completed; the
deferred count only separates "ready" from "preparing".

Expand Down
58 changes: 58 additions & 0 deletions docs/subsystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,3 +713,61 @@ GPS-derived), `lat_g_native`/`lon_g_native` (logger-native), `accel_x/y/z` (raw
IMU) — which coexist on a sample and must never collapse. `fieldResolver.ts` is the
settings-facing adapter. `toChannelKey()` is the idempotent shim that migrates
legacy display-name keys persisted in graph-prefs / saved overlay configs on load.

---

## Native video bridge (`nativeVideoExport` / `nativeVideoStore` / `nativeBytes`)

Native shell only (`isNativeApp()`); every entry point is a no-op or `null` on
the web, so callers invoke them unconditionally. All IPC goes through the lazy
`lib/loggers/native/ipc.ts` loader, so `@tauri-apps/api` stays off the web
payload. Shell-side contract: LapWing `docs/video-pipeline.md`. Design record:
[`plans/0024-native-export-bridge.md`](plans/0024-native-export-bridge.md).

- **`nativeBytes.ts`** — bytes *into* the shell travel as base64 strings in
ordinary JSON args, in `NATIVE_CHUNK_BYTES` (4 MB) chunks. Android's WebView
cannot hand Tauri a raw request body, so a `Uint8Array` arg degrades to a JSON
number array (and raw-body commands reject it outright) — that is how the
first on-device exports failed on their first chunk. Bytes *back* (raw
`tauri::ipc::Response`) are unaffected.
- **`nativeVideoExport.ts`** — `startNativeVideoExport()` resolves `null` when
the shell can't export (web, multi-chunk playlist, desktop stub's
`unsupported:` sentinel, or a shell that doesn't know the command), and the
caller (`VideoPlayer`) falls back to the in-WebView WebCodecs exporter. Any
other `video_export_begin` failure is reported, never silently swallowed.
Sequence: `begin` (trim/size/bitrate; `sourceKey` when the session has a
stored copy, retried without it if the key is stale) → `push_source` chunks
(skipped with a stored source) → `push_overlay` PNG layers rendered by the
same scene renderer as the preview (plan 0023) at 15 Hz → `run` (progress
channel) → `save` (gallery) or `collect` (bytes back) → `dispose`, always.
- **`nativeVideoStore.ts`** — the web remembers a session's video through a
`FileSystemFileHandle`; Android has none, so `useVideoSync` copies the picked
file into the shell's app-data store in the background (`video_store_*`) and
plays/exports from that copy on the next open. The copy is async and can
outlive a session switch, so its key is only adopted through a latest-wins
gate (`lib/latestGate`) — otherwise an export could pair one session's footage
with another's overlays. Stored copies are gigabytes: `fileStorage.deleteFile`
deletes a session's copy with the session, and the **native-storage** plugin's
"Videos on this device" card lists/removes them (`removeNativeStoredVideo` /
`clearNativeVideoStore` announce `NATIVE_VIDEO_STORE_CHANGED` so a session
streaming the deleted copy unloads).

---

## Offline durability (`pwaInstall` / `persistentStorage` / offline cache)

Design records: [`plans/0026-ios-offline-install.md`](plans/0026-ios-offline-install.md)
and [`plans/0027-precache-resilience.md`](plans/0027-precache-resilience.md).

- **`persistentStorage.ts`** — `requestPersistentStorage()` runs once at boot
(`main.tsx`) and asks `navigator.storage.persist()` so the precache and the
IndexedDB sessions stop being evictable (WebKit clears script-writable storage
after ~7 days without a visit). Never throws; a denial keeps default storage.
- **`pwaInstall.ts`** — pure install-nudge decision for `InstallPrompt`:
`installed` (standalone — nothing to offer), `ios-manual` (iOS never fires
`beforeinstallprompt`, so the Home Screen steps are shown by hand), or
`prompt-capable` (wait for the real event). The hint snoozes for 30 days.
- **`offlineWarmup.ts` / `offlineReadiness.ts`** — the heavy public dirs are kept
out of the all-or-nothing precache and warmed afterwards into a revisioned
runtime cache; the Settings row reports `native` / `unsupported` /
`not-ready` / `preparing` / `ready`. Details in plan 0027.
Loading
Loading