Skip to content

feat(mp-m5kf): support sub-minute match durations (mm:ss) - #365

Open
gitrgoliveira wants to merge 13 commits into
mainfrom
feat/match-duration-seconds-mp-m5kf
Open

feat(mp-m5kf): support sub-minute match durations (mm:ss)#365
gitrgoliveira wants to merge 13 commits into
mainfrom
feat/match-duration-seconds-mp-m5kf

Conversation

@gitrgoliveira

@gitrgoliveira gitrgoliveira commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Operators can now enter match durations with sub-minute granularity, for example 2:30, instead of whole minutes only.
  • A single masked m:ss field replaces the old minutes-only boxes on all three duration surfaces: per-competition Pool/Playoff match duration (Settings), the Schedule estimator, and the auto-schedule Time per match box.
  • Durations persist as integer seconds. New canonical PoolMatchDurationSeconds / PlayoffMatchDurationSeconds fields supersede the legacy whole-minute fields, which are retained and auto-migrated (x60) so existing config.md files keep working with no manual step.
  • Out-of-band durations are refused, not clamped. The accepted band is 1:00 to 60:00, enforced in the UI (error state, Save and Auto-schedule blocked) and again on the server (HTTP 400).
  • The whole-minute duration fields are removed, not kept alongside the new ones. poolMatchDuration / playoffMatchDuration / matchDuration are gone from the wire and from the OpenAPI schema. An old config.md is migrated onto seconds the first time it is read, clamped into the band, and its legacy keys are dropped on the next save. In practice the clamp never fires: the retired fields hold whole minutes, so their smallest value sits on the 1:00 floor and 60:00 is above any real match.
  • Changing a duration never invalidates a generated draw. Duration feeds the scheduler only, so it is deliberately absent from the draw-ready output-affecting set.

Why

The backend schedule estimator already accepted fractional minutes, but every UI input and the persisted per-competition config were whole-minute only, so a 2m30s match could not be expressed. This closes that gap end to end while keeping backward compatibility with older tournament data.

The control shipped first as two number boxes (minutes + seconds). A design critique of that version found two blocking defects that a review of the diff alone did not surface:

  1. It wrapped its children in className="row", but .row is a two-column CSS grid (styles.css:833), not a flex row. The four children laid out as a 2x2 block with each unit caption stranded 114-251px from its input, collapsing under 720px to four stacked strips where "min" sat directly above the seconds box.
  2. The label read (min:sec), but a type=number input cannot hold a colon: Chrome strips it, so typing 2:30 produced 230 minutes and saved with no warning. Match duration drives auto-scheduling for the whole event.

One masked field that actually accepts the advertised format removes both failure modes and halves the number of touch targets that must clear 44px on a tablet.

Files changed

File What
internal/state/models.go Add Pool/PlayoffMatchDurationSeconds fields + shared effectiveMatchSeconds 3-tier resolver; ApplyCompetitionDefaults back-fills seconds from legacy minutes
internal/engine/scheduler_slots.go Read effective seconds; keep sub-minute precision through the multiplier; default is now 180s, with a 1-minute floor so a sub-minute duration cannot round a slot to zero
internal/mobileapp/handlers_competition.go Validate the seconds fields against the 30-600s band; normalize on the read path so a legacy comp renders its real value
specs/openapi.yaml Document the seconds fields, the band, and why it does not apply to the legacy minute fields
web-mobile/js/duration.jsx Masked DurationInput plus pure parseDuration / formatDuration and the band constants
web-mobile/css/styles.css New .duration-input block (named class, not a .row reuse); coarse-pointer 44px/16px; --ink-4 border for WCAG 1.4.11
web-mobile/js/admin_schedule_utils.jsx clampDurationSeconds / effectiveDurationSeconds mirroring the Go resolver
web-mobile/js/admin_competition_settings.jsx Wire the masked field; label htmlFor binding; block Save while a duration is invalid
web-mobile/js/admin_schedule_page.jsx Same for both schedule instances; block Auto-schedule while invalid; swap the undefined .label / .form-group classes for .field__label / .field
internal/state/competition_test.go, internal/engine/scheduler_slots_test.go, internal/mobileapp/handlers_competition_test.go Go tests: seconds back-fill, precedence, sub-minute precision, band validation
web-mobile/js/__tests__/render/duration_input.render.test.jsx 21 render tests: parse/format, band refusal, blur normalization, aria wiring, prop resync
docs/user-guide/organisers/run-tournament.md m:ss durations and the accepted band in the organiser guide

Screenshots

Competition Settings, legacy match_duration: 4 reading back as 4:00. One field per duration, on one line, in the same visual language as every other field on the page:

settings m:ss field

A refused value. 0:03 gets a red border, an inline Minimum is 0:30., a red "Fix match duration" pill, and a disabled Save. The playoff field beside it shows the blank state and its resolved default:

refused duration

390x844, coarse pointer. 44px tall, 16px type (below 16px, iOS Safari zooms the page on focus). The previous version was 68x35px and rendered here as four stacked strips:

mobile m:ss field

Legacy migration

A competition stored as match_duration: 15 is converted to seconds when the store loads it, and carries over intact as 900s. Clamping into the band is what lets every other layer assume a stored duration is in range, so there is no grandfathering anywhere: validateCompetitionDurations is a flat check.

With the band at 1:00-60:00 the clamp is effectively never lossy. The retired fields hold whole minutes, so their smallest expressible value (1) sits exactly on the floor, and only an absurd stored value such as 90 minutes is pinned to the ceiling.

legacy 15-minute competition after migration

Test plan

  • Go tests green across . ./cmd/... ./internal/... ./tests/...; mobileapp coverage 85.8% (gate is 85%). golangci-lint reports 0 issues, gofmt clean, go build ./... clean.
  • JS: 2328 tests across 112 files pass, eslint clean on every changed file.
  • New unit tests cover the change: Go TestValidateCompetitionDurations_Band (floor, ceiling, band edges, unset, and that a legacy 15-minute value still saves); JS 21 render tests covering parse/format, band refusal, blur normalization, aria wiring and prop resync.
  • Legacy read path verified in Chrome: a hand-seeded config.md carrying only match_duration: 4 renders as 4:00.
  • The 230-minutes bug verified fixed: typing 2:30 is accepted, saves as 150s, and pool_match_duration_seconds: 150 lands on disk.
  • Refusal, not clamping verified: 0:03 shows "Minimum is 0:30." and disables Save; 20 shows "Maximum is 10:00." and disables Save; recovery to a valid value clears both.
  • Blur normalization verified: 2:3 becomes 2:03 on Tab.
  • Blank state verified on a competition with no duration key at all: empty field, 3:00 placeholder, and an explicit "Using the default, 3:00." note wired into aria-describedby.
  • Server-side band verified by bypassing the client: a raw PUT with poolMatchDurationSeconds: 3 returns 400 {"error":"match duration must be between 30 and 600 seconds"}; 150 returns 200.
  • Persistence verified across a full page reload, not just in-memory state.
  • Mobile 390x844 coarse pointer measured: 96x44px target, 16px font, one input (was 68x35px and four stacked strips).
  • Both schedule-page instances verified: single input, .field__label at 12px (the estimator label previously used .label, which has no definition anywhere in styles.css, so it rendered at inherited 16px/400), and both now carry a hint.
  • A duration change does not invalidate the draw, verified end to end: generate the draw, change 2:30 to 4:00, PUT returns 200, status stays draw-ready, and pools.csv is byte-identical (same md5). Pinned by TestUpdateCompetition_DurationChangeKeepsDraw.
  • Legacy competitions migrate and stay editable, verified end to end: a competition stored as match_duration: 15 reads back as poolMatchDurationSeconds: 900 with the legacy keys gone from the wire, renders as 15:00, and renaming it returns 200. Pinned by TestUpdateCompetition_LegacyDurationMigrates.
  • The 1:00-60:00 band is enforced identically on both sides, verified against a running server: 0:30 and 0:59 rejected, 1:00 / 15:00 / 60:00 accepted, 60:01 and 90:00 rejected, with matching Minimum is 1:00. / Maximum is 60:00. in the UI and HTTP 400 from the API.
  • Screenshots above are real headless-Chrome captures of the running app.
  • No console or page errors in any verified flow.
  • make docs/build could not run in this environment: the python3.14-venv system package is missing, so .venv-docs cannot bootstrap pip. The docs edit is prose-only with no new cross-file links or anchors, and I checked it by hand for em-dashes and internal references (none). This gate needs running by someone with a working docs toolchain.

Known unrelated failure

internal/engine's TestResolveQualifiedPools_ReSeedAfterRescore is flaky. Measured with -count=1 in isolation, 30 runs each: 9/30 failures with this branch's changes applied and 5/30 on a clean tree, so it fails independently of this work. internal/engine cannot import internal/mobileapp (import cycle), and this branch changes no engine file, so it cannot be the cause. Recorded on mp-n6ke alongside its sibling TestResolveQualifiedPools_Incremental.

Regression caught and fixed during review

The first cut of the band checked the raw incoming value. Because ApplyCompetitionDefaults back-fills the seconds fields from the legacy whole-minute fields on read, a competition configured as match_duration: 15 is served as poolMatchDurationSeconds: 900 and the settings form PUTs that back on every save, so an unrelated rename returned 400 and the competition became permanently uneditable. The band now only gates a value that actually changes.

The unit test missed it because it built state.Competition literals directly and never ran the back-fill the real request path runs. It has been replaced with before/after cases plus two end-to-end HTTP tests that exercise the round-trip the settings form performs.

Review findings fixed

A tri-review (3 lenses, adversarial verification) returned 3 confirmed findings, all fixed:

  1. [medium] DurationInput could permanently disable Save for the whole settings card. A grandfathered out-of-band value mounted without an error, but any keystroke re-parsed it through the hard client band and errored; onChange never fired, so the prop never moved and the error never cleared. Only blanking the field escaped, and nothing signposted that. Removing the legacy surface removes the root cause: no displayed value can be out of band any more. Verified in-browser by retyping the displayed 10:00 on a migrated competition, which leaves Save enabled.
  2. [medium] Clearing a duration silently kept the old value while the field displayed "Using the default, 3:00" -- a save confirming an outcome it had not performed. A clear now stages 0 (the wire value for "unset") and genuinely resets; verified by pool_match_duration_seconds disappearing from config.md.
  3. [low] A stranded comment block inside validateCompetitionFormat's doc comment still claimed the seconds cap was 24 hours, which stopped being true when the band landed.

Deliberately not changed

GET /api/schedule/estimate still takes a matchDuration query param in float minutes. It is a stateless calculator rather than a back-compat duplicate, and it is public and unauthenticated, so changing its contract belongs in its own PR.

Closes mp-m5kf

gitrgoliveira added a commit that referenced this pull request Jul 24, 2026
gitrgoliveira added a commit that referenced this pull request Jul 24, 2026
gitrgoliveira and others added 6 commits July 26, 2026 17:16
Operators could only enter whole-minute match durations. Add minutes+seconds
(mm:ss) input across all three duration surfaces — per-competition Pool/Playoff
config, the schedule estimator, and the auto-schedule box — and persist
durations as integer seconds.

Backend:
- state.Competition gains canonical PoolMatchDurationSeconds /
  PlayoffMatchDurationSeconds fields (YAML *_seconds tags) plus
  EffectivePoolMatchSeconds/EffectivePlayoffMatchSeconds helpers with a
  seconds -> legacy-minutes*60 -> MatchDuration*60 fallback chain.
- ApplyCompetitionDefaults back-fills the seconds fields from the legacy
  whole-minute fields so old config.md files migrate transparently; explicit
  sub-minute values are never clobbered.
- scheduler_slots.perMatchElapsedMinutes reads effective seconds and keeps
  sub-minute precision through the multiplier (rounds only at the minute-
  granular slot boundary). Fallback default is now 180s.
- Validation and the PUT settings merge cover the new fields; OpenAPI
  Competition schema documents them (and the legacy minute fields).

Frontend:
- New shared DurationInput (min + sec) bound to a single integer-seconds value.
- Pure helpers secondsToMMSS / mmssToSeconds / clampDurationSeconds /
  effectiveDurationSeconds in admin_schedule_utils.jsx (unit tested).
- Wired into competition settings and the schedule page (estimator sends
  seconds/60 as float minutes; auto-schedule accumulates fractional minutes
  and rounds each emitted HH:MM slot).

Tests: Go seconds back-fill/precedence/precision/validation; JS mm:ss helper
round-trips and updated settings allowlist. Verified end-to-end in a real
browser (mm:ss inputs render and persist 2m30s/3m30s).

Closes mp-m5kf

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Collapse the two near-identical Effective*MatchSeconds methods into one
  shared effectiveMatchSeconds helper.
- Remove dead secondsToMMSS / mmssToSeconds helpers (nothing consumes them;
  DurationInput uses separate min+sec fields), plus their exports and tests.
- DurationInput: drop no-op value ternaries and an unreachable finite-guard.
- scheduler_slots nil-comp branch: derive the default via integer division
  instead of a float round-trip.

No behavior change. Go + JS suites green, lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Findings from a code-review + security-review + robustness pass, applied:

- HIGH: the GET competition read handlers (list + single) now call
  ApplyCompetitionDefaults, so a legacy config carrying only the single
  match_duration field (or per-phase minutes) is normalized to the canonical
  *Seconds on the wire. Previously the SPA's 2-tier resolver returned NaN and
  the Settings page rendered blank ("default 3") while the scheduler ran the
  real value; an unrelated save could then wipe it. Verified end-to-end: a
  legacy match_duration:5 comp now shows 5:00.
- effectiveDurationSeconds (JS) gains the third legacy-MatchDuration tier so it
  is a faithful mirror of the Go effectiveMatchSeconds even for a raw
  (unnormalized) record such as an SSE push.
- perMatchElapsedMinutes floors a positive sub-minute clock to >=1 slot minute,
  so e.g. 15s (0.375 elapsed -> rounds to 0) no longer stacks every match at
  the same start time.
- validateCompetitionDurations gains upper bounds (24h: 86400s / 1440min) on
  all duration fields, preventing int64 time-arithmetic overflow into garbage
  schedules; DurationInput mirrors the cap client-side (clamp + max attr).
- OpenAPI: correct the "0 = 180s default" wording (the whole-minute sibling is
  consulted first) and document the min/max bounds.

Tests: Go PUT-persist + GET-normalize roundtrip, sub-minute floor, upper-bound
validation; new DurationInput render suite; 3-tier effectiveDurationSeconds.
Go + JS suites green, lint clean, coverage >=85% on touched packages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hDuration

Two low-severity findings from the parallel multi-lens + adversarial-verify
review, both confirmed:

- DurationInput.emit clamped minutes (<=1440) and seconds (<=59) independently
  but not the combined total, so 1440min + a nonzero seconds value emitted
  86430s > the server's 86400s cap, contradicting the component's own "never
  emits a value the server rejects" contract. Cap the combined total at
  DURATION_MAX_MINUTES*60. Added a render test.
- clampMatchDuration became dead code once this PR replaced its only call site
  with clampDurationSeconds. Removed the helper, its import/re-export, and its
  tests (same cleanup already done for secondsToMMSS/mmssToSeconds).

JS unit + render suites green, lint + check-imports clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- admin_competition_settings.jsx: the pool and playoff mm:ss field blocks were
  near-identical copy-paste; extract a local durationField(label, secKey,
  minKey, hint) render helper so the two hint strings can't drift. Verified in
  a real browser: Pool 2:30 / Playoff 3:30 render identically.
- duration.jsx emit(): unify the per-sub-field clamps into one Math.min/Math.max
  expression each (the mStr/sStr === "" special cases are subsumed by
  Number("") || 0 === 0); the combined-total cap is unchanged.

No behavior change. JS unit + render suites green, lint + check-imports clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gitrgoliveira
gitrgoliveira force-pushed the feat/match-duration-seconds-mp-m5kf branch from 91da53f to 72ba611 Compare July 26, 2026 16:19
gitrgoliveira and others added 7 commits July 27, 2026 11:44
/impeccable critique of duration.jsx scored 15/40 and found the control
I shipped was broken in ways my own browser check missed. Fixes all of it.

The two P0s:

1. `.row` is a two-column CSS grid (styles.css:833), not a flex row, so
   the four children laid out as a 2x2 block: each unit caption stranded
   114-251px from its input, collapsing to four stacked strips under
   720px with "min" sitting above the *seconds* box. Replaced with a
   named .duration-input class, per DESIGN.md section 9.

2. The label promised `(min:sec)` but the control could not accept a
   colon: Chrome strips it from a type=number input, so typing "2:30"
   saved as 230 MINUTES with no warning. Match duration drives
   auto-scheduling for the whole event.

Both are fixed by making the control one masked text field that actually
accepts the advertised format. A bare number still reads as whole minutes,
preserving the habit from the minutes-only field this replaced.

Out-of-band durations are now refused rather than clamped: a fat-fingered
0:03 used to persist to config.md and drive the day's timetable. The band
is 0:30-10:00, enforced client-side (error state, Save/Auto-schedule
blocked) and server-side (400), because a client-only block is not a
block. It applies only to the new *Seconds fields; the legacy whole-minute
fields keep their 24h ceiling so an existing 15-minute competition stays
saveable when an unrelated field is edited.

Also from the critique:
- Touch targets were 68x35px against the project's own >=44px coarse-
  pointer floor, and 14px type triggered iOS zoom-on-focus. Now 44px/16px.
- No programmatic label: aria-label="minutes"/"seconds" gave a screen
  reader four indistinguishable announcements with no Pool/Playoff
  distinction. Now label htmlFor + aria-describedby + inputmode=numeric.
- Blank state was ambiguous and took two steps to reach. Now one field,
  with an explicit "Using the default, 3:00." note.
- Resting border was 1.25:1 vs WCAG 1.4.11's 3:1. Now --ink-4 at 4.7:1.
- `.label` and `.form-group` have no definition anywhere in styles.css,
  so the estimator's label rendered at 16px/400 beside siblings at
  12px/600. Swapped to .field__label / .field.

Verified in Chrome at 1280x1000 and 390x844 (coarse pointer): legacy
match_duration:4 reads back as 4:00; "2:30" saves as 150s; "0:03" and
"20" are refused with Save disabled; "2:3" normalizes to "2:03" on blur;
a raw PUT of 3 seconds returns 400. No console errors.

Refs mp-m5kf

Co-Authored-By: Claude <noreply@anthropic.com>
Requirement: changing a match duration must not require regenerating the
draw. That already held, and is now pinned by a test: duration feeds the
scheduler only and reaches neither pool allocation nor the bracket, so it
is deliberately absent from the draw-ready outputAffecting set. Verified
end to end: PUT returns 200, status stays draw-ready, and pools.csv is
byte-identical (same md5) after the change.

Checking that surfaced a regression I introduced with the shiai band in
the previous commit, which was worse than the problem the band solved.

ApplyCompetitionDefaults back-fills the canonical seconds fields from the
legacy whole-minute fields on READ. A competition configured long ago as
`match_duration: 15` is therefore served to the settings form as
poolMatchDurationSeconds=900, and the form PUTs the full config back on
every save. Banding the raw incoming value meant renaming that
competition returned 400: it became permanently uneditable.

The band now gates a duration the operator actually CHANGES. An unchanged
value is grandfathered, so existing data stays editable, while a
fat-fingered 0:03 or a newly-typed 20:00 is still refused. Split out of
validateCompetitionDurations into validateDurationBand, which needs the
stored competition to compare against and so runs inside the PUT
transform; create passes nil, because a new competition has nothing to
grandfather.

My unit test asserted `{PoolMatchDuration: 15}` passes, but built the
struct literal directly and never ran the back-fill the real request path
runs, so it was checking a state that cannot occur. Replaced with
before/after cases, plus two end-to-end HTTP tests that exercise the
actual round-trip the settings form performs.

Also corrects the OpenAPI `maximum`, which claimed 600 while the server
accepts a grandfathered value above it; it is now the structural ceiling
with the band described in prose.

Refs mp-m5kf

Co-Authored-By: Claude <noreply@anthropic.com>
Removes the legacy duration API surface rather than maintaining it
alongside the canonical seconds fields.

poolMatchDuration / playoffMatchDuration / matchDuration are gone from
the wire (json:"-") and from the OpenAPI Competition schema. They survive
on the struct for one reason only: an old config.md on disk still carries
them, and ApplyCompetitionDefaults reads them once to migrate the value
onto the seconds fields, then zeroes them so omitempty drops the keys on
the next save. The on-disk file converges on the seconds-only schema.

The migrated value is CLAMPED into the 30-600s shiai band. That clamp is
load-bearing well beyond the migration: because no stored duration can be
out of band, nothing downstream has to special-case one. Which means the
grandfathering added last commit is deleted outright, not patched:
validateDurationBand and its next/prev comparison are gone, and
validateCompetitionDurations is a flat check again. A 15-minute
competition becomes 10:00, which is lossy and deliberate: preferred over
dropping the value (silent reset to 3:00) and over keeping it (a value
the UI can display but not re-submit).

Migration runs at BOTH store boundaries. parseCompetitionFile covers
reads, but loadCached serves a cached struct without re-parsing and
saveCompetitionChangedLocked both writes the bytes and seeds that cache,
so an in-process caller could otherwise reintroduce legacy keys. Applying
it on save too makes "a stored competition is always on canonical
seconds" true at every entry point.

This also resolves both medium findings from the tri-review:

1. DurationInput could permanently disable Save for the whole settings
   card. A grandfathered out-of-band value (900s) mounted without error,
   but any keystroke re-parsed it through the hard client band and
   errored, and onChange never fired, so the prop never moved and the
   error never cleared. Only blanking the field escaped, which nothing
   signposted. Root cause removed: no displayed value can be out of band.
   Verified in-browser by retyping the displayed 10:00 on a migrated
   competition, which now leaves Save enabled.

2. Clearing a duration staged NaN, which hit saveNow's safeNonNegInt
   disk-clobber fallback and silently re-saved the old value while the
   field displayed "Using the default, 3:00" -- a save confirming an
   outcome it had not performed. With no legacy fallback tier left, a
   clear can now stage 0 (the wire value for "unset") and genuinely
   reset. Verified: pool_match_duration_seconds disappears from
   config.md. The NaN fallback still guards fields nobody touched.

Third finding (low) fixed too: the duration-const comment block was
stranded inside validateCompetitionFormat's doc comment and still claimed
maxMatchDurationSeconds capped at 24 hours, which stopped being true when
the band landed.

Also drops the now-dead 3-tier resolver (Go effectiveMatchSeconds and its
JS mirror), the obsolete seconds-over-minutes precedence test, dead effect
deps on the removed fields, and two stale comments.

NOT changed: the matchDuration query param on GET /api/schedule/estimate.
That is a stateless calculator taking float minutes, not a back-compat
duplicate, and it is public and unauthenticated. Flagged, not folded in.

Refs mp-m5kf

Co-Authored-By: Claude <noreply@anthropic.com>
Was 0:30-10:00. Floor 60s, ceiling 3600s, per the operator requirement.

The wider ceiling makes the legacy migration effectively lossless. The
retired duration fields hold whole MINUTES, so the smallest value they can
express (1) now sits exactly on the floor, and 60:00 is far above any real
match: a competition stored as `match_duration: 15` carries over as 900s
untouched, where the old 600s ceiling pinned it to 10:00. The clamp stays,
but only fires on an absurd stored value such as 90 minutes.

One behavioural consequence worth naming: the bare ":ss" shorthand is now
unreachable. The seconds component tops out at 59, so any ":45" is under
the 1:00 floor by construction. It parsed fine under the old 0:30 floor.
Pinned by a test so it reads as intended rather than as a regression.

Verified against a running server, client and server agreeing exactly:
0:30 and 0:59 rejected, 1:00 / 15:00 / 60:00 accepted, 60:01 and 90:00
rejected, with matching "Minimum is 1:00." / "Maximum is 60:00." messages
in the UI and HTTP 400s from the API.

Refs mp-m5kf

Co-Authored-By: Claude <noreply@anthropic.com>
Second tri-review. Three distinct findings (two of the four confirmed
entries were the same bug at the same lines), all fixed.

1. [medium] The "no stored duration is out of band" invariant was false.
   ApplyCompetitionDefaults only clamped a value it had just derived from
   the retired whole-minute fields; an already-populated seconds value was
   left untouched however absurd. That invariant is exactly what lets
   validateCompetitionDurations be a flat band check with nothing to
   grandfather, so a config.md hand-edited to
   pool_match_duration_seconds: 99999 loaded as-is and then failed EVERY
   later settings save with 400. The competition became uneditable: the
   same failure the deleted grandfathering design existed to avoid,
   arriving through a different door.

   Fixed at the store boundary, NOT the way the review suggested. Its fix
   was to clamp unconditionally inside ApplyCompetitionDefaults, which is
   unsafe here: handlers call that on an inbound request body, and POST
   does so at line 350, BEFORE validateCompetitionDurations at line 446.
   Clamping there would have silently rewritten an out-of-band POST to the
   ceiling and returned 201 instead of 400, converting a refusal into a
   quiet mutation, which is the precise behaviour this control exists to
   prevent. So the clamp lives in a new store-only normalizeStoredDurations
   and the two jobs are now separate by construction:

     inbound (handlers) -> validateCompetitionDurations: reject, never clamp
     stored   (state)   -> normalizeStoredDurations:     clamp,  never reject

   Pinned by a test asserting POST still 400s, so a future unconditional
   clamp in the shared helper cannot silently re-break it.

2. [medium] DurationInput's prop-resync cleared its own error but never
   called onValidity, so the parent's per-field error map kept Save
   disabled showing "Fix match duration" with no error visible anywhere.
   Reachable because an invalid draft never reaches onChange, so the field
   is never added to editedFieldsRef and an SSE push CAN move the prop out
   from under it. The regression test fails without the one-line fix.

3. [low] The four state.ApplyCompetitionDefaults calls in the competition
   handlers are all redundant: the store normalizes on load and save, and
   the retired fields are json:"-" so an inbound body can never carry them.
   Removed. This also deletes the ordering hazard behind finding 1 outright
   rather than leaving it as a trap for the next person.

Verified against a running server: a hand-edited 99999s config loads as
3600, renaming it returns 200 (previously a permanent 400), a POST of
99999s still returns 400 rather than being clamped, and config.md
converges to 3600 on disk.

Refs mp-m5kf

Co-Authored-By: Claude <noreply@anthropic.com>
Third tri-review: 3 confirmed, all low, no agent failures. The deep
targets came back clean, including the ones round 2 left unproven (the
byte-equality early return in saveCompetitionChangedLocked, WAL/StoreTx
paths, mutating the caller's struct inside Save, spurious onValidity
clearing, parseDuration input safety, scheduler arithmetic).

1. [low] The saveNow payload allowlist still listed poolMatchDuration and
   playoffMatchDuration, with a comment claiming the minute fields were
   "retained for backward compatibility". Both statements stopped being
   true when those fields became json:"-". Commit 4ce364b corrected the
   sibling EXPECTED_DEPS list and missed this one.

   The assertion is one-directional (finalNext's keys must be a SUBSET of
   ALLOWED), so a stale entry does not just document the wrong thing: it
   silently permits the exact regression the guard exists to catch. Moved
   the three retired names into FORBIDDEN instead of merely dropping them
   from ALLOWED, which turns a silent pass into an explicit failure.
   Verified by injecting the regression: the guard fails with it and
   passes without it.

2. [low] EffectivePoolMatchSeconds' doc claimed duration reads have "one
   seam", which the code does not enforce: validateCompetitionDurations
   and the PUT merge read the struct field directly. Rewritten to say what
   is actually true, that the method is the seam for CONSUMERS of a
   duration (the scheduler), while code acting on the wire representation
   reads the field.

3. [low] Six engine-side ApplyCompetitionDefaults calls are now no-ops.
   NOT fixed here: those files are not in this PR's diff, the calls are
   pre-existing defensive code, and the same idiom already exists for
   tournaments (engine re-applies ApplyTournamentDefaults after a load
   that already applied it). That makes it a codebase-wide convention
   question rather than a defect in this change, so it is filed to be
   decided once and applied to both families.

Refs mp-m5kf

Co-Authored-By: Claude <noreply@anthropic.com>
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