Skip to content

feat(scoring): carry the reason for a result through to the record - #1474

Merged
CourtHive merged 3 commits into
mainfrom
feat/scoring-reason-codes
Sep 19, 2026
Merged

CourtHive merged 3 commits into
mainfrom
feat/scoring-reason-codes

Conversation

@CourtHive

@CourtHive CourtHive commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

What

The scoring modal can now record why a match ended — Ret [inj] rather than just RETIRED — and TMX carries that through to the record.

The gap this closes

The engine has accepted outcome.matchUpStatusCodes for a long time: it reaches modifyMatchUpScore untouched. TMX was the one place that dropped it, building its outcome without the field, so nothing a modal offered could ever have landed.

How the vocabulary is resolved

Per event, since a scoring policy can be attached at that level, and read through findPolicy — the same call renderDraws/getActionOptions.ts already uses.

Nothing is bundled. TMX ships no built-in vocabulary by design (CA, 2026-09-20): the reason control appears only where a governing body's policy is attached, and is absent everywhere else. undefined here is the ordinary case, not a failure to defend against.

One detail that is a decision

The code is forwarded only when one was actually chosen. The factory reads an empty matchUpStatusCodes array as an instruction to blank the codes — so sending [] to mean "no reason given" would erase what a previous edit recorded.

Verified against the published resolution, not the linked sibling

This is a cross-repo boundary change, so a local green against link:../courthive-components would have proved nothing. Reproduced CI's resolution instead — overrides stripped, pnpm install --no-frozen-lockfile, courthive-components resolving to 4.5.0 from npm:

gate result
check-types 0
lint 0
test 183 files / 2005 tests

The workspace file was then restored byte-identical and the sibling symlink is back.

Full gate also green against the linked sibling: lint / format:check / attr-audit --ci / i18n-audit --ci / check-types / test all 0.

The pin bump is load-bearing; the lockfile is not

pnpm-lock.yaml is deliberately unchanged. It records courthive-components as link:../courthive-components because the workspace override wins locally; CI strips that line and re-resolves this one entry from the package.json pin. 4.4.0 — the previous pin, and also the previously-latest published version — knew nothing of ScoringModalParams.matchUpStatusCodes or StatusCodeGroups.

Completes the series

courthive-components #571 (explicit double exits), #573 (the six policy endings), #574 (the reason-code picker) — all merged and released as 4.5.0.

Acceptance test included

Journey 126 drives the real modal end to end and asserts the chosen reason reaches matchUp.matchUpStatusCodes. Both halves are asserted, and the negative one is not a formality — "no reason control" is the state nearly every tournament is in, and a resolver that quietly returns undefined is indistinguishable from a tournament with no policy attached.

asserted
no policy attached control absent, result still submits
policy attached control offers the policy's own codes; RJ lands on the record

Falsified rather than assumed: removing the single line in scoreMatchUp.ts that forwards the codes turns the positive test red at exactly expect(codes).toContain('RJ'), while the negative test correctly stays green.

Also adds a mock tournament, "Reason Codes Invitational" — the only mock carrying a reason vocabulary — so the control can be found by hand and not only by Playwright. completeAllMatchUps is deliberately off: a tournament with everything already scored has nothing to demonstrate it on.

Both take the vocabulary from fixtures.policies.POLICY_SCORING_USTA rather than restating it; a test asserting against its own copy asserts nothing about the real one.

enterMatchUpScore joins the dev bridge for the same reason fetchUserContext is there: exposing the real entry point keeps the journey on the production path, including the policy lookup that decides whether the control exists at all.

Journey 126 passes locally (2/2). Note TMX PR CI does not run the journey suite — electron.yml scopes Playwright to the desktop smoke — so that number is local-only evidence, as the standards require me to say.

The scoring modal can now record WHY a match ended — "Ret [inj]" rather than
just RETIRED — when the tournament has a scoring policy that says what the
reasons are. This is the TMX half: resolve that vocabulary and forward the
chosen code into the mutation.

The engine has accepted `outcome.matchUpStatusCodes` for a long time; it reaches
`modifyMatchUpScore` untouched. TMX was the one place that dropped it, building
its outcome without the field, so nothing a modal offered could ever have landed.

The vocabulary is resolved per EVENT, since a scoring policy can be attached at
that level, and is read through `findPolicy` — the same call the draw action
options already use. Nothing is bundled: TMX ships no built-in vocabulary by
design (CA, 2026-09-20), so the reason control appears only where a governing
body's policy is attached and is absent everywhere else. `undefined` here is the
ordinary case, not a failure to defend against.

The code is forwarded only when one was actually chosen. The factory reads an
EMPTY `matchUpStatusCodes` array as an instruction to blank the codes, so
sending `[]` to mean "no reason given" would erase what a previous edit recorded.

DEPENDS ON UNPUBLISHED WORK — do not open this as a PR yet. It needs the
courthive-components status-code picker, which is merged into neither main nor a
release. TMX pins courthive-components 4.4.0, which is also the latest published
version, and CI strips the link: overrides and installs exactly that — so
check-types would fail in CI even though it passes locally against the linked
sibling. The order is: merge the components picker, publish components, bump the
pin here, then open this.

Local gate, all against the LINKED sibling and stated as such: lint, format:check,
attr-audit --ci, i18n-audit --ci, check-types all exit 0; 183 test files / 2005
tests pass.
4.5.0 is the release carrying the status-code picker this branch depends on.
Until it existed, CI could not type-check the commit below: TMX CI strips the
`link:` overrides and resolves the PUBLISHED package, and the previous pin —
4.4.0, which was also the latest published — knew nothing of
`ScoringModalParams.matchUpStatusCodes` or the `StatusCodeGroups` type.

`pnpm-lock.yaml` is deliberately unchanged. The lockfile records
`courthive-components` as `link:../courthive-components` because the workspace
override wins locally; CI strips that line and re-resolves this one entry from
the pin above, so the pin is the load-bearing value and the lockfile has nothing
to say about it.

Verified against the published resolution rather than the linked sibling —
overrides stripped, `pnpm install --no-frozen-lockfile`, components resolving to
4.5.0 from npm: check-types, lint, and 183 test files / 2005 tests all pass. The
workspace file was then restored byte-identical and the sibling symlink is back.
…hout

Journey 126 drives the real modal end to end and asserts the chosen reason
reaches `matchUp.matchUpStatusCodes`. Every layer beneath that assertion could
be green while the code was dropped in transit — the engine has accepted
`outcome.matchUpStatusCodes` for a long time, and TMX was the one place that
never sent it, which is exactly the kind of gap unit tests cannot see.

BOTH halves are asserted, and the negative one is not a formality. TMX ships no
built-in vocabulary by design, so "no reason control" is the state nearly every
tournament is in — and it is the state most likely to become permanent by
accident, because a resolver that quietly returns undefined is indistinguishable
from a tournament with no policy attached. So: without a policy the control must
be absent and the result must still submit; with one attached the control must
offer the policy's own codes and the chosen code must land on the record.

The journey enters a partial set before marking the retirement, which is both
what a retirement actually looks like and what REVEALS the irregular-ending
controls — dynamicSets keeps them hidden until a score is in progress. The
radios are in the DOM from the start but not visible, so waiting only for them
to be attached would have waited on a control the operator cannot click.

Also adds a mock tournament, "Reason Codes Invitational", carrying the same
policy. It is the only mock with a reason vocabulary attached, and it exists so
the control can be found by hand rather than only by Playwright.
`completeAllMatchUps` is deliberately off: a tournament with everything already
scored has nothing to demonstrate the control on.

Both take the vocabulary from `fixtures.policies.POLICY_SCORING_USTA` rather
than restating it. A hand-copied vocabulary is how the scoring editor and the
factory fixture drifted apart while both looked correct, and a test asserting
against its own copy asserts nothing about the real one.

`enterMatchUpScore` joins the dev bridge for the same reason `fetchUserContext`
is there — exposing the real entry point rather than a test-only stand-in keeps
the journey on the production path, including the policy lookup that decides
whether the control exists at all.

Verified by falsification, not just by passing: removing the one line in
scoreMatchUp.ts that forwards the codes turns the positive test red at exactly
`expect(codes).toContain('RJ')`, while the negative test correctly stays green.
@CourtHive
CourtHive merged commit 56d9fa3 into main Sep 19, 2026
2 checks passed
@CourtHive
CourtHive deleted the feat/scoring-reason-codes branch September 19, 2026 21:50
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