4.2.0 - drag mode Beta - #423
Draft
TheAngryRaven wants to merge 26 commits into
Draft
Conversation
A GPS log from a drag strip matches no known track, and worse, the return road loops back near the staging lanes so waypoint mode falsely detects out-and-back passes as laps. This adds the pure detection layer for drag mode: a staged/launch/run state machine that finds standing-start passes, times every traditional mark (60/330/660/1000/1320 ft) with sub-sample interpolation, and gates on "at least one straight, fast 660" so unknown circuits and autocross keep falling through to waypoint mode. Orchestration and UI land in follow-up commits on this plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g
dragRunsToLaps scores detected runs at a chosen distance and expresses them as ordinary Laps (splits in sectorTimes/sectorBoundaries under the circuit timing-line contract), so the whole lap UI works unchanged. A run that lifted before the scoring mark keeps its reached splits but is flagged with the new Lap.incomplete: its lapTimeMs is a data window that can undercut a real ET, so ranking goes through the new fastestRankedLap helper and calculateOptimalLap's deltaToFastest now skips incomplete laps — while their partial splits still feed the optimal, since the best 60 ft of the day is valid from any pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g
useDataLoader now consults detectDragRuns before accepting a waypoint result — the drag false-positive fix, since a strip's return road loops back near staging and waypoint mode would mis-time passes as laps. Detected runs pre-apply at the suggested distance (unpersisted, mirroring the waypoint branch) pending the user's prompt choice; applying persists the new FileMetadata.dragDistanceFt (feet, matching the lengthFt convention) so the session restores silently on reopen, and assigning a real course clears it. Distance switching re-maps the held runs without re-detection. Fastest-lap picks in useLapManagement and useReferenceLap now route through fastestRankedLap so incomplete runs can't be ranked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g
The no-track prompt grows a "Drag Runs Detected" branch (precedence: course step > drag > waypoint > no-track) with a scoring-distance picker pre-seeded from the longest distance a run actually covered; the session header gains a 1/8 / 1000 ft / 1/4 switcher while drag mode is active. The lap table renders the classic time slip — cumulative 60/330/660/1000 ft columns with best-per-mark highlights — labels rows "Run N" through the existing lapLabels plumbing, hides Min Speed (meaningless at a standing start), badges incomplete passes instead of showing their window as an ET, and its footer forks to Best ET with the optimal built from the best splits of the day. The crop-to-segment dropdown self-derives drag mode (only drag laps carry sectorBoundaries without a course). All new strings ship in the seven locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g
…-j5vmu1 Drag mode: standing-start run detection + time slips at unknown venues (plan 0022)
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
lapwing | 4037d02 | Commit Preview URL Branch Preview URL |
Sep 08 2026, 02:23 AM |
Coverage SummaryLines: 60.55% (8439/13937) · Statements: 59.58% · Functions: 56.54% · Branches: 57.14% Per-file coverage
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
…ls, data-time sweep The export renderer becomes the single drawing surface: per-type draw functions are exported with an OverlayLayout, measureOverlay() shares the draws' box math, OverlayLabels carries the user-facing strings (translated by React callers, English defaults elsewhere), and the sector state machine moves to sectorUtils.computeSectorDisplayStates — including completedAtMs, which drives the completion sweep in DATA time so it lands on the same frames in preview and export. Fidelity that existed on only one side (graph grid lines, needle/dot glow, bubble center dot, pace SLOW/FAST labels, preview's map framing and pace-bar stacking) is now in the shared draws. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LzfeVvASfX1hefekpHPUq
…omponents OverlayCanvas is the one preview host — a canvas sized by measureOverlay and drawn by the same per-type functions the export uses, with the theme glow filter and (for the formerly backdrop-blurred DOM widgets) a backdrop blur as the only CSS extras. VideoPlayer's per-type switch collapses to it; the nine overlay components and the wall-clock sparkle CSS are gone. The export context now threads the caller's translated labels, so exported videos stop being English-only. Changelog + plan doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LzfeVvASfX1hefekpHPUq
…enderer-unification plan 0023: one overlay scene renderer for preview + export (native phase 0)
startNativeVideoExport stages the job over LapWing's video_export_* IPC — begin (same quality mapping as the web path), the source blob in 8 MB raw-body chunks, overlay layers from the plan-0023 unified renderer at 15 Hz telemetry cadence (PNG at output resolution, timestamps relative to export start) — then drives the hardware transcode with streamed progress and hands the MP4 to the existing save/share flows. Resolves null (and the WebView exporter takes over unchanged) off the shell, on the desktop stub's `unsupported:` sentinel, on an older shell, or for multi-chunk playlists. VideoPlayer tries native first; web/desktop byte-identical. The renderer's per-frame invariant work is now memoized per session via WeakMap on array identity: per-source ranges (validated against the pace/braking arrays for special sources), map bounds, the pace scale, and the digital widget's double value-resolve. Speeds up preview, the WebView exporter, and native layer generation alike; draw output is bit-identical (existing tests unchanged, +1 counting-fixture memo test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LzfeVvASfX1hefekpHPUq
…port-bridge plan 0024: native export bridge — hardware-speed overlay export in the shell
"Save to device" inside the shell fell into downloadBlob() — an <a download href="blob:"> click the Android WebView has no download handler for — so the export completed and the file went nowhere. On native, destination "device" now means the gallery: the bridge calls video_export_save and the shell copies the finished MP4 straight out of its job dir into MediaStore Movies/LapWing (never through the WebView); the dialog's button reads "Save to Gallery" on native and VideoPlayer toasts export.savedToGallery. New ExportCallbacks.onSavedToDevice; the web path is untouched. The web remembers a session's video via a Chrome-only FileSystemFileHandle; the shell had nothing. nativeVideoStore.ts is its equivalent over the shell's video_store_* IPC: loadRecording copies a single-file recording into the shell's store in the background (8 MB raw-body chunks; the blob URL keeps playing meanwhile), and the restore effect tries the store before the IndexedDB fallback — the <video> then streams from app storage over the asset protocol. The key rides VideoSyncState.nativeStoredKey and the export bridge passes it as sourceKey, so exporting a remembered video skips the source upload entirely (a stale key silently falls back to uploading). i18n keys in all seven locales; plan 0024 + changelog updated. Gates: 2994 tests, tsc, eslint, build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LzfeVvASfX1hefekpHPUq
…tore-gallery plan 0024 follow-up: save to gallery + the remembered video in the shell
…) + visible export failures
"Save to Gallery" on-device stuttered for a second and went idle — no
file, no message. The shell's upload commands took raw invoke bodies
(invoke(cmd, bytes, { headers })), and Tauri never uses that IPC on
Android: the WebView can't hand the app a request body, so Tauri falls
back to window.ipc.postMessage and serializes a Uint8Array payload as a
JSON array of numbers, which the raw-body commands rejected. The first
chunk of every export (and of every video-store copy) failed, and
VideoPlayer.onError only console.error'd it.
- src/lib/nativeBytes.ts: bulk bytes now cross the bridge as base64
strings in ordinary JSON args, in 4 MB chunks — blobToBase64 via
FileReader.readAsDataURL (native, off the main thread), with a pure
chunked-btoa fallback (bytesToBase64). Unit-tested, including the
FileReader path through a stubbed reader.
- nativeVideoExport.ts / nativeVideoStore.ts send { jobId, offset|tMs,
data } / { key, offset, data } to the matching LapWing shell. Staging
progress now weights an overlay layer like one chunk push, which is
what it costs.
- Export failures are visible: onError toasts export.failed (7 locales)
with the shell's message, destructive variant — the WebView exporter's
errors get the same toast.
- Plan 0024 doc: follow-up 2 section; CHANGELOG under 4.2.0 Fixed.
Gates: tsc -b, eslint, vitest (3002 tests, +7).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LzfeVvASfX1hefekpHPUq
…chunk-transport plan 0024 follow-up 2: base64 chunk bridge (Android has no raw bodies) + visible export failures
The shell's copy per session's video (the remembered video) is the one thing in the app that grows by gigabytes, and nothing pruned it — the delete command existed but no UI called it. - New first-party plugin src/plugins/native-storage: on the native shell it contributes a "Videos on this device" panel to the Profile tab (order 3, between the account meters and lap snapshots) — every stored copy with file name, owning session, size and date, a per-copy delete with inline confirm, and a clear-all, each toasting the bytes freed. On a shell that predates the listing the card says it needs a newer build. Contributing the panel is also what makes the Profile tab exist in a native build without the cloud plugin. - nativeVideoStore.ts: listNativeStoredVideos / removeNativeStoredVideo / clearNativeVideoStore over LapWing's video_store_list / _remove / _clear; removals fire a native-video-store-changed window event. - useVideoSync listens for it: a session playing from the deleted copy (a restore) unloads its video exactly as deleting the in-app stored video does; one still holding the picked blob only loses the export shortcut. - Pure parts (ordering, totals, session label, applying a removal, byte formatting) in deviceVideos.ts with tests; the bridge functions tested against a mocked invoke. 7 locales. Plan doc + changelog. tsc/eslint clean; 3018 tests pass (+24). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LzfeVvASfX1hefekpHPUq
Android app only: "From Insta360 camera" joins the camera's Wi-Fi, lists its recordings and plays one without downloading it — the LapWing shell's native player streams the file off the camera and serves the frames as an MJPEG stream the player shows in an <img>. NativePlayerElement implements the <video> subset useVideoSync relies on (extrapolated clock between the shell's reports, seek watchdog), so sync lock, offset, scrubbing and the overlay canvas work on the stream unchanged. 360° recordings get a drag-to-point layer with lock/reset; export is disabled for streams. - lib/insta360: types, ipc (insta360_* over the lazy Tauri loader), pose + playerClock (pure, tested), nativePlayer (tested with a mocked bridge) - components/insta360: import dialog (Wi-Fi prefix/password, connect, list, load, disconnect), 360° view layer - useVideoSync: source-agnostic VideoSurface ref, loadCameraRecording, unloadVideo, nativeSource; VideoPlayer: <img> surface, camera buttons, 360° lock/reset; video namespace strings in all seven languages - docs/plans/0025, CHANGELOG, CLAUDE.md map Companion: LapWing plan 0002 (tauri-plugin-insta360 + docs/insta360.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sK5Tb8pa8bZEtUNiprYiH
…ore-ui plan 0024 follow-up 3: see and clear the videos the native app keeps
…camera-preview plan 0025: stream an Insta360 camera recording into the player (native)
…ne cache Demoing at a track on an iPhone with no signal came up empty. The service worker is fine — the whole app shell is precached and a warm Safari tab does run offline. Two WebKit policies take it away: 1. Safari deletes all script-writable storage for a site not interacted with for ~7 days of browser use. That sweep takes the SW registration, the Cache API precache AND our IndexedDB sessions, silently. A phone that last opened LapWing before the previous race weekend arrives with nothing cached. 2. iOS never fires `beforeinstallprompt`, and InstallPrompt was built entirely around that event — so on the one platform that most needs the nudge, the card rendered nothing at all. A Home Screen install is the documented escape hatch (far larger quota, and an idle counter that only advances on days the app is opened), and it has its own storage partition — so the instructions must end with "open it once while you still have signal", or the user installs an icon that is just as empty. - lib/pwaInstall.ts: pure detection of installed / ios-manual / prompt-capable, including the iPad's desktop-class UA (MacIntel + maxTouchPoints > 1) without catching real Macs. Dismissal moves from a session-only flag to a 30-day localStorage snooze — this hint is worth repeating, not silencing. - lib/persistentStorage.ts: request the Storage API's persistent mode once at startup so the cache and IndexedDB stop being evictable. Fire-and-forget; every outcome is a return value, never a throw. - InstallPrompt: same card, plus the iOS share-sheet steps and a one-line why. Strings moved to common:install.* and translated across all six locales. Vitest covers both new modules (23 cases). Also noted in the plan: the `app-html` NetworkFirst route is dead code — Workbox matches the precache NavigationRoute first — left as-is since precache-first is the more offline-robust behaviour, but the config reads misleadingly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKLfticLxeFfKK2WFDNYdd
The reported bug — load the app at a track, airplane mode, refresh, nothing
loads — is not iOS-specific and not eviction. Workbox's precache install is
all-or-nothing: one failed request rejects `install`, the service worker is
discarded, and NOTHING is cached. Reproduced in Chromium against a real dist
build by cutting the network 4s into the first load:
SW state at cut: { installing: 'installing', active: null }
cache entries: 53 of 267
after install failed: { installing: null, waiting: null, active: null }
offline reload → net::ERR_INTERNET_DISCONNECTED
The install was 10.16 MB across 267 entries, and the bundled demo datalogs
alone were 44% of it. Seven entries were precached twice on top of that
(includeAssets listed files globPatterns already matched), so every install
re-downloaded the 1.25 MB sample .nmea. On a congested cell connection that is
a long window in which any hiccup costs the entire offline cache, silently.
- vite.config.ts: DEFERRED_ASSET_DIRS (samples/, loggers/) is one source of
truth feeding globIgnores, a CacheFirst runtime route, and a build-emitted
offline-assets.json (generated from the filesystem, so a new logger photo
needs no code change). Install drops to 257 entries / 4.98 MB, almost
entirely the app itself. includeAssets cut to robots.txt — everything else
was already covered, which is where the duplicates came from.
- lib/offlineWarmup.ts: pulls the deferred assets in afterwards, per-URL, so a
failure costs that asset and retries next visit instead of everything. Writes
via `cache.add`, NOT fetch: on a first visit the worker is registered but not
yet controlling the page, so page-initiated requests bypass it and cached
nothing at all — the integration test caught exactly that. `addAll` would
have reintroduced the all-or-nothing behaviour this plan removes.
- lib/offlineReadiness.ts + useOfflineReadiness + a Settings row: the deeper
failure was that none of this was visible. You can now check readiness in the
paddock, and finish the download on demand, instead of finding out on track.
Verified end-to-end (scratchpad harness, real dist over a throttled server):
killing every deferred-asset request outright now leaves the worker activated,
the shell cached (254 entries) and the offline reload rendering — the same
failures that previously discarded the whole install. 18 new Vitest cases.
lint, typecheck, 3064 tests and build all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKLfticLxeFfKK2WFDNYdd
…i-cache-x5ny5i plan 0026: iOS install hint + persistent storage (offline hardening, part 1)
…resilience plan 0027: stop a flaky connection from wiping the whole offline cache
… no "any network" picker The dialog offered "leave empty to pick from every network in range", but Android rejects a Wi-Fi specifier that matches everything, so pressing Connect with the field empty failed with "cannot set match-all pattern for specifier". The shell now reads an empty field as "bind to the Wi-Fi this phone is already on" (LapWing side), which is the mode that actually works — so the hints have to tell the user to join the camera's network in the phone's settings first, and that a typed name is what brings up the Android picker. The password only applies to the typed-name path. Strings only, in all seven locales, plus the matching sentence in the unreleased 4.2.0 entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sK5Tb8pa8bZEtUNiprYiH
…nt-network Insta360: correct the Wi-Fi hints — Android has no "any network" picker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.