feat(mp-m5kf): support sub-minute match durations (mm:ss) - #365
Open
gitrgoliveira wants to merge 13 commits into
Open
feat(mp-m5kf): support sub-minute match durations (mm:ss)#365gitrgoliveira wants to merge 13 commits into
gitrgoliveira wants to merge 13 commits into
Conversation
gitrgoliveira
added a commit
that referenced
this pull request
Jul 24, 2026
gitrgoliveira
added a commit
that referenced
this pull request
Jul 24, 2026
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
force-pushed
the
feat/match-duration-seconds-mp-m5kf
branch
from
July 26, 2026 16:19
91da53f to
72ba611
Compare
/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>
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.
Summary
PoolMatchDurationSeconds/PlayoffMatchDurationSecondsfields supersede the legacy whole-minute fields, which are retained and auto-migrated (x60) so existingconfig.mdfiles keep working with no manual step.poolMatchDuration/playoffMatchDuration/matchDurationare gone from the wire and from the OpenAPI schema. An oldconfig.mdis 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.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:
className="row", but.rowis 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.(min:sec), but atype=numberinput cannot hold a colon: Chrome strips it, so typing2:30produced 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
internal/state/models.goPool/PlayoffMatchDurationSecondsfields + sharedeffectiveMatchSeconds3-tier resolver;ApplyCompetitionDefaultsback-fills seconds from legacy minutesinternal/engine/scheduler_slots.gointernal/mobileapp/handlers_competition.gospecs/openapi.yamlweb-mobile/js/duration.jsxDurationInputplus pureparseDuration/formatDurationand the band constantsweb-mobile/css/styles.css.duration-inputblock (named class, not a.rowreuse); coarse-pointer 44px/16px;--ink-4border for WCAG 1.4.11web-mobile/js/admin_schedule_utils.jsxclampDurationSeconds/effectiveDurationSecondsmirroring the Go resolverweb-mobile/js/admin_competition_settings.jsxlabel htmlForbinding; block Save while a duration is invalidweb-mobile/js/admin_schedule_page.jsx.label/.form-groupclasses for.field__label/.fieldinternal/state/competition_test.go,internal/engine/scheduler_slots_test.go,internal/mobileapp/handlers_competition_test.goweb-mobile/js/__tests__/render/duration_input.render.test.jsxdocs/user-guide/organisers/run-tournament.mdScreenshots
Competition Settings, legacy
match_duration: 4reading back as4:00. One field per duration, on one line, in the same visual language as every other field on the page:A refused value.
0:03gets a red border, an inlineMinimum 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: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:
Legacy migration
A competition stored as
match_duration: 15is 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:validateCompetitionDurationsis 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.
Test plan
. ./cmd/... ./internal/... ./tests/...;mobileappcoverage 85.8% (gate is 85%).golangci-lintreports 0 issues,gofmtclean,go build ./...clean.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.config.mdcarrying onlymatch_duration: 4renders as4:00.2:30is accepted, saves as 150s, andpool_match_duration_seconds: 150lands on disk.0:03shows "Minimum is 0:30." and disables Save;20shows "Maximum is 10:00." and disables Save; recovery to a valid value clears both.2:3becomes2:03on Tab.3:00placeholder, and an explicit "Using the default, 3:00." note wired intoaria-describedby.PUTwithpoolMatchDurationSeconds: 3returns400 {"error":"match duration must be between 30 and 600 seconds"};150returns200..field__labelat 12px (the estimator label previously used.label, which has no definition anywhere instyles.css, so it rendered at inherited 16px/400), and both now carry a hint.PUTreturns 200, status staysdraw-ready, andpools.csvis byte-identical (same md5). Pinned byTestUpdateCompetition_DurationChangeKeepsDraw.match_duration: 15reads back aspoolMatchDurationSeconds: 900with the legacy keys gone from the wire, renders as15:00, and renaming it returns 200. Pinned byTestUpdateCompetition_LegacyDurationMigrates.0:30and0:59rejected,1:00/15:00/60:00accepted,60:01and90:00rejected, with matchingMinimum is 1:00./Maximum is 60:00.in the UI and HTTP 400 from the API.make docs/buildcould not run in this environment: thepython3.14-venvsystem package is missing, so.venv-docscannot 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'sTestResolveQualifiedPools_ReSeedAfterRescoreis flaky. Measured with-count=1in 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/enginecannot importinternal/mobileapp(import cycle), and this branch changes no engine file, so it cannot be the cause. Recorded onmp-n6kealongside its siblingTestResolveQualifiedPools_Incremental.Regression caught and fixed during review
The first cut of the band checked the raw incoming value. Because
ApplyCompetitionDefaultsback-fills the seconds fields from the legacy whole-minute fields on read, a competition configured asmatch_duration: 15is served aspoolMatchDurationSeconds: 900and 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.Competitionliterals 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:
DurationInputcould 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;onChangenever 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 displayed10:00on a migrated competition, which leaves Save enabled.pool_match_duration_secondsdisappearing fromconfig.md.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/estimatestill takes amatchDurationquery 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