Beta - #398
Merged
Merged
Conversation
- Pad the MyChron/Alfano/DovesLogger/Fledgling download dialogs with safe-area-modal so they clear notches on edge-to-edge Android. - Pass the native FileEntry date/meta through the adapters into LoggerFile (new optional fields) and show the session date in FileListPanel, so Alfano sessions aren't bare hex ids. - Normalize download filenames (new pure fileNaming helpers): MyChron swaps the device's .xrz for .xrk instead of stacking extensions, and Alfano appends .csv to its extensionless session ids for save/import. - Update the adapter tests for the pass-through, add fileNaming tests, and record it all in the CHANGELOG under Unreleased. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdpAnoQ6Y1dqDX3ScnQekD
Inside the Tauri/Android WebView, window.location.origin is the shell's synthetic http://tauri.localhost, so the redirectTo/emailRedirectTo URLs baked into Supabase registration-confirmation and password-reset emails were dead links. New platform helper authRedirectOrigin() returns the canonical https://lapwingdata.com origin when isNativeApp() (emailed auth links open in a mail client / real browser, never the app WebView) and the current origin on the web; authBackend's resetPassword and signUp now use it. Since on native the emailed link completes in the browser rather than the app, the Register success and Forgot-Password sent toasts gain native-specific copy (all locales) telling the user to confirm via the email link in their browser, then return to the app and sign in. Also on the same screen: the check-login-rate pre-check now fails open — an infrastructure failure of the edge function (network error/throw) no longer aborts sign-in with a generic error, while a genuine rate-limit rejection still blocks with its message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdpAnoQ6Y1dqDX3ScnQekD
The Device tab (settings, tracks, battery) was dead in the native app: FileManagerDrawer gated it on isBleSupported(), the Tauri webview has no Web Bluetooth, and DeviceContext could only connect via navigator.bluetooth. Settings/tracks/battery now ride the native logger_* IPC that LapWing exposes. - New transport-neutral DeviceDetails seam (types.ts): battery, listSettings/setSetting/resetSettings, listTracks/getTrack/putTrack/ deleteTrack. Two implementations: createBleDeviceDetails (wraps the existing @/lib/ble functions — web behavior unchanged) and createNativeDeviceDetails (wraps new doveslogger ipc invokers for logger_battery / logger_list_settings / logger_set_setting / logger_reset_settings / logger_list_tracks / logger_download_track / logger_upload_track / logger_delete_track). - DeviceContext is transport-aware: scanNative/connectNative drive the IPC with an in-app picker (BLE has no OS chooser), expose isConnected + details on both transports, and scope the native connection to the drawer (closing it disconnects — the backend has one global slot). - The single native slot is guarded by an ownership token (loggers/native/owner.ts): the Device tab and the three download dialogs claim it before logger_connect and a second claimant is refused with a clear message instead of silently stealing the device. - DeviceSettingsTab / DeviceTracksTab take DeviceDetails instead of a raw BleConnection (deviceSettingsSchema was already transport-free). The firmware OTA section stays web-only; on native the settings tab points at the Fledgling download screen where native firmware update already lives. - dovesloggerConnection flips supportsDeviceDetails to true (the flag finally has a reader); fledglingConnection carries the BLE details. - i18n: scan-picker + busy-slot + firmware-hint strings in all 7 locales. - Tests: device-tab ipc invoker contracts, ownership token, updated adapter expectations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdpAnoQ6Y1dqDX3ScnQekD
feat(native): make the Device tab work in the LapWing app (settings + tracks + battery)
…directs # Conflicts: # CHANGELOG.md
…polish # Conflicts: # CHANGELOG.md
fix(auth): point emailed auth links at lapwingdata.com on native
fix(native): mobile polish — safe areas, session dates, file naming
The logger and DovesLapTimer have shipped sprint (autocross / point-to-point)
support for a while, but NOTHING can author a sprint track: the device reads
/TRACKS/SPRINT/*.json, picks the newest course by date_created and stands up
SprintTimer, and the only thing that would write those files is this app --
which had no concept of a course type. Sprint mode is currently testable only
by hand-writing JSON onto an SD card. This is the first of four PRs closing
that loop; it is model + validation + wire format, with no UI.
Course gains three optional fields:
type?: 'circuit' | 'sprint' absent means circuit, so every existing saved,
bundled and cloud-synced course keeps working
with no migration
finish?: SectorLine sprint only and REQUIRED there -- the device
cannot time a run without it, so validation
rejects it rather than shipping a course the
device will silently ignore
dateCreated?: string sprint only, sortable 'YYYY-MM-DDTHH:MM'
date_created is not cosmetic: the firmware chooses which sprint course to load
by comparing these stamps as plain STRINGS (sprint_select), so the zero-padded
shape is load-bearing -- an unpadded stamp collates wrong and the device runs
the wrong cone layout. New src/lib/sprintCourse.ts owns the format, and stamps
once and preserves, so editing a course cannot make it jump the device queue.
newestSprintCourseIndex mirrors the firmware's choice, sorting unstamped
courses oldest rather than hiding them -- the device would still see the file,
and hiding it here would make the app disagree with the card.
validateCourseSectors branches on type instead of growing a parallel
validator, so its three call sites keep working and cannot drift:
circuit 0 sectors, or exactly 3 majors total (unchanged)
sprint 0-2 splits, `major` ignored, finish line required
Sprint splits are stored unflagged. `major` is meaningless point-to-point, and
storing them as majors would let a course retyped to circuit silently look
like a valid three-major layout it never had. That means legacyMirror would
drop them on export, so the device projection is now type-aware
(deviceSectorProjection) and maps splits into the sector_2/sector_3 slots
positionally.
Also fixes a latent bug in coursesMatch: it compared only start/finish and the
two legacy sector lines, so ANY course carrying data outside that projection
read as "synced". For a sprint course that includes the finish line -- the
single most likely edit. It now compares the full per-type projection, and
treats a course that changed type as a different file rather than an edit,
since the two kinds live in separate folders on the device.
Tests: 25 new for sprintCourse (including that the stamps sort chronologically
as plain strings, the 09:00-vs-10:00 trap), 9 for the sprint validation
branch, 10 round-tripping the wire format and pinning the coursesMatch fix.
Full suite 2464 pass; lint, typecheck and build green.
plan 0015: sprint course model, validation and device wire format
Second of four. PR #375 added the model; this makes it reachable — you can now actually author the sprint track the logger has been able to read for weeks but nothing could produce. The editor gains a Circuit/Sprint picker, and a sprint course gets a second map handle for its separate finish line. LineId grows a 'finish' variant alongside 'sf' | number, and the finish is enumerated LAST in allLineIds because that is the order a driver crosses the lines — the same order the sector list renders, so map and list agree without a second sort. Sprint-specific behaviour in the list editor: the Finish row sits at the bottom, the Major switch disappears (meaningless point-to-point), rows read "Split 1/2" instead of "Sector n", the limit becomes MAX_SPRINT_SPLITS rather than the circuit line budget, and the guidance text explains the point-to-point shape. Picking Sprint drops a finish line north of the start rather than making the user hunt for a button, mirroring how a new course auto-drops its start/finish. Two deliberate scope calls: - The type picker is CREATE-ONLY. Retyping a course that already has geometry would silently invalidate it — a circuit's three majors are not a sprint's splits, and vice versa — so editing keeps the saved type. - TrackPromptDialog and the admin CoursesTab stay circuit-only. Both keep their own copy of the editor state, and courseType defaults to 'circuit', so they compile and behave exactly as before. Community submission of sprint courses is already out of scope for this plan, and the log-import prompt wants the runs view (PR 4) before it can do anything useful with a sprint log. The save-time fork lives in sprintCourse.finalizeCourseForSave rather than inside the hook: circuit normalizes (writing the legacy sector2/sector3 mirror), sprint stamps date_created and deliberately skips normalization, since that mirror is derived from MAJORS and sprint splits are unflagged — normalizing would emit an empty mirror and imply a sector layout the course does not have. Extracted because this repo's coverage config excludes src/components/**/*.tsx by design, so logic left in a .tsx is logic that cannot be tested. 4 new tests cover it. All 13 new UI strings are translated into all six shipped languages rather than left English — the locale-parity test enforces it, and each string follows the terminology already used in that language's file (de "Zwischen- zeit", fr "intermédiaire", ja "スプリット", etc.). 2468 tests pass; lint, typecheck and build green.
plan 0015: create and edit sprint courses in the track editor
Third of five. The client can now address the logger's /TRACKS/SPRINT folder alongside /TRACKS — list, download, upload, delete. New trackOpcodes.ts owns the verb table rather than leaving opcode strings as inline literals at four call sites. Every trackSync.ts function takes an optional `kind` defaulting to 'circuit', so nothing existing changes on the wire. The folder is chosen by the OPCODE, never by a path in the filename. That is the firmware's design, not an accident: its filename validator rejects '/' and '..' so a BLE client stays jailed to the tracks folders, and a path-carrying filename would have punched a hole in that. The cost is a parallel verb set. Only the LIST replies differ between kinds -- TSFILE:/TSEND vs TFILE:/TEND, so a sprint enumeration can never be mistaken for a circuit one; get/put/delete reuse the circuit replies verbatim. Verified by reading bluetooth.ino (the dispatch at :557-604, the listing tokens at :325-326) rather than trusting the protocol doc, which is what the doc now documents. Also fixes a collision that sprint mode would otherwise have introduced: buildMergedTrackList keyed entries on shortName alone, but a circuit "OKC" and a sprint "OKC" are two distinct files in two distinct folders. Keying on (kind, shortName) keeps them separate; without it one would have been reported as a mismatched version of the other and "syncing" it would have overwritten the wrong file. DeviceTrackFile.kind is optional and absent means circuit, so existing callers are unaffected. trackKind() derives which folder a track belongs in (any sprint course makes the track sprint) and isMixedKindTrack() flags the one shape the device cannot represent -- a track carrying both kinds would have to be split across two files. The editor cannot create one, but a cloud-synced or hand-edited track could be, so it is surfaced rather than silently half-pushed. NOT reachable from the Device tab yet, deliberately: that path goes through the transport-neutral DeviceDetails seam, which has two implementations -- Web Bluetooth and the native Android IPC bridge. The native side cannot speak TS* until the Android app does, so that needs an explicit decision rather than being smuggled in here. Recorded as PR 4 in the plan. 15 new tests (opcode table incl. the prefix-collision check, kind derivation, mixed-kind detection, merge namespacing in both directions). 2483 pass; lint, typecheck and build green.
plan 0015: sprint track sync over the device's TS* verbs
Fourth of five, and the one that closes the loop: a sprint course authored in the track editor can now be pushed to the logger and driven. The DeviceDetails seam gains an optional `kind` on listTracks/getTrack/ putTrack/deleteTrack (absent = circuit, so every existing caller is unchanged) plus a `supportsSprintTracks` capability flag. The tab loads both folders, tags each file with the folder it came from, and threads that kind through every upload, download and delete so an operation can never land in the wrong folder. Two places needed disambiguating now that a circuit and a sprint track can share a short name: the list row key (React would otherwise collapse them into one row) and the post-refresh re-find after downloading a course, which matched on shortName alone. NATIVE ANDROID IS DEFERRED TO THE END OF THE PROJECT, deliberately. The bridge does not speak the TS* verbs, and it is gated on that app's release rather than on anything in this repo — the app is not live yet, so nothing regresses by waiting. Rather than throwing or silently returning nothing, the native implementation reports supportsSprintTracks: false and the tab skips the sprint folder entirely: a missing capability must not render as an empty folder that reads "no sprint tracks on the device". Recorded as an end-of-project follow-up in the plan, with the exact verb mapping the bridge will need. 3 new tests pin the native behaviour — the capability flag, that a sprint list resolves empty WITHOUT touching the bridge, and that circuit listing still goes through it (explicit and defaulted). 2486 pass; lint, typecheck and build green.
plan 0015: sprint tracks in the Device tab, over Web Bluetooth
The firmware emits eight metadata columns; the parser read six, so the `race_mode` column that tells us a session was timed point-to-point was already on the wire and being silently dropped. Column names are taken from BirdsEye/dovex_header.cpp rather than the firmware's own docs, which still list the pre-rename `driver_name`/`course_name`/`optimal_lap_ms`. The lookup stays keyed by column name, which is what makes appending the two trailing columns readable from new logs while six-column logs keep parsing untouched. `parseRaceMode` returns undefined — not 'circuit' — for an absent, empty or unrecognized value. An unknown mode should leave downstream behaviour exactly as it was rather than assert a timing model the log never claimed, and callers already treat "unknown" as circuit. `lib/gps/dovepWriter.ts` is deliberately left emitting six columns: the phone laptimer records circuit sessions only, and an absent `race_mode` already means circuit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
calculateLaps was circuit-only by construction: it detected crossings of one line and paired *consecutive* ones into laps. A sprint run is one start crossing to a crossing of a different line, so a sprint log yielded zero laps. Pairing is the only thing that differs, so the per-lap body — speed stats and the sector-boundary walk — is extracted into a shared `buildLap`; the walk was already correct for a run once `end` is the finish crossing, since its last segment simply closes on `end`. The existing circuit assertions pass unmodified, which is the proof that extraction changed nothing. `pairSprintRuns` mirrors the device's SprintTimer (DovesDataLogger plan 0002 §7 Q4): a start crossing opens a run and cancels any run already in progress — the botched-course re-launch rule — a finish crossing completes it, and a finish with no armed run is ignored. Equivalently, each run opens at the LAST start crossing before its finish, which is also what makes the derivation robust to a driver crossing the start line on the way back to grid. The signature is unchanged, so all six call sites (course detection, share sessions, lap management, reference laps, overlays, the live timer) gain sprint without touching any of them. Runs stay `Lap[]` on purpose — every consumer keeps working. A sprint course with no finish line returns [] rather than pairing runs off the start line alone; validation already blocks saving one and the device ignores it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
Sprint splits are stored `major: false` on purpose — the flag is
meaningless point-to-point, and flagging them would let a course retyped
to circuit look like a valid three-major layout it never had. But both
readers of that flag treat its absence as "no sectors", so splits the
driver deliberately placed rendered as em-dashes: courseHasSectors
returned false for every sprint course, and rollupMajorSectors bailed on
the fewer-than-three-majors check.
Fixed in the readers, not the data. `displayedSectorIndices` names the
timing lines that open a displayed sector — the flagged majors on a
circuit, every split on a sprint course — and rollupMajorSectors consumes
it. With MAX_SPRINT_SPLITS = 2 a run has at most three segments, which is
exactly the S1/S2/S3 the lap table, video overlays and snapshots already
render; one split yields {s1, s2} and no splits yields nothing, since
then the single segment IS the run.
courseHasSectors gets the matching branch. Circuit behaviour, including
the legacy sector2/sector3 fallback, is unchanged.
sectorLabels is left alone: it also drives the track editor and
SectorCropSelect, so renumbering sprint splits there is a cosmetic change
to landed UI, not part of reading runs back.
racing.test.ts is new — these two predicates own the "absent type means
circuit" default and the sector-column decision for every view, so their
edges are worth pinning independently of the callers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
Parsing race_mode was not enough on its own: nothing in the app read dovexMetadata at all, so the column went from being dropped by the parser to being dropped by every consumer. A venue can carry both a circuit layout and a sprint one — separate tracks that may even share a short name. Left unfiltered, findNearestTrack can pick the wrong track outright and the session reports zero laps, with nothing on screen to explain why. tracksForRaceMode drops the courses that cannot be what was driven, then the tracks left empty, before auto-detection runs. It is conservative in both directions: an unknown mode — every log predating the column — and a filter that would leave nothing both return the input unchanged, so no log detects worse than it did before this existed. Wired into the file-load path only. The full track list still backs allTracks and the nearest-track prompt, and the live timer is untouched since there is no log header during a session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
"Lap" stays, in the code and on screen — autocross drivers call them laps and the logger kept the wording on-device for the same reason. Only the strings that are factually wrong for a point-to-point run fork on course type: the empty state told the user to pick a track with a start/finish line, and a run is not a loop, so "Avg Lap Length" becomes "Avg Run Length". The lap-number column header is unchanged. The Simple/Full toggle is suppressed for sprint. A sprint course caps at two splits, so its three segments already ARE the S1/S2/S3 of the simple view — and since every split is stored unflagged, the toggle's "has sub-sectors" test would otherwise say yes and label them "1 / 1.1 / 1.2". Both new keys are seeded to all seven locales, matching each file's existing sprint terminology from the track editor (manga / manche / Lauf / manche / 走行 / bateria). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
Marks PR 5 done in the plan with what actually landed and why: the firmware-derived run-pairing rule, splits-as-displayed-sectors, the detection narrowing, and the two deliberate non-changes (dovepWriter's six-column output and sectorLabels' sprint numbering). subsystems.md's DOVEX section still showed the six-column header; it now shows all eight, notes that columns are read by name rather than position, and documents what race_mode means to this app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…ing-a6x07o plan 0015 (PR 5/5): read sprint runs back out of a DOVEX log
The size cap lives in the firmware ALREADY INSTALLED on the logger, not in the image being delivered and not in this app — which has never enforced a cap of its own. Firmware older than v3.1.0 carved a 320 KiB staging region out of flash and rejects anything larger at the FWBEGIN handshake, before a byte is uploaded. So the user saw "Firmware update failed: SIZE" with no way to know that nothing is wrong with the image, and that the fix is to install v3.1.0 first — it fits under the old cap and carries the larger staging region, after which the current build installs normally. Hit for real on a unit that had been left a version behind. The three FWERR: sites now throw a typed FirmwareProtocolError carrying the raw token, so callers can act on the protocol rather than re-parse an English sentence. explainFirmwareFailure turns a SIZE into an actionable message and returns null for everything else, so every other token keeps its existing wording. Deliberately not a version->capability table: that mapping is its own piece of work. This is the single rule that strands people today, and the two predicates behind it (needsOtaLayoutUpgrade, exceedsLegacyOtaCap) are exported so a pre-flight warning in the confirm dialog is a one-line call when that lands. A device reporting v3.1.0+ that still refuses is NOT sent down the staged upgrade path — it gets pointed at USB, because telling that user to install 3.1.0 would be a wild goose chase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…n maps
Two bugs found on the first real bench run of the on-device course
creator. Neither is in the firmware — the file it writes is well-formed
383-byte JSON with the course and finish line present.
1. parseDeviceCourseJson only accepted the legacy bare-array shape. The
device writes the OBJECT shape — the same one this app's own track
files use and that the firmware's parseTrackFile() reads — so
JSON.parse succeeded, the result wasn't an array, and it fell through
to [] with nothing logged. A walked course synced back as a track with
no courses at all. Both shapes are now accepted, which is what the
device has always done in the other direction.
An existing test asserted the broken behaviour ("returns [] when JSON
is valid but not an array (e.g. wrapping object)"), which is how this
survived review: the bug was pinned as the contract. Replaced with
tests for the object format.
2. The sprint finish line was rendered in the track editor and nowhere
else. RaceLineView and SimMap each carried their own hardcoded
start/finish colour and no concept of a separate finish, so a
point-to-point course was indistinguishable from a circuit one on
every map outside the editor.
Rather than paste the editor's colours into two more files — which is
exactly the drift that caused this — the palette and the two decisions
that go with it (which colour opens the course, is there a finish line
to draw) move into a shared courseLineStyle unit. Sprint start goes
green so green-to-red reads as start-to-end; circuit start/finish
stays red, since there it is one line doing both jobs and recolouring
it would change every existing session map for nothing.
finishLineOf() guards on the course type as well as the field: a stray
finish left on a retyped course is stale data, not a phantom red line
to paint across a circuit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…ing-a6x07o fix: explain the OTA "SIZE" rejection instead of relaying the raw token
buildTrackJsonForUpload emitted a bare JSON array of courses. The firmware parses that — but its array branch (sd_functions.ino parseTrackFile) blanks longName, shortName and defaultCourse, and every course falls back to lengthFt = 0. lengthFt is what CourseDetector ranks courses by, so a track uploaded from this app could never be course-detected and dropped straight to Lap Anything, and the blank shortName reached the DOVEX header's short_name column. Emit the object form instead — the same shape the app's own track files and the on-device course creator already write, and one the firmware has parsed since well before any shipped release, so no version gate is needed. Also add parseDeviceTrackFile(), which keeps the wrapper's longName/shortName/ type/defaultCourse rather than discarding them; parseDeviceCourseJson stays as a thin wrapper over it for the callers that only want courses. The rename flow needs longName, and needs shortName because for a device-authored track the FILENAME is the 12-char longName (N260803_1432.json) while the 8-char shortName the sync merge keys on lives inside the file. The old "emits a JSON array of courses (not a wrapping object)" test asserted the lossy shape as the contract, which is how this survived review; it is replaced with assertions on the metadata the device actually consumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
A track the on-device course creator wrote is stored at N260803_1432.json but declares shortName "08031432" — 8 characters, chosen by the firmware precisely because that is this app's Track.shortName budget and the key its sync merge uses. buildMergedTrackList keyed on the FILENAME instead, so a track imported from the device could never be matched to the file it came from: it stayed "device_only" forever and the sync kept re-offering it. Separate the two concepts. DeviceTrackFile.shortName is now the identity (the declared shortName, falling back to the filename base only for legacy bare-array files that declare nothing), and the new fileName / deviceFileName carry the location. deviceTrackFileFrom() owns that rule so it is unit-tested rather than buried in the tab, and every write path now targets the real file instead of `shortName + ".json"` — which would otherwise orphan the original and leave two copies on the card. Also fixes the other half of the same nag: handleDownloadToApp never passed a shortName to addTrack, and buildMergedTrackList skips app tracks that have none, so downloaded tracks were invisible to the merge whatever the key was. It now carries the shortName over and names the track from the file's longName. The two course-level writers went through rebuildDeviceTrackJson so editing one course stops stripping the file's wrapper metadata and resetting every lengthFt — the same loss the bare-array uploader caused, reached from a different button. Verified by reverting the identity rule and watching the round-trip test report 2 merged entries instead of 1 — literally the app_only/device_only split that made the prompt re-fire. The first draft of that test derived its input from the value under test and passed either way; it now spells the expectation out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
plan 0016 (2/3): the two-screen sync + rename wizard
plan 0016 (3/3): prompt for firmware and tracks on connect
…e original Two problems, one screen. The bug: every generated course name came up pre-filled with the track name the user had just typed on the previous screen. That was my reading of "new courses get the same text box, auto-populated by the name", and it was wrong — a course is not its track, so the screen read as broken. The box now starts holding what would actually be saved if you touched nothing. A sprint course keeps its date stamp, which is a valid final answer there since a sprint venue re-lays its course every event. A circuit course starts EMPTY, because the stamp is not valid for it and pre-filling anything invites clicking straight past the one thing the screen exists to ask. Course names no longer follow the track name at all, so retargetCourseDraft is gone and goToCourses is a plain step change. The layout: the course row now mirrors the track row — the ORIGINAL name first, then the circuit/sprint badge, then the track it belongs to (several tracks can be on this screen at once, so a bare course name is ambiguous), then the walked date decoded to readable text, then the box. It previously showed the track name where the course name should have been, which is why the copied value looked like it belonged there. Verified the new tests bite by restoring the copy behaviour: five fail, including canSave going true when a circuit course is still unnamed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
fix(sync): course screen copied the track name, and hid the original
The device settings schema only knew 'string' and 'number', so anything with a fixed set of values was a free-text box. That is worse than it sounds: the firmware compares the literal, so a typo doesn't error — it silently falls back to the default, which reads as the setting not working at all. race_mode is the case that already existed. It lives on the logger but was never in this schema, so it rendered as a raw text field with no hint of what it does. It is now a Circuit / Sprint picker, described accurately as a tiebreak that only applies when both kinds of track are in range. spark_mode and cylinder_count are added ahead of the firmware that introduces them (plan 0003). Rows are built from what the DEVICE reports, not from the schema, so they stay invisible until a logger actually has them — the schema entry just means they render properly when it does. A stored value this build doesn't recognise — older or newer firmware, or a hand-edited SETTINGS.json — is shown verbatim rather than rendered as one of the options we do know. settingDisplayValue owns that rule so it is testable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
feat(settings): enum control type, and surface race_mode
Adds display_invert to the settings schema as a Normal / Inverted dropdown, reusing the enum control from the previous commit. Validation matters more than usual here: the firmware treats anything that is not an exact "inverted" as normal, so a value let through unchecked would read as the setting silently doing nothing. "invert", "Inverted" and "1" are all rejected. Rows are built from what the DEVICE reports, so this stays hidden until a logger has the firmware that adds the key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
feat(settings): expose the logger's display-colour setting
…(plan 0017) `deviceFileOf()` exists precisely so a write lands on the file the device actually has, and its comment says why: for a track the on-device course creator wrote, the identity (`08031432`) and the filename (`N260803_1432.json`) are different strings. Five call sites use it. `handleResyncAll` did not — it rebuilt `shortName + ".json"` for both the delete and the put. So on every device-created track, Resync All deleted nothing, wrote a second file under the identity name, and left the original behind. Each resync added one more, growing the number of files and the bytes on the card — the same budget the track JSON has to fit when the device parses it on the next boot, which is what the rest of plan 0017 is about. Not reachable by test: the call sites live in a component, and the suite runs in `node` with no DOM renderer. The helper it should have been using is one line and already documented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…n (plan 0017) The device reads a whole track file into a fixed buffer and parses it there. Past that buffer the read is cut mid-JSON, the parse fails, buildTrackList() adds no manifest entry, and the track stops being detected at the venue entirely — the failure is total, not partial. Uploads were written with `JSON.stringify(file, null, '\t')`. In a file of this shape the tabs and newlines are roughly a quarter of the bytes, and nothing reads them: the firmware parses with ArduinoJson, which is whitespace-insensitive, and a human wanting to read one has a formatter. Dropping the indent is about a quarter more courses per track, for free, with no change asked of any device already in the field. This is the single choke point all three upload writers funnel through, so it is also what the size projection in the rest of plan 0017 will measure — the number shown to the user has to be the number written. The test that pinned tab indentation asserted it "matches device expectation", which was never true; replaced with one that pins compact output and one that pins the saving being worth having. Also adds plan 0017 and the changelog entries for both fixes in this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…tion fix(sync): stop Resync All duplicating tracks, and stop uploads wasting a quarter of the device budget (plan 0017)
Pure logic only — nothing imports these yet. The wiring, the capability gate and the picker UI follow; splitting them keeps the part that decides what lands on a track day reviewable as maths rather than as UI. `deviceCourseSelection` is the single resolver, so the sync plan, the upload writers, the "is it synced?" check and the picker can never disagree about the subset — a disagreement between any two of those is a track that re-prompts on every connect, which is the trap plan 0016 was written to close. Circuit tracks keep every course: those are layouts of a fixed track and the driver still runs them. Sprint tracks keep only the newest by `dateCreated`, because a sprint venue re-lays its cones every event and the on-device creator mints a dated course each time, so the file grows without bound. Stamps compare as plain strings, the same comparison the firmware makes; a course with no stamp sorts oldest rather than newest, so an old import cannot displace this morning's course. Overrides are stored by name rather than index — courses get reordered and re-saved — and only genuine deviations are recorded, so a track the user never curated follows the rule forever instead of being frozen to whatever it looked like when they last opened the picker. An explicit exclude beats an explicit include: exclude is the direction that keeps a file under the buffer. `deviceTrackBudget` measures rather than estimates. It runs the real upload writer and the real TextEncoder, because the number shown to the user as the reason to drop a course has to be the number written. A per-course byte table would drift the first time the writer changed. An unknown capability assumes the SMALL buffer: guessing high overfills the card and takes the track out of detection at the venue, guessing low costs one course. `deviceCourseOverrides` is device-local and deliberately not a synced doc store — it describes one physical SD card, and pushing one card's contents onto every logger the user owns is how a file ends up over the buffer. The courses themselves already ride cloud sync, so nothing is lost by keeping it local. It also has no time expiry, unlike the firmware reminder it is otherwise modelled on: an expiring curation silently re-adds courses to a card that was deliberately trimmed, and the user finds out at the venue. Growth is bounded by count instead. The test that matters most is that an empty store is a working configuration: on a browser that has never seen this logger, the default rule alone still produces a set the device can hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
feat(sync): the model for which courses belong on the device (plan 0017)
…budget (plan 0017) Wires the model from the previous PR into the three places a curated device set was broken, and adds the one firmware capability it needs. **The nag.** `allSynced` compared every app course against the device, so a track holding a deliberate subset read `mismatch` forever and the on-connect prompt fired every single time. The merge now carries `plannedOnDevice` per course and settles on `courseIsSettled`, which is not the same question as `status === 'synced'`: a course kept off the device is settled by being absent, and one the user excluded that is still on the card is unsettled until it's removed. A `device_only` course stays outstanding exactly as before — it wants importing, which is what the wizard is for. **The guards.** The count guard now measures the planned set, so an eleven-course sprint venue is no longer skipped outright when only one course was ever going on the card. It reads the curation off the merge but still counts `appCourses`, because an `app_only` entry carries no merged courses at all and deriving the list from the merge would count it as empty and wave through a track that cannot fit. Alongside it, a byte guard: on the smaller buffer a sprint track overflows at about seven courses, under the ten-course cap, which is why the cap alone never caught it. Neither guard subsumes the other, so both stay. A caller that passes no budget gets no size check rather than a guessed one. **The upload.** `planOperations` now writes the subset to the device while the app keeps every course — two deliberately different lists. Writing `courses` to both is what made accepting the wizard silently undo the curation and put the file straight back over the buffer. The two manual upload paths in the tracks tab go through the same subset. **The capability.** `supportsLargeTrackBuffer` mirrors `needsOtaLayoutUpgrade` over the existing `compareVersions`, and is the only place a firmware version becomes this boolean. It inverts that function's treatment of an unknown version, deliberately: that one must never nag without certainty, this one must never overfill a card, because a file past the buffer doesn't degrade the track — it takes it out of detection at the venue. The version comes from the shared firmware context, which already owns the one DIS read. A track that still doesn't fit is reported as a skip with a reason, the way `too_many_courses` already was — never raised as a dialog. With nothing stored, on a new browser, the default rule alone has to settle, and it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
…ion-wiring feat(sync): apply the course curation, and gate on the device's real budget (plan 0017)
The last piece: somewhere to actually do the curation the previous PRs made possible. A Courses button on any track with more than one course opens a checklist with a live byte count — how much of the logger's room the current selection uses, and how much there is. The number is measured, not estimated: buildPickerState runs the real upload writer and the real TextEncoder, because a user is being asked to drop a course on the strength of it. The picker lives in the TRACKS LIST, not in the sync wizard. The wizard reports an oversized track as a skip whose message points here, which keeps the rule the rest of this plan rests on: curation is only ever reached by a deliberate action. A dialog that appeared mid-sync would be the nag the whole plan exists to end. An empty selection cannot be confirmed. A track file with no courses is not "a smaller track" — the logger parses it, finds nothing, and never detects it, which looks exactly like the overflow being avoided. Deleting a track is a different button, and it already exists. The newest sprint course is badged rather than pinned. The default rule keeps it, but a driver about to walk its replacement may well want it gone, and a checkbox that refuses to move is worse than no hint. Track rows also gain checkboxes and a bulk send. Every track in a bulk send goes through deviceJsonFor(), so it carries whatever was chosen for it — a bulk action that quietly undid the curation would be worse than having none. All logic is in deviceCoursePicker with 19 tests; the dialog only draws it. That is not a preference — the suite runs in node with no renderer and src/components is coverage-excluded, so anything deciding anything has to live in src/lib to be covered at all. Two small things fixed in passing: the app_only row's "Send" label was hardcoded English while the identical button elsewhere was translated, and the picker reuses the wizard's existing walkedOn string rather than duplicating the same sentence across seven locale files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
feat(sync): choose which courses go on the logger (plan 0017)
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
lapwing | 567e060 | Commit Preview URL Branch Preview URL |
Aug 10 2026, 04:43 PM |
Coverage SummaryLines: 58.66% (7601/12957) · Statements: 57.79% · Functions: 55.84% · Branches: 55.69% Per-file coverage
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
The firmware now hides the two diagnostic pages at the front of the race rotation behind a debug_pages setting (default "hide"). Add the enum schema entry so it renders as a proper dropdown instead of free text — the firmware only shows the pages on an exact "show", so a typo would read as the setting silently not working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDNr2DbMapSHHyieJigF3s
package.json 3.3.0 -> 4.0.0 and the [Unreleased] block becomes [4.0.0] - 2026-08-10, with the merge-accreted duplicate sections (three Addeds, four Fixeds) consolidated into one Added/Fixed set — content moved verbatim, nothing reworded. Version-aligned with the BirdsEye 4.0.0 firmware release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDNr2DbMapSHHyieJigF3s
…-haqpkw feat(settings): expose the logger's debug-pages setting
chore: cut v4.0.0 — version stamp + CHANGELOG cut
Merging BETA into the release-cut branch re-imported the pre-cut [Unreleased] sections next to the consolidated ones: six Fixed entries were duplicated verbatim and the section headings interleaved again. One Added/Changed/Fixed set, 31 unique entries, nothing reworded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDNr2DbMapSHHyieJigF3s
TheAngryRaven
marked this pull request as ready for review
August 10, 2026 16:40
…qpkw fix(changelog): dedupe the 4.0.0 block after the release-cut merge
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DovesDataViewer 4.0.0 — Sprint mode lands, end to end
The companion release to BirdsEye firmware 4.0.0. The headline: sprint/autocross support goes from "the logger can time it" to a complete loop — author a course in the app or walk it on the device, sync it over Bluetooth, drive it, and read the runs back. Around that: a sync system that finally understands device-authored tracks, smarter track-capacity management, connect-time prompts, and a long list of fixes.
Sprint mode, complete
race_modeand device name are finally read out (they were being written and thrown away), andrace_modesteers course detection so a sprint log can't mis-match the circuit track at a shared venue. "Lap" wording is kept — autocross drivers call them laps, and so does the logger.TS*verbs, the folder is chosen by opcode (never by a path in a filename), and sync entries are keyed on (kind, short name) so same-named tracks of different kinds can't collide. Sprint tracks are Bluetooth-only for now — the native Android bridge doesn't speakTS*yet and says so instead of showing an empty list.circuit/sprint, a sprint course carries its required finish line and a fixed sortabledate_createdstamp (the logger picks the newest by comparing these as plain strings). Existing courses need no migration — absent type means circuit.Sync that understands the logger
N260803_1432) because the logger has no keyboard. A new sync wizard lists everything that differs between app and logger, marks each row Upload or Download, and gives every device-named track and course a name box — short name auto-filled as you type. The new names are written back to the logger, so both sides agree and the prompt doesn't return. Tracks that can never sync are listed with the reason instead of retried forever.Fitting more on the card
Device settings
Firmware updates
Native Android app (LapWing)
.xrz.xrk→.xrk); Alfano downloads save as.csvso the importer routes them correctly.And one login fix