diff --git a/CHANGELOG.md b/CHANGELOG.md index 270f7abd..2b3d92ee 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 @@ -45,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 new file mode 100644 index 00000000..52fb0cca --- /dev/null +++ b/docs/plans/0016-device-track-sync-rename.md @@ -0,0 +1,172 @@ +# 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. + +## 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 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. 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/components/drawer/DeviceTracksTab.tsx b/src/components/drawer/DeviceTracksTab.tsx index c69933e1..dbbced87 100644 --- a/src/components/drawer/DeviceTracksTab.tsx +++ b/src/components/drawer/DeviceTracksTab.tsx @@ -30,14 +30,15 @@ import { MergedTrackEntry, MergedCourseEntry, buildMergedTrackList, - parseDeviceCourseJson, buildTrackJsonForUpload, + rebuildDeviceTrackJson, deviceCourseToAppCourse, appCourseToDeviceJson, countAppSectors, 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"; @@ -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"); @@ -71,50 +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); - const text = new TextDecoder().decode(raw); - const courses = parseDeviceCourseJson(text); - files.push({ shortName: fn.replace(/\.json$/i, ""), courses, kind: "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"); - const courses = parseDeviceCourseJson(new TextDecoder().decode(raw)); - files.push({ shortName: fn.replace(/\.json$/i, ""), courses, kind: "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); @@ -145,7 +121,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 +134,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 +160,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 +178,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 +221,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/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/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/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); +} 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, + }; +} 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, + }; + }); +} diff --git a/src/lib/deviceTrackSync.test.ts b/src/lib/deviceTrackSync.test.ts index a3849527..ee57efbb 100644 --- a/src/lib/deviceTrackSync.test.ts +++ b/src/lib/deviceTrackSync.test.ts @@ -5,6 +5,10 @@ import { appCourseToDeviceJson, buildTrackJsonForUpload, parseDeviceCourseJson, + parseDeviceTrackFile, + deviceTrackFileFrom, + rebuildDeviceTrackJson, + type DeviceTrackFileJson, buildMergedTrackList, countDeviceSectors, countAppSectors, @@ -214,13 +218,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 +273,219 @@ 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); + }); +}); + +// ─── 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", () => { + 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..468006ea 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 ──────────────────────────────────────────────────────────────────── @@ -45,8 +46,30 @@ 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 + * `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 +103,13 @@ 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; + /** + * 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[]; @@ -256,14 +286,76 @@ 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'); + 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. + shortName: track.shortName || deriveShortName(track.name), + 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 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 +371,70 @@ 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; +} + +/** + * 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 + * needs and which this deliberately drops. + */ +export function parseDeviceCourseJson(raw: string): DeviceCourseJson[] { + return parseDeviceTrackFile(raw)?.courses ?? []; } // ─── Track kind ─────────────────────────────────────────────────────────────── @@ -394,6 +529,8 @@ export function buildMergedTrackList( shortName: sn, kind, trackName: track.name, + deviceLongName: df.longName, + deviceFileName: df.fileName ?? `${df.shortName}.json`, status: allSynced ? 'synced' : 'mismatch', appTrack: track, appCourses: track.courses, @@ -425,6 +562,8 @@ export function buildMergedTrackList( entries.push({ shortName: df.shortName, kind: dfKind, + deviceLongName: df.longName, + deviceFileName: df.fileName ?? `${df.shortName}.json`, status: 'device_only', appCourses: [], deviceCourses: df.courses, 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. */ 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",