Skip to content

Report missed turns, and split them when the swimmer says so - #9

Merged
MaxWinterstein merged 4 commits into
mainfrom
feat/missed-turns-and-splits
Sep 25, 2026
Merged

MaxWinterstein merged 4 commits into
mainfrom
feat/missed-turns-and-splits

Conversation

@MaxWinterstein

Copy link
Copy Markdown
Owner

Why this PR exists: #7 and #8 were stacked on #6. #6 was squash-merged into main from the UI while they were still open, and my later merges of #7 and #8 went into #6's already-merged branch, so none of their content reached main. This PR is those four commits replayed onto main. The result is byte-identical to the reviewed stack, and main itself was verified identical to #6's final commit.

The full discussion lives on #7 (report a missed turn instead of hiding it, and stop writing its stroke) and #8 (split a missed turn when the swimmer says so). In short:

  • Missed turn — a length at ≥ 1.75× the unit in both time and strokes gets a finding and keeps the watch's stroke. That fixes swim-08 being written with a breaststroke length it never had. It also fixes an older bug: ambiguous strokes were overwritten despite the page saying they were kept. keepStrokeWhenUnsure, pinned off in AS_REFERENCE, keeps the goldens byte-exact.
  • Split — opt-in per length ("Split into 2 lengths — I remember turning here", or --split-missed-turns=LAP). The turn goes halfway and strokes divide with the time; made-up lengths are marked as such everywhere. swim-08 comes out at the confirmed 1100 m. Verified with Garmin Connect: the swimmer uploaded a split file and it was accepted.

Since #7/#8, from an adversarial review and CodeRabbit:

  • a split under a fixed lengths-per-lap got merged back and re-wrote breaststroke → a merged group containing a made-up length now keeps the watch's stroke
  • the made-up length was counted as recorded in the Watch column, lap-structure, phantom-turn and info.lengths → everything counts through a recorded-index map
  • keys were start times, which aren't unique → the length's index as recorded
  • the finding claimed a split that a fixed target had undone → gained says what really changed
  • repair() had its own copy of the pre-pass that fell behind analyze()'s → one prepare() helper, with tests through both
  • rounding could leave the last part of a split negative → floored

task check: 118 tests, lint, privacy scan, build.

🤖 Generated with Claude Code

MaxWinterstein and others added 4 commits September 25, 2026 09:34
A swim came back 50 m short and the page said nothing. Lap 12 held a 151 s
length with 51 strokes against a unit of ~79 s and a median of 28 strokes --
two lengths the watch recorded as one. The per-lap estimate saw three lengths
there and was capped, correctly, at the two recorded, because this tool merges
and never splits. The problem was the silence, and what followed from it: 51
strokes in "one length" cleared the breaststroke threshold, so the repair
wrote breaststroke into a swim the swimmer confirms was freestyle throughout.
The file came out worse than the watch left it.

A single length now reads as a missed turn when it runs at least 1.75x the
unit in duration *and* 1.75x the median in strokes. Both halves are needed.
Duration alone fires on an 18 m pool, which reaches 1.61x on its own, and on
a kick set or a pause at the wall, which are long without the strokes; the
tempting "rounds to two lengths" at 1.5x would have flagged both short-pool
fixtures. It is reported, never split -- splitting means inventing a turn time
and a stroke split nobody recorded -- as a finding that accounts for the
shortfall exactly (21 written + 1 reported = the confirmed 22) and as a marked
row in Show the working. It works under a fixed lengths-per-lap too, since
how the lap button was pressed has nothing to do with a missed turn.

Fixing the stroke found an older bug of the same shape. The page has always
said an ambiguous stroke -- count and duration disagreeing -- keeps the
watch's label. It did not: repair() re-derived the stroke from the count and
wrote that, and two ambiguous groups in the fixtures had the watch's
breaststroke overwritten with freestyle. analyze() now decides the stroke per
merged group once, including when to leave it alone, and repair() writes
exactly that, the way it already takes lapTargets instead of recomputing them.
keepStrokeWhenUnsure (default on) covers both cases; the Python reference
writes the verdict unconditionally, so AS_REFERENCE turns it off and the
goldens stay byte-exact. Default: 12 of 12 ambiguous groups keep the watch's
label; as the reference: the same 2 overwrites as before.

The swim is fixture swim-08, anonymized outside the tree and renamed on the
way in. Each part of the rule has a test that fails without it: detection
off, the ratio lowered to 1.5, the label kept off, and the stroke half of the
rule removed -- the last needed a doctored kick-set length, since no real
fixture exercises it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The missed-turn finding says a swim is short and by how much; this lets the
swimmer act on it. Each finding gets a "Split into 2 lengths" switch, off by
default, and --split-missed-turns[=LAPS] does the same on the command line.
swim-08 then comes out at the confirmed 1100 m.

It is the one place this tool adds data, which is why it is opt-in and per
length. A merge only discards: every number it writes is still one the watch
measured. A split has to make up two -- where the turn fell, and how the
strokes divide -- and on a threshold resting on a single real example the
worst it could do is add distance nobody swam. The choices are the neutral
ones: the turn goes halfway and the strokes divide with the time, the
remainder to the last part so the totals are exact.

It runs as a pre-pass. applySplits() writes the extra length as a copy of the
original frame placed right after it, so the definition in force is the same
one, and returns a new file; analyze() and repair() then run unchanged on
that. Lap targets, stroke decisions and the guarantee that the working table
adds up to what is written all hold on the split file with no special case.
The finding is carried across marked `split`, or the switch that turned it on
would vanish the moment it did.

What the tests pin is the narrowest honest claim: it adds a length and
nothing else. Total time and strokes are unchanged; the two parts sum to the
original and start back to back; files without a missed turn come out
byte-identical with it on; the output repairs to the same result again; it
commutes with anonymizing. Every one of those fails when the split is
deliberately broken -- strokes duplicated, parts started at the same second,
the finding dropped, the parts left unmarked, or the split turned on by
default.

Two details the browser caught. The stat cards' "was" figures were counted
from the split file and showed 1200 m for a swim the watch recorded as 1150
m -- counting made-up data as "before" -- so the made-up lengths come back out.
And the working table's note still said nothing is ever split.

The made-up lengths are italic and labelled "split -- made up, not recorded"
in the table and starred in the CLI, and the switch keeps keyboard focus when
the findings are rebuilt under it. Whether Garmin Connect accepts a file with
an added length message is untested from here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From an adversarial review of #7 and #8. One real bug and three that share
its cause: the split file was treated as if the watch had recorded it.

The bug. The missed-turn guard is computed on the split file, where each half
looks like one ordinary length. A fixed lengths-per-lap then merged the halves
back together, and the doubled stroke count was classified as breaststroke
again -- with lengths-per-lap 1, ticking "I remember turning here" wrote four
breaststroke lengths into this all-freestyle swim, against three without it.
A merged group that contains a made-up length is now as unsure as the missed
turn it came from, and keeps the watch's stroke. A test runs every target and
holds the split to never writing more breaststroke than not splitting.

Same cause, three symptoms. The finding kept saying "now split into 2" when a
fixed target had merged the split straight back and the distance had not
moved; it now carries `gained`, the lengths the split really added, and the
page and CLI say plainly when that is none. And everything that reports the
watch counted the made-up length as recorded: the Watch column said 3 for a
lap recorded as 2, the lap-structure guard said "24 lengths down to 16" beside
a stat card saying 23, a phantom-turn finding claimed the watch recorded
lengths it had not, and --dry-run counted 41 lengths in a 40-length file.
applySplits() now returns, for every length of the split file, its index in
the file as recorded; `recorded`, `info.lengths`, `madeUpLengths` and those
findings count through it.

That index is also the finding key now. Keys were start times, which two
lengths can share -- a doctored file with the 97 s length starting in the
missed turn's second had both split. The index is unique, and because it is
the index *as recorded* it survives the page re-analysing a file in which
another missed turn has already been split.

And one of mine, caught by the browser rather than the tests: the recorded-
index map was threaded through analyze() but not through repair()'s own copy
of the same pre-pass, so the page -- which calls repair() -- still showed 1200
m and "24 lengths" while every node test, which called analyze(), passed.
Both now go through one prepare() helper, and the test runs through both.

Also: "Reset to defaults" clears the splits, since the default is none; a
bare --split-missed-turns on a file with nothing to split says so; the
carried finding's note no longer says nothing is split; and two AGENTS.md
bullets are corrected -- one said a missed turn was out of reach, the other
still described the unit as max() of two estimators, a rule the same file
explains was discarded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From CodeRabbit on #8, and right. The non-last parts of a split were rounded,
so every one could round up and leave the last part a negative remainder: 2
strokes in 4 parts came out as 1, 1, 1 and -1. Written into an unsigned field,
-1 is the invalid marker, read back as 0, so total strokes rose from 2 to 3 --
the one thing a split promises not to do. Flooring keeps every non-last part at
or below the equal share, so the remainder is never negative. Durations had
the same pattern and get the same fix.

Real swims do not reach it (it needs about one stroke per length), so the test
doctors one: every length a single stroke, and one of them four lengths long.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 43 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: a5e56029-12c4-4237-b5b1-f3dd50842a0a

📥 Commits

Reviewing files that changed from the base of the PR and between 963628f and 7be4d05.

📒 Files selected for processing (12)
  • AGENTS.md
  • README.md
  • packages/fitfix/src/cli.mjs
  • packages/fitfix/src/swim-repair.js
  • packages/fitfix/test/cli.test.mjs
  • packages/fitfix/test/fixtures.mjs
  • packages/fitfix/test/fixtures/swim-08.fit
  • packages/fitfix/test/missed-turn.test.mjs
  • packages/fitfix/test/split.test.mjs
  • web/app.js
  • web/index.html
  • web/style.css

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@MaxWinterstein
MaxWinterstein merged commit 58c767a into main Sep 25, 2026
2 checks passed
@MaxWinterstein
MaxWinterstein deleted the feat/missed-turns-and-splits branch September 25, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant