[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-116eventCategory 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]".

2. The participant may not be eligible for the target category

Categories carry minAge, maxAge and gender. EventParticipantServiceEx.applyCategoryEligibility mirrors EventCategorySelectFormField#isEligible, so the admin path and the registration path must agree on what "eligible" means.

3. The race number may become the wrong sub-type

EventCategory carries defaultNumberSubType and defaultNumberSubType2 (EventCategory.java:278-287). Move a participant into a category with a different default sub-type and they keep a number of the old kind.

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.

  1. Pick — filterable dropdown of the event’s categories where entryCategory = true. Non-entry categories are never offered.

  2. PreviewPOST /api/event-participants/{id}/category-change-preview returns the impact summary. All three consequences above render together (see Preview contents). Read-only; commits nothing.

  3. 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. Confirm disabled.

  • AF-3 — eligibility warning showing and the note is empty. Confirm disabled, 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

GET /api/event-categories?eventId.equals={eventId}&entryCategory.equals=true

Populates the category picker.

POST /api/event-participants/{id}/category-change-preview

US #624 — shipped 2.4.13. Dry-run returning the race / start-group impact summary. Read-only.

POST /api/event-participants/{id}/change-category

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 — CategoryChangeServiceEx, both endpoints, start-group reconciliation, and the public detach-one primitive (RaceNumberAssignmentServiceEx.detachNumber). CategoryChangePreviewDTO carries every field the dialog renders. The list is kept as the record of what was required.

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:

  1. It does not reconcile StartGroupParticipant rows, so it commits the stale seeding this dialog exists to prevent.

  2. It has nowhere to record the eligibility-override note.

  3. It cannot detach the number in the same transaction.

Required:

  1. A public detach-one primitive on RaceNumberAssignmentServiceEx — clear one EP’s number_id, write a DETACHED RaceNumberStateLog row against the number, leave the number’s own state untouched. The existing detachFutureSameTypePeers does exactly this but is private and targets future peers rather than the originating EP; both public entry points (recordNumberAssignment) require a new number to assign. RaceNumberStateLogReason.DETACHED already exists, so this is a missing entry point, not a missing concept.

  2. Start-group reconciliation — on commit, remove StartGroupParticipant rows whose startGroup.race.eventCategory no longer matches. Re-seeding into the new race is not attempted; the participant is left unseeded and the existing seeding workflow picks them up.

  3. POST /api/event-participants/{id}/category-change-preview (US #624) — read-only, returns races and start groups left and joined.

  4. POST /api/event-participants/{id}/change-category taking (categoryId, note), returning the new category, the start-group rows removed, and whether the number was detached. One transaction.

  5. Eligibility reused, not reimplemented — the API records the override rather than refusing it.

Acceptance Criteria

  • Category picker offers only entryCategory = true categories 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; Confirm disabled until supplied

  • Detach box renders exactly when the target’s default sub-type differs from the held number’s

  • Number is detached with a DETACHED log row; the number’s own state is unchanged

  • Stale StartGroupParticipant rows 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. EventCategory carries a Product, 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

Notes

The "About this dialog" collapsible information panel established in C04 applies here too — default expanded on first open per browser, collapsed thereafter, state in localStorage.