[C08] Change Category Dialog
Summary
Cross-cutting dialog for moving an event participant from one EventCategory
to another. This is the concrete design for FU-1, declared in
E02 and left as a stub there. Its backend
dry-run is tracked as US #624.
First caller: E02, from either the click-to-change Category cell or the
overflow menu’s Change category — both open this dialog. Future callers:
E07 and
E09.
Split out from C04 on 2026-08-23. C04’s single job is deciding the fate of an old bib. A category move is a different decision with different consequences, and folding the two together would have cost C04 the reusability contract it holds for E07 and the E01 readiness panel.
The dialog exists because a category change is not a neutral field edit.
Today PATCH /api/event-participants/{id} writes the field and nothing else,
which silently leaves the participant in the wrong races and start groups.
Why this is not a plain field edit
Three consequences follow a category change, none visible in the participant list. Verified against the model on 2026-08-23.
1. Race membership moves — and start-group seeding is left behind
Race is the intersection of an EventRaceType, a Course and an
EventCategory (Race.java:100-116 — eventCategory is
@ManyToOne(optional = false)). So which Race a participant belongs to is
derived from their category: change the category and they belong to a
different race, with nothing to migrate.
Start groups are not derived. The chain is:
EventParticipant.startGroupParticipants (Set<StartGroupParticipant>)
-> StartGroupParticipant.startGroup
-> StartGroup.race
-> Race.eventCategory
StartGroupParticipant is a persisted join row carrying a seq
(StartGroupParticipant.java:35-71). After a category change those rows still
point at start groups belonging to the old category’s race. The participant
is seeded into a race they are no longer in.
Nothing cleans this up. EventParticipantService.update() and
partialUpdate() are plain mapper-driven saves — beyond the security check
they do only normaliseAdminReviewFlag and
customListUserMetaSyncService.syncEpToUserMeta. No start-group reconciliation
exists anywhere.
This is the impact FU-1 asks the dry-run to surface: "Removed from these races / start-groups: [list]; added to these: [list]".
Actor & Context
Actor: event organiser, results officer, tenant admin.
Frequency: ad-hoc — entry errors, riders moving up a category, corrections
after a date-of-birth fix.
Precondition: an EP exists; user has participants.manage.
Entry points: E02 click-to-change Category cell; E02 overflow menu
Change category. Both disabled while the bulk-action toolbar is active, per
E02.
Main Flow
Three steps, per FU-1.
-
Pick — filterable dropdown of the event’s categories where
entryCategory = true. Non-entry categories are never offered. -
Preview —
POST /api/event-participants/{id}/category-change-previewreturns the impact summary. All three consequences above render together (see Preview contents). Read-only; commits nothing. -
Confirm — commits as a single transaction.
Preview contents
Race and start-group impact
The headline. Rendered as two lists:
Leaving — Race: Vet Men 50km · Start group: 2026-09-05 06:20 · position 14
Joining — Race: Sub Vet Men 50km · Not seeded — the seeding workflow picks them up
Corrected 2026-08-26. An earlier revision of this page showed
Start group: B (position 14). There is no B — a StartGroup has no
stored name and deliberately never will: in practice a group is one race’s
batch, and requiring an operator to name each one would be maintenance for
nothing.
The label is derived on the entity (StartGroup.getName(), ADO Task #1038) and
arrives on StartGroupRef.name: the start time in the event’s zone, else
Batch <seq>, else Batch <id>, else Batch. When the event’s zone is
unknown it renders in UTC and says so, because an hour that is silently wrong
looks exactly like one that is right.
The UI appends the participant’s position and never formats the time itself — doing so would render it in the viewer’s browser zone, reintroducing the same bug at the other end of the wire.
Both sides are lists. A Race is the intersection of an EventRaceType, a
Course and an EventCategory, so a category maps to one race on the road and
to several on the track — typically one per EventRaceType (scratch, points
race, elimination, keirin). A single-race rendering is wrong for a whole
discipline while looking correct on every road example.
When the participant holds no start-group seeding, the leaving list shows the race only.
Eligibility warning
Shown when the participant fails the target’s minAge / maxAge / gender.
Copy names the specific failure, never a generic "not eligible":
-
Jan Botha is 41. Sub Vet Men is 42 and over. -
Jan Botha is male. Ladies is women only.
Decided 2026-08-23: warn, allow, and record. Moving someone into a category they do not qualify for is a legitimate override — a rider racing up, or a data error being worked around before the entry can be corrected — but it must leave a trace. The operator note becomes mandatory while this warning shows.
Number-detach information box
Shown when the target’s default sub-type differs from the sub-type of the held number. An information box, not a warning — this is the intended resting state:
Bib 412 is a P plate. Sub Vet Men issues H plates. It will be released, leaving this participant without a number until one is assigned.
Corrected 2026-08-26. The earlier wording ended "A new number is assigned
when numbers are next allocated", which is a promise about a future process.
Auto-assignment may not be configured for the event, may not run before race
day, and the response says nothing about it. Every other value in this notice
is a CategoryChangePreviewDTO field — heldNumber, currentNumberSubType
(the held number’s NumberType name), targetNumberSubType and
targetCategoryName — so the box now states the end state it can actually
vouch for.
numberNeedsDetaching also returns true when the held bib has no
NumberType at all, and applyNumberImpact then never sets
currentNumberSubType. Copy naming the current sub-type renders "Bib 412 is a
." in that case, so the box must branch: with no known sub-type it says the bib
does not match what the category issues, without naming a type it does not
have.
Decided 2026-08-23: clear rather than block or auto-swap. Clearing is what
the DETACH primitive already does for cascaded peers, and it is the state
auto-assignment is designed to reconcile — see
RaceNumberAssignmentServiceEx.detachFutureSameTypePeers, whose own note reads
"Auto-assign reconciles the NULLs later (ADR-0005, ADR-0006)". Picking a
replacement here would duplicate C04 inside C08 and force a stock decision the
operator came to this dialog to avoid.
When the sub-types match, no box renders and the number is left alone.
Alternative Flows
-
AF-1 — participant holds no number. No detach box, no detach call.
-
AF-2 — target category is the current category.
Confirmdisabled. -
AF-3 — eligibility warning showing and the note is empty.
Confirmdisabled, note field marked required. -
AF-4 — preview endpoint unavailable. Fall back to E02’s documented v1 stub: single-step dropdown commit with the banner "Impact preview available in a future release." The eligibility and detach notices are computed client-side from data already on the row, so they survive the fallback; only the race/start-group summary is lost.
-
AF-5 — the category write succeeds but start-group cleanup or the detach fails. Surface the move as done plus a warning naming what needs manual attention. Not rolled back — same posture as C04’s AF-1.
-
AF-6 — permission denied. Dialog closes with a clear error.
API Surface
All three shipped in admin-service 2.4.13.
| Call | Purpose |
|---|---|
|
Populates the category picker. |
|
US #624 — shipped 2.4.13. Dry-run returning the race / start-group impact summary. Read-only. |
|
Shipped 2.4.13. The commit, one transaction. |
Backend Work
|
Delivered 2026-08-26. This section was written on 2026-08-23 when neither
endpoint existed. All five items below shipped in admin-service 2.4.13 under
US #624 — C08 is therefore front-end only. |
The original finding, 2026-08-23: neither endpoint existed —
grep -rn "change-category\|changeCategory\|category-change-preview" over
admin-service/src/main returned nothing.
US #624 assumed the existing PATCH /api/event-participants/{id} would serve
as the commit. That is not sufficient, for three reasons found since:
-
It does not reconcile
StartGroupParticipantrows, so it commits the stale seeding this dialog exists to prevent. -
It has nowhere to record the eligibility-override note.
-
It cannot detach the number in the same transaction.
Required:
-
A public detach-one primitive on
RaceNumberAssignmentServiceEx— clear one EP’snumber_id, write aDETACHEDRaceNumberStateLogrow against the number, leave the number’s own state untouched. The existingdetachFutureSameTypePeersdoes exactly this but isprivateand targets future peers rather than the originating EP; both public entry points (recordNumberAssignment) require a new number to assign.RaceNumberStateLogReason.DETACHEDalready exists, so this is a missing entry point, not a missing concept. -
Start-group reconciliation — on commit, remove
StartGroupParticipantrows whosestartGroup.race.eventCategoryno longer matches. Re-seeding into the new race is not attempted; the participant is left unseeded and the existing seeding workflow picks them up. -
POST /api/event-participants/{id}/category-change-preview(US #624) — read-only, returns races and start groups left and joined. -
POST /api/event-participants/{id}/change-categorytaking(categoryId, note), returning the new category, the start-group rows removed, and whether the number was detached. One transaction. -
Eligibility reused, not reimplemented — the API records the override rather than refusing it.
Acceptance Criteria
-
Category picker offers only
entryCategory = truecategories for the event -
Preview lists races and start groups left and joined, and commits nothing — verified by an integration test asserting DB state is unchanged
-
Eligibility warning names the specific failure (age or gender), not a generic message
-
Note is mandatory while the eligibility warning shows;
Confirmdisabled until supplied -
Detach box renders exactly when the target’s default sub-type differs from the held number’s
-
Number is detached with a
DETACHEDlog row; the number’s own state is unchanged -
Stale
StartGroupParticipantrows are removed on commit; participant is left unseeded, not silently re-seeded -
Participant holding no number and no seeding changes category with neither extra call
-
Override note is retrievable afterwards — an auditor can find why the move happened
-
Commit is a single transaction — AF-5 is the only partial-failure path
-
Component is reusable: no E02-specific imports; EP context in, result event out
-
Bulk-toolbar interaction respected — entry points disabled while ≥2 rows selected
Out of Scope
-
Choosing the replacement number. That is C04.
-
Re-seeding into the new race’s start groups. The participant is left unseeded for the existing seeding workflow.
-
Bulk category change. Single-EP for v1, matching C04. E02 lists it as a future bulk action with FU-1-style preview.
-
Auto-assigning the replacement number. Future auto-assignment feature; this dialog deliberately leaves the number NULL for it to reconcile.
-
Re-pricing.
EventCategorycarries aProduct, so a category move can move a participant between price points. Nothing adjusts the order. Needs its own story — a finance question, not a dialog question.
Design Anchors
-
E02 Event Participants — FU-1, the click-to-change cell pattern, and the bulk-toolbar disable rule
-
C04 Reassignment Dialog — sibling dialog; match modal width, header treatment, footer toolbar, and the "About this dialog" panel pattern