SwissTennis Data-Import Integration¶
Owner: Alex (Business Analyst)
Version: 0.4 (2026-05-25) — see §10 changelog
Status: Rafael consult on §8.1 + §8.2 resolved. Unblocked for Morgan implementation planning (§8.5 schema and §8.6 date-indexing remain as in-flight impl-side items, not domain blockers).
Supersedes: none — this is a new capability that extends the staged ingestion roadmap (docs/requirements.md §3, stage 3 "Automated").
Companion docs:
- docs/specs/swisstennis-api-integration-impl-plan.md — Morgan's implementation plan (package layout, schema migration, sync scheduler, GitLab issue breakdown). Read this when planning the build.
- docs/import-spec-alex.md — offline file import (PlayerList.xls + DisplayDraw.pdf). Stays the fallback path.
- docs/requirements.md — entity model the import populates.
- docs/seeding-placement-rafael.md — SE seeding semantics, referenced by §8 open questions.
1. Executive Summary¶
SwissTennis (mytennis.ch SPA + comp.swisstennis.ch legacy Advantage servlets) is the master system of record for Swiss tournaments. Organizers currently re-key tournament data into CSD or import it via offline .xls/.pdf artifacts. This spec defines a live online import that pulls the same data directly over HTTP, eliminating re-keying and enabling background sync of schedule and result changes while a tournament is in flight.
The integration uses the anonymous JSON Advantage servlets (no SwissTennis credentials required) for Phase 1, with an opt-in authenticated path in Phase 2 to unlock surfaces the public endpoints do not cover (notably the cross-event master Calendar). PII exposed by the anonymous endpoints is filtered at ingest per a strict whitelist (§4.4).
Phased scope (PO decision, binding):
| Phase | Auth | Unlocks |
|---|---|---|
| 1 | Anonymous (no credentials) | Tournament + events + players + brackets + pool tables + schedule (per-event) + ongoing background sync |
| 2 | Organizer-supplied SwissTennis credentials (opt-in upgrade) | Cross-event master Calendar, anything else gated to logged-in users (TBD as Phase 2 discovery work) |
In-scope surfaces in CSD (all four, PO decision):
- Tournament import — one-shot pre-fill of tournament + all events + all registered players from a single tournament ID or URL.
- Per-event bracket / draw import — pull bracket structure and seed/slot assignments into CSD's bracket view.
- Schedule mirror — read the
court.contentfield on each draw cell into CSD's schedule (date, time, court/venue label). - Ongoing sync — background polling while the tournament is live, so SwissTennis-side score and schedule updates flow into CSD.
Out of scope: writing back to SwissTennis; standalone player/ranking lookups; live-streaming or websockets from SwissTennis; any non-tournament data (clubs, leagues, GP, officials).
2. Glossary¶
| Term | Meaning |
|---|---|
| trnId | SwissTennis tournament ID (numeric). E.g. 158149. Appears in SPA URL /de/turniere/158149 and as tournament=Id158149 on Advantage servlets. |
| eventId | SwissTennis event ID (numeric). One tournament has 1..N events (e.g. MS R3/R6, WS 30+ R5/R7). |
| Event | SwissTennis term for what CSD calls a Draw (one bracket / one pool group). Mapping established in §4.2. |
| Advantage servlet | Legacy ASP/Java HTTP endpoint at https://comp.swisstennis.ch/advantage/servlet/<Name>. Returns JSON when outputFormat=JSON&Lang=DE is appended. |
| Hasura GraphQL | The new SwissTennis backend at https://hasura.swisstennis.ch/v1/graphql. JWT-gated. Out of scope for Phase 1. |
| Whitelist | The fixed set of player fields CSD persists from anonymous responses (§4.4). Everything else is dropped before any database write. |
| MS / WS / MD / WD / MX | SwissTennis match-type codes — Men's Singles, Women's Singles, Men's Doubles, Women's Doubles, Mixed Doubles. Surfaced via ioMatchType.mtpName. |
3. User Stories¶
US-ST-01 — One-shot tournament import¶
As a Tournament Organizer I want to paste a SwissTennis tournament ID or URL into CSD and have CSD pre-fill the tournament, all events, and all registered players So that I don't re-key data that already exists in SwissTennis.
Acceptance criteria
- AC-01.1: Input accepts both forms: bare numeric ID (158149) and full SPA URL (https://www.mytennis.ch/de/turniere/158149 — also FR/IT/EN locale variants).
- AC-01.2: On success, CSD creates one Tournament row plus one Draw row per non-cancelled event, plus one Player row per unique whitelisted registrant, plus one Registration row per (player, event) pair.
- AC-01.3: Cancelled events (ioEventStatus.evsName == "Cancelled") are skipped — neither imported as draws nor used to seed registrations. The summary report lists them by event ID so the organizer is aware.
- AC-01.4: Player records carry only whitelisted fields (§4.4). PII fields present in the source response are dropped before persistence — verified by a regression test that asserts no email/address/birthdate/phone column on any newly created player is non-null when the source came from this importer.
- AC-01.5: A summary screen at the end reports counts: events imported / events skipped (and why) / players created / players reused (matched on licence_number) / registrations created.
- AC-01.6: The summary screen lists the import as an audit-trail record (§7.2) so the organizer can later see which fields came from SwissTennis vs manual edits.
- AC-01.7: If the tournament ID is unknown (servlet returns an empty IoTournament envelope with trnName == null), CSD shows a clear "Tournament not found on SwissTennis" error and does not create any rows.
- AC-01.8: Re-running the import on an already-imported tournament merges rather than duplicates (see §6 re-import behaviour).
US-ST-02 — Per-event bracket import¶
As a Tournament Organizer I want to pull the bracket of a specific SwissTennis event into CSD's bracket view So that seedings, BYE positions and slot assignments are pre-loaded without manual placement.
Acceptance criteria
- AC-02.1: Trigger is per-Draw, on a Draw that already has a swisstennis_event_id set (typically established by US-ST-01).
- AC-02.2: Single-elimination events (mode Draw) populate the matches table with one row per bracket cell, seeded by alevel (round depth) and aposition/rposition (slot index).
- AC-02.3: Round-robin events (mode Pool) populate the matches table with one row per pairing per pool group, sourced from DisplayPools (pool partition and standings derived per §5.5).
- AC-02.4: Unsupported event modes fail loudly, not silently. If an event's ioEventMode.evmName is "Bulk insert" (or any value other than "Draw" / "Pool"), the importer aborts that draw's import and surfaces a user-facing error: "This event uses a SwissTennis insert mode CourtsideDesk does not support — please contact us." No partial rows are written for that draw. The rest of the tournament's events continue to import normally. The audit-trail summary lists the failed event's eventId and evmName so the support contact can investigate. CSD does not call DisplayBulkMatches in Phase 1 (or later — see §9).
- AC-02.5: Seeds parsed from name.content ("(1) (R5) Wagen Stefan" → seed=1, ranking="R5", lastFirst="Wagen Stefan") populate Registration.is_seeded + a seed-order column.
- AC-02.6: BYE cells (name.content == "BYE") are stored as matches where the opponent slot is null and winner is pre-set to the non-BYE side (auto-walkover advance), consistent with the offline PDF import behaviour (docs/import-spec-alex.md §"For BYE entries: auto-advance as WALKOVER").
- AC-02.7: Bracket topology FKs (next_match_id, winner_slot per ENTITY-004) are computed from the alevel cascade: for an 8-slot bracket, alevel=3 (R8) pairs feed into alevel=2 (QF), and so on down to alevel=0 (Final). Algorithm in §5.3.
- AC-02.8: If a draw cell carries a completed-match result payload (verified shape: { ro: 1, content: "6/3 6/2" } on the winner's cell — see §5.3), the importer parses it into MatchResult and sets Match.status = COMPLETED. The match's loser is identified by the same-round sibling cell (algorithm in §5.3).
- AC-02.9: Re-importing the bracket preserves CSD-only fields, especially Match.court_id (when set by the organizer in CSD and not yet present in the SwissTennis schedule mirror). Re-import behaviour table in §6.
- AC-02.10: If the event has no draw yet (servlet returns empty drawbody.draw), the importer surfaces "Draw not yet published on SwissTennis — retry later" and does not modify the local Draw row.
US-ST-03 — Schedule mirror¶
As a Tournament Organizer I want match schedule fields (date/time + court label) populated from the SwissTennis draw cells So that the SCR-001 signage screen and SCR-005 court-assignment view reflect SwissTennis's authoritative schedule the moment matches are scheduled there.
Acceptance criteria
- AC-03.1: Each draw cell carries a court.content string when scheduled, e.g. "23/05/26 08:00 (Sa TC Dietikon)". The importer parses this into a planned_start_time (DateTime) and a swisstennis_venue_label (string, e.g. "Sa TC Dietikon").
- AC-03.2: The parsed venue label is stored on the Match as a denormalised string AND — only when the tournament's swisstennis_csd_owns_courts flag is FALSE (see §6.5.1; default is TRUE per #215) — the importer auto-creates a per-tournament Court row per distinct label, stamping Match.court_id on insert. Manual court_id assignments are preserved on re-import. See §6.5.2 for the auto-create rules (GitLab #205, PO decision 2026-05-24 — revised from "no auto-resolution"; default-on rolled back by #215).
- AC-03.3: The date is parsed as dd/MM/yy (Swiss locale) and the time as HH:mm. The combined datetime is treated as Europe/Zurich local time and stored as UTC.
- AC-03.4: Cells without a court field (unscheduled or not-yet-played) leave planned_start_time null.
- AC-03.5: A re-import that changes the scheduled time on SwissTennis overwrites planned_start_time (unless the organizer flagged the match as "locally overridden" — see §6.2).
US-ST-04 — Background sync while the tournament runs¶
As a Tournament Organizer I want CSD to poll SwissTennis at a sensible cadence during my tournament So that schedule changes and completed-match results made on the SwissTennis side appear in CSD without me clicking "import" again.
Acceptance criteria
- AC-04.1: Background sync is opt-in per tournament (toggle on the Tournament settings page). Off by default to avoid background traffic for archived tournaments.
- AC-04.2: When enabled, sync polls on the following schedule (PO-confirmed, §8.8 resolved):
- In-window: every 60 seconds. The play window is trnBegin 00:00 Europe/Zurich through trnEnd 23:59 Europe/Zurich inclusive (i.e. the full calendar days the tournament spans).
- Out-of-window before trnBegin: every 15 minutes. Catches pre-tournament draw publication and seed corrections.
- One final pull at trnEnd + 1 hour Europe/Zurich. Captures any late-entered results posted in the wind-down after the last match. After this pull, background sync stops entirely for the tournament (no further polls; manual "Sync now" still works on demand).
- AC-04.3: Each poll fetches TournamentDisplay once (covers tournament + events + players in 170 KB), then, per non-cancelled event whose mode is Draw or Pool, fetches DisplayDraw or DisplayPools respectively. Events with unsupported modes (Bulk insert or any other value) are skipped at the sync layer with a single audit-trail entry per cycle. With ~16 supported events that is ~17 requests per poll cycle.
- AC-04.4: Responses are deduped via HTTP caching (§7.1). If the body hash hasn't changed since the previous poll, no DB writes are performed and the cycle is a no-op.
- AC-04.5: Sync writes follow the merge rules in §6 (results overwrite, schedule overwrites unless locally overridden, court assignments preserved, manually added registrations preserved).
- AC-04.6: Sync failures (5xx, timeout, 301 to mytennis.ch indicating endpoint deprecation) are logged to the import audit trail (§7.2) but do not surface as user-facing errors unless they recur N times consecutively (recommend N=5 — Morgan to confirm).
- AC-04.7: A "last synced" timestamp is shown on the Tournament settings page next to the toggle.
- AC-04.8: Manual "Sync now" button on the same page triggers an immediate poll regardless of cadence.
4. Endpoint Contract¶
4.1 Base URL and request shape¶
- Base:
https://comp.swisstennis.ch/advantage/servlet/ - Method:
GET(anonymous) - Required query params:
outputFormat=JSON,Lang=DE(CSD pins toDEfor canonical field names;FR/IT/ENchange localised string contents but not field structure) - Headers: standard browser-ish
User-Agentadvisable;Accept: application/jsonnot required (servlet keys offoutputFormat) - Auth: none for Phase 1 endpoints below; Phase 2 endpoints TBD
4.2 Endpoint catalogue¶
| # | Endpoint | Params | JSON root path of interest | Called by | Cadence |
|---|---|---|---|---|---|
| E1 | TournamentDisplay |
tournament=Id<trnId> |
.Iotto.IoTournament (header) + .Iotto.IoTournament.ioEventSet.IoEvent[] (events) + per-event .ioPlayerSet.IoPlayer (registrations) |
US-ST-01, US-ST-04 | One-shot import + every poll cycle |
| E2 | PublicDisplayEvent |
eventId=<evtId> |
.Iotto.IoEvent — single event including ioEventMode, scoringMethod, officialBall |
Optional supplemental call only when an event needs richer config than E1 carries (Phase 1.5) | On-demand |
| E3 | DisplayDraw |
eventId=<evtId> |
.Iotto.drawtable.drawbody.draw[] (cells); .Iotto.drawtable.drawcaption.shownlevel[] (round inventory); .Iotto.drawtable.drawnavigator.area[] (round X-extent — optional, used for visual layout only) |
US-ST-02, US-ST-03, US-ST-04 | Per event per poll, only for events with evmName == "Draw" |
| E4 | DisplayPools |
eventId=<evtId> |
.Iotto.IoEvent.ioPoolSet.IoPool[] — pools + standings + head-to-head matches (verified against trn 154477 — see §5.5) |
US-ST-02, US-ST-04 | Per event per poll, only for events with evmName == "Pool" |
| ~~E5~~ | ~~DisplayBulkMatches~~ |
~~eventId=<evtId>~~ |
~~Flat match list~~ | Not called by CSD. Bulk-insert events fail-loud per AC-02.4. | n/a |
| E6 | DisplayBalls |
tournament=Id<trnId> |
Ball model per event | Optional — only if Morgan decides ball model belongs on the Draw entity | One-shot, optional |
| E7 | DisplayNews |
tournament=Id<trnId> |
HTML announcement text | Optional — not used by CSD v1 | n/a |
| E8 | DisplayDraw.pdf / DisplayPools.pdf |
same as E3/E4 | PDF binary | Not called by this integration (existing docs/import-spec-alex.md handles PDF; this spec uses the JSON twin) |
n/a |
| E9 | Calendar |
tournament=Id<trnId> + date range |
301 → mytennis.ch when anonymous — auth-gated | Phase 2 only. Unlocks cross-event master schedule. | TBD in Phase 2 |
4.3 Polling cadence summary¶
| Trigger | Frequency | Endpoints fetched |
|---|---|---|
| One-shot tournament import (US-ST-01) | once | E1 |
| Per-event bracket import (US-ST-02) on demand | once per click | E3 (mode Draw) or E4 (mode Pool); fail loudly on any other mode |
Background sync (US-ST-04), in-window (trnBegin 00:00 → trnEnd 23:59 Europe/Zurich) |
60 s | E1 + per supported event one of E3/E4 |
Background sync (US-ST-04), before trnBegin |
15 min | same as in-window |
Background sync (US-ST-04), final pull at trnEnd + 1h |
once | same as in-window; sync then stops |
| Manual "Sync now" button | once per click | same as in-window |
4.4 PII whitelist (binding policy)¶
The anonymous TournamentDisplay and PublicDisplayEvent responses include the full SwissTennis player profile per registrant, including data that should not be public. CSD applies a strict whitelist at ingest. Fields not on the whitelist are dropped before the row reaches the database — the IoPlayer object is not stored verbatim anywhere, not even in audit-trail payloads (§7.2 stores hashes of input payloads, not the payloads themselves, to remain compliant).
Kept (whitelist):
| IoPlayer field | CSD field | Notes |
|---|---|---|
playerId |
Player.swisstennis_person_id (new) |
SwissTennis numeric playerId. Also surfaces as person_nr on draw cells — verify equivalence at ingest. |
plyLicenceNb |
Player.license_number |
Existing canonical identity key (ENTITY-005). |
plyFirstName |
Player.first_name |
|
plyName (last name — confirm field name in payload during impl; the verified record uses lastname-not-shown-in-singleton — Sam to verify) |
Player.last_name |
The verified record returned plyFirstName only because the player was self-referencing. Probe a non-self player to lock in the lastname key. |
plyRankingComment |
Player.ranking_category |
E.g. "R5". This is the displayed ranking (per ENTITY-005 note). |
plyCoeffF (or plyRank — Rafael consult §8) |
Player.ranking_value |
Numeric ranking. Confirm which field is the canonical numeric value SwissTennis publishes. |
plySeedNb |
Registration.is_seeded (true if > 0) + seed-order column |
Drives bracket placement. |
plyConfirmed |
Registration.is_confirmed |
Boolean. |
plyOnLineRegistered |
Registration.is_online_registration |
Boolean. |
plyRegisteredOn |
Registration.registered_at |
SwissTennis date struct → ISO. |
plyAlternateRegister |
Registration.qualifier_wildcard derivation |
Combined with draw-cell (WC)/(Q)/(LL) prefix parsing. |
plyConstraints |
Registration.restrictions |
Admin note. |
plyComment / plyShortComment |
Registration.comment |
|
| Club (if surfaced — see §8 open question) | Player.club_code / Player.club_name |
IoPlayer in the verified sample does not include a club code/name field; the equivalent is shown only on the SPA. Open question §8.4. |
Dropped (NEVER persisted, NEVER logged, NEVER hashed-with-identity):
plyStreet,plyNpa,plyCity,plyCountry(address)plyBirthdate1,plyBirthdate2(date of birth)plyEMail,plyEmail2(email)plyTelMobile,plyTelMobile2,plyTelPriv(phone)plyCompany(employer)- Any
*2partner fields (doubles partner — only relevant for MD/WD/MX events; partner data is sourced separately from doubles registration records, where applicable; Phase 1 ships singles-only mapping) - Internal numerics:
plyCoeff2F,plyRank2,plySpecialGPPoints,plyRRTieBreakPoints,plyTeamId— not needed by CSD
Implementation enforcement (mandatory, do not punt to "trust the developer"):
- The whitelist must be expressed as a typed projection function at the parser boundary. The input type is RawIoPlayer = Record<string, unknown>; the output type is WhitelistedPlayer = { swissTennisPersonId, licenseNumber, firstName, lastName, rankingCategory, rankingValue, ... }. Nothing else.
- A unit test must assert that no whitelisted output object contains any key from the dropped list, even if a future field-name typo accidentally re-introduces one.
- The audit-trail payload (§7.2) stores only the SHA-256 hash of the raw response body and the whitelisted projection, never the raw IoPlayer.
Parallel action item (not CSD work): PO will raise the over-exposure with SwissTennis directly. Track in PO's external follow-up list. CSD ships the whitelist regardless of whether SwissTennis fixes the upstream surface.
5. Data Mapping¶
5.1 Tournament header → Tournament (ENTITY-001)¶
Source: TournamentDisplay JSON path .Iotto.IoTournament.
| SwissTennis field | Verified sample value (trn 158149) | CSD field | Notes |
|---|---|---|---|
tournamentId |
158149 |
Tournament.swisstennis_tournament_id (new column) |
Numeric ID, also used as upsert key for re-imports |
trnName |
"PS: Pfingstturnier" |
Tournament.name |
|
trnBegin (struct: year/month/day/hour/min/sec) |
2026-04-23 (note: Java Calendar months are 0-indexed — month 4 = May in Java semantics; verify during impl whether SwissTennis ships 0- or 1-indexed) |
Tournament.start_date |
Open question §8.6 |
trnEnd (struct) |
2026-04-23 |
Tournament.end_date |
Same indexing question |
trnLocation |
"Dietikon" |
(no current field — see §8.5 to decide whether to add Tournament.location) |
|
trnOrganizer |
"TC Dietikon" |
(no current field — §8.5) | |
trnNbCourts |
6 |
(informational; when CSD auto-creates Courts at all, it does so per-venue-label, not per-nbCourts count — see §6.5.2 and #205. By default the importer does NOT auto-create — see §6.5.1 and #215.) |
Originally suggested pre-populating N empty Court rows; the actual implementation creates a Court per distinct venue label seen on the cells, and only when the tournament's swisstennis_csd_owns_courts flag is FALSE. |
trnContact |
"Patrick Seiler" |
(no current field) | Considered organizer-internal, not public data; skip for v1 |
trnContactEMail, trnContactStreet, trnContactNPA, trnContactCity, trnContactCountry, trnContactTel1 |
(various) | Dropped. | Same PII rationale as players — even though this is the organizer contact, CSD has no current home for it and SwissTennis already publishes it; do not duplicate. |
trnRegistrationFee |
"CHF 68 (CHF 60 für Mitglieder TC Dietikon)" |
(no current field) | Skip for v1 |
courtType, ioRoofing, ioTournamentStatus |
(object) | (no current field) | Skip for v1 |
Source provenance: every Tournament row created via this importer must set Tournament.source = "scrape" (using the existing ENTITY-001 source enum if/when extended — note that ENTITY-001 does not currently carry a source field but ENTITY-004/005 do; Morgan to decide whether to extend the schema for Tournament or whether the audit trail in §7.2 is sufficient).
5.2 Event → Draw (ENTITY-003)¶
Source: per-event objects in .Iotto.IoTournament.ioEventSet.IoEvent[] (or PublicDisplayEvent for richer config).
| SwissTennis field | Verified sample (eventId 829592) | CSD field | Notes |
|---|---|---|---|
eventId |
829592 |
Draw.swisstennis_event_id (new column) |
Numeric ID, upsert key |
evtTitle (often empty) + composed from ioMatchType.mtpName + ranking band + age category |
Composed: "MS R3/R6" |
Draw.name |
The PDF importer (docs/import-spec-alex.md Part 2) builds the same name from the PDF title line. Use the same composition rule here. Composition rule: {matchType} {ageCategorySuffix?} {upperRanking}/{lowerRanking} — Rafael consult §8.2 to lock the canonical formula. |
ioMatchType.mtpName |
"MS" |
(used in Draw.name composition) |
|
ioMatchType.mtpSingle |
1 |
(informational; 1 = singles, 0 = doubles) | Phase 1 maps singles only; doubles deferred (§9). |
ioMatchType.mtpSex |
1 |
(informational) | |
rankingTypeEvtIdUpperRanking, rankingTypeEvtIdLowerRanking |
(object) | (used in Draw.name composition) |
|
ageCategoryEvtIdAgeCategory, ageCategoryEvtIdAgeCategory2 |
(object) | (used in Draw.name composition) |
|
ioEventMode.evmName |
"Draw" / "Pool" / "Bulk insert" |
Draw.format |
Mapping: Draw → SINGLE_ELIMINATION; Pool → ROUND_ROBIN; any other value (including Bulk insert) → fail-loud unsupported-mode error (AC-02.4). PO confirms (a) no support for bulk-insert events and (b) no mixed-mode events occur in practice — each event is exclusively Draw OR Pool. |
ioEventStatus.evsName |
"Published" / "Cancelled" |
(gate — Cancelled events are skipped entirely, AC-01.3) |
|
evtNoAd |
0 |
Inform Draw.draw_format_id resolution |
See §5.2.1 |
evtShortSets, evtShortSetsFinal |
0 |
Inform Draw.draw_format_id resolution |
See §5.2.1 |
evtMatchTiebreak, evtMatchTiebreakFinal |
10 |
Inform Draw.draw_format_id resolution |
See §5.2.1 |
evtSize |
0 (sample) |
(informational; bracket size derived from cell count in DisplayDraw) |
|
evtMaxNbPlayers |
512 |
(informational) | |
evtBegin, evtEnd |
(struct) | (informational — Match-level dates come from draw cells per §5.4) | |
evtConsolationDrawn |
0 |
Draw.is_loser_draw (true if 1) |
Per ENTITY-003: stored but never rendered. |
5.2.1 DrawFormat (ENTITY-010) resolution¶
SwissTennis encodes scoring rules as flat boolean/integer flags on the event (evtNoAd, evtShortSets, evtMatchTiebreak, etc.). CSD encodes scoring rules as a foreign key to a catalogued DrawFormat. The importer maps the SwissTennis flags onto one of 6 flag-reachable DrawFormat rows (F1, F2, F3, F5, F5b, F8); F6 and F7 are reachable only via manual organizer override (§8.1(a)/(b)). The canonical mapping table is in §8.1 — "Replacement mapping table" (resolved 2026-05-23, Rafael). Implementers must read that table; the high-level summary is repeated here for orientation:
| Trigger (priority order) | Resolves to |
|---|---|
evtShortSets=1 + evtNoAd=1 + bestOf=3 + evtMatchTiebreak=0 |
F5 (Fast4 Bo3) |
evtShortSets=1 + evtNoAd=1 + bestOf=5 + evtMatchTiebreak=0 |
F5b (Fast4 Bo5) |
evtShortSets=0 + evtMatchTiebreak=10 + evtNoAd=0 |
F3 (Match Tiebreak / Super TB) |
evtShortSets=0 + evtMatchTiebreak=0 + evtNoAd=0 (adult event) |
F1 (Standard, Advantage Final Set) |
evtShortSets=0 + evtMatchTiebreak=0 + evtNoAd=0 (junior event — see §8.2(b)) |
F2 (Standard, Tiebreak All Sets) |
evtShortSets=0 + evtMatchTiebreak=10 + evtNbSets=0 |
F8 (Standalone MTB) |
| Anything else | Fallback (F1 adult / F2 junior) + "format guessed" warning |
Any *Final flag non-zero |
Ignore for resolution; emit "final-match override ignored" warning |
F6 (8-game Pro Set) and F7 (Short Set No-Ad) require manual override on the Draw — they are not reachable from SwissTennis flags. Re-imports preserve a manually-set DrawFormat via the new Draw.draw_format_locally_overridden column (§8.5, follow-up from §8.1 resolution).
5.3 Bracket cells → Match (ENTITY-004)¶
Source: DisplayDraw JSON path .Iotto.drawtable.drawbody.draw[].
Cell shape (verified):
{
eventId: 829592,
alevel: 3, // round depth: higher = earlier round
aposition: 0, // y-index within the round
rposition: 0, // x-index (slot index — used for pairing)
rlevel: 1, // (semantics TBC)
name: {
person_nr: 19791365, // SwissTennis playerId
ro: 1,
content: "(1) (R5) Wagen Stefan" // human-readable, parse for seed + ranking + name
},
court: { // present only on scheduled cells
content: "23/05/26 08:00 (Sa TC Dietikon)"
}
}
Per-cell parsing:
| Source | Target | Parsing rule |
|---|---|---|
name.content |
seed, qualifier, ranking, lastname-firstname | Regex (same shape as docs/import-spec-alex.md): ^(?:\((\d+)\)\s+)?(?:\((WC\|Q\|LL)\)\s+)?(?:\(([A-Z]\d+)\)\s+)?(.+)$. Groups: seed / qualifier / ranking / fullName. Special-case name.content == "BYE". |
name.person_nr |
Player.swisstennis_person_id |
Authoritative identity — prefer this over name-matching when populating the Match. |
court.content |
Match.planned_start_time + Match.swisstennis_venue_label (new field) |
Parse ^(\d{2}/\d{2}/\d{2})\s+(\d{2}:\d{2})\s+\((.+)\)$ → date (dd/MM/yy in CH locale), time, venue. Combine into Europe/Zurich datetime, store as UTC. |
result.content (on the winner's cell) |
Match.result (MatchResult) + Match.winner + Match.status = COMPLETED |
Verified against trn 154477 evt 799508 (completed 4-player MS KO). Shape: result: { ro: 1, content: "6/3 6/2" }. The cell with a result is the winner's advancing slot — its name.person_nr is the match winner. The score string is winner-POV gamesWinner/gamesLoser per set, whitespace-separated, where the live API uses U+00A0 NBSP between sets (treat NBSP and ASCII space identically). A third "set" with games like 10/8, 10/6 represents a match tiebreak, not a regular set — map to MatchResult.matchTiebreak per ENTITY-007. The loser's identity is resolved from the same-round sibling slot (§5.3 algorithm step 6). Cells without a result field are not-yet-completed; leave the match at Match.status = SCHEDULED (or whatever pre-existing status). |
Bracket topology reconstruction (AC-02.7 algorithm):
- Group cells by
alevel. The smallestalevelis the final; the largest is the first round. - Within each
alevel, sort cells byrpositionascending. (rpositionis the x-index used for pairing;apositionis the y-index for visual layout.) - Pair cells at the deepest round: cells with
(rposition = 2k, rposition = 2k+1)atalevel = Nform one match. This match's parent cell is atalevel = N-1, rposition = k. - The winner's
winner_slotisUPPERwhen the parent's child came fromrposition = 2k,LOWERfromrposition = 2k+1. (Confirm convention against the existingpackages/scoringcomputeAdvancementbuilder — Morgan.) - BYE handling: when a first-round cell's
name.content == "BYE", the opponent advances automatically. Create the match with player2 = null, winner = player1, status = COMPLETED, MatchResult outcome = WALKOVER. - Result attribution (verified, evt 799508): the
resultpayload lives on the child cell atalevel = N-1(the parent / winner slot), not on either source cell atalevel = N. To attribute the match: - Take a child cell
Catalevel = Mwith aresult.contentstring. - Its winner is the player at
C.name.person_nr. - Its two source cells are at
alevel = M+1,rposition ∈ { C.rposition * 2, C.rposition * 2 + 1 }. - The loser is the player at whichever source cell's
name.person_nrdiffers fromC.name.person_nr. - The match row is keyed at
alevel = M+1(the source round), not atalevel = M(where the result label happens to render). The parent cell atalevel = Mis then either (a) the next match in the bracket or (b) absent if this was the final.
Worked example (evt 799508 — full bracket of 4):
| alevel | rposition | name.content | result.content | Role |
|---|---|---|---|---|
| 2 (R8) | 0 | (1) (R4) Grossenbacher Marc | — | SF1 source A |
| 2 (R8) | 1 | (R6) Meier Urs | — | SF1 source B |
| 2 (R8) | 2 | (R5) Wagen Stefan | — | SF2 source A |
| 2 (R8) | 3 | (2) (R4) von der Weid Roger | — | SF2 source B |
| 1 (SF) | 0 | Meier U. | "6/3 6/2" | SF1 winner; match row is alevel=2 r0–r1, winner Meier, loser Grossenbacher, score 6/3 6/2 |
| 1 (SF) | 1 | Wagen S. | "0/6 7/6 10/8" | SF2 winner; match row is alevel=2 r2–r3, winner Wagen, loser von der Weid, sets 0/6 7/6 + MTB 10/8 |
| 0 (F) | 0 | Wagen S. | "7/5 6/4" | Final winner; match row is alevel=1 r0–r1, winner Wagen, loser Meier, score 7/5 6/4 |
5.4 Schedule mirror → Match.planned_start_time / swisstennis_venue_label / Match.court_id¶
See §5.3 row for court.content. The venue label is denormalised (defence-in-depth fallback) and is always persisted regardless of the court-ownership flag. The auto-create + Match.court_id stamping path (GitLab #205) only runs when the tournament's swisstennis_csd_owns_courts flag is FALSE — see §6.5.1 for the gate (default TRUE per #215) and §6.5.2 for the auto-create rules. Manual organizer-assigned court_id values are always preserved on re-import.
5.5 Round-robin pools → Pool, PoolStanding, Match rows¶
Source: DisplayPools?Lang=DE&eventId=<id>&outputFormat=JSON, verified against trn 154477 (Tennis Grand Prix 25/26, Wangen SZ — completed, with both KO and RR events).
Response envelope: Iotto.IoEvent with the same header fields as DisplayDraw (no top-level drawtable), plus IoEvent.ioPoolSet.IoPool[] listing pools.
Per-pool shape:
| Source path | Target | Notes |
|---|---|---|
IoPool.poolId |
Pool.swisstennis_pool_id (new column) |
int, upsert key |
IoPool.polName |
Pool.name |
string label, e.g. "1", "A", "Gruppe A" — passed through verbatim |
IoPool.ioPlayerPoolSet.IoPlayerPool[] |
PoolStanding[] (one row per pool member) |
See per-standing table below |
Per-IoPlayerPool standing shape:
| Source field | Target | Notes |
|---|---|---|
plpRank |
PoolStanding.final_rank |
1-indexed final rank within the pool |
plpForceRank |
PoolStanding.force_rank (nullable / 0) |
Admin override; 0 means computed normally — do not treat 0 as rank 0 |
plpPosition |
PoolStanding.seed_position |
Seeding position in pool |
plpNbVictories |
PoolStanding.wins_total |
Includes walkovers |
plpNbVictoriesNoWO |
PoolStanding.wins_excluding_wo |
Used for tiebreaks per SwissTennis convention |
plpNbMatches |
PoolStanding.matches_played |
|
plpNbWonsets / plpNbLostSets |
PoolStanding.sets_won / sets_lost |
|
plpNbWonGames / plpNbLostGames |
PoolStanding.games_won / games_lost |
|
IoPlayerPool.ioPlayer.IoPlayer |
Player (resolved via §4.4 whitelist) |
Whitelist-filter as elsewhere |
Per-pool matches: The head-to-head matches are nested inside each player record at IoPlayer.ioRRMatchRrmIdPlayer1Set.IoRRMatch[], anchored only on the player-1 side. To enumerate every pool match exactly once: iterate every pool member's ioPlayer and collect every entry in its ioRRMatchRrmIdPlayer1Set — no dedup is required.
Per-IoRRMatch shape:
| Source field | Target | Notes |
|---|---|---|
rRMatchId |
Match.swisstennis_match_id (new column) |
int, stable identifier, upsert key for RR matches |
rrmDate (struct: year, month [0-indexed], day, hour, minute, second) |
Match.planned_start_time |
Same Java-month-indexing caveat as trnBegin — see §8.6. Treat as Europe/Zurich local, store as UTC. |
ioCourt.IoCourt.crtName (e.g. "Court 2") + courtId |
Match.swisstennis_venue_label (denormalised string, always populated); Match.court_id linked to the per-tournament auto-created Court row (#205) only when swisstennis_csd_owns_courts = FALSE |
The auto-create + link path applies symmetrically to KO (cell.court.content) and pool (IoCourt.crtName) sources, but is gated by the tournament-level flag (default TRUE — see §6.5.1). Manual court_id assignments are preserved on re-import per §6.5.2. |
rrmPlayer1Set1Games, rrmPlayer1Set2Games, rrmPlayer1Set3Games |
Per-set games for player 1 in MatchResult.sets[] |
Sentinel: a value of -1 means the set was not played (e.g. a straight-sets 2-set win has set 3 = -1 for both players). Drop unplayed sets from the SetScore array. |
rrmPlayer2Set1Games, rrmPlayer2Set2Games, rrmPlayer2Set3Games |
Per-set games for player 2 in MatchResult.sets[] |
Same -1 sentinel. |
rrmPlayer1WO, rrmPlayer2WO |
MatchResult.outcome = WALKOVER (if either is 1) + Match.winner set to the non-WO side |
Walkover flags, 0/1. |
rrmComment |
Fallback for MatchResult.display_string |
Pre-formatted score string, e.g. "6/1 0/6 10/3" (NBSP-separated). Useful as a display fallback when reconstructing from per-set games for verification. |
ioPlayerRrmIdPlayer2.IoPlayer |
Player 2 reference (whitelist-filter) | The opponent. |
Defensive shape note: when a pool player has no matches as player 1, the field ioRRMatchRrmIdPlayer1Set is returned as an empty string "" rather than as an object. The parser must tolerate that shape — the same kind of shape-polymorphism documented for ioPlayerSet in §6.1.
Pool-size note: evtSize=4 in this tournament's pools, but the actual pools had 3 members each (one player withdrew). The importer must not assume the pool is full — derive member count from the actual ioPlayerPoolSet.IoPlayerPool[] length.
Match-tiebreak handling: an RR match scored e.g. "6/1 0/6 10/3" represents two sets plus a match tiebreak (set 3 with games like 10/3, 10/8). Map per-set games like 10/3 to MatchResult.matchTiebreak, not to MatchResult.sets[2], consistent with KO handling (§5.3).
Upsert keys (RR): Match row keyed on rRMatchId for RR matches (preferred); Pool row keyed on poolId; PoolStanding row keyed on (poolId, player_id).
5.6 Bulk-insert events — explicitly unsupported¶
Bulk insert is a SwissTennis mode where the organizer enters arbitrary matches without a structured bracket or pool. CSD does not support it. Per PO decision:
- The importer never calls
DisplayBulkMatches. - If a tournament contains an event with
ioEventMode.evmName == "Bulk insert", the one-shot import (US-ST-01) imports the rest of the tournament normally and fails that one event with the user-facing message in AC-02.4. - Background sync (US-ST-04) skips bulk-insert events with a single audit-trail entry per cycle (AC-04.3); it does not generate a recurring per-cycle error toast.
- This is also true for any other
evmNamevalue we may encounter in future (defence in depth — if SwissTennis introduces a new mode, we fail-loud rather than guess). - PO has also confirmed no mixed-mode events occur in practice. Each event is exclusively
DrawORPool. The importer treats anything else as the unsupported case above.
6. Error and edge cases¶
6.1 Network / endpoint failures¶
| Condition | Behaviour |
|---|---|
| HTTP 5xx from servlet | Retry with exponential backoff (1s, 2s, 4s, 8s, 16s). On final failure, log to audit trail and surface as toast on manual import; silent in background sync until 5 consecutive failures (AC-04.6). |
| HTTP 301 → mytennis.ch | Treat as "endpoint deprecated by SwissTennis." Mark the integration health as degraded; surface a banner on the Tournament settings page: "SwissTennis has changed their public API. Background sync paused; offline import (XLS/PDF) still works." Fall back to manual mode. |
| HTTP 404 | Tournament/event not found. Surface to user as such. Do not retry. |
| HTTP timeout (configurable, default 10 s) | Same as 5xx — retry then log. |
| Malformed JSON | Log payload SHA-256 + first 200 bytes to audit trail. Skip this poll cycle; alert on N consecutive. |
Empty IoTournament envelope (e.g. trnName == null and ioEventSet empty) |
Treat as 404 — tournament does not exist on SwissTennis. |
ioPlayerSet is a string (cancelled event) vs object (singleton) vs object with array (multi-player) |
Importer must tolerate all three shapes. Treat as zero/one/many registrants respectively. |
6.2 Mid-tournament re-import — merge rules¶
The integration runs concurrently with the offline (XLS/PDF) importer and with manual organizer edits. The merge contract:
| Field class | On re-import |
|---|---|
| Tournament header (name, dates, location) | Overwrite unless the organizer has flagged the tournament as "locally edited" (toggle on settings page). Default: overwrite. |
| Player whitelisted fields | Overwrite — SwissTennis is master for rankings, names, license numbers. |
| Registration seed / wildcard | Overwrite — SwissTennis is master for seeding. |
Match planned_start_time + swisstennis_venue_label |
Overwrite unless Match.schedule_locally_overridden is true (new boolean, defaults to false). The flag is set automatically the first time an organizer drags a match to a court in SCR-005 with a different time than the SwissTennis source. |
Match court_id |
Preserve — CSD-only field (CSD's Court entity is per-tournament free text and has no SwissTennis equivalent). |
Draw draw_format_id |
Overwrite unless Draw.draw_format_locally_overridden is true (added by §8.1 resolution). The flag is set automatically the first time an organizer changes the DrawFormat away from the importer-derived value. F6 (Pro Set) and F7 (Short Set No-Ad) are reachable only via this manual-override path. |
Match result (MatchResult) |
Overwrite — SwissTennis is master for results. Exception: if Match.result_locally_entered is true (set when the organizer enters a result on SCR-005), preserve the local result and surface a "conflict — SwissTennis disagrees" warning in the summary. |
Match status |
Derived from result. If result overwritten to COMPLETED, status follows. |
| Registrations not present in SwissTennis (manually added in CSD) | Preserve. Track via Registration.source = "manual". |
| Matches not present in SwissTennis | Preserve unless the importer detects a bracket topology rebuild on the SwissTennis side, in which case surface a "topology changed — review needed" warning. |
6.3 Conflicting local edits¶
When an organizer has manually edited a field that SwissTennis later changes, the merge rules above choose one side. Every overwrite is logged to the audit trail (§7.2). The organizer can view a "what was changed by this sync" diff per import event.
6.4 Result-field shape — verified (was open in v1)¶
The match-result field shape on DisplayDraw cells is now verified against trn 154477 evt 799508 (completed 4-player MS KO). See §5.3 for the full mapping. Summary:
- Shape: result: { ro: 1, content: "6/3 6/2" } on the winner's advancing slot.
- Whitespace separator between sets is U+00A0 NBSP in the live API — parser treats NBSP and ASCII space identically.
- Third "set" with games like 10/8 is a match tiebreak, mapped to MatchResult.matchTiebreak.
- Loser identity resolved from same-round sibling slot (§5.3 step 6).
- Cells without a result field → not-yet-completed; leave match status unchanged.
- The MatchResult shape (ENTITY-007 — outcome enum + SetScore array + optional matchTiebreak) accommodates this without schema changes.
(Round-robin result shape is also verified — see §5.5 — though the per-set games are first-class numeric fields, not a content string, and live on each IoRRMatch.)
6.5 Court resolution (auto-create + link)¶
SwissTennis ships the court as a free-text label like "Sa TC Dietikon" (venue, not court number). CSD's Court entity is per-tournament free-text.
6.5.1 Tournament-level gate (swisstennis_csd_owns_courts, default TRUE — #215)¶
Stefan flipped the policy on 2026-05-25: court information from SwissTennis is cumbersome and rarely correct (organizers routinely stuff venue names into the court field). CSD now owns court assignment by default; SwissTennis-sourced courts are opt-in.
tournaments.swisstennis_csd_owns_courts (BOOLEAN NOT NULL DEFAULT TRUE — migration 0012) gates the entire court import path:
- When TRUE (default for all tournaments, new and existing):
- The importer SKIPS the entire court auto-create + court_id backfill pass.
matches.swisstennis_venue_labelIS still populated (defence-in-depth fallback / hint string).matches.court_idis NEVER written by the importer — organizers assign courts manually.- The audit row carries a single
courts_skipped_csd_ownedstructuredDiffEventper import cycle so the import history doesn't go silent. - When FALSE (organizer opted in):
- The §6.5.2 default-on behaviour below applies.
The flag is settable on first import via the importTournament tRPC procedure's csdOwnsCourts input (default true) and on the tournament's settings page after import. Re-imports never touch the column — organizer-toggled values survive.
Existing auto-created Courts from the pre-#215 era are preserved verbatim on migration apply (court_id column on matches stays untouched, and the Court rows themselves stay too). The importer simply stops creating new ones.
6.5.2 Auto-create + link behaviour (when swisstennis_csd_owns_courts = FALSE)¶
(GitLab #205, PO decision 2026-05-24 — revised from the pre-launch "manual only" position; rolled back from default-on by #215.)
Behaviour:
- The importer stores the SwissTennis label on Match.swisstennis_venue_label (denormalised, defence-in-depth fallback for the display layer when court_id IS NULL).
- The importer upserts a Court row per distinct venue label, scoped per-tournament. Dedupe key: case-insensitive trimmed name (NFD diacritic stripping is intentionally NOT applied — venue labels are organizer-owned free text and "Diétikon" vs "Dietikon" may be two distinct courts after a typo fix).
- On match insert, the importer stamps Match.court_id with the resolved Court UUID.
- On match update, the importer only stamps Match.court_id when the existing row is NULL — an organizer-assigned court_id is never overwritten by a subsequent import. Same preservation rule as the §6.2 "result_locally_entered" / "schedule_locally_overridden" gates.
- The audit row emits a court_created structured DiffEvent per Court row inserted (GitLab #197 producer pattern) so organizers can see what the importer added.
- Pre-existing Court rows (manual SCR-004 setup, or auto-created on a previous import) are re-used by name match; the importer never updates the row's name or sortOrder after creation — once a row exists, the organizer owns the label.
6.6 Rate limiting¶
SwissTennis publishes no rate-limit policy. The 60-second poll cadence per tournament with conditional fetch (HTTP If-None-Match / If-Modified-Since if supported, otherwise body-hash dedupe) keeps load minimal — ~17 requests per minute per active tournament. With <10 concurrent tournaments expected at the CSD platform scale, this is ~170 req/min total. If SwissTennis pushes back:
- Implement a global concurrency cap (e.g. 5 in-flight requests).
- Back off if a 429 is ever observed (no current evidence the endpoints emit 429s).
- Sam to expose a runtime-configurable SWISSTENNIS_POLL_INTERVAL_MS env var so we can dial back without redeploying.
6.7 Lang parameter drift¶
Lang affects localised string content (enum names, status labels). CSD pins Lang=DE for deterministic mapping. If a future SwissTennis change rotates enum string content within DE, the importer should fail closed (skip the row, log, surface the unknown value) rather than silently coerce.
7. Non-functional requirements¶
7.1 Caching¶
- In-process body-hash dedupe: keep a Map keyed by URL → SHA-256 of last response body. If the hash matches on the next poll, skip parsing and skip all DB writes. Cache lifetime: tournament lifecycle.
- HTTP conditional requests: if the servlet honours
ETagorLast-Modified(verify during impl), send the conditional headers to reduce bandwidth. If not, body-hash dedupe is the only mechanism. - Response cache TTL: within a single CSD request handling cycle, the same
TournamentDisplayresponse is reused for up to 60 seconds (matches the poll cadence). Multiple US-ST-02 clicks within that window do not re-fetch.
7.2 Audit trail¶
A new entity ImportEvent (Morgan to confirm exact shape) records every import operation:
| Field | Type | Notes |
|---|---|---|
id |
UUID | |
tournament_id |
UUID FK | |
kind |
Enum | one_shot / event_draw / background_sync / manual_sync |
triggered_by |
UUID FK / null | User ID; null for background sync |
started_at |
DateTime | |
finished_at |
DateTime / null | |
status |
Enum | success / partial / failed |
endpoints_called |
JSON array | List of {url, status, body_sha256, ms} |
summary |
JSON | { players_created, players_updated, registrations_created, matches_created, matches_updated, results_imported, errors[] } |
error |
Text / null | If status == "failed" |
Audit trail entries are immutable. The organizer can list them per-tournament and drill into the per-endpoint detail. No raw IoPlayer payloads are stored — only the SHA-256 hash plus the whitelisted projection that was actually written.
7.3 Graceful degradation¶
- If SwissTennis is unreachable, all CSD features that don't depend on fresh data continue to work (read existing CSD data normally).
- The Tournament settings page surfaces integration health:
connected/degraded/disabled. - The offline XLS/PDF importer (
docs/import-spec-alex.md) remains available as a manual fallback.
7.4 Idempotency¶
- Every endpoint call is idempotent (GET only).
- Every DB write is idempotent via upsert keys: Tournament on
swisstennis_tournament_id, Draw onswisstennis_event_id, Player onswisstennis_person_id(preferred) orlicense_number(fallback), Registration on(player_id, draw_id), Match on(draw_id, alevel, rposition_lower)for SE draws and onswisstennis_match_id(i.e.rRMatchId) for RR draws, Pool onswisstennis_pool_id(i.e.poolId), PoolStanding on(pool_id, player_id). - Re-running the same import yields the same DB state (modulo
updated_attimestamps).
7.5 Observability¶
- Structured log per import operation with
tournament_id,kind,duration_ms,endpoints_called,status. - Metrics: counter of imports by kind/status; histogram of import duration; gauge of integration health per tournament.
- Alerts (DevOps to set thresholds): 5+ consecutive sync failures on any tournament; >30 s duration on a single poll; any 301 response (indicates endpoint deprecation).
7.6 Security¶
- All endpoints are over HTTPS — refuse to call HTTP variants.
- No SwissTennis credentials stored in Phase 1 (anonymous only).
- Phase 2 credentials, when introduced, follow the existing CSD secrets pattern (
docs/gitlab-ci-variables-casey.mdfor env-var conventions); per-tournament credentials stored encrypted at rest with a tournament-scoped key — Morgan to specify the exact mechanism in Phase 2. - Outbound requests run from the same backend host as the existing CSD app (no separate worker tier required for Phase 1).
8. Open questions / consults¶
Items in this section need an answer before Morgan can plan implementation. Resolved items are kept inline (struck-through header + resolution paragraph) so reviewers can see what changed without diffing against v1.
8.1 [RESOLVED — Rafael 2026-05-23] DrawFormat resolution from SwissTennis flags¶
CSD has 8 catalogued DrawFormat rows (F1, F2, F3, F5, F5b, F6, F7, F8 — full table at docs/requirements.md ENTITY-010). SwissTennis encodes scoring rules as flat flags on each event: evtNoAd, evtNoAdFinal, evtShortSets, evtShortSetsFinal, evtMatchTiebreak, evtMatchTiebreakFinal. The §5.2.1 draft table needs to be reworked in light of the resolutions below — concrete replacement at the end of this section.
(a) RESOLVED — F6 (8-game Pro Set) is not directly encoded in the SwissTennis IoEvent flags.
The SwissTennis online tournament module (Advantage) does not expose a "Pro Set" or "games per set" flag on IoEvent. The event-level configuration assumes the SwissTennis-standard 6-game set; evtShortSets=1 switches that to 4-game sets ("Fast4" family), and evtMatchTiebreak=10 swaps the deciding set for a 10-point match tiebreak. There is no toggle for the 8-game Pro Set.
Pro Set (F6) at Swiss club events is a paper-only / referee-discretion format, used almost exclusively in consolation draws and informal vets/seniors play where the organizer schedules quick-fire matches that don't fit in a full 6/6/MTB window. It is not a SwissTennis-online-configurable scoring system, and in the wild it is captured by organizers manually entering Pro Set scores into the result string after the fact. The Wettspielreglement (Art. 35 — Spielsysteme) lists "8-Games Pro Set" as an admitted format but treats it as an offline/organizer choice, not a structured event flag. Consequence for the importer:
- F6 is not reachable from the SwissTennis flag combination. No
IoEventflag pattern resolves to F6 in §5.2.1. - If an organizer wants F6 in CSD, they set the DrawFormat manually on the Draw after import. The importer must not stomp a manually-chosen F6 with the flag-derived guess on re-import. Make
Draw.draw_format_idpart of the "manual override is preserved" set in §6.2's merge table (Morgan: add aDraw.draw_format_locally_overriddenboolean column following the same pattern asMatch.schedule_locally_overridden, default false, set the first time an organizer changes the format away from the importer-derived value). - If the F6 score pattern appears on completed matches (sets like 8/6, 9/8 with no
evtShortSetsflag set), the importer must not "correct" them — store the games as-given. The scoring package will tolerate non-standard set games on a Draw whose format is F6.
(b) RESOLVED — F7 ("Short Set No-Ad") = the Fast4 family stripped of the no-ad rule. It is functionally rare; F5/F5b are the common Fast4 variants.
F7's catalogue row (requirements.md L439): best-of-3, 4 games per set, tiebreak at 4-4 to 7 points (win-by-2), noAd = true per the table (cross-check below). My read of F7 as written in the catalogue is that it IS the no-ad variant — i.e. F7 is "4-game short sets + no-ad" — and the name ("Short Set No-Ad") is descriptive of the format itself, not a contrast to a "Short Set With Ad" alternative. Re-checking the requirements.md ENTITY-010 table column "No-Ad" for F7 — it reads "Yes", confirming F7 has noAd = true. So F7 differs from F5 only in the tiebreak target (F5: first-to-5 win-by-1; F7: first-to-7 win-by-2) — F7 is the "longer-set-tiebreak Fast4 Bo3" variant.
Important correction to the v0.2 draft mapping in §5.2.1: the row evtShortSets=1 AND evtNoAd=0 → F7 is wrong. F7 has noAd=true per the catalogue. The correct rule is:
evtShortSets=1 AND evtNoAd=1 AND evtMatchTiebreak=0 AND bestOf=3→ F5 (Fast4 Bo3, set tiebreak at 4-4 to 5 win-by-1)evtShortSets=1 AND evtNoAd=1 AND evtMatchTiebreak=0 AND bestOf=5→ F5b (Fast4 Bo5)evtShortSets=1 AND evtNoAd=1 AND evtMatchTiebreak=10→ F7-ish but not exactly — see "set-tiebreak target" gap below
Caveat on F5 vs F7 disambiguation: SwissTennis does not expose the set-level tiebreak target (5 vs 7) as a flag. The Fast4 default in SwissTennis is first-to-5 win-by-1 (i.e. F5). F7's first-to-7 win-by-2 set tiebreak is a Wettspielreglement-permitted variant but is not a separate SwissTennis configuration — it's organizer-set on paper. For the importer, F7 is therefore not reachable from flags either, same as F6 — it can only be set manually. The flag pattern evtShortSets=1 AND evtNoAd=1 AND evtMatchTiebreak=0 always resolves to F5 (or F5b based on bestOf), never F7. F7 is an organizer-only override.
In Swiss club play, F7 is rare — most Fast4 events run as F5/F5b. F7 exists in the catalogue to support a small number of veteran/senior tournaments that want longer set tiebreaks for safety-margin play.
(c) RESOLVED — Default to F1 (Standard, Advantage Final Set), not F2.
Sam's v0.2 proposal was F2 (Standard, Tiebreak All Sets). I disagree. The dominant scoring system at Swiss adult club tournaments — by a clear majority of events I've seen — is F1: full 6-game sets with advantage in the final set. F2 (final-set tiebreak) is increasingly common at lower ranking levels (R6+) and at junior events, but it is not the silent default; the silent default in adult Swiss club tennis is still Wettspielreglement Art. 35.1.a, which is F1.
Rationale: a fallback default should match what the organizer would have selected if forced to pick blind. Organizers who don't toggle any scoring flag on SwissTennis are explicitly choosing "the standard" — and SwissTennis's own paper-form default for adult events is "Vorteil im Entscheidungssatz" (advantage in the deciding set), i.e. F1. F2 became common with the ITF rule-trial era (post-2018) but has not displaced F1 as the assumed baseline.
Decision: When the flag combination doesn't match any catalogued row, default to F1 and emit a "format guessed (no SwissTennis flag matched), please verify" note in the import summary. Junior / U18 / U14 events are an exception — when agcName indicates a junior age band (see §8.2(b) for the canonical junior detection), default to F2 instead, because junior Swiss events have almost universally adopted final-set tiebreak. Sam to implement this as a two-line branch in the fallback path.
Update §5.2.1's last paragraph accordingly.
(d) RESOLVED — *Final flags mean "applies only to the literal final match of the event", not "applies only to the deciding set when all sets are equal".
This is consistent with the field naming (*Final = final match) and with how SwissTennis documents the Advantage tournament module for organizers. Source: Wettspielreglement Art. 35.2 + SwissTennis's Advantage organizer manual, which describes the toggle as "Andere Regelung im Final" (different rule in the final match). The convention CSD developers might expect — "deciding set when all sets are equal" — is what finalSetRule means in ENTITY-010, but that is not what SwissTennis's *Final flags mean.
Semantics:
evtNoAd = 1→ no-advantage scoring applies to every match in the event, including the final.evtNoAd = 0ANDevtNoAdFinal = 1→ standard advantage in all matches except the final match, which uses no-ad. (i.e. the Final flag is an additive override for the final match only; it does not* require the non-final flag to be 0 to take effect, but the common-sense reading is that organizers setevtNoAd=0 + evtNoAdFinal=1rather thanevtNoAd=1 + evtNoAdFinal=0.)evtNoAd = 1ANDevtNoAdFinal = 1→ no-ad everywhere. (Redundant but legal; treat the same asevtNoAd=1.)evtNoAd = 1ANDevtNoAdFinal = 0→ no-ad in every match. The*Final = 0does not override back to advantage in the final; "0" means "no special final-match rule", not "explicit standard-rule in the final". (i.e.evtNoAdFinalis additive only when set to 1, never subtractive.)- Same logic applies to
evtShortSetsFinalandevtMatchTiebreakFinal.
Problem: CSD's ENTITY-010 DrawFormat does NOT support "different rules for the literal final match". ENTITY-010 has finalSetRule (deciding-set behavior) but no concept of "the final match uses a different DrawFormat". Options:
- Extend the schema to allow per-Draw "final match override" — out of scope for Phase 1.
- Best-effort: ignore the
*Finalflags and resolve DrawFormat from the non-final flags only. Emit a warning into the import summary:"Event has a special rule for the final match (evtNoAdFinal/evtShortSetsFinal/evtMatchTiebreakFinal) — CSD ignores final-match overrides; please verify the final's scoring manually." - Refuse to import the event.
Decision: option 2. Best-effort using the non-final flags, with a per-event warning surfaced in the audit-trail summary. Refusing the import (option 3) penalizes the organizer for a rule that affects exactly one match per draw and that the organizer can manually correct after the fact. Sam: implement the warning, and make the warning text appear in the per-event row of the one-shot import summary so the organizer sees it without drilling into the audit log.
Edge case organizers will hit: junior tournaments occasionally configure evtShortSets=0 + evtShortSetsFinal=1 (full sets in early rounds, short sets in the final to keep the prize ceremony on schedule). Importer surfaces the warning, organizer manually flags the final match if it matters for downstream stats. Acceptable trade-off for v1.
8.1 — Replacement mapping table (replaces draft in §5.2.1)¶
This table replaces the proposed mapping in §5.2.1. Morgan / Sam: implement against this version.
| Trigger (in priority order) | Resolves to | Notes |
|---|---|---|
evtShortSets=1 AND evtNoAd=1 AND bestOf=3 AND evtMatchTiebreak=0 |
F5 (Fast4 Bo3) | SwissTennis Fast4 default. bestOf derived from evtNbSets if exposed, else assume 3. |
evtShortSets=1 AND evtNoAd=1 AND bestOf=5 AND evtMatchTiebreak=0 |
F5b (Fast4 Bo5) | Rare; mostly final stages of vets cups. |
evtShortSets=0 AND evtMatchTiebreak=10 AND evtNoAd=0 |
F3 (Match Tiebreak / Super TB) | 2 full sets + 10-point MTB for set 3. The Swiss club default for "doubles + most Bo3 vets". |
evtShortSets=0 AND evtMatchTiebreak=0 AND evtNoAd=0 AND (no evtFinalSetTiebreak indicator in payload) |
F1 (Standard, Advantage Final Set) | Adult-tournament canonical default. |
evtShortSets=0 AND evtMatchTiebreak=0 AND evtNoAd=0 AND junior age band (per §8.2(b)) |
F2 (Standard, Tiebreak All Sets) | Junior events default to TB-all-sets even when no flag is set. |
evtShortSets=0 AND evtMatchTiebreak=10 AND evtNbSets=0 (no full sets) |
F8 (Standalone MTB) | Rare — only used for tiebreak-only consolation rounds. Require evtNbSets=0 to avoid stealing F3 events. |
Anything else (including all combinations that involve *Final flags) |
Fallback default (see (c) above) — F1 for adult events, F2 for junior events | Emit "format guessed — please verify" + (if any *Final flag is non-zero) "final-match override ignored" warnings into the import summary. |
| Manually-set DrawFormat (organizer changed it after import) | Preserve — never overwrite on re-import | New column Draw.draw_format_locally_overridden (Morgan to add to §8.5 schema list). F6 and F7 are reachable only through this path. |
Items removed vs v0.2 draft:
- The row evtShortSets=1 AND evtNoAd=0 → F7 is dropped (F7 requires noAd=true per catalogue; that combination is not catalogued and falls into the fallback default).
- The row "no direct SwissTennis flag for 8-game pro set → F6" is dropped (F6 is not reachable from flags; manual override only).
- Final-set-only flags (evtShortSetsFinal, evtMatchTiebreakFinal, evtNoAdFinal) are intentionally ignored for DrawFormat resolution per (d); a warning is surfaced when any is non-zero.
8.2 [RESOLVED — Rafael 2026-05-23] Draw-name composition rule¶
Draw.name is the German-language human-readable label organizers see throughout the UI (signage, draw list, schedule). It must read naturally to a Swiss organizer who knows the SwissTennis paper-form conventions. From the SwissTennis IoEvent we have ioMatchType.mtpName, ageCategoryEvtIdAgeCategory.AgeCategory, ageCategoryEvtIdAgeCategory2, rankingTypeEvtIdUpperRanking.RankingType.rnkDescr, rankingTypeEvtIdLowerRanking.RankingType.rnkDescr.
(a) RESOLVED — Canonical formula:
Where:
- <genderNoun> is the German full word, NOT the SwissTennis code, mapped from mtpName:
- "MS" → "Herren"
- "WS" → "Damen"
- "MD" → "Herren Doppel"
- "WD" → "Damen Doppel"
- "MX" or "XD" → "Mixed" (SwissTennis ships either code depending on the tournament — accept both)
- Unknown mtpName → fall back to the raw code, log a warning. (Defensive: if SwissTennis introduces a new code, we don't crash, just render the raw value.)
- <ageSuffix> is omitted entirely when the event has no age restriction (see (c)). When present, it is the display band string from AgeCategory per the rule in (b).
- Ranking range is always rendered as R<lower-number>-R<higher-number> where the lower number (R1, R2, …) appears first because R1 is the highest/best ranking and R9 the lowest. The separator is the ASCII hyphen -, not en-dash — Swiss organizers and SwissTennis paper draws both use hyphen, and the en-dash creates ambiguity with the digit-minus in some fonts. Always order best-to-worst (low number first), regardless of which SwissTennis field is upper vs lower in the payload. I.e. always emit R<min(upper,lower)>-R<max(upper,lower)> after stripping the R prefix and comparing the integer.
Real-example renderings (using the brief's examples):
| Event | mtpName | agcName | agcMenDescr/WomenDescr | Upper / Lower | Composed name |
|---|---|---|---|---|---|
| 158149 / 829592 | MS | A | (n/a — open) | R3 / R6 | Herren R3-R6 |
| 158149 / 829596 | MS | S3 | "55+" | R3 / R6 | Herren 55+ R3-R6 |
| 154477 / 799512 | WS | A | (n/a — open) | R1 / R4 | Damen R1-R4 |
| 154477 / 799508 | MS | A | (n/a — open) | R4 / R1 (lower/upper inverted in payload) | Herren R1-R4 |
Note the last row: the payload's upper/lower labels are not always in "best→worst" order. The canonical formula sorts numerically, so the rendered name is identical regardless of which side of the payload carries which value.
(b) RESOLVED — Age-suffix shape: use the displayed age-band string, gender-aware. Drop the short code.
The suffix is the band string ("35+", "45+", "55+", "U18", "U14", etc.), selected from the AgeCategory object per match type:
mtpName ∈ {"MS","MD"}→ useagcMenDescrmtpName ∈ {"WS","WD"}→ useagcWomenDescrmtpName ∈ {"MX","XD"}→ useagcMixtDescr; ifagcMixtDescris empty/null, fall back to whichever ofagcMenDescr/agcWomenDescris non-empty (mixed doubles sometimes ships only one populated band when the partners are constrained to the same age tier).
Do NOT include the short code ("S3", "JS" etc.) in the rendered name. Swiss organizers and paper draws say "Herren 55+ R3-R6", not "Herren S3 (55+) R3-R6". The short code is an internal index — useful for filtering and SwissTennis joins, but not for display.
Known band shapes (non-exhaustive, alphabetical):
- Senior brackets: "30+", "35+", "40+", "45+", "50+", "55+", "60+", "65+", "70+", "75+", "80+" (men typically 5-year tiers from 35+; women typically 5-year tiers from 30+ — note the gender asymmetry, which is exactly why (b) says gender-aware band selection)
- Junior brackets: "U10", "U12", "U14", "U16", "U18" (sometimes "J18" in older payloads — accept both)
- The displayed bands ARE the canonical SwissTennis Wettspielreglement age categories (Art. 3 — Alterskategorien). Pass them through verbatim.
Junior-age detection (referenced by §8.1(c) fallback): an event is "junior" iff agcName starts with "J" or "U", OR the displayed band starts with "U". This is the trigger that flips the §8.1 fallback default from F1 to F2.
(c) RESOLVED — Omit the age suffix iff agcName === "A".
"A" is SwissTennis's code for "Aktive / all ages / no age restriction" (Art. 3.1 of the Wettspielreglement — the open / "Aktivkategorie"). It is the only value for which we drop the suffix. Other markers to be defensive against:
agcManAgeMin === 0 && agcWomAgeMin === 0is a corroborating signal (a0minimum age means no lower bound) but is NOT the primary check — some open events shipagcName = "A"withagcManAgeMin = 18to encode "adults only / no juniors". UseagcNameas the primary trigger.- If
agcNameis null/empty/missing entirely, treat as if it were"A"and omit the suffix. Log a warning. (Defensive: no real event should ship without an age category, but we don't want the renderer to crash on a malformed payload.) - If
agcNameis non-"A"but the correspondingagcMenDescr/agcWomenDescr/agcMixtDescris empty/null, fall back to theagcNameshort code as the suffix (e.g."Herren S3 R3-R6"). Better to show something than nothing — and this case should never happen in well-formed payloads, so the slight ugliness is acceptable as a defence.
(d) RESOLVED — Ignore ageCategoryEvtIdAgeCategory2 in the name. Use the first age category only.
SwissTennis's IoEvent ships two age category slots because the underlying data model supports gender-asymmetric senior doubles (e.g. mixed doubles where the men's-side band differs from the women's-side band). In practice, for singles events, the second slot is always either (a) identical to the first or (b) an empty object — neither case affects the rendered name. For doubles events, the gender-aware selection in (b) already routes to the correct band via agcMenDescr/agcWomenDescr/agcMixtDescr on the FIRST category, so the second slot adds no information we care about for display.
Rule: Always render from ageCategoryEvtIdAgeCategory (slot 1) only. Ignore slot 2 for Draw.name composition.
Safety net: If slot 2 ever ships a non-empty AgeCategory whose agcName differs from slot 1's agcName, log a warning ("event {evtId} ships two distinct age categories: slot1={agcName1} / slot2={agcName2}") so we can investigate post-hoc. This is a "should never happen" diagnostic, not a user-facing error. Don't change the rendered name.
Probe to find a real divergent example (for Sam to run during impl when he wants to harden the warning path):
# Iterate the SwissTennis calendar for past mixed-doubles cup competitions; the most likely
# candidates are vets/seniors cup events 2024/2025 with mixed format, e.g. "Swiss Senioren-MX
# Cup" entries on https://www.mytennis.ch/de/turniere?type=mixed&category=seniors&season=2024.
# For each trnId, grep evtTitle for "MX" or "XD", then compare:
curl -s 'https://comp.swisstennis.ch/advantage/servlet/PublicDisplayEvent?eventId=<id>&Lang=DE&outputFormat=JSON' \
| jq '.Iotto.IoEvent | {a1: .ageCategoryEvtIdAgeCategory.AgeCategory.agcName, a2: .ageCategoryEvtIdAgeCategory2.AgeCategory.agcName // .ageCategoryEvtIdAgeCategory2}'
8.3 [RESOLVED in v2 — PO decision] "Bulk insert" and "Pool" mode semantics¶
Resolution:
- Pool mode is now fully specified — see §5.5. Verified against trn 154477. Pools are independent groups with their own standings; the head-to-head matches live nested inside each pool member's player record at
ioRRMatchRrmIdPlayer1Set. Pool partition is fully encoded inDisplayPools(no derivation needed). Cross-group playoffs do exist in SwissTennis but are exposed as separate events (one event per pool stage, one event per KO stage), so CSD never has to handle a "pool + KO inside one event" case. - Bulk insert mode is unsupported (PO decision). CSD does not call
DisplayBulkMatchesand surfaces a user-facing error when such an event is encountered (AC-02.4, §5.6). - Mixed-mode events do not occur in practice (PO confirmation). Each event is exclusively
DrawORPool. The fail-loud rule in AC-02.4 covers anything else as a safety net.
8.4 [RESOLVED in v2 — verified data] Result/score field on DisplayDraw cells¶
Resolution: Verified against trn 154477 evt 799508 (completed 4-player MS KO). Full mapping in §5.3; defensive notes in §6.4. Shape: result: { ro: 1, content: "<winner-POV games>/<loser-POV games> ..." } on the winner's advancing cell. Match tiebreaks render as a third "set" with games like 10/8. Loser identity is resolved from the same-round sibling slot. Format aligns cleanly with CSD's MatchResult (ENTITY-007 — outcome enum + SetScore array + optional matchTiebreak) — no schema change required.
8.5 [Morgan] Schema additions¶
The importer needs new columns / entities:
- Tournament.swisstennis_tournament_id (int, unique, nullable)
- Draw.swisstennis_event_id (int, unique, nullable)
- Player.swisstennis_person_id (int, unique, nullable) — distinct from license_number
- Match.swisstennis_venue_label (text, nullable)
- Match.swisstennis_match_id (int, unique nullable) — populated for RR matches (rRMatchId); KO matches use the composite (draw_id, alevel, rposition_lower) key
- Match.schedule_locally_overridden (boolean, default false)
- Match.result_locally_entered (boolean, default false)
- Draw.draw_format_locally_overridden (boolean, default false) — added by §8.1 resolution (2026-05-23, Rafael). Set the first time an organizer changes the DrawFormat away from the importer-derived value. While true, re-imports must preserve the local choice. Required because F6 and F7 are only reachable via manual override (not via SwissTennis flags) — without this column the next sync would silently revert the organizer's choice.
- New entity Pool (§5.5) — at minimum id, draw_id (FK), name, swisstennis_pool_id (int, unique nullable)
- New entity PoolStanding (§5.5) — pool_id, player_id, final_rank, force_rank, seed_position, wins_total, wins_excluding_wo, matches_played, sets_won, sets_lost, games_won, games_lost. (Morgan: decide whether to denormalise these on Registration instead, since standings are per-event-member.)
- New entity ImportEvent (§7.2)
- Question: should Tournament gain a location / organizer / source column to round-trip §5.1's currently-dropped fields, or are they intentionally out of scope for CSD's model?
8.6 [Sam] Verify SwissTennis date struct month indexing¶
The trnBegin / evtBegin / plyBirthdate1 / plyRegisteredOn / rrmDate (RR match start time) structures all use a Java-style {year, month, day, hour, minute, second} shape. Java Calendar months are 0-indexed (Calendar.JANUARY == 0). The verified sample shows trnBegin.month == 4 for a tournament starting in May, which is consistent with 0-indexed (May = 4). Sam: verify with at least one other tournament that we can lock the convention before shipping — and write a parser test that catches off-by-one in date conversion. (Same parser will be used for both top-level dates and per-RR-match rrmDate.)
8.7 [Jamie] UX: where does the SwissTennis import surface in the UI?¶
- The Tournament list page needs a "Create from SwissTennis" entry point next to "Create blank tournament."
- The Tournament settings page needs the "Background sync" toggle + "Last synced" timestamp + "Sync now" button + integration health badge.
- The Draw page needs an "Import from SwissTennis" button (per-event bracket import, US-ST-02).
- The Match card on SCR-005 may need to surface the SwissTennis venue label as a subtle subtitle.
- An unsupported-mode error state for bulk-insert events (AC-02.4) — how does it surface in the one-shot import summary? Inline per-event row with red status?
- Wireframes: deferred to Jamie once §8.1–§8.2 are answered.
8.8 [RESOLVED in v2 — PO decision] Poll cadence¶
Resolution: PO confirms 60 seconds in-window, 15 minutes pre-tournament, plus one final pull at trnEnd + 1h, then sync stops. "In-window" is defined as trnBegin 00:00 through trnEnd 23:59 Europe/Zurich. See AC-04.2 and §4.3 for the concrete schedule.
9. Out of scope¶
The following are explicitly excluded from this spec and tracked separately (or not at all in v1):
| Item | Reason | Where it goes |
|---|---|---|
Bulk insert event mode (and any future non-Draw/non-Pool SwissTennis event mode) |
PO decision (2026-05-23): CSD does not support this SwissTennis insert mode. Fail-loud user-facing error per AC-02.4 / §5.6. DisplayBulkMatches is never called. |
Not on the CSD roadmap. If a user repeatedly hits this, follow up with PO; do not silently work around. |
| Writing data back to SwissTennis (results, scheduling) | SwissTennis has no public write API; would require Hasura GraphQL + service-account auth | Not in CSD roadmap |
| Standalone player or ranking lookup (e.g. "find player Wagen Stefan across all tournaments") | Not a tournament-management use case | Future, if ever |
| Importing non-tournament data: clubs, leagues, Grand Prix series, officials/referees | Out of CSD's product scope | Out |
| Doubles (MD/WD/MX) event support | Phase 1 ships singles only; doubles partner data lives in different fields (*2 suffixes) |
Phase 2 follow-up issue |
| Phase 2 Hasura GraphQL path generally | Anonymous Advantage covers the four MVP surfaces | Phase 2 |
| ~~Auto-creating CSD Courts from SwissTennis venue labels~~ | ~~Manual court setup in SCR-004 is fast and gives the organizer naming control~~ | Implemented in GitLab #205 (PO decision 2026-05-24) but rolled back from default-on by #215 (PO decision 2026-05-25): CSD now owns courts by default and the auto-create path is opt-in per tournament. See §6.5.1 (the gate) and §6.5.2 (the auto-create behaviour when opted-in). |
| Cross-tournament historical sync (importing past tournaments for analytics) | Not a live tournament-day need | Future |
| Real-time websockets / SSE from SwissTennis | SwissTennis does not expose a streaming API; polling is the only option | n/a |
| Live ranking updates between tournaments | Ranking is a snapshot at import time (ENTITY-005 note) | Out (existing decision) |
10. Document control¶
Changelog (most recent first)¶
v0.4 — 2026-05-25 (Sam + Morgan)
- §6.5 split into §6.5.1 (new tournament-level swisstennis_csd_owns_courts gate, default TRUE) and §6.5.2 (the pre-existing #205 auto-create + link behaviour, now conditional on the flag being FALSE). PO decision (Stefan, 2026-05-25): SwissTennis court data is cumbersome and rarely correct — CSD now owns court assignment by default, SwissTennis-sourced courts are opt-in per tournament. Migration 0012 adds the column with DEFAULT TRUE; existing auto-created Courts and matches.court_id assignments are preserved verbatim, the importer just stops creating new ones. New structured courts_skipped_csd_owned DiffEvent (per the #197 producer pattern) keeps the audit feed from going silent. Cross-references in §3 (AC-03.2), §4 (trnNbCourts row), §5.4 (schedule mirror header), §5.5 (ioCourt row), and §9 (out-of-scope table) updated to point readers at §6.5.1 vs §6.5.2 explicitly. Header version bumped from 0.3 to 0.4. Spec change shipped in commit 8cc54a5 (#215); cross-ref tightening + version bump shipped in #220.
v0.3 — 2026-05-23 (Rafael)
- §8.1 closed end-to-end: F6 and F7 declared not reachable from SwissTennis flags (manual override only — Morgan to add Draw.draw_format_locally_overridden to §8.5 schema list); §5.2.1 mapping table replaced (the v0.2 evtShortSets=1 AND evtNoAd=0 → F7 row was incorrect — F7 has noAd=true per the catalogue); fallback default flipped from F2 to F1 for adult events, F2 for junior events (junior-age detection rule cross-referenced in §8.2(b)); *Final flag semantics nailed down ("applies to the literal final match", additive only when set to 1), best-effort import-with-warning chosen over refuse-import for events that use them.
- §8.2 closed: canonical Draw.name formula = <genderNoun>[ <ageSuffix>] R<lower>-R<upper> with full-word German gender nouns (Herren / Damen / Herren Doppel / Damen Doppel / Mixed), gender-aware age-band selection (agcMenDescr for MS/MD, agcWomenDescr for WS/WD, agcMixtDescr with sane fallback for MX/XD), short-code dropped from display, age suffix omitted iff agcName === "A", ranking range always rendered best-to-worst regardless of payload order, hyphen separator (not en-dash), ageCategoryEvtIdAgeCategory2 ignored for display with a "should-never-happen" diagnostic warning + a documented probe to surface a divergent example if one exists.
v0.2 — 2026-05-23 (Alex)
- §8.3 closed: "Bulk insert" mode declared unsupported per PO; AC-02.4 rewritten to fail-loud with a clear user-facing error; §5.6 reframed accordingly. PO also confirms no mixed-mode events occur in practice — spec assumes each event is exclusively Draw OR Pool. E5 (DisplayBulkMatches) is no longer called; struck through in §4.2 and §4.3.
- §8.4 closed: completed-match score shape verified against trn 154477 evt 799508. §5.3 result-row mapping rewritten with the verified result: { ro, content } shape on the winner's cell, NBSP-as-whitespace note, and match-tiebreak handling. §5.3 algorithm extended with step 6 (result attribution) + a worked example walking through SF1/SF2/Final of the verified event. §6.4 flipped from "unknown" to "verified" with a summary of the verified shape.
- §8.8 closed: PO confirms 60 s in-window / 15 min pre-tournament / one final pull at trnEnd + 1h then stop. AC-04.2 rewritten with the concrete schedule; §4.3 cadence table updated; in-window defined as trnBegin 00:00 → trnEnd 23:59 Europe/Zurich.
- §5.5 rewritten end-to-end against verified DisplayPools data from trn 154477: full IoPool / IoPlayerPool field mapping; head-to-head matches enumerated via player-1-anchored ioRRMatchRrmIdPlayer1Set; -1 games sentinel for unplayed sets documented; ""-vs-object shape polymorphism on ioRRMatchRrmIdPlayer1Set documented; pool-not-necessarily-full caveat (evtSize vs actual member count); match-tiebreak mapping for RR.
- §5.3 KO algorithm: switched primary pairing index from aposition (y-layout) to rposition (x-pairing) to match verified bracket topology.
- §7.4 upsert keys updated: RR matches keyed on swisstennis_match_id (rRMatchId); Pool keyed on swisstennis_pool_id (poolId); PoolStanding on (pool_id, player_id).
- §8.5 expanded: new entities Pool and PoolStanding; new Match.swisstennis_match_id column for RR.
- §8.1 and §8.2 left open but tightened with numbered sub-questions for Rafael (a/b/c/d each) so he can answer crisply without re-reading the whole spec.
- §8.6 expanded to note that rrmDate shares the same Java-month indexing as the top-level date structs.
- Header status updated: "Draft for Rafael review on §8.1 + §8.2; otherwise unblocked for Morgan implementation planning."
v0.1 — 2026-05-23 (Alex) - Initial draft based on Nora's research against trn 158149 and PO scope decisions.
Status of blockers¶
- Phase 1 implementation prerequisite (Morgan can plan once these resolve): ~~§8.1~~, ~~§8.2~~, §8.5, §8.6.
- Resolved in v0.3: §8.1, §8.2 (Rafael — DrawFormat resolution from SwissTennis flags + Draw.name composition rule).
- Resolved in v0.2: §8.3, §8.4, §8.8.
- Morgan is unblocked for Phase 1 implementation planning. §8.5 (schema additions) is a Morgan-internal question, not a domain consult. §8.6 (date-struct month indexing) is Sam-verifies-during-impl. Nothing else is gating.
- New small follow-up for Morgan (from §8.1 resolution): add
Draw.draw_format_locally_overridden(boolean, default false) to the schema list in §8.5; mirrorsMatch.schedule_locally_overridden. Without this, the merge contract in §6.2 silently stomps a manually-chosen F6/F7 on re-import. - Parallel PO action (unchanged from v0.1): raise the anonymous PII over-exposure with SwissTennis directly. Not tracked in CSD issue queue.
Version history table¶
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.4 | 2026-05-25 | Sam (Backend) + Morgan (Architect) | Split §6.5 into §6.5.1 (new tournament-level swisstennis_csd_owns_courts gate, default TRUE per PO decision 2026-05-25 — #215) and §6.5.2 (pre-existing #205 auto-create, now conditional on flag = FALSE). Cross-references in §3 / §4 / §5.4 / §5.5 / §9 re-targeted to the appropriate subsection. |
| 0.3 | 2026-05-23 | Rafael (Domain Expert) | Closed §8.1 (DrawFormat resolution from SwissTennis flags — F6/F7 not flag-reachable, F2 vs F1 fallback flipped to F1-adult/F2-junior, *Final-flag semantics + best-effort-with-warning policy, §5.2.1 mapping table replaced) and §8.2 (canonical Draw.name formula, German gender nouns, gender-aware age-band selection, slot-2 age category ignored). Added Draw.draw_format_locally_overridden follow-up for §8.5. |
| 0.2 | 2026-05-23 | Alex (BA) | Folded in verified data from trn 154477 (KO result shape + RR pool structure); closed §8.3 / §8.4 / §8.8 per PO decisions; tightened §8.1 / §8.2 for Rafael; expanded schema list in §8.5. |
| 0.1 | 2026-05-23 | Alex (BA) | Initial draft based on Nora's research against trn 158149 and PO scope decisions. |