From 66c6506ced84e2b960faa4436aca50b44964fa07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 00:58:21 +0000 Subject: [PATCH 1/8] plan 0022/0023: keep drag runs out of course snapshots and fastest picks A drag (or waypoint) load kept the previous file's course selection, so canSnapshot stayed live and a 10-second drag run could replace that course's real snapshot (then cloud-sync it). Course-less pre-application now goes through applyCourselessLaps, which clears the selection, and useLapSnapshots refuses drag sessions outright (snapshotEligible). Every remaining bare min-reduce "fastest" pick (overlay best-lap, the Overlays menu and external-ref trophies, lapSnapshot.fastestLap, the generated course outline, useDataLoader's own helper) now goes through fastestRankedLap so an incomplete run's data window never ranks. The overlay range memo keyed only on the session arrays, but the data source defs close over the unit toggles; include their identity so a MPH/KPH or metric switch re-ranges the gauges (plan 0023). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- CHANGELOG.md | 13 ++++++++ src/components/ExternalRefBar.tsx | 6 ++-- src/components/OverlaysMenu.tsx | 10 +++--- src/components/TrackEditor.tsx | 5 ++- src/hooks/useDataLoader.test.ts | 35 ++++++++++++++++++- src/hooks/useDataLoader.ts | 48 ++++++++++++++++----------- src/hooks/useLapSnapshots.test.ts | 20 +++++++++++ src/hooks/useLapSnapshots.ts | 39 ++++++++++++++++++---- src/lib/lapSnapshot.test.ts | 8 +++++ src/lib/lapSnapshot.ts | 10 ++++-- src/lib/overlayCanvasRenderer.test.ts | 16 +++++++++ src/lib/overlayCanvasRenderer.ts | 21 ++++++++---- src/pages/Index.tsx | 1 + 13 files changed, 184 insertions(+), 48 deletions(-) create mode 100644 src/hooks/useLapSnapshots.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fec4b4..5237fb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,19 @@ 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. ## [4.1.0] - 2026-08-24 diff --git a/src/components/ExternalRefBar.tsx b/src/components/ExternalRefBar.tsx index e092ea8f..8d340270 100644 --- a/src/components/ExternalRefBar.tsx +++ b/src/components/ExternalRefBar.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { FileEntry } from '@/lib/fileStorage'; -import { formatLapTime } from '@/lib/lapCalculation'; +import { fastestRankedLap, formatLapTime } from '@/lib/lapCalculation'; import { FileSearch, Loader2, X, Trophy } from 'lucide-react'; interface ExternalRefBarProps { @@ -134,8 +134,8 @@ export function ExternalRefBar({ {!loading && !error && stage === 'laps' && (
{(() => { - const fastestIdx = laps.reduce((minIdx, lap, idx, arr) => - lap.lapTimeMs < arr[minIdx].lapTimeMs ? idx : minIdx, 0); + const fastest = fastestRankedLap(laps); + const fastestIdx = fastest ? laps.indexOf(fastest) : -1; return (
    {laps.map((lap, idx) => { diff --git a/src/components/OverlaysMenu.tsx b/src/components/OverlaysMenu.tsx index 546dbd4a..4393902e 100644 --- a/src/components/OverlaysMenu.tsx +++ b/src/components/OverlaysMenu.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { listAllMetadata } from '@/lib/fileStorage'; import { filesTaggedWithCourse, type CourseFileEntry } from '@/lib/fileBrowserTree'; -import { formatLapTime } from '@/lib/lapCalculation'; +import { fastestRankedLap, formatLapTime } from '@/lib/lapCalculation'; import { overlayId, externalOverlayId, type OverlayLine } from '@/lib/lapOverlays'; import type { Lap } from '@/types/racing'; import { Layers, Loader2, Trophy, Check, Target, X, ChevronDown, ChevronRight } from 'lucide-react'; @@ -30,10 +30,10 @@ interface OverlaysMenuProps { onSetOverlayReference: (line: OverlayLine) => void; } -/** Index of the fastest lap (by time) in a list, or -1 when empty. */ -function fastestLapIdx(laps: Array<{ lapTimeMs: number }>): number { - if (laps.length === 0) return -1; - return laps.reduce((minIdx, lap, idx, arr) => (lap.lapTimeMs < arr[minIdx].lapTimeMs ? idx : minIdx), 0); +/** Index of the fastest rankable lap in a list (incomplete runs skipped), or -1. */ +function fastestLapIdx(laps: Array<{ lapTimeMs: number; incomplete?: boolean }>): number { + const best = fastestRankedLap(laps); + return best ? laps.indexOf(best) : -1; } /** diff --git a/src/components/TrackEditor.tsx b/src/components/TrackEditor.tsx index 302cdc85..112452d6 100644 --- a/src/components/TrackEditor.tsx +++ b/src/components/TrackEditor.tsx @@ -25,6 +25,7 @@ import { onGarageChange } from '@/lib/garageEvents'; import { buildSubmissionPlan } from '@/lib/trackSubmission'; import { loadSubmittedRecords } from '@/lib/submittedTracksStorage'; import { abbreviateTrackName, buildCourseOutline } from '@/lib/trackUtils'; +import { fastestRankedLap } from '@/lib/lapCalculation'; import { Select, SelectContent, @@ -290,9 +291,7 @@ function CourseDrawingMini({ points, size = 36 }: { points: Array<{ lat: number; // lap (or the whole trace when no laps were detected) so the new course // already has a drawing — no need for the user to open the Generate picker. if (samples && samples.length >= 2) { - const fastest = laps && laps.length > 0 - ? laps.reduce((best, l) => (l.lapTimeMs < best.lapTimeMs ? l : best)) - : null; + const fastest = laps ? fastestRankedLap(laps) : null; const source = fastest ? samples.slice(fastest.startIndex, fastest.endIndex + 1) : samples; const outline = buildCourseOutline(source); if (outline.length >= 2) form.handleVisualLayoutChange(outline); diff --git a/src/hooks/useDataLoader.test.ts b/src/hooks/useDataLoader.test.ts index 356e17c0..57835b1d 100644 --- a/src/hooks/useDataLoader.test.ts +++ b/src/hooks/useDataLoader.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { detectionMetadataPatch, dragMetadataPatch } from "./useDataLoader"; +import type { Lap, TrackCourseSelection } from "@/types/racing"; +import { applyCourselessLaps, detectionMetadataPatch, dragMetadataPatch } from "./useDataLoader"; const laps = [ { lapNumber: 1, lapTimeMs: 65000 }, @@ -65,3 +66,35 @@ describe("dragMetadataPatch (drag-session tagging)", () => { expect(patch.fastestLapNumber).toBeUndefined(); }); }); + +describe("applyCourselessLaps (drag / waypoint pre-application)", () => { + function sink(initial: TrackCourseSelection | null) { + const state = { selection: initial, laps: [] as Lap[], selected: null as number | null }; + return { + state, + setSelection: (sel: TrackCourseSelection | null) => { state.selection = sel; }, + setLaps: (l: Lap[]) => { state.laps = l; }, + setSelectedLapNumber: (n: number | null) => { state.selected = n; }, + }; + } + const lap = (lapNumber: number, lapTimeMs: number, incomplete?: boolean) => + ({ lapNumber, lapTimeMs, ...(incomplete ? { incomplete } : {}) }) as Lap; + + it("clears a course selection carried over from the previous file", () => { + // Regression: a drag log opened after a circuit session kept that course + // selected, so canSnapshot stayed live and a run could replace the course PB. + const s = sink({ trackName: "OKC", courseName: "CW", course: {} as TrackCourseSelection["course"] }); + applyCourselessLaps(s, [lap(1, 13400), lap(2, 12900)]); + expect(s.state.selection).toBeNull(); + expect(s.state.laps).toHaveLength(2); + expect(s.state.selected).toBe(2); + }); + + it("selects the fastest complete run, never an incomplete one", () => { + const s = sink(null); + applyCourselessLaps(s, [lap(1, 4200, true), lap(2, 12900)]); + expect(s.state.selected).toBe(2); + applyCourselessLaps(s, [lap(1, 4200, true)]); + expect(s.state.selected).toBeNull(); + }); +}); diff --git a/src/hooks/useDataLoader.ts b/src/hooks/useDataLoader.ts index c1410aa4..afade4dc 100644 --- a/src/hooks/useDataLoader.ts +++ b/src/hooks/useDataLoader.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { GpsSample, + Lap, ParsedData, Track, TrackCourseSelection, @@ -61,19 +62,9 @@ export interface UseDataLoaderReturn { handleUseWaypoint: () => void; } -/** Pick the lap with the lowest lapTimeMs (linear, no Math.min spread). */ -function pickFastestLap(laps: T[]): T | null { - if (laps.length === 0) return null; - let fastest = laps[0]; - for (let i = 1; i < laps.length; i++) { - if (laps[i].lapTimeMs < fastest.lapTimeMs) fastest = laps[i]; - } - return fastest; -} - -/** Pick the lap number with the lowest lapTimeMs. */ -function pickFastestLapNumber(laps: { lapNumber: number; lapTimeMs: number }[]): number | null { - return pickFastestLap(laps)?.lapNumber ?? null; +/** The fastest rankable lap's number (incomplete drag runs never qualify). */ +function pickFastestLapNumber(laps: { lapNumber: number; lapTimeMs: number; incomplete?: boolean }[]): number | null { + return fastestRankedLap(laps)?.lapNumber ?? null; } /** @@ -90,7 +81,7 @@ export function detectionMetadataPatch( ): Partial> { const patch: Partial> = { trackName, courseName }; if (startDate) patch.sessionStartTime = startDate.getTime(); - const fastest = pickFastestLap(laps); + const fastest = fastestRankedLap(laps); if (fastest) { patch.fastestLapMs = fastest.lapTimeMs; patch.fastestLapNumber = fastest.lapNumber; @@ -118,6 +109,26 @@ export function dragMetadataPatch( return patch; } +/** The slice of useLapManagement a drag (or waypoint) pre-application writes. */ +export interface CourselessLapSink { + setSelection: (selection: TrackCourseSelection | null) => void; + setLaps: (laps: Lap[]) => void; + setSelectedLapNumber: (lapNumber: number | null) => void; +} + +/** + * Swap course-less laps (drag runs, or the waypoint fallback) into the lap + * manager. The course selection is cleared first: a drag/waypoint session has + * no course, and a selection left over from the previously loaded file would + * otherwise keep `canSnapshot` live and let a 10-second drag run overwrite + * that course's snapshot (plan 0022). Pure so the clearing stays testable. + */ +export function applyCourselessLaps(sink: CourselessLapSink, laps: Lap[]): void { + sink.setSelection(null); + sink.setLaps(laps); + sink.setSelectedLapNumber(fastestRankedLap(laps)?.lapNumber ?? null); +} + /** * File-load orchestration: connects sessionData (parsing), lapMgmt (lap calc), * sessionMeta (per-file kart/setup/weather metadata), and the track-prompt UI. @@ -152,8 +163,7 @@ export function useDataLoader({ persist = true, ) => { const dragLaps = dragRunsToLaps(samples, drag.runs, distanceFt); - lapMgmt.setLaps(dragLaps); - lapMgmt.setSelectedLapNumber(fastestRankedLap(dragLaps)?.lapNumber ?? null); + applyCourselessLaps(lapMgmt, dragLaps); setDragDetection(drag); setDragDistanceFt(distanceFt); if (persist && fileName) { @@ -290,8 +300,7 @@ export function useDataLoader({ if (detection && detection.isWaypointMode) { // Waypoint mode — apply laps and prompt the user to confirm - lapMgmt.setLaps(detection.laps); - lapMgmt.setSelectedLapNumber(pickFastestLapNumber(detection.laps)); + applyCourselessLaps(lapMgmt, detection.laps); setDetectedTrack(null); setTrackPromptOpen(true); return; @@ -358,8 +367,7 @@ export function useDataLoader({ // prompt keeps its laps unpersisted. const handleUseWaypoint = useCallback(() => { if (!detectionResult?.isWaypointMode) return; - lapMgmt.setLaps(detectionResult.laps); - lapMgmt.setSelectedLapNumber(pickFastestLapNumber(detectionResult.laps)); + applyCourselessLaps(lapMgmt, detectionResult.laps); setDragDetection(null); setDragDistanceFt(null); }, [detectionResult, lapMgmt]); diff --git a/src/hooks/useLapSnapshots.test.ts b/src/hooks/useLapSnapshots.test.ts new file mode 100644 index 00000000..2815424d --- /dev/null +++ b/src/hooks/useLapSnapshots.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { snapshotEligible } from "./useLapSnapshots"; + +describe("snapshotEligible", () => { + const base = { hasCourse: true, lapCount: 3, engine: "KA100", isDragSession: false }; + + it("allows a course session with laps and an engine", () => { + expect(snapshotEligible(base)).toBe(true); + }); + + it("refuses without a course, laps, or engine", () => { + expect(snapshotEligible({ ...base, hasCourse: false })).toBe(false); + expect(snapshotEligible({ ...base, lapCount: 0 })).toBe(false); + expect(snapshotEligible({ ...base, engine: "" })).toBe(false); + }); + + it("refuses a drag session even when a stale course selection survived (plan 0022)", () => { + expect(snapshotEligible({ ...base, isDragSession: true })).toBe(false); + }); +}); diff --git a/src/hooks/useLapSnapshots.ts b/src/hooks/useLapSnapshots.ts index 29650bb9..f96a5b17 100644 --- a/src/hooks/useLapSnapshots.ts +++ b/src/hooks/useLapSnapshots.ts @@ -24,6 +24,12 @@ export interface UseLapSnapshotsParams { setups: VehicleSetup[]; sessionKartId: string | null; sessionSetupId: string | null; + /** + * True for a drag session (plan 0022): its runs are standing-start passes, + * not laps of the selected course, so they must never become a course + * snapshot — even if a stale selection survived. + */ + isDragSession?: boolean; /** Load a lap's samples as the (non-playable) comparison overlay. */ onLoadOverlay: (samples: GpsSample[], label: string) => void; onClearOverlay: () => void; @@ -48,6 +54,20 @@ export interface SaveSnapshotResult { existingLapMs?: number; } +/** + * Whether a session may capture a course snapshot at all: it needs a course, at + * least one lap, an engine to key the snapshot by, and must not be a drag + * session. Pure so the drag gate stays testable. + */ +export function snapshotEligible(opts: { + hasCourse: boolean; + lapCount: number; + engine: string; + isDragSession: boolean; +}): boolean { + return opts.hasCourse && opts.lapCount > 0 && opts.engine.length > 0 && !opts.isDragSession; +} + /** * Orchestrates lap snapshots for the active session: the per-course list, the * save-as-snapshot action, the "new course fastest lap" prompt on engine @@ -57,6 +77,7 @@ export function useLapSnapshots(params: UseLapSnapshotsParams) { const { data, laps, selection, selectedLapNumber, currentFileName, vehicles, setups, sessionKartId, sessionSetupId, onLoadOverlay, onClearOverlay, + isDragSession = false, } = params; const [snapshots, setSnapshots] = useState([]); @@ -99,7 +120,7 @@ export function useLapSnapshots(params: UseLapSnapshotsParams) { /** Build the snapshot for a lap under a given engine/setup assignment, or null. */ const buildCandidate = useCallback( (lap: Lap | null, kartId: string | null, setupId: string | null): LapSnapshot | null => { - if (!lap || !data || !selection?.course) return null; + if (!lap || !data || !selection?.course || isDragSession) return null; const { vehicle, setup, engine } = resolveContext(kartId, setupId); if (!engine) return null; @@ -126,13 +147,19 @@ export function useLapSnapshots(params: UseLapSnapshotsParams) { createdAt: existing?.createdAt, }); }, - [data, selection, snapshots, currentFileName, resolveContext], + [data, selection, snapshots, currentFileName, resolveContext, isDragSession], ); /** True when the session has everything needed to capture a snapshot. */ const canSnapshot = useMemo( - () => Boolean(selection?.course && laps.length > 0 && resolveContext(sessionKartId, sessionSetupId).engine), - [selection, laps.length, resolveContext, sessionKartId, sessionSetupId], + () => + snapshotEligible({ + hasCourse: Boolean(selection?.course), + lapCount: laps.length, + engine: resolveContext(sessionKartId, sessionSetupId).engine, + isDragSession, + }), + [selection, laps.length, resolveContext, sessionKartId, sessionSetupId, isDragSession], ); // ── Overlay loading (shares the external-reference slot; never auto-plays) ─── @@ -154,7 +181,7 @@ export function useLapSnapshots(params: UseLapSnapshotsParams) { // prompt path already gates on snapshotPromptKind; manual save must not blow // away a faster personal-best baseline without an explicit confirm. const saveSelectedLap = useCallback(async (force = false): Promise => { - if (!selection?.course) return { saved: false, replaced: false, reason: "no-course" }; + if (!selection?.course || isDragSession) return { saved: false, replaced: false, reason: "no-course" }; const lap = (selectedLapNumber !== null ? laps.find((l) => l.lapNumber === selectedLapNumber) : null) ?? fastestLap(laps); @@ -168,7 +195,7 @@ export function useLapSnapshots(params: UseLapSnapshotsParams) { } await saveSnapshot(candidate); return { saved: true, replaced: !!existing }; - }, [selection, selectedLapNumber, laps, buildCandidate, sessionKartId, sessionSetupId, snapshots]); + }, [selection, selectedLapNumber, laps, buildCandidate, sessionKartId, sessionSetupId, snapshots, isDragSession]); const removeSnapshot = useCallback( async (id: string) => { diff --git a/src/lib/lapSnapshot.test.ts b/src/lib/lapSnapshot.test.ts index 4d32d362..00d0c917 100644 --- a/src/lib/lapSnapshot.test.ts +++ b/src/lib/lapSnapshot.test.ts @@ -137,6 +137,14 @@ describe("fastestLap", () => { expect(fastestLap(laps)?.lapNumber).toBe(2); expect(fastestLap([])).toBeNull(); }); + + it("never picks an incomplete drag run, whose time is only a data window", () => { + const samples = makeSamples(); + const aborted = { ...makeLap(0, 2, samples, 1), incomplete: true }; + const laps = [aborted, makeLap(0, 8, samples, 2)]; + expect(fastestLap(laps)?.lapNumber).toBe(2); + expect(fastestLap([aborted])).toBeNull(); + }); }); describe("snapshotPromptKind", () => { diff --git a/src/lib/lapSnapshot.ts b/src/lib/lapSnapshot.ts index 1f6b8d37..3e7dbfd2 100644 --- a/src/lib/lapSnapshot.ts +++ b/src/lib/lapSnapshot.ts @@ -14,6 +14,7 @@ import type { Course, CourseDirection, GpsSample, Lap } from "@/types/racing"; import type { VehicleSetup } from "./setupStorage"; +import { fastestRankedLap } from "./lapCalculation"; /** Samples kept on each side of the lap, so a later start/finish nudge still fits. */ export const SNAPSHOT_BUFFER_MS = 5000; @@ -167,10 +168,13 @@ export function snapshotLapSamples(snap: LapSnapshot): GpsSample[] { return clean.length > 0 ? clean : snap.samples; } -/** The fastest lap in a list (min lapTimeMs), or null when empty. */ +/** + * The fastest rankable lap (min lapTimeMs, incomplete drag runs skipped), or + * null when none qualifies — an incomplete run's "time" is only a data window + * and must never become a course snapshot. + */ export function fastestLap(laps: Lap[]): Lap | null { - if (laps.length === 0) return null; - return laps.reduce((min, l) => (l.lapTimeMs < min.lapTimeMs ? l : min), laps[0]); + return fastestRankedLap(laps); } export type SnapshotPromptKind = "new" | "faster"; diff --git a/src/lib/overlayCanvasRenderer.test.ts b/src/lib/overlayCanvasRenderer.test.ts index 4a01d1ca..ff4c7baf 100644 --- a/src/lib/overlayCanvasRenderer.test.ts +++ b/src/lib/overlayCanvasRenderer.test.ts @@ -330,4 +330,20 @@ describe("range memoization", () => { expect(minCalls).toBe(2); expect(maxCalls).toBe(2); }); + + it("re-ranges when the data sources are rebuilt (MPH/KPH or metric toggle)", () => { + // Regression: the cache keyed only on the session arrays, so after a unit + // toggle the gauges kept scaling KPH values against the old MPH range. + const samples = [sample(0), sample(100), sample(200)]; + const paceData = [null, null, null]; + const brakingGData: number[] = []; + const shared = { samples, allSamples: samples, paceData, brakingGData }; + let kphMaxCalls = 0; + const mph: DataSourceDef = { ...speedSource, getMax: () => 100 }; + const kph: DataSourceDef = { ...speedSource, getMax: () => { kphMaxCalls++; return 160; } }; + const inst = makeInstance({ type: "analog" }); + drawAnalog(makeStubCtx().c, inst, makeRenderCtx({ ...shared, dataSources: [mph] }), L); + drawAnalog(makeStubCtx().c, inst, makeRenderCtx({ ...shared, dataSources: [kph] }), L); + expect(kphMaxCalls).toBe(1); + }); }); diff --git a/src/lib/overlayCanvasRenderer.ts b/src/lib/overlayCanvasRenderer.ts index 36b33554..de76480d 100644 --- a/src/lib/overlayCanvasRenderer.ts +++ b/src/lib/overlayCanvasRenderer.ts @@ -35,6 +35,7 @@ import { type SectorStatus, } from "@/components/video-overlays/sectorUtils"; import { courseHasSectors } from "@/types/racing"; +import { fastestRankedLap } from "@/lib/lapCalculation"; import { findCurrentLap, formatOverlayLapTime, getOverlayLapStartTime } from "@/components/video-overlays/overlayUtils"; import type { GpsSample } from "@/types/racing"; @@ -95,6 +96,7 @@ const SECTOR_CELL_BG: Record = { // nothing here needs invalidation or leaks. interface RangeEntry { + dataSources: OverlayRenderContext["dataSources"]; paceData: (number | null)[]; brakingGData: number[]; range: { min: number; max: number }; @@ -112,12 +114,20 @@ function memoRange( } const hit = bySource.get(sourceId); // Special sources (__pace__, __braking_g__) range over these arrays, not - // the samples — a hit is only valid while they are the same arrays too. - if (hit && hit.paceData === ctx.paceData && hit.brakingGData === ctx.brakingGData) { + // the samples — a hit is only valid while they are the same arrays too. The + // data-source defs close over the unit toggles (KPH, metric distance), so a + // rebuilt set means the same samples now range in different units. + if ( + hit && + hit.dataSources === ctx.dataSources && + hit.paceData === ctx.paceData && + hit.brakingGData === ctx.brakingGData + ) { return hit.range; } const range = resolveRange(sourceId, ctx.samples, ctx.dataSources, ctx.paceData, ctx.brakingGData); bySource.set(sourceId, { + dataSources: ctx.dataSources, paceData: ctx.paceData, brakingGData: ctx.brakingGData, range, @@ -865,11 +875,8 @@ export function drawLapTime( // Best lap let bestTimeStr = "—"; let bestLapLabel = ""; - if (ctx.laps.length > 0) { - let best = ctx.laps[0]; - for (const la of ctx.laps) { - if (la.lapTimeMs < best.lapTimeMs) best = la; - } + const best = fastestRankedLap(ctx.laps); + if (best) { bestTimeStr = formatOverlayLapTime(best.lapTimeMs / 1000); bestLapLabel = `L${best.lapNumber}`; } diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b3acf262..110cde8c 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -437,6 +437,7 @@ export default function Index() { setups: setupManager.setups, sessionKartId, sessionSetupId, + isDragSession: dragDistanceFt != null, onLoadOverlay: loadSnapshotOverlay, onClearOverlay: handleClearExternalRef, }); From 977c4eccf966b9a2dc9a9c41df3c3808fc67dd5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 00:59:23 +0000 Subject: [PATCH 2/8] plan 0024: drop a native video copy that finishes after a session switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadRecording's background storeNativeVideo copy (and the async tryLoadNativeStored lookup) set nativeStoredKey whenever it resolved, so a copy started for session A that landed after switching to session B attached A's key to B — and VideoPlayer then exported A's footage under B's overlays. A small latest-wins gate (lib/latestGate) is invalidated on every session/video change; stale results are dropped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- CHANGELOG.md | 5 +++++ src/hooks/useVideoSync.ts | 20 ++++++++++++++++---- src/lib/latestGate.test.ts | 33 +++++++++++++++++++++++++++++++++ src/lib/latestGate.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 src/lib/latestGate.test.ts create mode 100644 src/lib/latestGate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5237fb67..71ebb5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -180,6 +180,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ## [4.1.0] - 2026-08-24 diff --git a/src/hooks/useVideoSync.ts b/src/hooks/useVideoSync.ts index 8ebd7b2f..96e6ee7c 100644 --- a/src/hooks/useVideoSync.ts +++ b/src/hooks/useVideoSync.ts @@ -15,6 +15,7 @@ import { findNearestIndex } from "@/components/video-overlays/overlayUtils"; import { coverageOf, sessionMsToVideoSec, videoSecToSessionMs, fitVideoTimeline, needsResync, type VideoCoverage } from "@/lib/videoTimeline"; import { buildPlaylist, groupVideoRecordings, virtualToLocal, localToVirtual, type Playlist, type VideoRecording } from "@/lib/videoPlaylist"; import { takeStagedGoProVideo, type StagedGoProVideo } from "@/lib/gopro/videoHandoff"; +import { createLatestGate } from "@/lib/latestGate"; interface UseVideoSyncOptions { samples: GpsSample[]; @@ -211,6 +212,10 @@ export function useVideoSync({ samples, allSamples, currentIndex, onScrub, sessi // True while the