From 7451288848808c38d1dc5f9b7d6ce45b82a206aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 05:51:14 +0000 Subject: [PATCH 1/9] fix(sync): upload the object track format instead of a bare array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTrackJsonForUpload emitted a bare JSON array of courses. The firmware parses that — but its array branch (sd_functions.ino parseTrackFile) blanks longName, shortName and defaultCourse, and every course falls back to lengthFt = 0. lengthFt is what CourseDetector ranks courses by, so a track uploaded from this app could never be course-detected and dropped straight to Lap Anything, and the blank shortName reached the DOVEX header's short_name column. Emit the object form instead — the same shape the app's own track files and the on-device course creator already write, and one the firmware has parsed since well before any shipped release, so no version gate is needed. Also add parseDeviceTrackFile(), which keeps the wrapper's longName/shortName/ type/defaultCourse rather than discarding them; parseDeviceCourseJson stays as a thin wrapper over it for the callers that only want courses. The rename flow needs longName, and needs shortName because for a device-authored track the FILENAME is the 12-char longName (N260803_1432.json) while the 8-char shortName the sync merge keys on lives inside the file. The old "emits a JSON array of courses (not a wrapping object)" test asserted the lossy shape as the contract, which is how this survived review; it is replaced with assertions on the metadata the device actually consumes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/lib/deviceTrackSync.test.ts | 125 ++++++++++++++++++++++++++++++-- src/lib/deviceTrackSync.ts | 88 +++++++++++++++++++--- 2 files changed, 194 insertions(+), 19 deletions(-) diff --git a/src/lib/deviceTrackSync.test.ts b/src/lib/deviceTrackSync.test.ts index a3849527..dbac68d6 100644 --- a/src/lib/deviceTrackSync.test.ts +++ b/src/lib/deviceTrackSync.test.ts @@ -5,6 +5,8 @@ import { appCourseToDeviceJson, buildTrackJsonForUpload, parseDeviceCourseJson, + parseDeviceTrackFile, + type DeviceTrackFileJson, buildMergedTrackList, countDeviceSectors, countAppSectors, @@ -214,13 +216,53 @@ describe("appCourseToDeviceJson", () => { // ─── buildTrackJsonForUpload ────────────────────────────────────────────────── describe("buildTrackJsonForUpload", () => { - it("emits a JSON array of courses (not a wrapping object)", () => { + // This writer used to emit a bare array, and a test asserted that shape as the + // contract. The firmware parses an array, but its array branch blanks + // longName/shortName/defaultCourse and defaults every course to lengthFt 0 — + // and lengthFt is what CourseDetector ranks by, so an array-uploaded track + // could never be detected. The object form is what both the app's own track + // files and the on-device course creator already write. + it("emits the object form, not a bare array", () => { const track = makeAppTrack("OKC", [makeAppCourse()]); - const json = buildTrackJsonForUpload(track); - const parsed = JSON.parse(json); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBe(1); - expect(parsed[0].name).toBe("Full CW"); + const parsed = JSON.parse(buildTrackJsonForUpload(track)); + expect(Array.isArray(parsed)).toBe(false); + expect(parsed.longName).toBe("Track-OKC"); + expect(parsed.shortName).toBe("OKC"); + expect(parsed.courses).toHaveLength(1); + expect(parsed.courses[0].name).toBe("Full CW"); + }); + + it("keeps lengthFt on the emitted courses (CourseDetector ranks by it)", () => { + const track = makeAppTrack("OKC", [makeAppCourse({ lengthFt: 1500 })]); + const parsed: DeviceTrackFileJson = JSON.parse(buildTrackJsonForUpload(track)); + expect(parsed.courses[0].lengthFt).toBe(1500); + }); + + it("marks the track type so the firmware's isSprint flag agrees with the folder", () => { + const circuit = makeAppTrack("OKC", [makeAppCourse()]); + const sprint = makeAppTrack("OKC", [ + makeAppCourse({ + type: "sprint", + finish: { a: { lat: 35.41, lon: -97.31 }, b: { lat: 35.41, lon: -97.32 } }, + }), + ]); + expect(JSON.parse(buildTrackJsonForUpload(circuit)).type).toBe("circuit"); + expect(JSON.parse(buildTrackJsonForUpload(sprint)).type).toBe("sprint"); + }); + + it("names the first course as the default", () => { + const track = makeAppTrack("OKC", [ + makeAppCourse({ name: "A" }), + makeAppCourse({ name: "B" }), + ]); + expect(JSON.parse(buildTrackJsonForUpload(track)).defaultCourse).toBe("A"); + }); + + // An empty shortName reaches the DOVEX header's short_name column AND is the + // key the next connect's merge looks the file up by, so it can never ship blank. + it("derives a shortName when the app track has none", () => { + const track: Track = { name: "Sunset Park", courses: [makeAppCourse()], isUserDefined: true }; + expect(JSON.parse(buildTrackJsonForUpload(track)).shortName).toBe("SP"); }); it("emits all courses in order", () => { @@ -229,14 +271,81 @@ describe("buildTrackJsonForUpload", () => { makeAppCourse({ name: "B" }), makeAppCourse({ name: "C" }), ]); - const parsed: DeviceCourseJson[] = JSON.parse(buildTrackJsonForUpload(track)); - expect(parsed.map((c) => c.name)).toEqual(["A", "B", "C"]); + const parsed: DeviceTrackFileJson = JSON.parse(buildTrackJsonForUpload(track)); + expect(parsed.courses.map((c) => c.name)).toEqual(["A", "B", "C"]); }); it("uses tab indentation (matches device expectation)", () => { const json = buildTrackJsonForUpload(makeAppTrack("OKC", [makeAppCourse()])); expect(json).toContain("\t"); }); + + // The round trip that decides whether the sync wizard re-prompts forever. + it("round-trips through parseDeviceTrackFile", () => { + const track = makeAppTrack("OKC", [makeAppCourse()]); + const back = parseDeviceTrackFile(buildTrackJsonForUpload(track)); + expect(back?.longName).toBe("Track-OKC"); + expect(back?.shortName).toBe("OKC"); + expect(back?.courses).toHaveLength(1); + }); +}); + +// ─── parseDeviceTrackFile ───────────────────────────────────────────────────── + +describe("parseDeviceTrackFile", () => { + afterEach(() => vi.restoreAllMocks()); + + // The metadata parseDeviceCourseJson drops. The rename flow needs longName to + // show "what this track is currently called", and shortName because for a + // device-authored file the FILENAME is the 12-char longName, not the 8-char + // shortName the merge keys on. + it("keeps the object wrapper's metadata", () => { + const raw = JSON.stringify({ + longName: "N260804_1432", + shortName: "08041432", + type: "sprint", + defaultCourse: "N260804_1432", + courses: [makeDeviceCourse()], + }); + const file = parseDeviceTrackFile(raw); + expect(file).toEqual({ + longName: "N260804_1432", + shortName: "08041432", + type: "sprint", + defaultCourse: "N260804_1432", + courses: [makeDeviceCourse()], + }); + }); + + it("reports a bare array as courses with no metadata", () => { + const file = parseDeviceTrackFile(JSON.stringify([makeDeviceCourse()])); + expect(file?.courses).toHaveLength(1); + expect(file?.longName).toBeUndefined(); + expect(file?.shortName).toBeUndefined(); + }); + + it("treats empty-string metadata as absent", () => { + const raw = JSON.stringify({ longName: "", shortName: "", courses: [] }); + const file = parseDeviceTrackFile(raw); + expect(file?.longName).toBeUndefined(); + expect(file?.shortName).toBeUndefined(); + }); + + it("returns null for malformed JSON without throwing", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(parseDeviceTrackFile("not json {")).toBeNull(); + }); + + it("returns null for a JSON scalar", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(parseDeviceTrackFile("42")).toBeNull(); + }); + + it("survives a non-array courses field", () => { + const file = parseDeviceTrackFile(JSON.stringify({ longName: "X", courses: "nope" })); + expect(file?.courses).toEqual([]); + expect(file?.longName).toBe("X"); + }); }); // ─── parseDeviceCourseJson ──────────────────────────────────────────────────── diff --git a/src/lib/deviceTrackSync.ts b/src/lib/deviceTrackSync.ts index ff366f01..b80f379f 100644 --- a/src/lib/deviceTrackSync.ts +++ b/src/lib/deviceTrackSync.ts @@ -8,6 +8,7 @@ import { Track, Course, CourseSector, SectorLine, isSprintCourse } from '@/types import type { TrackKind } from '@/lib/ble/trackOpcodes'; import { haversineDistance } from '@/lib/parserUtils'; import { legacyMirror, majorSectorLines, normalizeCourseSectors } from '@/lib/courseSectors'; +import { deriveShortName } from '@/lib/trackUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -47,6 +48,13 @@ export interface DeviceCourseJson { export interface DeviceTrackFile { shortName: string; // filename without .json courses: DeviceCourseJson[]; + /** + * `longName` out of the object wrapper, when the file had one. Distinct from + * `shortName` above, which is the FILENAME base — for a track the on-device + * course creator wrote those differ (`N260803_1432.json` carrying + * `shortName: "08031432"`), and the rename flow needs the human-facing one. + */ + longName?: string; /** * Which folder this file came out of. Circuit (`/TRACKS`) and sprint * (`/TRACKS/SPRINT`) are separate namespaces on the device, so the same @@ -80,6 +88,8 @@ export interface MergedTrackEntry { /** Circuit or sprint. Entries are keyed on (kind, shortName), never shortName alone. */ kind: TrackKind; trackName?: string; // full name from webapp (if known) + /** `longName` the device file carries, when it has one. */ + deviceLongName?: string; status: TrackSyncStatus; appTrack?: Track; appCourses: Course[]; @@ -256,14 +266,50 @@ export function appCourseToDeviceJson(course: Course): DeviceCourseJson { return dc; } -/** Build the full track JSON string the device expects (flat array of courses). */ +/** + * The object-form track file, as the firmware writes it and parses it back + * (`BirdsEye/sd_functions.ino` `parseTrackFile()`). + * + * Everything outside `courses` is optional because the legacy bare-array shape + * carries none of it — the firmware blanks all four fields for an array file. + */ +export interface DeviceTrackFileJson { + longName?: string; + shortName?: string; + /** `"sprint"` marks a sprint track. The folder is authoritative; this is a hint. */ + type?: string; + defaultCourse?: string; + courses: DeviceCourseJson[]; +} + +/** + * Build the full track JSON string the device expects. + * + * Emits the **object** form. This used to emit a bare array, which the firmware + * still parses — but its array branch explicitly blanks `longName`, `shortName` + * and `defaultCourse`, and every course then falls back to `lengthFt = 0`. That + * costs real behaviour on the device: `lengthFt` is what CourseDetector ranks + * courses by, so an array-uploaded track could never be detected and dropped + * straight to Lap Anything, and the blank `shortName` reached the DOVEX header's + * `short_name` column. The object form is what the app's own track files and the + * on-device course creator already write, so nothing new is being asked of the + * firmware — the array writer was simply lossy. + */ export function buildTrackJsonForUpload(track: Track): string { - const courses = track.courses.map(appCourseToDeviceJson); - return JSON.stringify(courses, null, '\t'); + const file: DeviceTrackFileJson = { + longName: track.name, + // Never emit an empty shortName: it lands in the log header, and it is the + // key this app's own merge uses to recognise the file on the next connect. + shortName: track.shortName || deriveShortName(track.name), + type: trackKind(track), + defaultCourse: track.courses[0]?.name ?? '', + courses: track.courses.map(appCourseToDeviceJson), + }; + return JSON.stringify(file, null, '\t'); } /** - * Parse a track JSON file pulled off the device into its course array. + * Parse a track JSON file pulled off the device, keeping the wrapper metadata. * * BOTH on-disk shapes are accepted, matching the firmware's own * `parseTrackFile()`: @@ -279,27 +325,45 @@ export function buildTrackJsonForUpload(track: Track): string { * fell through to `[]` — so a walked course synced back as a track with no * courses at all, with nothing logged to say why. */ -export function parseDeviceCourseJson(raw: string): DeviceCourseJson[] { +export function parseDeviceTrackFile(raw: string): DeviceTrackFileJson | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { console.error('Failed to parse device track JSON'); - return []; + return null; } - if (Array.isArray(parsed)) return parsed as DeviceCourseJson[]; + // Legacy bare array: courses only, no metadata to recover. + if (Array.isArray(parsed)) return { courses: parsed as DeviceCourseJson[] }; if (parsed && typeof parsed === 'object') { - const courses = (parsed as { courses?: unknown }).courses; - if (Array.isArray(courses)) return courses as DeviceCourseJson[]; + const obj = parsed as Record; + const courses = Array.isArray(obj.courses) ? (obj.courses as DeviceCourseJson[]) : []; + const str = (v: unknown): string | undefined => + typeof v === 'string' && v !== '' ? v : undefined; // A well-formed object with no course list is a real (if empty) track — // distinct from unparseable, so don't shout about it. - return []; + return { + longName: str(obj.longName), + shortName: str(obj.shortName), + type: str(obj.type), + defaultCourse: str(obj.defaultCourse), + courses, + }; } console.error('Device track JSON is neither a course array nor a track object'); - return []; + return null; +} + +/** + * Course array only — the shape most callers want. See `parseDeviceTrackFile` + * for the wrapper metadata (`longName` / `shortName`), which the rename flow + * needs and which this deliberately drops. + */ +export function parseDeviceCourseJson(raw: string): DeviceCourseJson[] { + return parseDeviceTrackFile(raw)?.courses ?? []; } // ─── Track kind ─────────────────────────────────────────────────────────────── @@ -394,6 +458,7 @@ export function buildMergedTrackList( shortName: sn, kind, trackName: track.name, + deviceLongName: df.longName, status: allSynced ? 'synced' : 'mismatch', appTrack: track, appCourses: track.courses, @@ -425,6 +490,7 @@ export function buildMergedTrackList( entries.push({ shortName: df.shortName, kind: dfKind, + deviceLongName: df.longName, status: 'device_only', appCourses: [], deviceCourses: df.courses, From 5e2116c63b3dae25196fcde2643af0713d123290 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:04:06 +0000 Subject: [PATCH 2/9] fix(sync): key device tracks by identity, not by filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A track the on-device course creator wrote is stored at N260803_1432.json but declares shortName "08031432" — 8 characters, chosen by the firmware precisely because that is this app's Track.shortName budget and the key its sync merge uses. buildMergedTrackList keyed on the FILENAME instead, so a track imported from the device could never be matched to the file it came from: it stayed "device_only" forever and the sync kept re-offering it. Separate the two concepts. DeviceTrackFile.shortName is now the identity (the declared shortName, falling back to the filename base only for legacy bare-array files that declare nothing), and the new fileName / deviceFileName carry the location. deviceTrackFileFrom() owns that rule so it is unit-tested rather than buried in the tab, and every write path now targets the real file instead of `shortName + ".json"` — which would otherwise orphan the original and leave two copies on the card. Also fixes the other half of the same nag: handleDownloadToApp never passed a shortName to addTrack, and buildMergedTrackList skips app tracks that have none, so downloaded tracks were invisible to the merge whatever the key was. It now carries the shortName over and names the track from the file's longName. The two course-level writers went through rebuildDeviceTrackJson so editing one course stops stripping the file's wrapper metadata and resetting every lengthFt — the same loss the bare-array uploader caused, reached from a different button. Verified by reverting the identity rule and watching the round-trip test report 2 merged entries instead of 1 — literally the app_only/device_only split that made the prompt re-fire. The first draft of that test derived its input from the value under test and passed either way; it now spells the expectation out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/components/drawer/DeviceTracksTab.tsx | 52 +++++--- src/lib/deviceTrackSync.test.ts | 140 ++++++++++++++++++++++ src/lib/deviceTrackSync.ts | 79 +++++++++++- 3 files changed, 250 insertions(+), 21 deletions(-) diff --git a/src/components/drawer/DeviceTracksTab.tsx b/src/components/drawer/DeviceTracksTab.tsx index c69933e1..4fa0e89f 100644 --- a/src/components/drawer/DeviceTracksTab.tsx +++ b/src/components/drawer/DeviceTracksTab.tsx @@ -30,8 +30,9 @@ import { MergedTrackEntry, MergedCourseEntry, buildMergedTrackList, - parseDeviceCourseJson, + deviceTrackFileFrom, buildTrackJsonForUpload, + rebuildDeviceTrackJson, deviceCourseToAppCourse, appCourseToDeviceJson, countAppSectors, @@ -49,6 +50,17 @@ interface DeviceTracksTabProps { type View = "loading" | "tracks" | "courses"; +/** + * The device file to write or delete for an entry. + * + * Never `shortName + ".json"`: for a track the on-device course creator wrote, + * the identity (`08031432`) and the filename (`N260803_1432.json`) are different + * strings, and writing to the identity would orphan the real file. + * `app_only` entries have no file yet, so the identity names the new one. + */ +const deviceFileOf = (entry: MergedTrackEntry): string => + entry.deviceFileName ?? `${entry.shortName}.json`; + export function DeviceTracksTab({ details }: DeviceTracksTabProps) { const { t } = useTranslation("drawer"); const [view, setView] = useState("loading"); @@ -83,9 +95,7 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { setLoadProgress({ current: i + 1, total: filenames.length, label: fn }); try { const raw = await details.getTrack(fn); - const text = new TextDecoder().decode(raw); - const courses = parseDeviceCourseJson(text); - files.push({ shortName: fn.replace(/\.json$/i, ""), courses, kind: "circuit" }); + files.push(deviceTrackFileFrom(fn, new TextDecoder().decode(raw), "circuit")); } catch (err) { console.error(`Failed to download ${fn}:`, err); } @@ -104,8 +114,7 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { setLoadProgress({ current: i + 1, total: sprintNames.length, label: fn }); try { const raw = await details.getTrack(fn, "sprint"); - const courses = parseDeviceCourseJson(new TextDecoder().decode(raw)); - files.push({ shortName: fn.replace(/\.json$/i, ""), courses, kind: "sprint" }); + files.push(deviceTrackFileFrom(fn, new TextDecoder().decode(raw), "sprint")); } catch (err) { console.error(`Failed to download sprint ${fn}:`, err); } @@ -145,7 +154,7 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { try { const json = buildTrackJsonForUpload(entry.appTrack); const data = new TextEncoder().encode(json); - await details.putTrack(entry.shortName + ".json", data, entry.kind); + await details.putTrack(deviceFileOf(entry), data, entry.kind); toast.success(t("deviceTracks.sentToast", { name: entry.shortName })); await syncAll(); } catch (err) { @@ -158,12 +167,19 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { // ── Download device track to app ── const handleDownloadToApp = async (entry: MergedTrackEntry) => { try { - const trackName = entry.shortName; + // Prefer the file's own longName — for a track the on-device course + // creator wrote that is the human-facing "N260803_1432", not the 8-char + // shortName the merge keys on. + const trackName = entry.deviceLongName || entry.shortName; for (const dc of entry.deviceCourses) { const course = deviceCourseToAppCourse(dc); await addCourse(trackName, course); } - await addTrack(trackName); + // The shortName MUST be carried over: buildMergedTrackList skips app + // tracks that have none, so a track imported without one could never be + // matched to the device file it came from — it stayed "device_only" + // forever and the sync kept re-offering it. + await addTrack(trackName, undefined, entry.shortName); toast.success(t("deviceTracks.downloadedToast", { name: trackName })); const tracks = await loadTracks(); setAppTracks(tracks); @@ -177,7 +193,7 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { const handleDeleteTrackFromDevice = async (entry: MergedTrackEntry) => { setUploading(entry.shortName); try { - await details.deleteTrack(entry.shortName + ".json", entry.kind); + await details.deleteTrack(deviceFileOf(entry), entry.kind); toast.success(t("deviceTracks.deletedToast", { name: entry.shortName })); setDeleteConfirm(null); await syncAll(); @@ -195,13 +211,14 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { try { if (remaining.length === 0) { // No courses left, delete the whole file - await details.deleteTrack(trackEntry.shortName + ".json", trackEntry.kind); + await details.deleteTrack(deviceFileOf(trackEntry), trackEntry.kind); toast.success(t("deviceTracks.deletedNoCoursesToast", { name: trackEntry.shortName })); } else { - // Re-upload without the deleted course - const json = JSON.stringify(remaining, null, '\t'); - const data = new TextEncoder().encode(json); - await details.putTrack(trackEntry.shortName + ".json", data, trackEntry.kind); + // Re-upload without the deleted course, keeping the file's wrapper + // metadata — a bare array would strip longName/shortName and reset + // every lengthFt on the device. + const data = new TextEncoder().encode(rebuildDeviceTrackJson(trackEntry, remaining)); + await details.putTrack(deviceFileOf(trackEntry), data, trackEntry.kind); toast.success(t("deviceTracks.removedCourseToast", { name: courseName })); } setDeleteConfirm(null); @@ -237,9 +254,8 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { setUploading(trackEntry.shortName); try { - const json = JSON.stringify(allDeviceCourses, null, '\t'); - const data = new TextEncoder().encode(json); - await details.putTrack(trackEntry.shortName + ".json", data, trackEntry.kind); + const data = new TextEncoder().encode(rebuildDeviceTrackJson(trackEntry, allDeviceCourses)); + await details.putTrack(deviceFileOf(trackEntry), data, trackEntry.kind); toast.success(t("deviceTracks.sentCourseToast", { name: courseName })); setDiffCourse(null); await syncAll(); diff --git a/src/lib/deviceTrackSync.test.ts b/src/lib/deviceTrackSync.test.ts index dbac68d6..ee57efbb 100644 --- a/src/lib/deviceTrackSync.test.ts +++ b/src/lib/deviceTrackSync.test.ts @@ -6,6 +6,8 @@ import { buildTrackJsonForUpload, parseDeviceCourseJson, parseDeviceTrackFile, + deviceTrackFileFrom, + rebuildDeviceTrackJson, type DeviceTrackFileJson, buildMergedTrackList, countDeviceSectors, @@ -290,6 +292,144 @@ describe("buildTrackJsonForUpload", () => { }); }); +// ─── rebuildDeviceTrackJson ─────────────────────────────────────────────────── + +describe("rebuildDeviceTrackJson", () => { + it("keeps the wrapper metadata when a single course is rewritten", () => { + const entry = { + deviceLongName: "Orlando Kart Center", + trackName: "Orlando Kart Center", + shortName: "OKC", + kind: "circuit" as const, + }; + const parsed: DeviceTrackFileJson = JSON.parse( + rebuildDeviceTrackJson(entry, [makeDeviceCourse({ name: "B" })]), + ); + expect(parsed.longName).toBe("Orlando Kart Center"); + expect(parsed.shortName).toBe("OKC"); + expect(parsed.type).toBe("circuit"); + expect(parsed.defaultCourse).toBe("B"); + expect(parsed.courses[0].lengthFt).toBe(1500); + }); + + it("falls back to the app track name, then the shortName, for longName", () => { + const fromApp = JSON.parse( + rebuildDeviceTrackJson({ trackName: "From App", shortName: "FA", kind: "circuit" }, []), + ); + expect(fromApp.longName).toBe("From App"); + const bare = JSON.parse(rebuildDeviceTrackJson({ shortName: "FA", kind: "circuit" }, [])); + expect(bare.longName).toBe("FA"); + }); +}); + +// ─── deviceTrackFileFrom ────────────────────────────────────────────────────── + +describe("deviceTrackFileFrom", () => { + afterEach(() => vi.restoreAllMocks()); + + // The identity rule. A track the on-device course creator wrote is stored at + // N260803_1432.json but declares shortName "08031432" — 8 chars, chosen by the + // firmware precisely because that is this app's Track.shortName budget. Keying + // the merge on the filename meant the imported track could never match the file + // it came from, so the sync re-offered it on every connect forever. + it("keys on the declared shortName, not the filename", () => { + const raw = JSON.stringify({ + longName: "N260803_1432", + shortName: "08031432", + type: "sprint", + courses: [makeDeviceCourse()], + }); + const file = deviceTrackFileFrom("N260803_1432.json", raw, "sprint"); + expect(file.shortName).toBe("08031432"); + expect(file.fileName).toBe("N260803_1432.json"); + expect(file.longName).toBe("N260803_1432"); + expect(file.kind).toBe("sprint"); + }); + + it("falls back to the filename base when the file declares no shortName", () => { + const file = deviceTrackFileFrom("OKC.json", JSON.stringify([makeDeviceCourse()]), "circuit"); + expect(file.shortName).toBe("OKC"); + expect(file.fileName).toBe("OKC.json"); + expect(file.longName).toBeUndefined(); + }); + + it("strips the extension case-insensitively", () => { + expect(deviceTrackFileFrom("OKC.JSON", "[]", "circuit").shortName).toBe("OKC"); + }); + + it("yields an empty course list for an unreadable file rather than throwing", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const file = deviceTrackFileFrom("BAD.json", "not json {", "circuit"); + expect(file.shortName).toBe("BAD"); + expect(file.courses).toEqual([]); + }); +}); + +// ─── round trip: does the sync settle? ──────────────────────────────────────── + +describe("device round trip", () => { + // The load-bearing property of the whole sync flow: after a track has been + // imported and pushed back, the next connect must see `synced`. Anything else + // means the on-connect prompt re-fires forever. + it("settles to 'synced' after an app track is uploaded and re-listed", () => { + const track = makeAppTrack("OKC", [makeAppCourse()]); + const onDevice = deviceTrackFileFrom( + "OKC.json", + buildTrackJsonForUpload(track), + "circuit", + ); + const merged = buildMergedTrackList([track], [onDevice]); + expect(merged).toHaveLength(1); + expect(merged[0].status).toBe("synced"); + }); + + // The rename case: the file moves from N260803_1432.json to SUNSET.json, and + // the app track carries name "Sunset Park" / shortName "SUNSET". + it("settles to 'synced' after a device-authored track is renamed", () => { + const renamed: Track = { + name: "Sunset Park", + shortName: "SUNSET", + courses: [makeAppCourse({ name: "Sunset Park" })], + isUserDefined: true, + }; + const onDevice = deviceTrackFileFrom( + "SUNSET.json", + buildTrackJsonForUpload(renamed), + "circuit", + ); + const merged = buildMergedTrackList([renamed], [onDevice]); + expect(merged[0].status).toBe("synced"); + expect(merged[0].deviceFileName).toBe("SUNSET.json"); + }); + + // Before the identity split this was the nag: the app track (shortName from + // the file) and the device file (keyed by filename) never met. + it("matches a device-authored file to the track imported from it", () => { + const deviceRaw = JSON.stringify({ + longName: "N260803_1432", + shortName: "08031432", + defaultCourse: "N260803_1432", + courses: [makeDeviceCourse({ name: "N260803_1432" })], + }); + const onDevice = deviceTrackFileFrom("N260803_1432.json", deviceRaw, "circuit"); + // What handleDownloadToApp now stores: longName as the name, the file's + // DECLARED shortName as the key. Both are spelled out literally rather than + // read back off `onDevice` — deriving them from the value under test made + // this pass either way, which is exactly the bug it is meant to catch. + const imported: Track = { + name: "N260803_1432", + shortName: "08031432", + courses: onDevice.courses.map(deviceCourseToAppCourse), + isUserDefined: true, + }; + const merged = buildMergedTrackList([imported], [onDevice]); + expect(merged).toHaveLength(1); + expect(merged[0].status).toBe("synced"); + // …and writes still go to the real file, not "08031432.json". + expect(merged[0].deviceFileName).toBe("N260803_1432.json"); + }); +}); + // ─── parseDeviceTrackFile ───────────────────────────────────────────────────── describe("parseDeviceTrackFile", () => { diff --git a/src/lib/deviceTrackSync.ts b/src/lib/deviceTrackSync.ts index b80f379f..468006ea 100644 --- a/src/lib/deviceTrackSync.ts +++ b/src/lib/deviceTrackSync.ts @@ -46,7 +46,22 @@ export interface DeviceCourseJson { } export interface DeviceTrackFile { - shortName: string; // filename without .json + /** + * The track's IDENTITY — what the merge keys on, matched against app + * `Track.shortName`. Prefer the file's declared `shortName`; fall back to the + * filename base for legacy bare-array files that declare nothing. + * + * This is NOT necessarily the filename. For a track the on-device course + * creator wrote they differ: `N260803_1432.json` declares `shortName: + * "08031432"`. Keying on the filename there meant the imported track could + * never match its own device file again, so the sync re-offered it forever. + */ + shortName: string; + /** + * Where the track LIVES on the device — the actual `*.json` to overwrite or + * delete. Absent for entries that exist only in the app. + */ + fileName?: string; courses: DeviceCourseJson[]; /** * `longName` out of the object wrapper, when the file had one. Distinct from @@ -90,6 +105,11 @@ export interface MergedTrackEntry { trackName?: string; // full name from webapp (if known) /** `longName` the device file carries, when it has one. */ deviceLongName?: string; + /** + * The device file backing this entry. Absent for `app_only`. Always write and + * delete through this, never `shortName + '.json'` — see `DeviceTrackFile`. + */ + deviceFileName?: string; status: TrackSyncStatus; appTrack?: Track; appCourses: Course[]; @@ -296,7 +316,7 @@ export interface DeviceTrackFileJson { * firmware — the array writer was simply lossy. */ export function buildTrackJsonForUpload(track: Track): string { - const file: DeviceTrackFileJson = { + return serializeDeviceTrackFile({ longName: track.name, // Never emit an empty shortName: it lands in the log header, and it is the // key this app's own merge uses to recognise the file on the next connect. @@ -304,10 +324,36 @@ export function buildTrackJsonForUpload(track: Track): string { type: trackKind(track), defaultCourse: track.courses[0]?.name ?? '', courses: track.courses.map(appCourseToDeviceJson), - }; + }); +} + +/** Serialize an object-form track file the way the device expects it. */ +export function serializeDeviceTrackFile(file: DeviceTrackFileJson): string { return JSON.stringify(file, null, '\t'); } +/** + * Rebuild an existing device file around a new course list, keeping its wrapper + * metadata intact. + * + * Callers that edit a single course used to re-serialize the course array on its + * own, which quietly stripped `longName`/`shortName`/`defaultCourse` off the file + * and reset every `lengthFt` — the same loss the bare-array uploader caused, just + * reached from a different button. + */ +export function rebuildDeviceTrackJson( + entry: Pick, + courses: DeviceCourseJson[], +): string { + return serializeDeviceTrackFile({ + longName: entry.deviceLongName || entry.trackName || entry.shortName, + shortName: entry.shortName, + type: entry.kind, + defaultCourse: courses[0]?.name ?? '', + courses, + }); +} + /** * Parse a track JSON file pulled off the device, keeping the wrapper metadata. * @@ -357,6 +403,31 @@ export function parseDeviceTrackFile(raw: string): DeviceTrackFileJson | null { return null; } +/** + * Turn one file pulled off the device into a merge-ready `DeviceTrackFile`. + * + * This owns the identity rule: a track is keyed by the `shortName` it declares, + * and only falls back to the filename base when it declares none (legacy + * bare-array files). Keeping that here rather than at the call site is the + * point — it is the rule that decides whether an imported track ever matches + * its own device file again. + */ +export function deviceTrackFileFrom( + fileName: string, + raw: string, + kind: TrackKind, +): DeviceTrackFile { + const base = fileName.replace(/\.json$/i, ''); + const parsed = parseDeviceTrackFile(raw); + return { + shortName: parsed?.shortName || base, + fileName, + longName: parsed?.longName, + courses: parsed?.courses ?? [], + kind, + }; +} + /** * Course array only — the shape most callers want. See `parseDeviceTrackFile` * for the wrapper metadata (`longName` / `shortName`), which the rename flow @@ -459,6 +530,7 @@ export function buildMergedTrackList( kind, trackName: track.name, deviceLongName: df.longName, + deviceFileName: df.fileName ?? `${df.shortName}.json`, status: allSynced ? 'synced' : 'mismatch', appTrack: track, appCourses: track.courses, @@ -491,6 +563,7 @@ export function buildMergedTrackList( shortName: df.shortName, kind: dfKind, deviceLongName: df.longName, + deviceFileName: df.fileName ?? `${df.shortName}.json`, status: 'device_only', appCourses: [], deviceCourses: df.courses, From 099359cdeffefc444500b35bd3cd69cdd48bb2ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:11:16 +0000 Subject: [PATCH 3/9] feat(sync): pure model for the device sync + rename flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four modules, all Arduino-free pure logic with no React, because the test environment is "node" with no testing-library — a dialog cannot be rendered, so anything worth asserting has to live outside the component. - deviceGeneratedNames: recognises the on-device course creator's N{YYMMDD}_{HHMM} names and MMDDHHMM short names. The date and time parts are validated, so a real name that merely looks the part isn't mistaken for a placeholder and the user pushed to rename something they already named. - deviceSyncPlan: decides what a sync would offer and in which direction. Synced tracks are dropped; app tracks the user didn't create are never pushed (the two we ship are reference data, not "unknown tracks"); a mismatch uploads the app's version after importing any course walked on the device. Crucially it also refuses to offer rows that could never converge — mixed circuit+sprint tracks, tracks past the firmware's MAX_LAYOUTS (whose tail its parser silently ignores, so the file can never read back as written), and sprint tracks on a transport that can't reach /TRACKS/SPRINT. Each of those would otherwise report a difference on every connect forever. They are surfaced with a reason rather than trimmed to fit: dropping a user's courses to turn a checkmark green is the worse failure. - deviceSyncNames: the edit rules and the save gate. A short name follows the long name until the user takes it over, and editing the long name takes it back; a course name follows its track's name the same way. Track names are required for both kinds — a venue is permanent. Course names are required for circuit only: a sprint venue re-lays its course every event, so the date it was walked genuinely is the most useful label. - deviceSyncOps: the ordered operation list. Put before delete, so a failure between them leaves the track on the card twice rather than nowhere; device before app, so a failure after the write leaves a correctly-named file the next connect offers as a plain download, instead of stranding a renamed app track beside its old device file. FAT is case-insensitive, so a case-only filename change is not a rename — deleting "the old file" would delete the one just written. The load-bearing tests replay a plan back through deviceTrackFileFrom and buildMergedTrackList and assert "synced". If that ever fails, the on-connect prompt re-fires on every connect, which is the whole thing this is avoiding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/lib/deviceGeneratedNames.test.ts | 97 +++++++++ src/lib/deviceGeneratedNames.ts | 87 ++++++++ src/lib/deviceSyncNames.test.ts | 227 ++++++++++++++++++++ src/lib/deviceSyncNames.ts | 177 ++++++++++++++++ src/lib/deviceSyncOps.test.ts | 263 ++++++++++++++++++++++++ src/lib/deviceSyncOps.ts | 148 +++++++++++++ src/lib/deviceSyncPlan.test.ts | 297 +++++++++++++++++++++++++++ src/lib/deviceSyncPlan.ts | 207 +++++++++++++++++++ 8 files changed, 1503 insertions(+) create mode 100644 src/lib/deviceGeneratedNames.test.ts create mode 100644 src/lib/deviceGeneratedNames.ts create mode 100644 src/lib/deviceSyncNames.test.ts create mode 100644 src/lib/deviceSyncNames.ts create mode 100644 src/lib/deviceSyncOps.test.ts create mode 100644 src/lib/deviceSyncOps.ts create mode 100644 src/lib/deviceSyncPlan.test.ts create mode 100644 src/lib/deviceSyncPlan.ts diff --git a/src/lib/deviceGeneratedNames.test.ts b/src/lib/deviceGeneratedNames.test.ts new file mode 100644 index 00000000..d2222785 --- /dev/null +++ b/src/lib/deviceGeneratedNames.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { + isDeviceGeneratedName, + isDeviceGeneratedShortName, + parseDeviceGeneratedName, +} from "./deviceGeneratedNames"; + +describe("isDeviceGeneratedName", () => { + it("accepts the firmware's N{YYMMDD}_{HHMM} format", () => { + expect(isDeviceGeneratedName("N260803_1432")).toBe(true); + expect(isDeviceGeneratedName("N260101_0000")).toBe(true); + expect(isDeviceGeneratedName("N261231_2359")).toBe(true); + }); + + it("rejects names a user would have chosen", () => { + expect(isDeviceGeneratedName("Sunset Park")).toBe(false); + expect(isDeviceGeneratedName("Orlando Kart Center")).toBe(false); + expect(isDeviceGeneratedName("Full CW")).toBe(false); + }); + + // Without validating the parts, a name that merely looks the part would be + // treated as a placeholder and the user pushed to rename something they named. + it("rejects the shape when the date or time is impossible", () => { + expect(isDeviceGeneratedName("N261301_1200")).toBe(false); // month 13 + expect(isDeviceGeneratedName("N260230_1200")).toBe(false); // Feb 30 + expect(isDeviceGeneratedName("N260803_2460")).toBe(false); // 24:60 + expect(isDeviceGeneratedName("N260803_1260")).toBe(false); // minute 60 + }); + + it("accepts Feb 29 in a leap year and rejects it otherwise", () => { + expect(isDeviceGeneratedName("N280229_1200")).toBe(true); // 2028 + expect(isDeviceGeneratedName("N260229_1200")).toBe(false); // 2026 + }); + + it("rejects near misses on the shape", () => { + expect(isDeviceGeneratedName("260803_1432")).toBe(false); // no N + expect(isDeviceGeneratedName("N260803-1432")).toBe(false); // wrong separator + expect(isDeviceGeneratedName("N2608031432")).toBe(false); // no separator + expect(isDeviceGeneratedName("N260803_143")).toBe(false); // short time + expect(isDeviceGeneratedName("xN260803_1432")).toBe(false); // prefixed + expect(isDeviceGeneratedName("N260803_1432x")).toBe(false); // suffixed + }); + + it("handles empty and nullish input", () => { + expect(isDeviceGeneratedName("")).toBe(false); + expect(isDeviceGeneratedName(undefined)).toBe(false); + expect(isDeviceGeneratedName(null)).toBe(false); + }); +}); + +describe("parseDeviceGeneratedName", () => { + // UTC, because that is the clock the GPS stamped it from. + it("recovers the moment the course was walked", () => { + const d = parseDeviceGeneratedName("N260803_1432")!; + expect(d.getUTCFullYear()).toBe(2026); + expect(d.getUTCMonth()).toBe(7); // August + expect(d.getUTCDate()).toBe(3); + expect(d.getUTCHours()).toBe(14); + expect(d.getUTCMinutes()).toBe(32); + }); + + it("returns null for anything that isn't a generated name", () => { + expect(parseDeviceGeneratedName("Sunset Park")).toBeNull(); + expect(parseDeviceGeneratedName("N261301_1200")).toBeNull(); + }); +}); + +describe("isDeviceGeneratedShortName", () => { + it("accepts the firmware's MMDDHHMM format", () => { + expect(isDeviceGeneratedShortName("08031432")).toBe(true); + expect(isDeviceGeneratedShortName("12312359")).toBe(true); + }); + + it("rejects a short name a user would have chosen", () => { + expect(isDeviceGeneratedShortName("OKC")).toBe(false); + expect(isDeviceGeneratedShortName("SUNSET")).toBe(false); + }); + + it("rejects impossible dates and times", () => { + expect(isDeviceGeneratedShortName("13011200")).toBe(false); // month 13 + expect(isDeviceGeneratedShortName("08032460")).toBe(false); // 24:60 + }); + + it("rejects wrong lengths", () => { + expect(isDeviceGeneratedShortName("0803143")).toBe(false); + expect(isDeviceGeneratedShortName("080314322")).toBe(false); + expect(isDeviceGeneratedShortName("")).toBe(false); + expect(isDeviceGeneratedShortName(undefined)).toBe(false); + }); + + // The two checks are independent: renaming the track doesn't rename the short + // name, and the sync flow has to be able to tell which half still needs work. + it("is independent of the long-name check", () => { + expect(isDeviceGeneratedName("08031432")).toBe(false); + expect(isDeviceGeneratedShortName("N260803_1432")).toBe(false); + }); +}); diff --git a/src/lib/deviceGeneratedNames.ts b/src/lib/deviceGeneratedNames.ts new file mode 100644 index 00000000..e6632b38 --- /dev/null +++ b/src/lib/deviceGeneratedNames.ts @@ -0,0 +1,87 @@ +/** + * Recognising the names the on-device course creator generates. + * + * The logger has no text entry — deliberately, and permanently — so a course + * walked in the field is named from the GPS clock and renamed here afterwards. + * The firmware's format (`BirdsEye/course_creator.h`): + * + * - track `longName` **and** its first course name: `N{YYMMDD}_{HHMM}`, e.g. + * `N260803_1432`. 12 characters, so it fits the device's 13-char track + * browser whole. + * - track `shortName`: `MMDDHHMM`, e.g. `08031432`. Exactly the 8 characters + * this app's `Track.shortName` budget allows, which is why the sync merge can + * key on it directly. + * + * Detecting these is what tells the sync flow a name is a placeholder rather + * than something the user chose, so it knows to prompt for a real one. + */ + +/** `N` + YYMMDD + `_` + HHMM. */ +const GENERATED_NAME_RE = /^N(\d{2})(\d{2})(\d{2})_(\d{2})(\d{2})$/; + +/** MMDDHHMM — the generated short name. */ +const GENERATED_SHORT_NAME_RE = /^(\d{2})(\d{2})(\d{2})(\d{2})$/; + +/** Century the two-digit year is in. The creator shipped in 2026. */ +const CENTURY = 2000; + +function isRealDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1 || day > 31) return false; + const d = new Date(Date.UTC(year, month - 1, day)); + return d.getUTCMonth() === month - 1 && d.getUTCDate() === day; +} + +function isRealTime(hour: number, minute: number): boolean { + return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59; +} + +/** + * True when a track or course name is one the device generated — i.e. a + * placeholder the user should be asked to replace. + * + * The date and time parts are validated, so a genuine name that merely looks + * like the shape (`N999999_9999`) isn't mistaken for a placeholder. + */ +export function isDeviceGeneratedName(name: string | undefined | null): boolean { + return parseDeviceGeneratedName(name) !== null; +} + +/** + * The moment a generated name encodes, or null if it isn't one. + * + * Returned as a UTC `Date` because that is what the GPS clock stamped; render + * it with UTC accessors, not local ones, or a name walked at 14:32 will display + * as some other time. + */ +export function parseDeviceGeneratedName(name: string | undefined | null): Date | null { + if (!name) return null; + const m = GENERATED_NAME_RE.exec(name); + if (!m) return null; + const [, yy, mm, dd, hh, mi] = m; + const year = CENTURY + Number(yy); + const month = Number(mm); + const day = Number(dd); + const hour = Number(hh); + const minute = Number(mi); + if (!isRealDate(year, month, day) || !isRealTime(hour, minute)) return null; + return new Date(Date.UTC(year, month - 1, day, hour, minute)); +} + +/** + * True when a short name is one the device generated (`MMDDHHMM`). + * + * Kept separate from the long-name check because the two are independent: a + * user can rename the track and leave the short name, or the reverse. Note this + * carries no year, so it can only be validated as a plausible month/day/time. + */ +export function isDeviceGeneratedShortName(shortName: string | undefined | null): boolean { + if (!shortName) return false; + const m = GENERATED_SHORT_NAME_RE.exec(shortName); + if (!m) return false; + const [, mm, dd, hh, mi] = m; + const month = Number(mm); + const day = Number(dd); + // No year to check against, so accept any day a month could have. + if (month < 1 || month > 12 || day < 1 || day > 31) return false; + return isRealTime(Number(hh), Number(mi)); +} diff --git a/src/lib/deviceSyncNames.test.ts b/src/lib/deviceSyncNames.test.ts new file mode 100644 index 00000000..5ebd4e5e --- /dev/null +++ b/src/lib/deviceSyncNames.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeShortName, + initialTrackDraft, + editTrackName, + editTrackShortName, + initialCourseDraft, + editCourseName, + retargetCourseDraft, + validateTrackDraft, + validateCourseDraft, +} from "./deviceSyncNames"; +import type { SyncCourseRow, SyncTrackRow } from "./deviceSyncPlan"; + +function trackRow(overrides: Partial = {}): SyncTrackRow { + return { + key: "circuit:08031432", + shortName: "08031432", + name: "N260803_1432", + kind: "circuit", + direction: "download", + needsRename: true, + deviceOnlyCourses: [], + courses: [], + ...overrides, + }; +} + +function courseRow(overrides: Partial = {}): SyncCourseRow { + return { + key: "circuit:08031432::N260803_1432", + name: "N260803_1432", + kind: "circuit", + needsRename: true, + direction: "download", + ...overrides, + }; +} + +// ─── normalizeShortName ────────────────────────────────────────────────────── + +describe("normalizeShortName", () => { + it("strips anything the device filename can't hold", () => { + expect(normalizeShortName("Sun set!/..")).toBe("SUNSET"); + }); + + it("uppercases and caps at 8", () => { + expect(normalizeShortName("abcdefghij")).toBe("ABCDEFGH"); + }); + + it("can produce an empty string", () => { + expect(normalizeShortName("!!!")).toBe(""); + }); +}); + +// ─── Track name editing ────────────────────────────────────────────────────── + +describe("initialTrackDraft", () => { + // The row exists because the name is unusable; pre-filling it invites the user + // to click straight past the thing they were asked to do. + it("starts empty for a device-generated name", () => { + expect(initialTrackDraft(trackRow())).toEqual({ + name: "", + shortName: "", + shortNameTouched: false, + }); + }); + + it("keeps a name the user already chose", () => { + const draft = initialTrackDraft( + trackRow({ name: "Orlando Kart Center", shortName: "OKC", needsRename: false }), + ); + expect(draft.name).toBe("Orlando Kart Center"); + expect(draft.shortName).toBe("OKC"); + }); + + it("derives a short name when the existing one is unusable", () => { + const draft = initialTrackDraft( + trackRow({ name: "Sunset Park", shortName: "!!", needsRename: false }), + ); + expect(draft.shortName).toBe("SP"); + }); +}); + +describe("editTrackName", () => { + it("derives the short name as the user types", () => { + let d = initialTrackDraft(trackRow()); + d = editTrackName(d, "Sunset Park"); + expect(d.name).toBe("Sunset Park"); + expect(d.shortName).toBe("SP"); + }); + + it("derives 4 letters from a single word", () => { + expect(editTrackName(initialTrackDraft(trackRow()), "Bushnell").shortName).toBe("BUSH"); + }); + + // The user's explicit call: "if they edit it then edit the full name, just + // regen the short name, their fault they changed it". + it("re-derives over a short name the user had customised", () => { + let d = initialTrackDraft(trackRow()); + d = editTrackName(d, "Sunset Park"); + d = editTrackShortName(d, "SUNSET"); + expect(d.shortName).toBe("SUNSET"); + expect(d.shortNameTouched).toBe(true); + + d = editTrackName(d, "Sunset Park North"); + expect(d.shortName).toBe("SPN"); + expect(d.shortNameTouched).toBe(false); + }); +}); + +describe("editTrackShortName", () => { + it("normalizes what the user types", () => { + const d = editTrackShortName(initialTrackDraft(trackRow()), "sun-set park!"); + expect(d.shortName).toBe("SUNSETPA"); + }); + + it("leaves the long name alone", () => { + let d = editTrackName(initialTrackDraft(trackRow()), "Sunset Park"); + d = editTrackShortName(d, "SP2"); + expect(d.name).toBe("Sunset Park"); + }); +}); + +// ─── Course name editing ───────────────────────────────────────────────────── + +describe("course name drafts", () => { + // The firmware gives a new track and its first course the same stamp, so the + // course is the track named twice — following the track name is the right default. + it("follows the track's new name when generated", () => { + expect(initialCourseDraft(courseRow(), "Sunset Park")).toEqual({ + name: "Sunset Park", + touched: false, + }); + }); + + it("keeps a real course name", () => { + const d = initialCourseDraft(courseRow({ name: "Full CW", needsRename: false }), "Sunset Park"); + expect(d.name).toBe("Full CW"); + }); + + it("re-points an untouched draft when the track is renamed", () => { + const d = initialCourseDraft(courseRow(), "Sunset Park"); + expect(retargetCourseDraft(d, "Sunset Park North").name).toBe("Sunset Park North"); + }); + + it("never overwrites a course name the user typed", () => { + let d = initialCourseDraft(courseRow(), "Sunset Park"); + d = editCourseName(d, "Sunset Park - Reverse"); + expect(retargetCourseDraft(d, "Anything Else").name).toBe("Sunset Park - Reverse"); + }); +}); + +// ─── Track validation ──────────────────────────────────────────────────────── + +describe("validateTrackDraft", () => { + const ok = { name: "Sunset Park", shortName: "SUNSET", shortNameTouched: false }; + + it("passes a good draft", () => { + expect(validateTrackDraft(ok)).toBeNull(); + }); + + it("requires a name", () => { + expect(validateTrackDraft({ ...ok, name: " " })).toBe("required"); + }); + + // Both kinds. A venue is permanent — a date stamp is never the right name for + // one, even when its courses get re-walked every event. + it("refuses a name left as the device's date stamp", () => { + expect(validateTrackDraft({ ...ok, name: "N260803_1432" })).toBe("still_generated"); + }); + + it("requires a short name", () => { + expect(validateTrackDraft({ ...ok, shortName: "" })).toBe("short_required"); + }); + + it("refuses characters the device filename can't hold", () => { + expect(validateTrackDraft({ ...ok, shortName: "SUN SET" })).toBe("short_charset"); + expect(validateTrackDraft({ ...ok, shortName: "SUN.SET" })).toBe("short_charset"); + }); + + it("refuses a short name past the 8-char budget", () => { + expect(validateTrackDraft({ ...ok, shortName: "SUNSETPARK" })).toBe("short_too_long"); + }); + + // Two tracks sharing a short name would be one file on the device — the + // second write would silently overwrite the first. + it("refuses a short name another row or device file already claims", () => { + expect(validateTrackDraft(ok, { takenShortNames: ["OKC", "SUNSET"] })).toBe( + "short_duplicate", + ); + }); + + it("compares claimed names case-insensitively", () => { + expect(validateTrackDraft(ok, { takenShortNames: ["sunset"] })).toBe("short_duplicate"); + }); + + it("passes when nothing else claims the name", () => { + expect(validateTrackDraft(ok, { takenShortNames: ["OKC"] })).toBeNull(); + }); +}); + +// ─── Course validation ─────────────────────────────────────────────────────── + +describe("validateCourseDraft", () => { + it("requires a name for either kind", () => { + expect(validateCourseDraft({ name: "", touched: false }, "circuit")).toBe("required"); + expect(validateCourseDraft({ name: " ", touched: false }, "sprint")).toBe("required"); + }); + + it("refuses a circuit course left as the device's date stamp", () => { + expect(validateCourseDraft({ name: "N260803_1432", touched: false }, "circuit")).toBe( + "still_generated", + ); + }); + + // A sprint venue re-lays its course every event, so the date it was walked + // genuinely is the most useful label. Forcing a name would just get noise. + it("allows a sprint course to keep the device's date stamp", () => { + expect(validateCourseDraft({ name: "N260803_1432", touched: false }, "sprint")).toBeNull(); + }); + + it("passes a named course of either kind", () => { + expect(validateCourseDraft({ name: "Full CW", touched: true }, "circuit")).toBeNull(); + expect(validateCourseDraft({ name: "Morning Run", touched: true }, "sprint")).toBeNull(); + }); +}); diff --git a/src/lib/deviceSyncNames.ts b/src/lib/deviceSyncNames.ts new file mode 100644 index 00000000..6c8b5f92 --- /dev/null +++ b/src/lib/deviceSyncNames.ts @@ -0,0 +1,177 @@ +/** + * Naming the tracks and courses a sync is about to write. + * + * Two jobs, both pure so the wizard's `.tsx` stays a renderer: + * + * 1. **The edit rules.** A short name follows the long name until the user + * takes it over — and, by explicit decision, editing the long name after + * that takes it back. A course name follows its track's new name the same + * way. Simple and predictable beats clever here. + * 2. **The save gate.** A name that reaches the device has to be legal there, + * unique, and actually chosen by a human rather than left as a date stamp. + */ + +import type { TrackKind } from '@/lib/ble/trackOpcodes'; +import { MAX_SHORT_NAME_LENGTH, deriveShortName } from '@/lib/trackUtils'; +import { isDeviceGeneratedName } from '@/lib/deviceGeneratedNames'; +import type { SyncCourseRow, SyncTrackRow } from '@/lib/deviceSyncPlan'; + +/** + * Characters a short name may use. + * + * The firmware's validator (`BirdsEye/filename_validator.cpp`) also permits + * `.`, `_` and `-`, but a short name becomes the FILENAME base, and a dot there + * reads as an extension. Alphanumerics only — which is also exactly what + * `deriveShortName` emits, so the auto-derived value is always legal. + */ +const SHORT_NAME_CHARSET = /^[A-Za-z0-9]+$/; + +export interface TrackNameDraft { + name: string; + shortName: string; + /** True once the user has typed in the short-name box themselves. */ + shortNameTouched: boolean; +} + +export interface CourseNameDraft { + name: string; + /** True once the user has typed here; stops the track name overwriting it. */ + touched: boolean; +} + +export type NameProblem = + /** Nothing typed. */ + | 'required' + /** Still the date stamp the device generated. */ + | 'still_generated' + | 'short_required' + | 'short_charset' + | 'short_too_long' + | 'short_duplicate'; + +// ─── Editing ───────────────────────────────────────────────────────────────── + +/** + * Force a typed short name into something the device will accept: alphanumerics + * only, uppercase, capped. Matches `deriveShortName`'s own normalisation, so a + * hand-typed name and a derived one can't disagree about what is legal. + */ +export function normalizeShortName(raw: string): string { + return raw.replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, MAX_SHORT_NAME_LENGTH); +} + +/** + * The starting state for a track's name fields. + * + * A device-generated name starts the box EMPTY rather than pre-filled: the row + * exists precisely because that name isn't usable, and pre-filling it invites + * clicking straight past. A name the user already chose is kept as-is. + */ +export function initialTrackDraft(row: SyncTrackRow): TrackNameDraft { + if (isDeviceGeneratedName(row.name)) { + return { name: '', shortName: '', shortNameTouched: false }; + } + return { + name: row.name, + shortName: normalizeShortName(row.shortName) || deriveShortName(row.name), + shortNameTouched: false, + }; +} + +/** + * Typing in the long-name box. + * + * This always re-derives the short name, even one the user had customised — + * their call: "if they edit it then edit the full name, just regen the short + * name, their fault they changed it". + */ +export function editTrackName(draft: TrackNameDraft, name: string): TrackNameDraft { + return { name, shortName: deriveShortName(name), shortNameTouched: false }; +} + +/** Typing in the short-name box. Takes ownership until the long name changes. */ +export function editTrackShortName(draft: TrackNameDraft, shortName: string): TrackNameDraft { + return { ...draft, shortName: normalizeShortName(shortName), shortNameTouched: true }; +} + +/** + * The starting state for a course's name field. + * + * A generated course name follows the track's new name — the firmware gives a + * new track and its first course the same stamp, so they are the same thing + * named twice. + */ +export function initialCourseDraft(row: SyncCourseRow, trackName: string): CourseNameDraft { + if (isDeviceGeneratedName(row.name)) return { name: trackName, touched: false }; + return { name: row.name, touched: false }; +} + +export function editCourseName(draft: CourseNameDraft, name: string): CourseNameDraft { + return { name, touched: true }; +} + +/** + * Re-point an untouched course name at a track name that just changed — the + * user went Back, renamed the track, and came forward again. A course name they + * typed themselves is left alone. + */ +export function retargetCourseDraft( + draft: CourseNameDraft, + trackName: string, +): CourseNameDraft { + return draft.touched ? draft : { name: trackName, touched: false }; +} + +// ─── Validation ────────────────────────────────────────────────────────────── + +export interface TrackNameContext { + /** + * Short names already claimed — by another row in this wizard, or by a file + * already on the device of the same kind. Compared case-insensitively, and + * must exclude the row's own current name. + */ + takenShortNames: Iterable; +} + +/** + * What still stands between this track and "Save & import", or null if nothing. + * + * A track name is required whichever kind it is: a venue is permanent, so a + * date stamp is never the right name for one. (Its *courses* are a different + * matter — see `validateCourseDraft`.) + */ +export function validateTrackDraft( + draft: TrackNameDraft, + context: TrackNameContext = { takenShortNames: [] }, +): NameProblem | null { + const name = draft.name.trim(); + if (!name) return 'required'; + if (isDeviceGeneratedName(name)) return 'still_generated'; + + const shortName = draft.shortName.trim(); + if (!shortName) return 'short_required'; + if (!SHORT_NAME_CHARSET.test(shortName)) return 'short_charset'; + if (shortName.length > MAX_SHORT_NAME_LENGTH) return 'short_too_long'; + + const taken = new Set(Array.from(context.takenShortNames, (s) => s.toUpperCase())); + if (taken.has(shortName.toUpperCase())) return 'short_duplicate'; + + return null; +} + +/** + * What still stands between this course and "Save & import", or null. + * + * Circuit courses must be named; **sprint courses need not be**. A sprint venue + * re-lays its course every event, so the date it was walked genuinely is the + * most useful label — forcing a name there would just make people type noise. + */ +export function validateCourseDraft( + draft: CourseNameDraft, + kind: TrackKind, +): NameProblem | null { + const name = draft.name.trim(); + if (!name) return 'required'; + if (kind === 'circuit' && isDeviceGeneratedName(name)) return 'still_generated'; + return null; +} diff --git a/src/lib/deviceSyncOps.test.ts b/src/lib/deviceSyncOps.test.ts new file mode 100644 index 00000000..62375276 --- /dev/null +++ b/src/lib/deviceSyncOps.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect } from "vitest"; +import { planOperations, type SyncOperation, type SyncResolution } from "./deviceSyncOps"; +import type { SyncTrackRow } from "./deviceSyncPlan"; +import { + buildMergedTrackList, + deviceTrackFileFrom, + type DeviceCourseJson, + type DeviceTrackFileJson, +} from "./deviceTrackSync"; +import type { Course, Track } from "@/types/racing"; + +function makeCourse(overrides: Partial = {}): Course { + return { + name: "Full CW", + startFinishA: { lat: 35.4, lon: -97.3 }, + startFinishB: { lat: 35.4001, lon: -97.3001 }, + isUserDefined: true, + ...overrides, + }; +} + +function makeDeviceCourse(name = "N260803_1432"): DeviceCourseJson { + return { + name, + start_a_lat: 35.5, + start_a_lng: -97.5, + start_b_lat: 35.5001, + start_b_lng: -97.5001, + }; +} + +function row(overrides: Partial = {}): SyncTrackRow { + return { + key: "circuit:08031432", + shortName: "08031432", + name: "N260803_1432", + kind: "circuit", + direction: "download", + needsRename: true, + deviceFileName: "N260803_1432.json", + deviceOnlyCourses: [makeDeviceCourse()], + courses: [], + ...overrides, + }; +} + +function resolve(overrides: Partial = {}): SyncResolution { + return { row: row(), name: "Sunset Park", shortName: "SUNSET", ...overrides }; +} + +const types = (ops: SyncOperation[]) => ops.map((o) => o.type); +const put = (ops: SyncOperation[]) => + ops.find((o) => o.type === "device_put") as Extract; +const appPut = (ops: SyncOperation[]) => + ops.find((o) => o.type === "app_put") as Extract; + +// ─── Ordering ──────────────────────────────────────────────────────────────── + +describe("planOperations ordering", () => { + // Put before delete: a failure between them leaves the track on the card + // twice, which the next sync reconciles. Delete-first loses a field recording + // to a dropped BLE packet. + it("writes the new file before deleting the old one", () => { + const ops = planOperations([resolve()]); + expect(types(ops)).toEqual(["device_put", "device_delete", "app_put"]); + }); + + // Device before app: if the app write fails, the device holds a correctly + // named file and the app holds nothing, so the next connect offers a plain + // download. The reverse strands a renamed app track beside its old file and + // the user sees the track twice. + it("finishes with the device before touching local storage", () => { + const ops = planOperations([resolve()]); + const lastDevice = Math.max( + ops.findIndex((o) => o.type === "device_delete"), + ops.findIndex((o) => o.type === "device_put"), + ); + const firstApp = ops.findIndex((o) => o.type.startsWith("app_")); + expect(lastDevice).toBeLessThan(firstApp); + }); + + it("keeps each track's operations together", () => { + const ops = planOperations([ + resolve(), + resolve({ + row: row({ key: "circuit:OKC", shortName: "OKC", name: "OKC", deviceFileName: "OKC.json" }), + name: "Orlando Kart Center", + shortName: "OKC", + }), + ]); + expect(ops.slice(0, 3).every((o) => o.trackKey === "circuit:08031432")).toBe(true); + expect(ops.slice(3).every((o) => o.trackKey === "circuit:OKC")).toBe(true); + }); +}); + +// ─── Deletes ───────────────────────────────────────────────────────────────── + +describe("planOperations deletes", () => { + it("skips the delete when the filename didn't change", () => { + const ops = planOperations([ + resolve({ + row: row({ deviceFileName: "SUNSET.json" }), + name: "Sunset Park", + shortName: "SUNSET", + }), + ]); + expect(types(ops)).toEqual(["device_put", "app_put"]); + }); + + // The card is FAT — "OKC.json" and "okc.json" are one file, so a + // case-only difference would delete the file just written. + it("treats a case-only filename difference as the same file", () => { + const ops = planOperations([ + resolve({ row: row({ deviceFileName: "sunset.json" }), shortName: "SUNSET" }), + ]); + expect(types(ops)).not.toContain("device_delete"); + }); + + it("has nothing to delete for a track that was never on the device", () => { + const ops = planOperations([ + resolve({ + row: row({ deviceFileName: undefined, appTrack: { name: "Sunset Park", courses: [] } }), + }), + ]); + expect(types(ops)).not.toContain("device_delete"); + }); + + it("drops the old local track when the name changed", () => { + const appTrack: Track = { name: "Old Name", shortName: "OLD", courses: [makeCourse()] }; + const ops = planOperations([ + resolve({ row: row({ appTrack, deviceFileName: "OLD.json" }), name: "New Name" }), + ]); + const del = ops.find((o) => o.type === "app_delete"); + expect(del).toMatchObject({ type: "app_delete", trackName: "Old Name" }); + }); + + it("keeps the local track when the name is unchanged", () => { + const appTrack: Track = { name: "Sunset Park", shortName: "SUNSET", courses: [] }; + const ops = planOperations([resolve({ row: row({ appTrack }), name: "Sunset Park" })]); + expect(types(ops)).not.toContain("app_delete"); + }); +}); + +// ─── Course handling ───────────────────────────────────────────────────────── + +describe("planOperations courses", () => { + it("renames courses the user renamed", () => { + const ops = planOperations([ + resolve({ courseNames: { "circuit:08031432::N260803_1432": "Morning Run" } }), + ]); + expect(put(ops).json).toContain("Morning Run"); + expect(appPut(ops).track.courses[0].name).toBe("Morning Run"); + }); + + it("leaves courses the user didn't rename", () => { + const ops = planOperations([resolve()]); + expect(appPut(ops).track.courses[0].name).toBe("N260803_1432"); + }); + + it("ignores a rename that is only whitespace", () => { + const ops = planOperations([ + resolve({ courseNames: { "circuit:08031432::N260803_1432": " " } }), + ]); + expect(appPut(ops).track.courses[0].name).toBe("N260803_1432"); + }); + + // An upload means "the app's edits win", never "discard what was recorded in + // the field" — that is the exact thing this flow exists to rescue. + it("imports device-only courses alongside the app's", () => { + const appTrack: Track = { + name: "Sunset Park", + shortName: "SUNSET", + courses: [makeCourse({ name: "Existing" })], + }; + const ops = planOperations([ + resolve({ row: row({ appTrack, deviceOnlyCourses: [makeDeviceCourse("Walked")] }) }), + ]); + expect(appPut(ops).track.courses.map((c) => c.name)).toEqual(["Existing", "Walked"]); + }); + + it("lets the app's copy win when a rename collides with an existing course", () => { + const appTrack: Track = { + name: "Sunset Park", + shortName: "SUNSET", + courses: [makeCourse({ name: "Full CW" })], + }; + const ops = planOperations([ + resolve({ + row: row({ appTrack, deviceOnlyCourses: [makeDeviceCourse("Walked")] }), + courseNames: { "circuit:08031432::Walked": "Full CW" }, + }), + ]); + expect(appPut(ops).track.courses.map((c) => c.name)).toEqual(["Full CW"]); + }); + + it("names the first course as the file's default", () => { + const ops = planOperations([resolve()]); + const file: DeviceTrackFileJson = JSON.parse(put(ops).json); + expect(file.defaultCourse).toBe("N260803_1432"); + }); +}); + +// ─── The written file ──────────────────────────────────────────────────────── + +describe("planOperations output", () => { + it("writes to the new short name, in the track's own folder", () => { + const ops = planOperations([resolve({ row: row({ kind: "sprint" }) })]); + expect(put(ops).fileName).toBe("SUNSET.json"); + expect(put(ops).folder).toBe("sprint"); + }); + + it("carries the chosen names into the file", () => { + const file: DeviceTrackFileJson = JSON.parse(put(planOperations([resolve()])).json); + expect(file.longName).toBe("Sunset Park"); + expect(file.shortName).toBe("SUNSET"); + }); + + it("marks the imported track as the user's own", () => { + expect(appPut(planOperations([resolve()])).track.isUserDefined).toBe(true); + }); +}); + +// ─── The property the whole flow rests on ──────────────────────────────────── + +describe("planOperations settles the sync", () => { + // If this fails, the on-connect prompt re-fires on every single connect — + // the exact failure this feature exists to avoid. + it("leaves the device and the app agreeing, so nothing is re-offered", () => { + const ops = planOperations([resolve()]); + + // Replay the plan: the device ends up holding the written file... + const written = put(ops); + const onDevice = deviceTrackFileFrom(written.fileName, written.json, written.folder); + // ...and local storage holds the track that was stored. + const stored = appPut(ops).track; + + const merged = buildMergedTrackList([stored], [onDevice]); + expect(merged).toHaveLength(1); + expect(merged[0].status).toBe("synced"); + }); + + it("settles a renamed sprint track too", () => { + const sprintCourse = makeDeviceCourse("N260803_1432"); + sprintCourse.finish_a_lat = 35.51; + sprintCourse.finish_a_lng = -97.51; + sprintCourse.finish_b_lat = 35.52; + sprintCourse.finish_b_lng = -97.52; + sprintCourse.date_created = "2026-08-03T14:32"; + + const ops = planOperations([ + resolve({ + row: row({ kind: "sprint", deviceOnlyCourses: [sprintCourse] }), + name: "Sunset Autocross", + shortName: "SUNAX", + }), + ]); + const written = put(ops); + const onDevice = deviceTrackFileFrom(written.fileName, written.json, "sprint"); + const merged = buildMergedTrackList([appPut(ops).track], [onDevice]); + expect(merged).toHaveLength(1); + expect(merged[0].status).toBe("synced"); + }); +}); diff --git a/src/lib/deviceSyncOps.ts b/src/lib/deviceSyncOps.ts new file mode 100644 index 00000000..2ad80534 --- /dev/null +++ b/src/lib/deviceSyncOps.ts @@ -0,0 +1,148 @@ +/** + * The exact work a sync will do, as data. + * + * Separating "what to do" from "doing it" is what makes the risky part + * testable: the ordering below is the difference between a failed sync you can + * recover from and one that loses a track. The async runner just walks this + * list. + */ + +import type { TrackKind } from '@/lib/ble/trackOpcodes'; +import type { Course, Track } from '@/types/racing'; +import { + appCourseToDeviceJson, + deviceCourseToAppCourse, + serializeDeviceTrackFile, + type DeviceCourseJson, +} from '@/lib/deviceTrackSync'; +import type { SyncTrackRow } from '@/lib/deviceSyncPlan'; + +/** A track row plus the names the user settled on for it. */ +export interface SyncResolution { + row: SyncTrackRow; + /** Final track long name. */ + name: string; + /** Final short name — becomes the device filename base. */ + shortName: string; + /** Final course names by course-row key. Courses absent here keep their name. */ + courseNames?: Record; +} + +export type SyncOperation = + /** Write the whole track file to the device. */ + | { type: 'device_put'; trackKey: string; folder: TrackKind; fileName: string; json: string } + /** Remove the file the track used to live in, after a rename. */ + | { type: 'device_delete'; trackKey: string; folder: TrackKind; fileName: string } + /** Store the track locally under its final name. */ + | { type: 'app_put'; trackKey: string; track: Track } + /** Drop the local track's old name, after a rename. */ + | { type: 'app_delete'; trackKey: string; trackName: string }; + +/** Rename a course if the user gave it a new name, else keep what it had. */ +function finalCourseName( + resolution: SyncResolution, + trackKey: string, + currentName: string, +): string { + const key = `${trackKey}::${currentName}`; + return resolution.courseNames?.[key]?.trim() || currentName; +} + +/** + * Every course the track ends up with: the app's, plus anything walked on the + * device that the app doesn't have yet. + * + * Device-only courses are always imported rather than overwritten. An upload is + * "the app's version wins for edits", not "throw away what was recorded in the + * field" — that would quietly destroy the exact thing this flow exists to + * rescue. + */ +function finalCourses(resolution: SyncResolution): Course[] { + const { row } = resolution; + const fromApp = (row.appTrack?.courses ?? []).map((c) => ({ + ...c, + name: finalCourseName(resolution, row.key, c.name), + })); + const seen = new Set(fromApp.map((c) => c.name)); + const fromDevice = row.deviceOnlyCourses + .map((dc) => ({ + ...deviceCourseToAppCourse(dc), + name: finalCourseName(resolution, row.key, dc.name), + })) + // A rename can collide with an existing app course; the app's copy wins, + // since it is the one that may carry sub-sectors the device can't hold. + .filter((c) => !seen.has(c.name)); + return [...fromApp, ...fromDevice]; +} + +function deviceCoursesFor(courses: Course[]): DeviceCourseJson[] { + return courses.map(appCourseToDeviceJson); +} + +/** + * Turn resolved rows into an ordered operation list. + * + * **Order is the whole point.** Per track: write the new file, then delete the + * old one, then update local storage. + * + * - *Put before delete* so a failure between them leaves the track on the card + * twice — annoying, and the next sync reconciles it — rather than leaving it + * nowhere. Delete-first would lose a field recording to a dropped BLE packet. + * - *Device before app* so a failure after the write leaves the device holding + * a correctly-named file and the app holding nothing: the next connect offers + * it as a plain download with no rename needed. The reverse order strands a + * renamed app track next to its old device file, and the user sees the same + * track twice. + */ +export function planOperations(resolutions: SyncResolution[]): SyncOperation[] { + const ops: SyncOperation[] = []; + + for (const resolution of resolutions) { + const { row } = resolution; + const name = resolution.name.trim(); + const shortName = resolution.shortName.trim(); + const courses = finalCourses(resolution); + const fileName = `${shortName}.json`; + + ops.push({ + type: 'device_put', + trackKey: row.key, + folder: row.kind, + fileName, + json: serializeDeviceTrackFile({ + longName: name, + shortName, + type: row.kind, + defaultCourse: courses[0]?.name ?? '', + courses: deviceCoursesFor(courses), + }), + }); + + // Only when the file actually moved. Comparing case-insensitively because + // the card is FAT: "OKC.json" and "okc.json" are the same file, and + // "deleting the old one" would delete the one just written. + if (row.deviceFileName && row.deviceFileName.toLowerCase() !== fileName.toLowerCase()) { + ops.push({ + type: 'device_delete', + trackKey: row.key, + folder: row.kind, + fileName: row.deviceFileName, + }); + } + + ops.push({ + type: 'app_put', + trackKey: row.key, + track: { name, shortName, courses, isUserDefined: true }, + }); + + // Local storage keys tracks by name, so a rename is a new entry — the old + // one has to go or the user ends up with both. + const previousName = row.appTrack?.name; + if (previousName && previousName !== name) { + ops.push({ type: 'app_delete', trackKey: row.key, trackName: previousName }); + } + } + + return ops; +} diff --git a/src/lib/deviceSyncPlan.test.ts b/src/lib/deviceSyncPlan.test.ts new file mode 100644 index 00000000..9716762e --- /dev/null +++ b/src/lib/deviceSyncPlan.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect } from "vitest"; +import { + buildSyncPlan, + planHasWork, + rowsNeedingRename, + DEVICE_MAX_COURSES, +} from "./deviceSyncPlan"; +import type { MergedTrackEntry, MergedCourseEntry, DeviceCourseJson } from "./deviceTrackSync"; +import type { Course, Track } from "@/types/racing"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +function makeCourse(overrides: Partial = {}): Course { + return { + name: "Full CW", + startFinishA: { lat: 35.4, lon: -97.3 }, + startFinishB: { lat: 35.4001, lon: -97.3001 }, + isUserDefined: true, + ...overrides, + }; +} + +function makeSprintCourse(name = "Run"): Course { + return makeCourse({ + name, + type: "sprint", + finish: { a: { lat: 35.41, lon: -97.31 }, b: { lat: 35.41, lon: -97.32 } }, + }); +} + +function makeDeviceCourse(name = "Full CW"): DeviceCourseJson { + return { + name, + start_a_lat: 35.4, + start_a_lng: -97.3, + start_b_lat: 35.4001, + start_b_lng: -97.3001, + }; +} + +function makeEntry(overrides: Partial = {}): MergedTrackEntry { + const appCourses = overrides.appCourses ?? []; + const deviceCourses = overrides.deviceCourses ?? []; + return { + shortName: "OKC", + kind: "circuit", + status: "app_only", + appCourses, + deviceCourses, + mergedCourses: [], + ...overrides, + }; +} + +function appTrack(courses: Course[], overrides: Partial = {}): Track { + return { name: "Track", shortName: "OKC", courses, isUserDefined: true, ...overrides }; +} + +function courseEntries( + specs: Array<[string, MergedCourseEntry["status"]]>, +): MergedCourseEntry[] { + return specs.map(([name, status]) => ({ + name, + status, + appCourse: status === "device_only" ? undefined : makeCourse({ name }), + deviceCourse: status === "app_only" ? undefined : makeDeviceCourse(name), + })); +} + +// ─── What gets offered ─────────────────────────────────────────────────────── + +describe("buildSyncPlan", () => { + it("drops synced tracks — there is nothing to do", () => { + const plan = buildSyncPlan([makeEntry({ status: "synced" })]); + expect(plan.rows).toEqual([]); + expect(planHasWork(plan)).toBe(false); + }); + + it("offers a device-only track as a download", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "device_only", + shortName: "08031432", + deviceLongName: "N260803_1432", + deviceFileName: "N260803_1432.json", + deviceCourses: [makeDeviceCourse("N260803_1432")], + mergedCourses: courseEntries([["N260803_1432", "device_only"]]), + }), + ]); + expect(plan.rows).toHaveLength(1); + expect(plan.rows[0].direction).toBe("download"); + expect(plan.rows[0].name).toBe("N260803_1432"); + expect(plan.rows[0].deviceFileName).toBe("N260803_1432.json"); + }); + + it("offers a user-defined app-only track as an upload", () => { + const plan = buildSyncPlan([ + makeEntry({ status: "app_only", appTrack: appTrack([makeCourse()]) }), + ]); + expect(plan.rows).toHaveLength(1); + expect(plan.rows[0].direction).toBe("upload"); + }); + + // The app ships two reference tracks. "Unknown tracks" meant the user's, not + // every track the app happens to know about. + it("never offers a track the user didn't create", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "app_only", + appTrack: appTrack([makeCourse()], { isUserDefined: false }), + }), + ]); + expect(plan.rows).toEqual([]); + expect(plan.skipped).toEqual([]); // silently absent, not reported + }); + + it("uploads a mismatched track and still imports its device-only courses", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "mismatch", + appTrack: appTrack([makeCourse({ name: "A" })]), + appCourses: [makeCourse({ name: "A" })], + deviceCourses: [makeDeviceCourse("A"), makeDeviceCourse("Walked")], + mergedCourses: courseEntries([ + ["A", "mismatch"], + ["Walked", "device_only"], + ]), + }), + ]); + expect(plan.rows[0].direction).toBe("upload"); + expect(plan.rows[0].deviceOnlyCourses.map((c) => c.name)).toEqual(["Walked"]); + }); + + it("lists only the courses that actually differ", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "mismatch", + appTrack: appTrack([makeCourse()]), + mergedCourses: courseEntries([ + ["Same", "synced"], + ["Changed", "mismatch"], + ["New", "device_only"], + ]), + }), + ]); + expect(plan.rows[0].courses.map((c) => c.name)).toEqual(["Changed", "New"]); + expect(plan.rows[0].courses.map((c) => c.direction)).toEqual(["upload", "download"]); + }); +}); + +// ─── Rename detection ──────────────────────────────────────────────────────── + +describe("buildSyncPlan rename flags", () => { + it("flags a track whose long name is a device placeholder", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "device_only", + shortName: "08031432", + deviceLongName: "N260803_1432", + }), + ]); + expect(plan.rows[0].needsRename).toBe(true); + expect(rowsNeedingRename(plan)).toHaveLength(1); + }); + + // The two halves are independent — a user can fix one and leave the other. + it("flags a track whose short name alone is a placeholder", () => { + const plan = buildSyncPlan([ + makeEntry({ status: "device_only", shortName: "08031432", deviceLongName: "Sunset Park" }), + ]); + expect(plan.rows[0].needsRename).toBe(true); + }); + + it("leaves a properly named track alone", () => { + const plan = buildSyncPlan([ + makeEntry({ status: "app_only", shortName: "OKC", trackName: "Orlando Kart Center", appTrack: appTrack([makeCourse()]) }), + ]); + expect(plan.rows[0].needsRename).toBe(false); + expect(rowsNeedingRename(plan)).toEqual([]); + }); + + it("flags device-named courses", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "device_only", + appTrack: undefined, + mergedCourses: courseEntries([ + ["N260803_1432", "device_only"], + ["Full CW", "device_only"], + ]), + }), + ]); + expect(plan.rows[0].courses.map((c) => c.needsRename)).toEqual([true, false]); + }); +}); + +// ─── Rows that can never converge ──────────────────────────────────────────── + +describe("buildSyncPlan skips what can never converge", () => { + // Each of these would report a difference on every single connect. Retrying + // them forever is exactly the nag this whole flow exists to stop. + it("skips a track holding both circuit and sprint courses", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "app_only", + appTrack: appTrack([makeCourse(), makeSprintCourse()]), + }), + ]); + expect(plan.rows).toEqual([]); + expect(plan.skipped[0].reason).toBe("mixed_kind"); + }); + + it("skips a track with more courses than the firmware will read back", () => { + const many = Array.from({ length: DEVICE_MAX_COURSES + 1 }, (_, i) => + makeCourse({ name: `C${i}` }), + ); + const plan = buildSyncPlan([ + makeEntry({ status: "app_only", appTrack: appTrack(many), appCourses: many }), + ]); + expect(plan.rows).toEqual([]); + expect(plan.skipped[0].reason).toBe("too_many_courses"); + }); + + it("counts imported device courses toward that limit", () => { + const nine = Array.from({ length: 9 }, (_, i) => makeCourse({ name: `C${i}` })); + const entry = makeEntry({ + status: "mismatch", + appTrack: appTrack(nine), + appCourses: nine, + mergedCourses: courseEntries([ + ["D1", "device_only"], + ["D2", "device_only"], + ]), + }); + expect(buildSyncPlan([entry]).skipped[0].reason).toBe("too_many_courses"); + }); + + it("allows a track exactly at the limit", () => { + const ten = Array.from({ length: DEVICE_MAX_COURSES }, (_, i) => makeCourse({ name: `C${i}` })); + const plan = buildSyncPlan([ + makeEntry({ status: "app_only", appTrack: appTrack(ten), appCourses: ten }), + ]); + expect(plan.rows).toHaveLength(1); + expect(plan.skipped).toEqual([]); + }); + + // The native IPC drops the `kind` argument, so a sprint write lands among the + // circuit tracks. Better to say we can't than to corrupt the card. + it("skips sprint tracks on a transport that can't reach the sprint folder", () => { + const entry = makeEntry({ + status: "device_only", + kind: "sprint", + shortName: "SPR", + }); + expect(buildSyncPlan([entry], { supportsSprintTracks: false }).skipped[0].reason).toBe( + "sprint_unsupported", + ); + expect(buildSyncPlan([entry], { supportsSprintTracks: true }).rows).toHaveLength(1); + }); + + it("defaults to assuming sprint is supported", () => { + const entry = makeEntry({ status: "device_only", kind: "sprint", shortName: "SPR" }); + expect(buildSyncPlan([entry]).rows).toHaveLength(1); + }); + + it("never reports a synced track as skipped", () => { + const plan = buildSyncPlan([ + makeEntry({ status: "synced", appTrack: appTrack([makeCourse(), makeSprintCourse()]) }), + ]); + expect(plan.skipped).toEqual([]); + }); +}); + +// ─── Keys ──────────────────────────────────────────────────────────────────── + +describe("buildSyncPlan keys", () => { + // Circuit and sprint are separate files on the device, so the same shortName + // in each is two different tracks and must not collide in the checkbox set. + it("keys rows on (kind, shortName)", () => { + const plan = buildSyncPlan([ + makeEntry({ status: "device_only", kind: "circuit", shortName: "OKC" }), + makeEntry({ status: "device_only", kind: "sprint", shortName: "OKC" }), + ]); + expect(plan.rows.map((r) => r.key)).toEqual(["circuit:OKC", "sprint:OKC"]); + }); + + it("keys course rows under their track", () => { + const plan = buildSyncPlan([ + makeEntry({ + status: "device_only", + shortName: "OKC", + mergedCourses: courseEntries([["Full CW", "device_only"]]), + }), + ]); + expect(plan.rows[0].courses[0].key).toBe("circuit:OKC::Full CW"); + }); +}); diff --git a/src/lib/deviceSyncPlan.ts b/src/lib/deviceSyncPlan.ts new file mode 100644 index 00000000..be24f16d --- /dev/null +++ b/src/lib/deviceSyncPlan.ts @@ -0,0 +1,207 @@ +/** + * What a device sync would actually do — computed, not performed. + * + * `buildMergedTrackList` answers "how do these two sides differ?". This answers + * the next question: "which of those differences are we going to offer to fix, + * in which direction, and which can never be fixed at all?" Keeping it pure + * means the wizard's whole decision surface is unit-testable in a node + * environment, where the dialog itself cannot even be rendered. + */ + +import type { TrackKind } from '@/lib/ble/trackOpcodes'; +import type { Course, Track } from '@/types/racing'; +import { isSprintCourse } from '@/types/racing'; +import { + isMixedKindTrack, + type DeviceCourseJson, + type MergedTrackEntry, +} from '@/lib/deviceTrackSync'; +import { isDeviceGeneratedName, isDeviceGeneratedShortName } from '@/lib/deviceGeneratedNames'; + +/** + * Courses the firmware keeps per track file (`MAX_LAYOUTS` in `project.h`). + * + * Its parser silently ignores everything past this (`sd_functions.ino` + * `if (numOfTracks < MAX_LAYOUTS)`), so a bigger track can never read back as + * the file we wrote — it would report a mismatch on every connect forever. We + * do NOT trim to fit: dropping a user's courses to make a checkmark go green is + * the worse failure. Such tracks are skipped and surfaced instead. + */ +export const DEVICE_MAX_COURSES = 10; + +/** Which way a row moves. Purely a label for the bubble; the ops decide the work. */ +export type SyncDirection = 'upload' | 'download'; + +/** Why a difference is being reported rather than offered. */ +export type SkipReason = + /** Circuit and sprint courses in one track — two files on the device, no way to represent it. */ + | 'mixed_kind' + /** More courses than the firmware will read back. */ + | 'too_many_courses' + /** A sprint track on a transport that can't reach the sprint folder. */ + | 'sprint_unsupported'; + +export interface SyncCourseRow { + /** Stable across re-renders and edits; the checkbox `Set` keys on it. */ + key: string; + /** The name as it stands now, before any rename. */ + name: string; + kind: TrackKind; + /** True when the name is a device placeholder, so the row gets a text box. */ + needsRename: boolean; + direction: SyncDirection; + appCourse?: Course; + deviceCourse?: DeviceCourseJson; +} + +export interface SyncTrackRow { + key: string; + /** Current identity — the shortName the merge keys on. */ + shortName: string; + /** Current long name, from the app track or the device file's `longName`. */ + name: string; + kind: TrackKind; + direction: SyncDirection; + /** + * True when either the long name or the short name is a device placeholder. + * Only the long name blocks saving; the short name re-derives from it. + */ + needsRename: boolean; + /** The file to overwrite, when one already exists. */ + deviceFileName?: string; + appTrack?: Track; + /** Courses on the device that the app doesn't have — always imported. */ + deviceOnlyCourses: DeviceCourseJson[]; + courses: SyncCourseRow[]; +} + +export interface SkippedTrack { + key: string; + shortName: string; + name: string; + kind: TrackKind; + reason: SkipReason; +} + +export interface SyncPlan { + rows: SyncTrackRow[]; + /** Real differences we deliberately won't act on. Surfaced once, never retried. */ + skipped: SkippedTrack[]; +} + +export interface SyncPlanOptions { + /** + * False on transports that cannot reach `/TRACKS/SPRINT`. The native IPC + * currently drops the `kind` argument on get/put/delete, so a sprint write + * would silently land among the circuit tracks. + */ + supportsSprintTracks?: boolean; +} + +/** The display name for an entry, preferring whichever side actually has one. */ +function entryName(entry: MergedTrackEntry): string { + return entry.trackName || entry.deviceLongName || entry.shortName; +} + +function courseRows(entry: MergedTrackEntry, trackKey: string): SyncCourseRow[] { + return entry.mergedCourses + .filter((mc) => mc.status !== 'synced') + .map((mc) => ({ + key: `${trackKey}::${mc.name}`, + name: mc.name, + // A course's kind follows its own shape, not the track's — the track is + // uniform by construction (mixed ones are skipped above). + kind: mc.appCourse + ? isSprintCourse(mc.appCourse) + ? ('sprint' as const) + : ('circuit' as const) + : entry.kind, + needsRename: isDeviceGeneratedName(mc.name), + direction: mc.status === 'device_only' ? ('download' as const) : ('upload' as const), + appCourse: mc.appCourse, + deviceCourse: mc.deviceCourse, + })); +} + +/** + * Turn a merged track list into the rows a sync would act on. + * + * Rules, in the order they apply: + * + * - `synced` tracks are dropped — there is nothing to do, and offering them is + * how a "sync?" prompt becomes noise that fires on every connect. + * - `app_only` tracks are offered **only when user-defined**. The two tracks + * this app ships are reference data; pushing them onto every logger that + * connects is not what "unknown tracks" meant. + * - `device_only` tracks download. + * - `mismatch` tracks upload, after their device-only courses are imported — + * so an edit made here wins, but a course walked on the device is never lost. + * - Anything that could never converge is skipped with a reason instead. + */ +export function buildSyncPlan( + merged: MergedTrackEntry[], + options: SyncPlanOptions = {}, +): SyncPlan { + const { supportsSprintTracks = true } = options; + const rows: SyncTrackRow[] = []; + const skipped: SkippedTrack[] = []; + + for (const entry of merged) { + if (entry.status === 'synced') continue; + + const key = `${entry.kind}:${entry.shortName}`; + const name = entryName(entry); + const skip = (reason: SkipReason) => + skipped.push({ key, shortName: entry.shortName, name, kind: entry.kind, reason }); + + if (entry.kind === 'sprint' && !supportsSprintTracks) { + skip('sprint_unsupported'); + continue; + } + + if (entry.appTrack && isMixedKindTrack(entry.appTrack)) { + skip('mixed_kind'); + continue; + } + + // Count what the file would end up holding, not just what one side has. + const deviceOnlyCourses = entry.mergedCourses + .filter((mc) => mc.status === 'device_only' && mc.deviceCourse) + .map((mc) => mc.deviceCourse!); + const resultingCourseCount = entry.appCourses.length + deviceOnlyCourses.length; + if (resultingCourseCount > DEVICE_MAX_COURSES) { + skip('too_many_courses'); + continue; + } + + // The app's own reference tracks are not "unknown tracks the device is + // missing" — they are things the user never asked to carry. + if (entry.status === 'app_only' && !entry.appTrack?.isUserDefined) continue; + + rows.push({ + key, + shortName: entry.shortName, + name, + kind: entry.kind, + direction: entry.status === 'device_only' ? 'download' : 'upload', + needsRename: + isDeviceGeneratedName(name) || isDeviceGeneratedShortName(entry.shortName), + deviceFileName: entry.deviceFileName, + appTrack: entry.appTrack, + deviceOnlyCourses, + courses: courseRows(entry, key), + }); + } + + return { rows, skipped }; +} + +/** True when a plan has anything worth interrupting the user for. */ +export function planHasWork(plan: SyncPlan): boolean { + return plan.rows.length > 0; +} + +/** Rows whose names still need the user's attention, in wizard order. */ +export function rowsNeedingRename(plan: SyncPlan): SyncTrackRow[] { + return plan.rows.filter((r) => r.needsRename); +} From 744ac9dedd68ccbd4c9129d93aa1614da0de2779 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:13:04 +0000 Subject: [PATCH 4/9] docs: record the device sync rename design as plan 0016 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures why the sync path had four separate ways to produce a track that could never reach "synced", the identity-vs-location split that fixes the worst of them, why operation order (put before delete, device before app) is the load-bearing part, and the decisions taken with the owner — including the ones about what NOT to build: no truncation, no firmware capability layer, no new opcodes. Also records that no capability gate is needed here, with the evidence: the firmware has parsed the object track format since before any shipped release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- CHANGELOG.md | 19 +++ docs/plans/0016-device-track-sync-rename.md | 149 ++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/plans/0016-device-track-sync-rename.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 270f7abd..cb10b9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Tracks sent to the logger keep their name and course lengths** (plan 0016). + Uploading a track wrote it in the older bare-list format, which the logger + reads but which carries no track name, no short name, and no course length. + The length is what the logger ranks courses by when it works out which one + you're on — so a track sent from this app could never be recognised and fell + back to timing "anything", and the blank short name ended up in the log + header. Uploads now use the same full format the app's own track files and + the logger's course creator already use, so nothing new is asked of any + device already in the field. Editing a single course preserves it too — that + path stripped the same information from a file that had it. +- **A track downloaded from the logger can now be sent back to it** (plan 0016). + Importing a track from the device dropped its short name, and the sync + matches the two sides by short name — so an imported track was invisible to + every later sync. It stayed listed as "on device only" forever, and the + device kept re-offering it. Two things were wrong: the short name wasn't + carried across on import, and the sync matched on the *filename* rather than + the short name the file declares. Those differ for a course walked on the + device, which is stored as `N260803_1432.json` but names itself `08031432`. + Writes still go to the real file, so nothing is orphaned on the card. - **A course created on the logger now actually arrives when you sync it.** Syncing a track the device authored itself brought the track across with **no courses in it** — the walked course was on the card and simply diff --git a/docs/plans/0016-device-track-sync-rename.md b/docs/plans/0016-device-track-sync-rename.md new file mode 100644 index 00000000..16cdbd67 --- /dev/null +++ b/docs/plans/0016-device-track-sync-rename.md @@ -0,0 +1,149 @@ +# Device Track Sync — renaming walked courses, and prompting on connect + +> Status: **IN PROGRESS**. Three stacked PRs: foundations (this repo's sync +> model + four bugs), the rename wizard, and the on-connect prompts. No firmware +> changes — see *No capability gate is needed* below. + +## Why this exists + +The on-device course creator (`DovesDataLogger/docs/plans/0002-sprint-mode.md` +§5) shipped: you walk a course on the logger and it writes +`/TRACKS[/SPRINT]/N260803_1432.json`. The name is a GPS date stamp because the +device has **no text entry, ever** — that is a permanent design constraint, not +a gap. So the rename has to happen here. + +And it has to be written *back* to the device. Otherwise the app renames its own +copy, the card still holds `N260803_1432.json`, the two no longer match, and the +"please name this" flow fires again on every single connect. That failure mode — +**a track that can never reach `synced`** — turned out to be the thing worth +designing around, because the sync path already had four separate ways to land +in it. + +## The four bugs found on the way in + +All pre-existing; all produce the same symptom. + +1. **`buildTrackJsonForUpload` emitted a bare JSON array.** The firmware parses + an array, but its array branch (`BirdsEye/sd_functions.ino:475-484`) + explicitly blanks `longName` / `shortName` / `defaultCourse`, and every + course falls back to `lengthFt = 0` (`:507`). `lengthFt` is what + CourseDetector ranks courses by — so a track uploaded from this app could + never be course-detected and dropped straight to Lap Anything, and the blank + `shortName` reached the DOVEX header's `short_name` column. The two + course-level writers in `DeviceTracksTab.tsx` hand-rolled the same array. +2. **`parseDeviceCourseJson` discarded `longName` / `shortName`.** It read the + object form but returned only `.courses`. +3. **`handleDownloadToApp` never passed a `shortName`** to `addTrack`, and + `buildMergedTrackList` skips app tracks that have none (`if (!sn) continue`). + Every downloaded device track was therefore invisible to the merge forever. +4. **Identity was being confused with location.** A device-authored file lives + at `N260803_1432.json` but declares `shortName: "08031432"` — 8 characters, + chosen by the firmware author precisely because that is this app's + `Track.shortName` budget and the key its merge uses + (`BirdsEye/course_creator.h:36-57`). The merge keyed on the *filename*, so an + imported track could never match the file it came from. + +## Decisions + +Confirmed with the owner before building: + +| Question | Answer | +|---|---| +| PR split | Three, stacked: foundations → wizard → on-connect prompts | +| What blocks "Save & import" | **Track names always required**, circuit *and* sprint — a venue is permanent. **Course names required for circuit only**: a sprint venue re-lays its course every event, so the date it was walked genuinely is the most useful label | +| Which app tracks are offered as uploads | `isUserDefined` only. The two tracks this app ships are reference data, not "unknown tracks the device is missing" | +| The existing Device → Tracks tab | Untouched. The wizard is connect-only | + +And from the brief: **no truncation logic**, on either side — deferred +deliberately; **no firmware capability layer** — "not doing that yet, just +consider it"; **no new BLE opcodes** — a rename is `put(new)` + `delete(old)`, +accepted as the cost of the file needing a rebuild anyway. + +### No capability gate is needed + +The firmware has parsed the object track form since well before any shipped +release (field units are on 3.0.1 / 3.1.0), and reads `longName`, `shortName`, +`defaultCourse`, `type` and per-course `lengthFt` (`sd_functions.ino:449-507`). +`type: "sprint"` sets `isSprint`; the *folder* stays authoritative. So switching +the writer to the object form asks nothing new of any device in the field. + +If a gate is ever wanted, the seam already exists and is already the right +shape: the boolean `DeviceDetails.supportsSprintTracks`. + +## Model (PR A — landed) + +Pure modules, because the test environment is `node` with no testing-library — +a dialog cannot be rendered, so anything worth asserting lives outside it. +Coverage also excludes `src/components/**/*.tsx` by design. + +| Module | Owns | +|---|---| +| `src/lib/deviceTrackSync.ts` | The object-form writer/parser, and the **identity vs location** split: `DeviceTrackFile.shortName` is the declared short name (filename base only as a legacy fallback), `fileName` / `MergedTrackEntry.deviceFileName` is where it lives. `deviceTrackFileFrom()` owns that rule | +| `src/lib/deviceGeneratedNames.ts` | Recognising `N{YYMMDD}_{HHMM}` and `MMDDHHMM`, with the date/time parts validated so a real name that looks the part isn't treated as a placeholder | +| `src/lib/deviceSyncPlan.ts` | What gets offered, in which direction, and what is refused | +| `src/lib/deviceSyncNames.ts` | The name-edit rules and the save gate | +| `src/lib/deviceSyncOps.ts` | The ordered operation list | + +### Rows that can never converge are refused, not retried + +`buildSyncPlan` drops them with a `SkipReason` rather than offering work that +would fail or re-appear forever: + +- **`mixed_kind`** — circuit and sprint courses in one track. Two files in two + folders on the device; not representable. +- **`too_many_courses`** — past the firmware's `MAX_LAYOUTS` (10), whose parser + silently ignores the tail, so the file can never read back as written. We do + **not** trim to fit: dropping a user's courses to turn a checkmark green is + the worse failure, and truncation is explicitly deferred. +- **`sprint_unsupported`** — a sprint track on the native IPC, which currently + drops the `kind` argument on get/put/delete and would land the write among the + circuit tracks (tracked as Android IPC parity, plan 0015). + +### Operation ordering is the load-bearing part + +Per track: **write the new file → delete the old → update local storage.** + +- *Put before delete*, so a failure between them leaves the track on the card + twice (annoying; the next sync reconciles it) rather than nowhere. + Delete-first loses a field recording to a dropped BLE packet. +- *Device before app*, so a failure after the write leaves the device holding a + correctly-named file and the app holding nothing — the next connect offers a + plain download with no rename needed. The reverse strands a renamed app track + beside its old device file and the user sees the same track twice. +- FAT is case-insensitive, so a case-only filename change is **not** a rename; + treating it as one would delete the file just written. + +### The test that matters + +`deviceSyncOps.test.ts` → *"leaves the device and the app agreeing, so nothing +is re-offered"* replays a plan back through `deviceTrackFileFrom` and +`buildMergedTrackList` and asserts `synced`. Verified to bite: dropping +`shortName` from the stored track makes it report `device_only` — literally the +nag state. An earlier draft of the sibling test in `deviceTrackSync.test.ts` +derived its input from the value under test and passed either way; it now spells +the expectation out. + +The same discipline caught the original bug surviving review: a test named +*"emits a JSON array of courses (not a wrapping object)"* had pinned the lossy +shape as the contract. + +## Still to come + +- **PR B — the wizard.** Two screens: tracks (checkbox, upload/download bubble, + name + short-name boxes on device-named rows), then courses (same, plus a + circuit/sprint bubble). Back / Next, then Back / Save & import. Needs two + additions to `src/lib/trackStorage.ts` it doesn't have: setting a `shortName` + (`updateTrackName` exists, has zero callers, and doesn't touch it) and a real + course rename (`TrackEditor` fakes one with `deleteCourse` + `addCourse`). +- **PR C — the on-connect prompts.** Firmware check first, with a "remind me + tomorrow" suppressing for 24 h; then a yes/no track sync, only when the plan + has actionable rows. `checkForUpdates` needs a `silent` option — today every + non-update outcome toasts unconditionally, which is wrong for an auto-check. + Suppression goes in a standalone `src/lib/` module in the + `pendingCheckout.ts` shape, **not** `AppSettings`, which is cloud-synced. + +## Deliberately not done + +- No truncation of courses, on either side — owner's call, pending. +- No firmware capability/mapping layer, and no firmware changes at all. +- The native transport's dropped `kind` argument is guarded around, not fixed. From df45801ea67a393fa85c3c538db453270ad29f83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:24:25 +0000 Subject: [PATCH 5/9] feat(sync): add a wholesale track upsert for a resolved sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addTrack/addCourse deliberately only ever ADD: they backfill a short name only when there isn't one, and never remove a course. That is right for "add this one thing" and wrong for a sync, which has already worked out what the track must look like for both sides to agree — anything less than a wholesale write leaves them disagreeing, and the next connect re-offers the same track. Courses are marked user-defined on the way in because saveUserTracks drops any that aren't, which would silently discard a course walked on the device. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/lib/trackStorage.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lib/trackStorage.ts b/src/lib/trackStorage.ts index a3df528e..c0ce490c 100644 --- a/src/lib/trackStorage.ts +++ b/src/lib/trackStorage.ts @@ -410,6 +410,38 @@ export async function addCourse(trackName: string, course: Course): Promise { + const tracks = await loadTracks(); + const resolved: Track = { + ...track, + isUserDefined: true, + courses: track.courses.map((c) => ({ ...c, isUserDefined: true })), + }; + + const i = tracks.findIndex((t) => t.name === track.name); + if (i >= 0) tracks[i] = { ...tracks[i], ...resolved }; + else tracks.push(resolved); + + stampTrack(tracks, track.name); + saveUserTracks(tracks); + emitTrackChange(track.name); + return tracks; +} + /** * Update a track's name. */ From 09222d8f5d0ee8ea1f376e9475d237c06823fe2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:24:39 +0000 Subject: [PATCH 6/9] feat(sync): execute a sync plan, and read the device in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deviceSyncRunner walks the operation list with injected executors, so the risky part is testable without a radio or a browser. It keeps going after a failure, but a failed track ABANDONS ITS OWN remaining operations — once the new file didn't write, deleting the old one destroys the only copy, which for a course walked in the field is unrecoverable. Other tracks still run: one track failing is no reason to leave the other nine untouched, and the plan orders each track's work contiguously so that split is clean. deviceSyncFetch owns reading both folders. The Device → Tracks tab now uses it instead of its own copy of the loop, so there is exactly one place that applies deviceTrackFileFrom's identity rule — a second copy that keyed files by filename would quietly reintroduce the bug where an imported track never matches the file it came from. Behaviour is unchanged: same order, same per-file error swallowing, same sprint-capability skip. buildDeviceSyncSnapshot also collects the short names of tracks the plan is NOT touching, so a rename can't be pointed at an already-synced track's file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/components/drawer/DeviceTracksTab.tsx | 43 +---- src/lib/deviceSyncFetch.test.ts | 182 ++++++++++++++++++++++ src/lib/deviceSyncFetch.ts | 101 ++++++++++++ src/lib/deviceSyncRunner.test.ts | 132 ++++++++++++++++ src/lib/deviceSyncRunner.ts | 102 ++++++++++++ 5 files changed, 522 insertions(+), 38 deletions(-) create mode 100644 src/lib/deviceSyncFetch.test.ts create mode 100644 src/lib/deviceSyncFetch.ts create mode 100644 src/lib/deviceSyncRunner.test.ts create mode 100644 src/lib/deviceSyncRunner.ts diff --git a/src/components/drawer/DeviceTracksTab.tsx b/src/components/drawer/DeviceTracksTab.tsx index 4fa0e89f..dbbced87 100644 --- a/src/components/drawer/DeviceTracksTab.tsx +++ b/src/components/drawer/DeviceTracksTab.tsx @@ -30,7 +30,6 @@ import { MergedTrackEntry, MergedCourseEntry, buildMergedTrackList, - deviceTrackFileFrom, buildTrackJsonForUpload, rebuildDeviceTrackJson, deviceCourseToAppCourse, @@ -39,6 +38,7 @@ import { countDeviceSectors, startADistance, } from "@/lib/deviceTrackSync"; +import { fetchDeviceTrackFiles } from "@/lib/deviceSyncFetch"; import { loadTracks, addTrack, addCourse } from "@/lib/trackStorage"; import { Track } from "@/types/racing"; import { toast } from "sonner"; @@ -83,47 +83,14 @@ export function DeviceTracksTab({ details }: DeviceTracksTabProps) { setSelectedTrack(null); try { setLoadProgress({ current: 0, total: 0, label: t("deviceTracks.fetchingList") }); - const filenames = await details.listTracks(); + // Both folders, with the identity rule applied — shared with the sync + // wizard so there is only one place that knows how a file is keyed. + const files = await fetchDeviceTrackFiles(details, setLoadProgress); - if (filenames.length === 0) { + if (files.length === 0) { setLoadProgress({ current: 0, total: 0, label: t("deviceTracks.noFilesOnDevice") }); } - const files: DeviceTrackFile[] = []; - for (let i = 0; i < filenames.length; i++) { - const fn = filenames[i]; - setLoadProgress({ current: i + 1, total: filenames.length, label: fn }); - try { - const raw = await details.getTrack(fn); - files.push(deviceTrackFileFrom(fn, new TextDecoder().decode(raw), "circuit")); - } catch (err) { - console.error(`Failed to download ${fn}:`, err); - } - } - - // Sprint tracks live in a second folder, reached by the TS* verbs. A - // transport that can't get there reports supportsSprintTracks: false and - // is skipped entirely, so a missing capability never looks like an empty - // folder. Failures here are logged and swallowed: the circuit list is - // already loaded and is worth showing on its own. - if (details.supportsSprintTracks) { - try { - const sprintNames = await details.listTracks("sprint"); - for (let i = 0; i < sprintNames.length; i++) { - const fn = sprintNames[i]; - setLoadProgress({ current: i + 1, total: sprintNames.length, label: fn }); - try { - const raw = await details.getTrack(fn, "sprint"); - files.push(deviceTrackFileFrom(fn, new TextDecoder().decode(raw), "sprint")); - } catch (err) { - console.error(`Failed to download sprint ${fn}:`, err); - } - } - } catch (err) { - console.error("Sprint track list failed:", err); - } - } - setDeviceFiles(files); const tracks = await loadTracks(); setAppTracks(tracks); diff --git a/src/lib/deviceSyncFetch.test.ts b/src/lib/deviceSyncFetch.test.ts new file mode 100644 index 00000000..c9d3ce7f --- /dev/null +++ b/src/lib/deviceSyncFetch.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { fetchDeviceTrackFiles, buildDeviceSyncSnapshot } from "./deviceSyncFetch"; +import type { DeviceDetails } from "@/lib/loggers"; +import type { Track } from "@/types/racing"; + +const COURSE = { + name: "Full CW", + lengthFt: 1500, + start_a_lat: 35.4, + start_a_lng: -97.3, + start_b_lat: 35.4001, + start_b_lng: -97.3001, +}; + +function trackFileJson(longName: string, shortName: string, type = "circuit") { + return JSON.stringify({ longName, shortName, type, defaultCourse: COURSE.name, courses: [COURSE] }); +} + +function details(overrides: Partial = {}): DeviceDetails { + return { + battery: vi.fn(), + listSettings: vi.fn(), + setSetting: vi.fn(), + resetSettings: vi.fn(), + listTracks: vi.fn(async () => []), + getTrack: vi.fn(async () => new Uint8Array()), + putTrack: vi.fn(async () => {}), + deleteTrack: vi.fn(async () => {}), + supportsSprintTracks: true, + ...overrides, + } as unknown as DeviceDetails; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("fetchDeviceTrackFiles", () => { + it("reads both folders and tags each file with its kind", async () => { + const d = details({ + listTracks: vi.fn(async (kind?: string) => + kind === "sprint" ? ["SPR.json"] : ["OKC.json"], + ), + getTrack: vi.fn(async (name: string) => + new TextEncoder().encode( + name === "SPR.json" ? trackFileJson("Sprint Venue", "SPR", "sprint") : trackFileJson("Orlando", "OKC"), + ), + ), + }); + + const files = await fetchDeviceTrackFiles(d); + expect(files.map((f) => [f.shortName, f.kind])).toEqual([ + ["OKC", "circuit"], + ["SPR", "sprint"], + ]); + expect(files[0].fileName).toBe("OKC.json"); + expect(files[0].longName).toBe("Orlando"); + }); + + // A missing capability must not read as an empty folder. + it("skips the sprint folder when the transport can't reach it", async () => { + const listTracks = vi.fn(async () => ["OKC.json"]); + const d = details({ + listTracks, + supportsSprintTracks: false, + getTrack: vi.fn(async () => new TextEncoder().encode(trackFileJson("Orlando", "OKC"))), + }); + + const files = await fetchDeviceTrackFiles(d); + expect(files).toHaveLength(1); + expect(listTracks).toHaveBeenCalledTimes(1); + expect(listTracks).toHaveBeenCalledWith("circuit"); + }); + + it("keeps the circuit list when the sprint listing fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const d = details({ + listTracks: vi.fn(async (kind?: string) => { + if (kind === "sprint") throw new Error("TERR:BUSY"); + return ["OKC.json"]; + }), + getTrack: vi.fn(async () => new TextEncoder().encode(trackFileJson("Orlando", "OKC"))), + }); + await expect(fetchDeviceTrackFiles(d)).resolves.toHaveLength(1); + }); + + it("skips one unreadable file rather than losing the listing", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const d = details({ + supportsSprintTracks: false, + listTracks: vi.fn(async () => ["BAD.json", "OKC.json"]), + getTrack: vi.fn(async (name: string) => { + if (name === "BAD.json") throw new Error("TERR:NO_FILE"); + return new TextEncoder().encode(trackFileJson("Orlando", "OKC")); + }), + }); + const files = await fetchDeviceTrackFiles(d); + expect(files.map((f) => f.shortName)).toEqual(["OKC"]); + }); + + it("reports progress per file", async () => { + const d = details({ + supportsSprintTracks: false, + listTracks: vi.fn(async () => ["A.json", "B.json"]), + getTrack: vi.fn(async () => new TextEncoder().encode("[]")), + }); + const seen: string[] = []; + await fetchDeviceTrackFiles(d, (p) => seen.push(`${p.current}/${p.total} ${p.label}`)); + expect(seen).toEqual(["1/2 A.json", "2/2 B.json"]); + }); +}); + +describe("buildDeviceSyncSnapshot", () => { + const appTrack: Track = { + name: "Orlando", + shortName: "OKC", + courses: [ + { + name: "Full CW", + lengthFt: 1500, + startFinishA: { lat: 35.4, lon: -97.3 }, + startFinishB: { lat: 35.4001, lon: -97.3001 }, + isUserDefined: true, + }, + ], + isUserDefined: true, + }; + + it("offers nothing when both sides already agree", async () => { + const d = details({ + supportsSprintTracks: false, + listTracks: vi.fn(async () => ["OKC.json"]), + getTrack: vi.fn(async () => new TextEncoder().encode(trackFileJson("Orlando", "OKC"))), + }); + const snapshot = await buildDeviceSyncSnapshot(d, [appTrack]); + expect(snapshot.plan.rows).toEqual([]); + }); + + // A rename must not be allowed to land on an already-synced track's file. + it("reserves the short names of tracks the plan isn't touching", async () => { + const d = details({ + supportsSprintTracks: false, + listTracks: vi.fn(async () => ["OKC.json", "N260803_1432.json"]), + getTrack: vi.fn(async (name: string) => + new TextEncoder().encode( + name === "OKC.json" + ? trackFileJson("Orlando", "OKC") + : trackFileJson("N260803_1432", "08031432"), + ), + ), + }); + const snapshot = await buildDeviceSyncSnapshot(d, [appTrack]); + expect(snapshot.plan.rows.map((r) => r.shortName)).toEqual(["08031432"]); + expect(snapshot.reserved).toEqual([{ kind: "circuit", shortName: "OKC" }]); + }); + + it("passes the sprint capability through to the plan", async () => { + const d = details({ + supportsSprintTracks: false, + listTracks: vi.fn(async () => []), + getTrack: vi.fn(async () => new Uint8Array()), + }); + // An app-side sprint track can't be pushed on a transport that can't reach + // the sprint folder — the write would land among the circuit tracks. + const sprintTrack: Track = { + name: "Sprint Venue", + shortName: "SPR", + isUserDefined: true, + courses: [ + { + name: "Run", + type: "sprint", + startFinishA: { lat: 35.4, lon: -97.3 }, + startFinishB: { lat: 35.4001, lon: -97.3001 }, + finish: { a: { lat: 35.41, lon: -97.31 }, b: { lat: 35.41, lon: -97.32 } }, + isUserDefined: true, + }, + ], + }; + const snapshot = await buildDeviceSyncSnapshot(d, [sprintTrack]); + expect(snapshot.plan.rows).toEqual([]); + expect(snapshot.plan.skipped[0].reason).toBe("sprint_unsupported"); + }); +}); diff --git a/src/lib/deviceSyncFetch.ts b/src/lib/deviceSyncFetch.ts new file mode 100644 index 00000000..591df63b --- /dev/null +++ b/src/lib/deviceSyncFetch.ts @@ -0,0 +1,101 @@ +/** + * Reading every track file off a connected logger. + * + * Shared by the Device → Tracks tab and the sync wizard so there is one place + * that knows how the two folders are enumerated — and, more importantly, one + * place that applies `deviceTrackFileFrom`'s identity rule. A second copy of + * this loop that keyed files by filename would quietly reintroduce the bug + * where an imported track never matches the file it came from. + */ + +import type { TrackKind } from '@/lib/ble/trackOpcodes'; +import type { DeviceDetails } from '@/lib/loggers'; +import type { Track } from '@/types/racing'; +import { + buildMergedTrackList, + deviceTrackFileFrom, + type DeviceTrackFile, +} from '@/lib/deviceTrackSync'; +import { buildSyncPlan, type SyncPlan } from '@/lib/deviceSyncPlan'; +import type { ReservedShortName } from '@/lib/deviceSyncWizard'; + +export interface FetchProgress { + current: number; + total: number; + /** The filename being read, for a status line. */ + label: string; +} + +async function readFolder( + details: DeviceDetails, + kind: TrackKind, + onProgress?: (p: FetchProgress) => void, +): Promise { + const names = await details.listTracks(kind); + const files: DeviceTrackFile[] = []; + for (let i = 0; i < names.length; i++) { + const fn = names[i]; + onProgress?.({ current: i + 1, total: names.length, label: fn }); + try { + const raw = await details.getTrack(fn, kind); + files.push(deviceTrackFileFrom(fn, new TextDecoder().decode(raw), kind)); + } catch (err) { + // One unreadable file shouldn't cost the user the whole listing. + console.error(`Failed to download ${kind} track ${fn}:`, err); + } + } + return files; +} + +/** + * Every track file on the device, both folders. + * + * Sprint is reached by the `TS*` verbs; a transport that can't get there + * reports `supportsSprintTracks: false` and is skipped entirely, so a missing + * capability never looks like an empty folder. A sprint listing that fails is + * logged and swallowed — the circuit list is already loaded and worth showing. + */ +export async function fetchDeviceTrackFiles( + details: DeviceDetails, + onProgress?: (p: FetchProgress) => void, +): Promise { + const files = await readFolder(details, 'circuit', onProgress); + if (details.supportsSprintTracks) { + try { + files.push(...(await readFolder(details, 'sprint', onProgress))); + } catch (err) { + console.error('Sprint track list failed:', err); + } + } + return files; +} + +export interface DeviceSyncSnapshot { + plan: SyncPlan; + /** + * Short names held by tracks the plan isn't touching. The wizard needs these + * so a rename can't land on an already-synced track's file. + */ + reserved: ReservedShortName[]; + files: DeviceTrackFile[]; +} + +/** Read the device and work out what a sync would do, in one call. */ +export async function buildDeviceSyncSnapshot( + details: DeviceDetails, + appTracks: Track[], + onProgress?: (p: FetchProgress) => void, +): Promise { + const files = await fetchDeviceTrackFiles(details, onProgress); + const merged = buildMergedTrackList(appTracks, files); + const plan = buildSyncPlan(merged, { + supportsSprintTracks: details.supportsSprintTracks, + }); + + const inPlan = new Set(plan.rows.map((r) => r.key)); + const reserved: ReservedShortName[] = merged + .filter((m) => !inPlan.has(`${m.kind}:${m.shortName}`)) + .map((m) => ({ kind: m.kind, shortName: m.shortName })); + + return { plan, reserved, files }; +} diff --git a/src/lib/deviceSyncRunner.test.ts b/src/lib/deviceSyncRunner.test.ts new file mode 100644 index 00000000..6de84812 --- /dev/null +++ b/src/lib/deviceSyncRunner.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from "vitest"; +import { runSyncOperations, type SyncExecutors } from "./deviceSyncRunner"; +import type { SyncOperation } from "./deviceSyncOps"; + +function executors(overrides: Partial = {}): SyncExecutors { + return { + devicePut: vi.fn(async () => {}), + deviceDelete: vi.fn(async () => {}), + appPut: vi.fn(async () => {}), + appDelete: vi.fn(async () => {}), + ...overrides, + }; +} + +/** One track's worth of ops: write, delete the old file, store locally. */ +function trackOps(key: string): SyncOperation[] { + return [ + { type: "device_put", trackKey: key, folder: "circuit", fileName: "NEW.json", json: "{}" }, + { type: "device_delete", trackKey: key, folder: "circuit", fileName: "OLD.json" }, + { type: "app_put", trackKey: key, track: { name: "New", shortName: "NEW", courses: [] } }, + ]; +} + +describe("runSyncOperations", () => { + it("runs every operation in the order given", async () => { + const order: string[] = []; + const exec = executors({ + devicePut: vi.fn(async () => void order.push("put")), + deviceDelete: vi.fn(async () => void order.push("delete")), + appPut: vi.fn(async () => void order.push("app")), + }); + const result = await runSyncOperations(trackOps("t1"), exec); + expect(order).toEqual(["put", "delete", "app"]); + expect(result.succeeded).toEqual(["t1"]); + expect(result.failures).toEqual([]); + }); + + it("encodes the JSON for the device write", async () => { + const exec = executors(); + await runSyncOperations( + [{ type: "device_put", trackKey: "t1", folder: "sprint", fileName: "A.json", json: '{"a":1}' }], + exec, + ); + const [folder, fileName, data] = (exec.devicePut as ReturnType).mock.calls[0]; + expect(folder).toBe("sprint"); + expect(fileName).toBe("A.json"); + expect(new TextDecoder().decode(data as Uint8Array)).toBe('{"a":1}'); + }); + + it("reports progress over the whole list", async () => { + const seen: number[] = []; + await runSyncOperations(trackOps("t1"), executors(), (p) => { + seen.push(p.done); + expect(p.total).toBe(3); + }); + expect(seen).toEqual([1, 2, 3]); + }); +}); + +describe("runSyncOperations failure handling", () => { + // Once the new file didn't write, deleting the old one destroys the only + // copy — which for a course walked in the field is unrecoverable. + it("abandons a track's remaining operations after one of its own fails", async () => { + const exec = executors({ + devicePut: vi.fn(async () => { + throw new Error("TERR:WRITE_FAIL"); + }), + }); + const result = await runSyncOperations(trackOps("t1"), exec); + + expect(exec.deviceDelete).not.toHaveBeenCalled(); + expect(exec.appPut).not.toHaveBeenCalled(); + expect(result.failed).toEqual(["t1"]); + expect(result.succeeded).toEqual([]); + expect(result.failures[0].message).toBe("TERR:WRITE_FAIL"); + }); + + // One track failing is no reason to leave the other nine untouched. + it("keeps going with other tracks", async () => { + let calls = 0; + const exec = executors({ + devicePut: vi.fn(async () => { + calls += 1; + if (calls === 1) throw new Error("boom"); + }), + }); + const result = await runSyncOperations([...trackOps("t1"), ...trackOps("t2")], exec); + + expect(result.failed).toEqual(["t1"]); + expect(result.succeeded).toEqual(["t2"]); + expect(exec.appPut).toHaveBeenCalledTimes(1); + }); + + it("still reports progress for the operations it skips", async () => { + const exec = executors({ + devicePut: vi.fn(async () => { + throw new Error("boom"); + }), + }); + const seen: number[] = []; + await runSyncOperations(trackOps("t1"), exec, (p) => seen.push(p.done)); + expect(seen).toEqual([1, 2, 3]); + }); + + it("records a non-Error throw without crashing", async () => { + const exec = executors({ + appPut: vi.fn(async () => { + throw "just a string"; + }), + }); + const result = await runSyncOperations(trackOps("t1"), exec); + expect(result.failures[0].message).toBe("just a string"); + }); + + it("names the operation that failed, so the UI can say which step", async () => { + const exec = executors({ + deviceDelete: vi.fn(async () => { + throw new Error("TERR:NO_FILE"); + }), + }); + const result = await runSyncOperations(trackOps("t1"), exec); + expect(result.failures[0].operation.type).toBe("device_delete"); + // The write had already succeeded, so the track is on the card twice — + // recoverable, and exactly why put comes before delete. + expect(exec.devicePut).toHaveBeenCalled(); + }); + + it("does nothing gracefully for an empty plan", async () => { + const result = await runSyncOperations([], executors()); + expect(result).toEqual({ succeeded: [], failed: [], failures: [] }); + }); +}); diff --git a/src/lib/deviceSyncRunner.ts b/src/lib/deviceSyncRunner.ts new file mode 100644 index 00000000..b4c44d6f --- /dev/null +++ b/src/lib/deviceSyncRunner.ts @@ -0,0 +1,102 @@ +/** + * Walking a sync operation list. + * + * The ordering that makes a partial failure recoverable is decided in + * `deviceSyncOps`; this only has to honour it, and report honestly when it + * couldn't finish. Executors are injected so the whole thing is testable + * without a radio or a browser. + */ + +import type { TrackKind } from '@/lib/ble/trackOpcodes'; +import type { Track } from '@/types/racing'; +import type { SyncOperation } from '@/lib/deviceSyncOps'; + +export interface SyncExecutors { + devicePut(folder: TrackKind, fileName: string, data: Uint8Array): Promise; + deviceDelete(folder: TrackKind, fileName: string): Promise; + appPut(track: Track): Promise; + appDelete(trackName: string): Promise; +} + +export interface SyncProgress { + /** Operations finished so far, successful or not. */ + done: number; + total: number; + /** The track being worked on, for a status line. */ + trackKey: string; +} + +export interface SyncFailure { + operation: SyncOperation; + message: string; +} + +export interface SyncRunResult { + /** Track keys that completed every one of their operations. */ + succeeded: string[]; + /** Track keys where something failed; each also appears in `failures`. */ + failed: string[]; + failures: SyncFailure[]; +} + +function messageOf(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +async function runOne(op: SyncOperation, exec: SyncExecutors): Promise { + switch (op.type) { + case 'device_put': + return exec.devicePut(op.folder, op.fileName, new TextEncoder().encode(op.json)); + case 'device_delete': + return exec.deviceDelete(op.folder, op.fileName); + case 'app_put': + return exec.appPut(op.track); + case 'app_delete': + return exec.appDelete(op.trackName); + } +} + +/** + * Run every operation in order, and keep going after a failure. + * + * A failed track **abandons its own remaining operations** — once its file + * didn't write, deleting the old one would destroy the only copy — but other + * tracks still run. One track failing to sync is not a reason to leave the + * other nine untouched, and the operation list is ordered so each track's work + * is contiguous. + */ +export async function runSyncOperations( + operations: SyncOperation[], + exec: SyncExecutors, + onProgress?: (progress: SyncProgress) => void, +): Promise { + const failures: SyncFailure[] = []; + const failedKeys = new Set(); + const touchedKeys: string[] = []; + + for (let i = 0; i < operations.length; i++) { + const op = operations[i]; + if (!touchedKeys.includes(op.trackKey)) touchedKeys.push(op.trackKey); + + // Everything after a failure within the same track is unsafe: the delete + // would remove the copy the failed write was meant to replace. + if (failedKeys.has(op.trackKey)) { + onProgress?.({ done: i + 1, total: operations.length, trackKey: op.trackKey }); + continue; + } + + try { + await runOne(op, exec); + } catch (e) { + failedKeys.add(op.trackKey); + failures.push({ operation: op, message: messageOf(e) }); + } + onProgress?.({ done: i + 1, total: operations.length, trackKey: op.trackKey }); + } + + return { + succeeded: touchedKeys.filter((k) => !failedKeys.has(k)), + failed: touchedKeys.filter((k) => failedKeys.has(k)), + failures, + }; +} From 08edaf5c98ce01ae0f8c1a19d99dbb027c3f92b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:25:02 +0000 Subject: [PATCH 7/9] feat(sync): wizard state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two screens, the selection, and the validation that decides whether "Save & import" can fire — all as pure functions, because the test environment is node with no testing-library and logic left in a component is logic nobody checks. Three rules worth naming: - Unchecking a row stops it being validated. Otherwise one track you don't want to name blocks the entire sync with no way past it. - A course name follows its track's name until the user types in it, and going Back to rename the track re-points every course that is still following. A name they typed themselves is never overwritten. - canSave re-checks the TRACK screen, not just the course screen. Going forward and then back and clearing a track name must not leave Save live. Duplicate short names are caught across the whole selection and against tracks outside the plan, per kind — two tracks sharing a short name are one file on the device, so the second write silently overwrites the first, while the same short name in the circuit and sprint folders is two legitimate files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/lib/deviceSyncWizard.test.ts | 276 +++++++++++++++++++++++++++++++ src/lib/deviceSyncWizard.ts | 222 +++++++++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 src/lib/deviceSyncWizard.test.ts create mode 100644 src/lib/deviceSyncWizard.ts diff --git a/src/lib/deviceSyncWizard.test.ts b/src/lib/deviceSyncWizard.test.ts new file mode 100644 index 00000000..cd093e46 --- /dev/null +++ b/src/lib/deviceSyncWizard.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from "vitest"; +import { + initWizard, + toggleRow, + selectedRows, + selectedCourseRows, + setTrackName, + setTrackShortName, + setCourseName, + goToCourses, + goToTracks, + trackProblems, + courseProblems, + canAdvance, + canSave, + resolutions, + type WizardState, +} from "./deviceSyncWizard"; +import type { SyncCourseRow, SyncPlan, SyncTrackRow } from "./deviceSyncPlan"; + +function courseRow(overrides: Partial = {}): SyncCourseRow { + return { + key: "circuit:08031432::N260803_1432", + name: "N260803_1432", + kind: "circuit", + needsRename: true, + direction: "download", + ...overrides, + }; +} + +function trackRow(overrides: Partial = {}): SyncTrackRow { + return { + key: "circuit:08031432", + shortName: "08031432", + name: "N260803_1432", + kind: "circuit", + direction: "download", + needsRename: true, + deviceFileName: "N260803_1432.json", + deviceOnlyCourses: [], + courses: [courseRow()], + ...overrides, + }; +} + +function plan(rows: SyncTrackRow[]): SyncPlan { + return { rows, skipped: [] }; +} + +/** The common case: one walked track, renamed properly. */ +function named(): WizardState { + let s = initWizard(plan([trackRow()])); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + return goToCourses(s); +} + +// ─── Setup ─────────────────────────────────────────────────────────────────── + +describe("initWizard", () => { + it("starts on the track screen with everything checked", () => { + const s = initWizard(plan([trackRow(), trackRow({ key: "circuit:OKC" })])); + expect(s.step).toBe("tracks"); + expect(s.selected.size).toBe(2); + }); + + it("starts a device-named track with empty boxes", () => { + const s = initWizard(plan([trackRow()])); + expect(s.trackDrafts["circuit:08031432"]).toEqual({ + name: "", + shortName: "", + shortNameTouched: false, + }); + }); + + it("keeps names the user already chose", () => { + const s = initWizard( + plan([trackRow({ name: "Orlando Kart Center", shortName: "OKC", needsRename: false })]), + ); + expect(s.trackDrafts["circuit:08031432"].name).toBe("Orlando Kart Center"); + }); +}); + +// ─── Selection ─────────────────────────────────────────────────────────────── + +describe("selection", () => { + it("unchecks and re-checks a row", () => { + let s = initWizard(plan([trackRow()])); + s = toggleRow(s, "circuit:08031432"); + expect(selectedRows(s)).toEqual([]); + s = toggleRow(s, "circuit:08031432"); + expect(selectedRows(s)).toHaveLength(1); + }); + + // Unchecking a track you don't want to name is a legitimate way past its + // rename requirement — otherwise one unwanted track blocks the whole sync. + it("stops validating a row once it is unchecked", () => { + let s = initWizard(plan([trackRow()])); + expect(canAdvance(s)).toBe(false); // unnamed + s = toggleRow(s, "circuit:08031432"); + expect(trackProblems(s)).toEqual({}); + expect(canAdvance(s)).toBe(false); // …but nothing is selected either + }); + + it("advances once the remaining selection is valid", () => { + let s = initWizard(plan([trackRow(), trackRow({ key: "circuit:OKC" })])); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + expect(canAdvance(s)).toBe(false); // the second is still unnamed + s = toggleRow(s, "circuit:OKC"); + expect(canAdvance(s)).toBe(true); + }); + + it("only lists courses of selected tracks", () => { + let s = initWizard(plan([trackRow(), trackRow({ key: "circuit:OKC" })])); + expect(selectedCourseRows(s)).toHaveLength(2); + s = toggleRow(s, "circuit:OKC"); + expect(selectedCourseRows(s)).toHaveLength(1); + }); +}); + +// ─── Navigation and the follow-the-track-name rule ─────────────────────────── + +describe("navigation", () => { + it("carries the track's new name onto its course", () => { + const s = named(); + expect(s.step).toBe("courses"); + expect(s.courseDrafts["circuit:08031432::N260803_1432"].name).toBe("Sunset Park"); + }); + + // Back, rename, forward again — the course name should follow. + it("re-points an untouched course name after the track is renamed", () => { + let s = named(); + s = goToTracks(s); + s = setTrackName(s, "circuit:08031432", "Sunset Park North"); + s = goToCourses(s); + expect(s.courseDrafts["circuit:08031432::N260803_1432"].name).toBe("Sunset Park North"); + }); + + it("never overwrites a course name the user typed", () => { + let s = named(); + s = setCourseName(s, "circuit:08031432::N260803_1432", "Morning Run"); + s = goToTracks(s); + s = setTrackName(s, "circuit:08031432", "Anything Else"); + s = goToCourses(s); + expect(s.courseDrafts["circuit:08031432::N260803_1432"].name).toBe("Morning Run"); + }); +}); + +// ─── Validation ────────────────────────────────────────────────────────────── + +describe("trackProblems", () => { + it("reports a track still carrying its date stamp", () => { + let s = initWizard(plan([trackRow()])); + s = setTrackName(s, "circuit:08031432", "N260803_1432"); + expect(trackProblems(s)["circuit:08031432"]).toBe("still_generated"); + }); + + // Two tracks sharing a short name are one file on the device; the second + // write silently overwrites the first. + it("catches two rows resolving to the same short name", () => { + let s = initWizard(plan([trackRow(), trackRow({ key: "circuit:B", shortName: "B" })])); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + s = setTrackName(s, "circuit:B", "Sunset Park"); + expect(trackProblems(s)["circuit:08031432"]).toBe("short_duplicate"); + expect(trackProblems(s)["circuit:B"]).toBe("short_duplicate"); + }); + + it("allows the same short name across the two folders", () => { + let s = initWizard(plan([trackRow(), trackRow({ key: "sprint:B", kind: "sprint" })])); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + s = setTrackName(s, "sprint:B", "Sunset Park"); + expect(trackProblems(s)).toEqual({}); + }); + + // Renaming a walked track onto an already-synced track's short name would + // overwrite that track's file on the card. + it("refuses a short name held by a track outside this plan", () => { + let s = initWizard(plan([trackRow()]), [{ kind: "circuit", shortName: "SP" }]); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + expect(trackProblems(s)["circuit:08031432"]).toBe("short_duplicate"); + }); + + it("ignores a reservation in the other folder", () => { + let s = initWizard(plan([trackRow()]), [{ kind: "sprint", shortName: "SP" }]); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + expect(trackProblems(s)).toEqual({}); + }); + + it("clears once the user picks a different short name", () => { + let s = initWizard(plan([trackRow()]), [{ kind: "circuit", shortName: "SP" }]); + s = setTrackName(s, "circuit:08031432", "Sunset Park"); + s = setTrackShortName(s, "circuit:08031432", "SUNSET"); + expect(trackProblems(s)).toEqual({}); + }); +}); + +describe("courseProblems", () => { + it("blocks a circuit course left as a date stamp", () => { + let s = initWizard(plan([trackRow({ name: "OKC", shortName: "OKC", needsRename: false })])); + s = goToCourses(s); + s = setCourseName(s, "circuit:08031432::N260803_1432", "N260803_1432"); + expect(courseProblems(s)["circuit:08031432::N260803_1432"]).toBe("still_generated"); + expect(canSave(s)).toBe(false); + }); + + // A sprint venue re-lays its course every event, so the walked date is the + // useful label — the owner's explicit call. + it("lets a sprint course keep its date stamp", () => { + const sprint = trackRow({ + key: "sprint:08031432", + kind: "sprint", + name: "Sunset AX", + shortName: "SUNAX", + needsRename: false, + courses: [courseRow({ key: "sprint:08031432::N260803_1432", kind: "sprint" })], + }); + let s = initWizard(plan([sprint])); + s = goToCourses(s); + s = setCourseName(s, "sprint:08031432::N260803_1432", "N260803_1432"); + expect(courseProblems(s)).toEqual({}); + expect(canSave(s)).toBe(true); + }); + + it("blocks an empty course name for either kind", () => { + let s = named(); + s = setCourseName(s, "circuit:08031432::N260803_1432", ""); + expect(courseProblems(s)["circuit:08031432::N260803_1432"]).toBe("required"); + }); +}); + +describe("canSave", () => { + it("is true for a fully named plan", () => { + expect(canSave(named())).toBe(true); + }); + + // Going forward, then back and clearing the track name, must not leave Save + // live just because the course screen looks fine. + it("re-checks the track screen, not just the course screen", () => { + let s = named(); + expect(canSave(s)).toBe(true); + s = setTrackName(s, "circuit:08031432", ""); + expect(canSave(s)).toBe(false); + }); + + it("is false with nothing selected", () => { + let s = named(); + s = toggleRow(s, "circuit:08031432"); + expect(canSave(s)).toBe(false); + }); +}); + +// ─── Handing off ───────────────────────────────────────────────────────────── + +describe("resolutions", () => { + it("carries the final names through", () => { + let s = named(); + s = setCourseName(s, "circuit:08031432::N260803_1432", "Morning Run"); + const [r] = resolutions(s); + expect(r.name).toBe("Sunset Park"); + expect(r.shortName).toBe("SP"); + expect(r.courseNames?.["circuit:08031432::N260803_1432"]).toBe("Morning Run"); + }); + + it("trims what the user typed", () => { + let s = initWizard(plan([trackRow()])); + s = setTrackName(s, "circuit:08031432", " Sunset Park "); + expect(resolutions(s)[0].name).toBe("Sunset Park"); + }); + + it("omits unchecked rows", () => { + let s = initWizard(plan([trackRow(), trackRow({ key: "circuit:OKC" })])); + s = toggleRow(s, "circuit:OKC"); + expect(resolutions(s)).toHaveLength(1); + expect(resolutions(s)[0].row.key).toBe("circuit:08031432"); + }); +}); diff --git a/src/lib/deviceSyncWizard.ts b/src/lib/deviceSyncWizard.ts new file mode 100644 index 00000000..6cc8be1d --- /dev/null +++ b/src/lib/deviceSyncWizard.ts @@ -0,0 +1,222 @@ +/** + * The sync wizard's state, as data. + * + * Two screens — name the tracks, then name their courses — plus the selection + * and validation that decide whether "Save & import" can fire. All of it lives + * here rather than in the dialog because the test environment is `node`: a + * component cannot be rendered, so logic left in the `.tsx` is logic nobody + * checks. + */ + +import type { TrackKind } from '@/lib/ble/trackOpcodes'; +import type { SyncPlan, SyncCourseRow, SyncTrackRow } from '@/lib/deviceSyncPlan'; +import type { SyncResolution } from '@/lib/deviceSyncOps'; +import { + editCourseName, + editTrackName, + editTrackShortName, + initialCourseDraft, + initialTrackDraft, + retargetCourseDraft, + validateCourseDraft, + validateTrackDraft, + type CourseNameDraft, + type NameProblem, + type TrackNameDraft, +} from '@/lib/deviceSyncNames'; + +/** Which screen the wizard is on. */ +export type WizardStep = 'tracks' | 'courses'; + +/** A short name already spoken for by a track this wizard isn't touching. */ +export interface ReservedShortName { + kind: TrackKind; + shortName: string; +} + +export interface WizardState { + step: WizardStep; + plan: SyncPlan; + /** Track row keys the user wants to sync. Everything starts checked. */ + selected: ReadonlySet; + trackDrafts: Readonly>; + courseDrafts: Readonly>; + /** + * Short names held by tracks outside this plan — already-synced ones. Renaming + * a walked track onto one of those would overwrite a real track's file. + */ + reserved: readonly ReservedShortName[]; +} + +export function initWizard( + plan: SyncPlan, + reserved: readonly ReservedShortName[] = [], +): WizardState { + const trackDrafts: Record = {}; + const courseDrafts: Record = {}; + + for (const row of plan.rows) { + const draft = initialTrackDraft(row); + trackDrafts[row.key] = draft; + for (const course of row.courses) { + courseDrafts[course.key] = initialCourseDraft(course, draft.name); + } + } + + return { + step: 'tracks', + plan, + // Everything the plan offers is checked: it only contains real differences, + // and the rows that could never converge were dropped before this point. + selected: new Set(plan.rows.map((r) => r.key)), + trackDrafts, + courseDrafts, + reserved, + }; +} + +// ─── Selection ─────────────────────────────────────────────────────────────── + +export function toggleRow(state: WizardState, key: string): WizardState { + const selected = new Set(state.selected); + if (selected.has(key)) selected.delete(key); + else selected.add(key); + return { ...state, selected }; +} + +/** Rows the user is actually syncing. Everything downstream works off this. */ +export function selectedRows(state: WizardState): SyncTrackRow[] { + return state.plan.rows.filter((r) => state.selected.has(r.key)); +} + +/** Course rows of the selected tracks, in track order. */ +export function selectedCourseRows(state: WizardState): SyncCourseRow[] { + return selectedRows(state).flatMap((r) => r.courses); +} + +// ─── Editing ───────────────────────────────────────────────────────────────── + +export function setTrackName(state: WizardState, key: string, name: string): WizardState { + const draft = state.trackDrafts[key]; + if (!draft) return state; + return { ...state, trackDrafts: { ...state.trackDrafts, [key]: editTrackName(draft, name) } }; +} + +export function setTrackShortName( + state: WizardState, + key: string, + shortName: string, +): WizardState { + const draft = state.trackDrafts[key]; + if (!draft) return state; + return { + ...state, + trackDrafts: { ...state.trackDrafts, [key]: editTrackShortName(draft, shortName) }, + }; +} + +export function setCourseName( + state: WizardState, + courseKey: string, + name: string, +): WizardState { + const draft = state.courseDrafts[courseKey]; + if (!draft) return state; + return { + ...state, + courseDrafts: { ...state.courseDrafts, [courseKey]: editCourseName(draft, name) }, + }; +} + +// ─── Navigation ────────────────────────────────────────────────────────────── + +/** + * Move to the course screen, re-pointing any course name still following its + * track — the user may have gone Back and renamed the track since. + */ +export function goToCourses(state: WizardState): WizardState { + const courseDrafts = { ...state.courseDrafts }; + for (const row of state.plan.rows) { + const trackName = state.trackDrafts[row.key]?.name ?? row.name; + for (const course of row.courses) { + const draft = courseDrafts[course.key]; + if (draft) courseDrafts[course.key] = retargetCourseDraft(draft, trackName); + } + } + return { ...state, step: 'courses', courseDrafts }; +} + +export function goToTracks(state: WizardState): WizardState { + return { ...state, step: 'tracks' }; +} + +// ─── Validation ────────────────────────────────────────────────────────────── + +/** + * Naming problems on the track screen, by row key. Only selected rows are + * checked — unchecking a row you don't want to name is a legitimate way past it. + */ +export function trackProblems(state: WizardState): Record { + const problems: Record = {}; + const rows = selectedRows(state); + + for (const row of rows) { + const draft = state.trackDrafts[row.key]; + if (!draft) continue; + // Everything else of the same kind that will exist on the device afterwards. + const taken = [ + ...state.reserved.filter((r) => r.kind === row.kind).map((r) => r.shortName), + ...rows + .filter((other) => other.key !== row.key && other.kind === row.kind) + .map((other) => state.trackDrafts[other.key]?.shortName ?? other.shortName), + ].filter(Boolean); + + const problem = validateTrackDraft(draft, { takenShortNames: taken }); + if (problem) problems[row.key] = problem; + } + + return problems; +} + +/** Naming problems on the course screen, by course row key. */ +export function courseProblems(state: WizardState): Record { + const problems: Record = {}; + for (const course of selectedCourseRows(state)) { + const draft = state.courseDrafts[course.key]; + if (!draft) continue; + const problem = validateCourseDraft(draft, course.kind); + if (problem) problems[course.key] = problem; + } + return problems; +} + +/** Whether Next is live: at least one row, and every selected one named. */ +export function canAdvance(state: WizardState): boolean { + if (state.selected.size === 0) return false; + return Object.keys(trackProblems(state)).length === 0; +} + +/** Whether Save & import is live. Re-checks the track screen too. */ +export function canSave(state: WizardState): boolean { + return canAdvance(state) && Object.keys(courseProblems(state)).length === 0; +} + +// ─── Handing off ───────────────────────────────────────────────────────────── + +/** The selected rows plus their final names, ready for `planOperations`. */ +export function resolutions(state: WizardState): SyncResolution[] { + return selectedRows(state).map((row) => { + const draft = state.trackDrafts[row.key]; + const courseNames: Record = {}; + for (const course of row.courses) { + const courseDraft = state.courseDrafts[course.key]; + if (courseDraft) courseNames[course.key] = courseDraft.name.trim(); + } + return { + row, + name: draft?.name.trim() ?? row.name, + shortName: draft?.shortName.trim() ?? row.shortName, + courseNames, + }; + }); +} From 96fe1f2f74529a9eb5643f87427974c86ee3e505 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:25:02 +0000 Subject: [PATCH 8/9] feat(sync): the two-screen sync wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screen 1 names the tracks: a checkbox per row, an upload/download bubble, a sprint bubble, and — only on rows the logger named itself — a full-name box with a narrower short-name box beside it that fills in as you type. Screen 2 does the same for course names with a circuit/sprint bubble. Back/Next, then Back and "Save & import". Rows the sync refuses to attempt are listed underneath with the reason, rather than silently missing. The component holds one useState and the markup; every decision comes from the pure modules. Problem and skip-reason strings are spelled out in switch statements because the i18n keys are literal-union typed — a computed key won't typecheck, which is the point. 37 new keys across all seven locales, matching each language's existing terminology for track and course. The walked-on date renders in UTC: the name encodes the GPS clock, so a local-time render would show the wrong minute. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- src/components/drawer/DeviceSyncWizard.tsx | 405 +++++++++++++++++++++ src/locales/de/drawer.json | 41 ++- src/locales/en/drawer.json | 41 ++- src/locales/es/drawer.json | 41 ++- src/locales/fr/drawer.json | 41 ++- src/locales/it/drawer.json | 41 ++- src/locales/ja/drawer.json | 41 ++- src/locales/pt-BR/drawer.json | 41 ++- 8 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 src/components/drawer/DeviceSyncWizard.tsx diff --git a/src/components/drawer/DeviceSyncWizard.tsx b/src/components/drawer/DeviceSyncWizard.tsx new file mode 100644 index 00000000..e6654f9e --- /dev/null +++ b/src/components/drawer/DeviceSyncWizard.tsx @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, ArrowLeft, ArrowRight, Check, Download, Loader2, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { DeviceDetails } from "@/lib/loggers"; +import { deleteTrack as deleteAppTrack, saveSyncedTrack } from "@/lib/trackStorage"; +import { parseDeviceGeneratedName } from "@/lib/deviceGeneratedNames"; +import type { NameProblem } from "@/lib/deviceSyncNames"; +import type { SkipReason, SyncDirection, SyncPlan } from "@/lib/deviceSyncPlan"; +import { planOperations } from "@/lib/deviceSyncOps"; +import { runSyncOperations, type SyncExecutors } from "@/lib/deviceSyncRunner"; +import { + canAdvance, + canSave, + courseProblems, + goToCourses, + goToTracks, + initWizard, + resolutions, + selectedCourseRows, + selectedRows, + setCourseName, + setTrackName, + setTrackShortName, + toggleRow, + trackProblems, + type ReservedShortName, +} from "@/lib/deviceSyncWizard"; + +/** + * The two-screen sync wizard: name the tracks, then their courses, then write + * both sides. + * + * Everything this renders is decided in `@/lib/deviceSyncWizard` and friends — + * the test environment is `node` with no testing-library, so a component cannot + * be rendered and any logic left here is logic nobody checks. This file holds + * one `useState` and the markup. + */ + +const DIRECTION_STYLE: Record = { + upload: "bg-primary/20 text-primary", + download: "bg-sky-500/20 text-sky-400", +}; + +/** Locale-aware, and UTC — the name encodes the GPS clock, not the viewer's. */ +function formatWalkedOn(date: Date, locale: string): string { + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(date); +} + +export function DeviceSyncWizard({ + open, + onOpenChange, + plan, + reserved = [], + details, + onDone, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + plan: SyncPlan; + reserved?: ReservedShortName[]; + details: DeviceDetails; + onDone?: () => void; +}) { + const { t, i18n } = useTranslation("drawer"); + const [state, setState] = useState(() => initWizard(plan, reserved)); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(0); + + // Rebuild whenever the dialog opens — the plan is a snapshot of the device, + // and a stale one would write against files that have since moved. + useEffect(() => { + if (open) { + setState(initWizard(plan, reserved)); + setRunning(false); + setProgress(0); + } + // `plan` / `reserved` are rebuilt by the caller per open; keying on `open` + // is what makes this a reset rather than a re-init on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const trackIssues = useMemo(() => trackProblems(state), [state]); + const courseIssues = useMemo(() => courseProblems(state), [state]); + const rows = selectedRows(state); + const courseRows = selectedCourseRows(state); + + const problemText = useCallback( + (problem: NameProblem): string => { + // Keys are literal-union typed, so these must be spelled out rather than + // built from the problem string. + switch (problem) { + case "required": + return t("deviceTracks.wizard.problemRequired"); + case "still_generated": + return t("deviceTracks.wizard.problemStillGenerated"); + case "short_required": + return t("deviceTracks.wizard.problemShortRequired"); + case "short_charset": + return t("deviceTracks.wizard.problemShortCharset"); + case "short_too_long": + return t("deviceTracks.wizard.problemShortTooLong"); + case "short_duplicate": + return t("deviceTracks.wizard.problemShortDuplicate"); + } + }, + [t], + ); + + const skipText = useCallback( + (reason: SkipReason, name: string): string => { + switch (reason) { + case "mixed_kind": + return t("deviceTracks.wizard.skippedMixedKind", { name }); + case "too_many_courses": + return t("deviceTracks.wizard.skippedTooManyCourses", { name }); + case "sprint_unsupported": + return t("deviceTracks.wizard.skippedSprintUnsupported", { name }); + } + }, + [t], + ); + + const handleSave = async () => { + const operations = planOperations(resolutions(state)); + const executors: SyncExecutors = { + devicePut: (folder, fileName, data) => details.putTrack(fileName, data, folder), + deviceDelete: (folder, fileName) => details.deleteTrack(fileName, folder), + appPut: async (track) => void (await saveSyncedTrack(track)), + appDelete: async (trackName) => void (await deleteAppTrack(trackName)), + }; + + setRunning(true); + setProgress(0); + try { + const result = await runSyncOperations(operations, executors, (p) => + setProgress(p.total > 0 ? Math.round((p.done / p.total) * 100) : 0), + ); + if (result.succeeded.length > 0) { + toast.success( + t("deviceTracks.wizard.doneToast", { count: result.succeeded.length }), + ); + } + if (result.failed.length > 0) { + toast.error( + t("deviceTracks.wizard.partialToast", { count: result.failed.length }), + ); + } + onOpenChange(false); + onDone?.(); + } catch (err) { + toast.error( + t("deviceTracks.wizard.failedToast", { + error: err instanceof Error ? err.message : t("deviceTracks.unknownError"), + }), + ); + } finally { + setRunning(false); + } + }; + + const onTracks = state.step === "tracks"; + + return ( + !running && onOpenChange(next)}> + running && e.preventDefault()} + onEscapeKeyDown={(e) => running && e.preventDefault()} + > + + + {onTracks + ? t("deviceTracks.wizard.trackStepTitle") + : t("deviceTracks.wizard.courseStepTitle")} + + + {onTracks + ? t("deviceTracks.wizard.trackStepDesc") + : t("deviceTracks.wizard.courseStepDesc")} + + + + {onTracks ? ( +
+ {state.plan.rows.map((row) => { + const checked = state.selected.has(row.key); + const draft = state.trackDrafts[row.key]; + const problem = trackIssues[row.key]; + const walked = parseDeviceGeneratedName(row.name); + return ( +
+ + + {checked && row.needsRename && draft && ( +
+ {walked && ( +

+ {t("deviceTracks.wizard.walkedOn", { + date: formatWalkedOn(walked, i18n.language), + })} +

+ )} +
+ setState((s) => setTrackName(s, row.key, e.target.value))} + placeholder={t("deviceTracks.wizard.namePlaceholder")} + aria-label={t("deviceTracks.wizard.nameLabel")} + className="flex-1" + /> + + setState((s) => setTrackShortName(s, row.key, e.target.value)) + } + placeholder={t("deviceTracks.wizard.shortNameLabel")} + aria-label={t("deviceTracks.wizard.shortNameLabel")} + maxLength={8} + className="w-24 shrink-0 font-mono uppercase" + /> +
+ {problem && ( +

{problemText(problem)}

+ )} +
+ )} +
+ ); + })} + + {state.plan.skipped.length > 0 && ( +
+

+ + {t("deviceTracks.wizard.skippedTitle")} +

+ {state.plan.skipped.map((s) => ( +

+ {skipText(s.reason, s.name)} +

+ ))} +
+ )} +
+ ) : ( +
+ {courseRows.length === 0 && ( +

{t("deviceTracks.noCourses")}

+ )} + {rows.map((row) => + row.courses.map((course) => { + const draft = state.courseDrafts[course.key]; + const problem = courseIssues[course.key]; + return ( +
+
+ + {state.trackDrafts[row.key]?.name || row.name} + + + {course.kind === "sprint" + ? t("deviceTracks.sprintBadge") + : t("deviceTracks.wizard.circuitBadge")} + +
+ setState((s) => setCourseName(s, course.key, e.target.value))} + placeholder={t("deviceTracks.wizard.coursePlaceholder")} + aria-label={t("deviceTracks.wizard.coursePlaceholder")} + /> + {problem &&

{problemText(problem)}

} +
+ ); + }), + )} +
+ )} + + {running && ( +
+
+
+ )} + + + {onTracks ? ( + <> + + + + ) : ( + <> + + + + )} + + +
+ ); +} + +/** The yes/no that opens the wizard. Deliberately one question, two buttons. */ +export function DeviceSyncPrompt({ + open, + count, + onAccept, + onDecline, +}: { + open: boolean; + count: number; + onAccept: () => void; + onDecline: () => void; +}) { + const { t } = useTranslation("drawer"); + return ( + !next && onDecline()}> + + + {t("deviceTracks.wizard.promptTitle")} + + {t("deviceTracks.wizard.promptDesc", { count })} + + + + + + + + + ); +} diff --git a/src/locales/de/drawer.json b/src/locales/de/drawer.json index 0482da5d..37028a2b 100644 --- a/src/locales/de/drawer.json +++ b/src/locales/de/drawer.json @@ -370,7 +370,46 @@ "startLineDistance": "Abstand der Startlinie:", "sendToDevice": "An das Gerät senden", "downloadToApp": "In die App herunterladen", - "sprintBadge": "Sprint" + "sprintBadge": "Sprint", + "wizard": { + "title": "Strecken synchronisieren", + "trackStepTitle": "Benenne deine Strecken", + "trackStepDesc": "Diese unterscheiden sich zwischen App und Logger. Strecken, die der Logger selbst benannt hat, brauchen einen echten Namen, bevor sie gespeichert werden können.", + "courseStepTitle": "Benenne deine Kurse", + "courseStepDesc": "Rundkurse brauchen einen Namen. Sprint-Kurse können das Datum ihrer Vermessung behalten.", + "upload": "Hochladen", + "download": "Herunterladen", + "circuitBadge": "Rundkurs", + "nameLabel": "Name", + "shortNameLabel": "Kurz", + "namePlaceholder": "Streckenname", + "coursePlaceholder": "Kursname", + "next": "Weiter", + "back": "Zurück", + "save": "Speichern & importieren", + "saving": "Synchronisiere…", + "walkedOn": "Vermessen am {{date}}", + "skippedTitle": "Nicht synchronisiert", + "skippedMixedKind": "{{name}} mischt Rundkurs- und Sprint-Kurse, die der Logger getrennt speichert.", + "skippedTooManyCourses": "{{name}} hat mehr Kurse, als der Logger zurücklesen kann.", + "skippedSprintUnsupported": "{{name}} ist eine Sprint-Strecke, die über diese Verbindung nicht erreichbar ist.", + "problemRequired": "Namen eingeben", + "problemStillGenerated": "Gib dem einen echten Namen", + "problemShortRequired": "Kurznamen eingeben", + "problemShortCharset": "Nur Buchstaben und Zahlen", + "problemShortTooLong": "Maximal 8 Zeichen", + "problemShortDuplicate": "Wird bereits von einer anderen Strecke verwendet", + "doneToast_one": "{{count}} Strecke synchronisiert", + "doneToast_other": "{{count}} Strecken synchronisiert", + "partialToast_one": "{{count}} Strecke wurde nicht synchronisiert", + "partialToast_other": "{{count}} Strecken wurden nicht synchronisiert", + "failedToast": "Synchronisierung fehlgeschlagen: {{error}}", + "promptTitle": "Strecken auf dem Gerät synchronisieren?", + "promptDesc_one": "{{count}} Strecke unterscheidet sich zwischen dieser App und dem Logger.", + "promptDesc_other": "{{count}} Strecken unterscheiden sich zwischen dieser App und dem Logger.", + "promptYes": "Synchronisieren", + "promptNo": "Jetzt nicht" + } }, "vehicleTypeEditor": { "title": "Fahrzeugtypen verwalten", diff --git a/src/locales/en/drawer.json b/src/locales/en/drawer.json index e06e6b29..f009a513 100644 --- a/src/locales/en/drawer.json +++ b/src/locales/en/drawer.json @@ -369,7 +369,46 @@ "startLineDistance": "Start line distance:", "sendToDevice": "Send to Device", "downloadToApp": "Download to App", - "sprintBadge": "Sprint" + "sprintBadge": "Sprint", + "wizard": { + "title": "Sync tracks", + "trackStepTitle": "Name your tracks", + "trackStepDesc": "These differ between the app and the logger. Tracks the logger named itself need a real name before they can be saved.", + "courseStepTitle": "Name your courses", + "courseStepDesc": "Circuit courses need a name. Sprint courses can keep the date they were walked.", + "upload": "Upload", + "download": "Download", + "circuitBadge": "Circuit", + "nameLabel": "Name", + "shortNameLabel": "Short", + "namePlaceholder": "Track name", + "coursePlaceholder": "Course name", + "next": "Next", + "back": "Back", + "save": "Save & import", + "saving": "Syncing…", + "walkedOn": "Walked {{date}}", + "skippedTitle": "Not synced", + "skippedMixedKind": "{{name}} mixes circuit and sprint courses, which the logger stores separately.", + "skippedTooManyCourses": "{{name}} has more courses than the logger can read back.", + "skippedSprintUnsupported": "{{name}} is a sprint track, which this connection cannot reach.", + "problemRequired": "Enter a name", + "problemStillGenerated": "Give this a real name", + "problemShortRequired": "Enter a short name", + "problemShortCharset": "Letters and numbers only", + "problemShortTooLong": "8 characters max", + "problemShortDuplicate": "Already used by another track", + "doneToast_one": "Synced {{count}} track", + "doneToast_other": "Synced {{count}} tracks", + "partialToast_one": "{{count}} track did not sync", + "partialToast_other": "{{count}} tracks did not sync", + "failedToast": "Sync failed: {{error}}", + "promptTitle": "Sync tracks on device?", + "promptDesc_one": "{{count}} track differs between this app and the logger.", + "promptDesc_other": "{{count}} tracks differ between this app and the logger.", + "promptYes": "Sync", + "promptNo": "Not now" + } }, "vehicleTypeEditor": { "title": "Manage Vehicle Types", diff --git a/src/locales/es/drawer.json b/src/locales/es/drawer.json index bb52883c..39bf7f80 100644 --- a/src/locales/es/drawer.json +++ b/src/locales/es/drawer.json @@ -370,7 +370,46 @@ "startLineDistance": "Distancia de la línea de salida:", "sendToDevice": "Enviar al dispositivo", "downloadToApp": "Descargar a la app", - "sprintBadge": "Sprint" + "sprintBadge": "Sprint", + "wizard": { + "title": "Sincronizar pistas", + "trackStepTitle": "Nombra tus pistas", + "trackStepDesc": "Estas difieren entre la app y el registrador. Las pistas que el registrador nombró por sí mismo necesitan un nombre real antes de poder guardarse.", + "courseStepTitle": "Nombra tus recorridos", + "courseStepDesc": "Los recorridos de circuito necesitan un nombre. Los de sprint pueden conservar la fecha en que se trazaron.", + "upload": "Subir", + "download": "Descargar", + "circuitBadge": "Circuito", + "nameLabel": "Nombre", + "shortNameLabel": "Corto", + "namePlaceholder": "Nombre de la pista", + "coursePlaceholder": "Nombre del recorrido", + "next": "Siguiente", + "back": "Atrás", + "save": "Guardar e importar", + "saving": "Sincronizando…", + "walkedOn": "Trazado el {{date}}", + "skippedTitle": "Sin sincronizar", + "skippedMixedKind": "{{name}} mezcla recorridos de circuito y de sprint, que el registrador guarda por separado.", + "skippedTooManyCourses": "{{name}} tiene más recorridos de los que el registrador puede releer.", + "skippedSprintUnsupported": "{{name}} es una pista de sprint, a la que esta conexión no puede acceder.", + "problemRequired": "Introduce un nombre", + "problemStillGenerated": "Dale un nombre real", + "problemShortRequired": "Introduce un nombre corto", + "problemShortCharset": "Solo letras y números", + "problemShortTooLong": "Máximo 8 caracteres", + "problemShortDuplicate": "Ya lo usa otra pista", + "doneToast_one": "{{count}} pista sincronizada", + "doneToast_other": "{{count}} pistas sincronizadas", + "partialToast_one": "{{count}} pista no se sincronizó", + "partialToast_other": "{{count}} pistas no se sincronizaron", + "failedToast": "Fallo la sincronización: {{error}}", + "promptTitle": "¿Sincronizar las pistas del dispositivo?", + "promptDesc_one": "{{count}} pista difiere entre esta app y el registrador.", + "promptDesc_other": "{{count}} pistas difieren entre esta app y el registrador.", + "promptYes": "Sincronizar", + "promptNo": "Ahora no" + } }, "vehicleTypeEditor": { "title": "Gestionar tipos de vehículo", diff --git a/src/locales/fr/drawer.json b/src/locales/fr/drawer.json index f23baf51..98dd1348 100644 --- a/src/locales/fr/drawer.json +++ b/src/locales/fr/drawer.json @@ -370,7 +370,46 @@ "startLineDistance": "Distance de la ligne de départ :", "sendToDevice": "Envoyer à l'appareil", "downloadToApp": "Télécharger dans l'app", - "sprintBadge": "Sprint" + "sprintBadge": "Sprint", + "wizard": { + "title": "Synchroniser les circuits", + "trackStepTitle": "Nommez vos circuits", + "trackStepDesc": "Ceux-ci diffèrent entre l'app et l'enregistreur. Les circuits que l'enregistreur a nommés lui-même ont besoin d'un vrai nom avant d'être enregistrés.", + "courseStepTitle": "Nommez vos parcours", + "courseStepDesc": "Les parcours de circuit ont besoin d'un nom. Les parcours sprint peuvent garder la date de leur tracé.", + "upload": "Envoyer", + "download": "Télécharger", + "circuitBadge": "Circuit", + "nameLabel": "Nom", + "shortNameLabel": "Court", + "namePlaceholder": "Nom du circuit", + "coursePlaceholder": "Nom du parcours", + "next": "Suivant", + "back": "Retour", + "save": "Enregistrer et importer", + "saving": "Synchronisation…", + "walkedOn": "Tracé le {{date}}", + "skippedTitle": "Non synchronisé", + "skippedMixedKind": "{{name}} mélange des parcours circuit et sprint, que l'enregistreur stocke séparément.", + "skippedTooManyCourses": "{{name}} a plus de parcours que l'enregistreur ne peut relire.", + "skippedSprintUnsupported": "{{name}} est un circuit sprint, inaccessible via cette connexion.", + "problemRequired": "Saisissez un nom", + "problemStillGenerated": "Donnez-lui un vrai nom", + "problemShortRequired": "Saisissez un nom court", + "problemShortCharset": "Lettres et chiffres uniquement", + "problemShortTooLong": "8 caractères maximum", + "problemShortDuplicate": "Déjà utilisé par un autre circuit", + "doneToast_one": "{{count}} circuit synchronisé", + "doneToast_other": "{{count}} circuits synchronisés", + "partialToast_one": "{{count}} circuit n'a pas été synchronisé", + "partialToast_other": "{{count}} circuits n'ont pas été synchronisés", + "failedToast": "Échec de la synchronisation : {{error}}", + "promptTitle": "Synchroniser les circuits de l'appareil ?", + "promptDesc_one": "{{count}} circuit diffère entre cette app et l'enregistreur.", + "promptDesc_other": "{{count}} circuits diffèrent entre cette app et l'enregistreur.", + "promptYes": "Synchroniser", + "promptNo": "Pas maintenant" + } }, "vehicleTypeEditor": { "title": "Gérer les types de véhicule", diff --git a/src/locales/it/drawer.json b/src/locales/it/drawer.json index 762f2560..619799da 100644 --- a/src/locales/it/drawer.json +++ b/src/locales/it/drawer.json @@ -370,7 +370,46 @@ "startLineDistance": "Distanza della linea di partenza:", "sendToDevice": "Invia al dispositivo", "downloadToApp": "Scarica nell'app", - "sprintBadge": "Sprint" + "sprintBadge": "Sprint", + "wizard": { + "title": "Sincronizza le piste", + "trackStepTitle": "Dai un nome alle tue piste", + "trackStepDesc": "Queste differiscono tra l'app e il logger. Le piste che il logger ha nominato da solo hanno bisogno di un nome vero prima di poter essere salvate.", + "courseStepTitle": "Dai un nome ai tuoi percorsi", + "courseStepDesc": "I percorsi da circuito hanno bisogno di un nome. Quelli sprint possono mantenere la data in cui sono stati tracciati.", + "upload": "Carica", + "download": "Scarica", + "circuitBadge": "Circuito", + "nameLabel": "Nome", + "shortNameLabel": "Breve", + "namePlaceholder": "Nome della pista", + "coursePlaceholder": "Nome del percorso", + "next": "Avanti", + "back": "Indietro", + "save": "Salva e importa", + "saving": "Sincronizzazione…", + "walkedOn": "Tracciato il {{date}}", + "skippedTitle": "Non sincronizzato", + "skippedMixedKind": "{{name}} mescola percorsi da circuito e sprint, che il logger memorizza separatamente.", + "skippedTooManyCourses": "{{name}} ha più percorsi di quanti il logger possa rileggere.", + "skippedSprintUnsupported": "{{name}} è una pista sprint, non raggiungibile con questa connessione.", + "problemRequired": "Inserisci un nome", + "problemStillGenerated": "Dagli un nome vero", + "problemShortRequired": "Inserisci un nome breve", + "problemShortCharset": "Solo lettere e numeri", + "problemShortTooLong": "Massimo 8 caratteri", + "problemShortDuplicate": "Già usato da un'altra pista", + "doneToast_one": "{{count}} pista sincronizzata", + "doneToast_other": "{{count}} piste sincronizzate", + "partialToast_one": "{{count}} pista non è stata sincronizzata", + "partialToast_other": "{{count}} piste non sono state sincronizzate", + "failedToast": "Sincronizzazione fallita: {{error}}", + "promptTitle": "Sincronizzare le piste sul dispositivo?", + "promptDesc_one": "{{count}} pista differisce tra questa app e il logger.", + "promptDesc_other": "{{count}} piste differiscono tra questa app e il logger.", + "promptYes": "Sincronizza", + "promptNo": "Non ora" + } }, "vehicleTypeEditor": { "title": "Gestisci tipi di veicolo", diff --git a/src/locales/ja/drawer.json b/src/locales/ja/drawer.json index f188f1de..5f87d917 100644 --- a/src/locales/ja/drawer.json +++ b/src/locales/ja/drawer.json @@ -370,7 +370,46 @@ "startLineDistance": "スタートライン距離:", "sendToDevice": "デバイスに送信", "downloadToApp": "アプリにダウンロード", - "sprintBadge": "スプリント" + "sprintBadge": "スプリント", + "wizard": { + "title": "トラックを同期", + "trackStepTitle": "トラックに名前を付ける", + "trackStepDesc": "これらはアプリとロガーで内容が異なります。ロガーが自動で名付けたトラックは、保存する前に正式な名前が必要です。", + "courseStepTitle": "コースに名前を付ける", + "courseStepDesc": "サーキットのコースには名前が必要です。スプリントのコースは計測した日付のままで構いません。", + "upload": "アップロード", + "download": "ダウンロード", + "circuitBadge": "サーキット", + "nameLabel": "名前", + "shortNameLabel": "略称", + "namePlaceholder": "トラック名", + "coursePlaceholder": "コース名", + "next": "次へ", + "back": "戻る", + "save": "保存してインポート", + "saving": "同期中…", + "walkedOn": "{{date}} に計測", + "skippedTitle": "同期しません", + "skippedMixedKind": "{{name}} にはサーキットとスプリントのコースが混在しています。ロガーはこれらを別々に保存します。", + "skippedTooManyCourses": "{{name}} のコース数が、ロガーが読み戻せる上限を超えています。", + "skippedSprintUnsupported": "{{name}} はスプリントのトラックで、この接続からはアクセスできません。", + "problemRequired": "名前を入力してください", + "problemStillGenerated": "正式な名前を付けてください", + "problemShortRequired": "略称を入力してください", + "problemShortCharset": "英数字のみ", + "problemShortTooLong": "8文字以内", + "problemShortDuplicate": "他のトラックで使用済みです", + "doneToast_one": "{{count}} 件のトラックを同期しました", + "doneToast_other": "{{count}} 件のトラックを同期しました", + "partialToast_one": "{{count}} 件のトラックを同期できませんでした", + "partialToast_other": "{{count}} 件のトラックを同期できませんでした", + "failedToast": "同期に失敗しました: {{error}}", + "promptTitle": "デバイスのトラックを同期しますか?", + "promptDesc_one": "{{count}} 件のトラックがこのアプリとロガーで異なります。", + "promptDesc_other": "{{count}} 件のトラックがこのアプリとロガーで異なります。", + "promptYes": "同期", + "promptNo": "後で" + } }, "vehicleTypeEditor": { "title": "車両タイプを管理", diff --git a/src/locales/pt-BR/drawer.json b/src/locales/pt-BR/drawer.json index 8245d041..4a130f10 100644 --- a/src/locales/pt-BR/drawer.json +++ b/src/locales/pt-BR/drawer.json @@ -370,7 +370,46 @@ "startLineDistance": "Distância da linha de largada:", "sendToDevice": "Enviar ao dispositivo", "downloadToApp": "Baixar para o app", - "sprintBadge": "Sprint" + "sprintBadge": "Sprint", + "wizard": { + "title": "Sincronizar pistas", + "trackStepTitle": "Dê nome às suas pistas", + "trackStepDesc": "Estas diferem entre o app e o registrador. Pistas que o registrador nomeou sozinho precisam de um nome de verdade antes de serem salvas.", + "courseStepTitle": "Dê nome aos seus percursos", + "courseStepDesc": "Percursos de circuito precisam de um nome. Os de sprint podem manter a data em que foram demarcados.", + "upload": "Enviar", + "download": "Baixar", + "circuitBadge": "Circuito", + "nameLabel": "Nome", + "shortNameLabel": "Curto", + "namePlaceholder": "Nome da pista", + "coursePlaceholder": "Nome do percurso", + "next": "Avançar", + "back": "Voltar", + "save": "Salvar e importar", + "saving": "Sincronizando…", + "walkedOn": "Demarcado em {{date}}", + "skippedTitle": "Não sincronizado", + "skippedMixedKind": "{{name}} mistura percursos de circuito e de sprint, que o registrador guarda separadamente.", + "skippedTooManyCourses": "{{name}} tem mais percursos do que o registrador consegue reler.", + "skippedSprintUnsupported": "{{name}} é uma pista de sprint, inacessível por esta conexão.", + "problemRequired": "Digite um nome", + "problemStillGenerated": "Dê um nome de verdade", + "problemShortRequired": "Digite um nome curto", + "problemShortCharset": "Apenas letras e números", + "problemShortTooLong": "Máximo de 8 caracteres", + "problemShortDuplicate": "Já usado por outra pista", + "doneToast_one": "{{count}} pista sincronizada", + "doneToast_other": "{{count}} pistas sincronizadas", + "partialToast_one": "{{count}} pista não foi sincronizada", + "partialToast_other": "{{count}} pistas não foram sincronizadas", + "failedToast": "Falha na sincronização: {{error}}", + "promptTitle": "Sincronizar as pistas do dispositivo?", + "promptDesc_one": "{{count}} pista difere entre este app e o registrador.", + "promptDesc_other": "{{count}} pistas diferem entre este app e o registrador.", + "promptYes": "Sincronizar", + "promptNo": "Agora não" + } }, "vehicleTypeEditor": { "title": "Gerenciar tipos de veículo", From 7af0c31ee9db2f1a576d7c4ce6c0fe081d225ede Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:26:24 +0000 Subject: [PATCH 9/9] docs: record the wizard in plan 0016 and the changelog Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD --- CHANGELOG.md | 18 +++++++++++ docs/plans/0016-device-track-sync-rename.md | 35 +++++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb10b9b6..2b3d92ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dialog can warn *before* the download rather than after the handshake. ### Added +- **Name the courses you walked on the logger, without a laptop** (plan 0016). + A course created on the device is named from its GPS clock — `N260803_1432` — + because the logger has no way to type on it. A new sync wizard lists + everything that differs between the app and the logger, marks each row + **Upload** or **Download**, and gives every device-named track a name box + with a short-name box beside it that fills itself in as you type. A second + screen does the same for course names. Circuit courses must be named; sprint + courses can keep the date they were walked, since a sprint venue re-lays its + course every event and the date is genuinely the useful label. Track names + are always required. + - **The new names are written back to the logger**, not just kept here — the + whole point, since otherwise the two sides disagree and the same prompt + returns on every connect. + - Tracks that could never sync are listed with the reason instead of being + retried forever: a track mixing circuit and sprint courses (the logger + stores those separately), one with more courses than the logger can read + back, or a sprint track on a connection that can't reach the sprint folder. + - The existing Device → Tracks tab is unchanged for per-track work. - **Sprint sessions can finally be read back** (plan 0015). Load a log recorded on a sprint course and the app now lists one row per run — start line to the separate finish line — instead of showing nothing at all. Split times, the diff --git a/docs/plans/0016-device-track-sync-rename.md b/docs/plans/0016-device-track-sync-rename.md index 16cdbd67..52fb0cca 100644 --- a/docs/plans/0016-device-track-sync-rename.md +++ b/docs/plans/0016-device-track-sync-rename.md @@ -127,14 +127,37 @@ The same discipline caught the original bug surviving review: a test named *"emits a JSON array of courses (not a wrapping object)"* had pinned the lossy shape as the contract. +## The wizard (PR B — landed) + +| Module | Owns | +|---|---| +| `src/lib/deviceSyncWizard.ts` | Two-screen state, selection, and the save gate | +| `src/lib/deviceSyncRunner.ts` | Walking the operation list, with injected executors | +| `src/lib/deviceSyncFetch.ts` | Reading both device folders; `buildDeviceSyncSnapshot` | +| `src/components/drawer/DeviceSyncWizard.tsx` | One `useState` and the markup | + +Three behaviours worth knowing before changing anything here: + +- **Unchecking a row stops it being validated.** Otherwise one track you don't + want to name blocks the whole sync with no way past it. +- **A course name follows its track's name until the user types in it**, and + going Back to rename the track re-points every course still following. A name + they typed is never overwritten. +- **`canSave` re-checks the track screen**, not just the course screen — going + forward, then back, then clearing a track name must not leave Save live. + +`runSyncOperations` keeps going after a failure, but a failed track **abandons +its own remaining operations**: once the new file didn't write, deleting the old +one destroys the only copy. Other tracks still run, which the contiguous +per-track ordering from `planOperations` makes safe. + +`trackStorage.saveSyncedTrack` was added because `addTrack`/`addCourse` only +ever *add* — they backfill a short name only when absent and never remove a +course. A partial write leaves the two sides disagreeing, which is the loop this +is all trying to end. + ## Still to come -- **PR B — the wizard.** Two screens: tracks (checkbox, upload/download bubble, - name + short-name boxes on device-named rows), then courses (same, plus a - circuit/sprint bubble). Back / Next, then Back / Save & import. Needs two - additions to `src/lib/trackStorage.ts` it doesn't have: setting a `shortName` - (`updateTrackName` exists, has zero callers, and doesn't touch it) and a real - course rename (`TrackEditor` fakes one with `deleteCourse` + `addCourse`). - **PR C — the on-connect prompts.** Firmware check first, with a "remind me tomorrow" suppressing for 24 h; then a yes/no track sync, only when the plan has actionable rows. `checkForUpdates` needs a `silent` option — today every