Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ as in the markup. The UI is Dutch by default with English available.

```bash
dotnet build -c Release # what CI builds — warnings are errors here (see below)
dotnet test # 401 tests, xUnit v3, real SQLite
dotnet test # 398 tests, xUnit v3, real SQLite
cd src/FootballFormation.Web && dotnet run # http://localhost:5228
cd tests/ui && npm test # 39 Playwright tests in a browser, ~1 min (npm install first)
scripts/visual-check.sh # screenshots every page, then measures every touch target
Expand Down
31 changes: 17 additions & 14 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Models/
Game.cs — Game entity (incl. SeasonId + live match clock/state), GameSplitType and MatchState enums
GamePeriod.cs — GamePeriod entity, PeriodType enum, PeriodTypeExtensions
GamePlayerPosition.cs — Links player to position in a period (IsSubstitute flag)
GameGoal.cs — A goal: scorer (null for the opponent), assister, minute, own/opponent flags
GameGoal.cs — A goal: scorer (null for the opponent), assister, minute (+ stoppage), own/opponent flags
GameSubstitution.cs — A timestamped change made during a live match
MatchPreferences.cs — Per-season game defaults (duration, split, formation, match day)
GameComment.cs — An admin's note on a game: body, public/private, author, edited marker
Expand Down Expand Up @@ -51,11 +51,11 @@ Reporting/
SeasonStatsReport.cs — Team totals + form for /stats (SeasonStats, GameResult)
PlayerStatsReport.cs — Per-player aggregates (PlayerStats, PositionStat, PlayerGameStat)
PositionFitHelper.cs — 5-tier position fit: Preferred, NaturalFit, Alternative, Compatible, OutOfPosition
MatchClockReport.cs — Derives the live clock and period state from the stored anchor + banked total
PlannedChangesReport.cs — What the next period changes versus the one on the pitch, minus the
swaps play has already overtaken (Build() for the card in
UI/Components/PlannedChangesList, Swaps() for MatchClockService, which
applies the overtaken half rather than deciding again)
MatchClockReport.cs — Derives the live clock and the half's reading from the stored anchor +
banked total, and the MatchMinute an event is written down against
PlannedChangesReport.cs — What the plan for the middle of a half changes versus the line-up on the
pitch, minus the swaps play has already overtaken, for
UI/Components/PlannedChangesList
ScoreProgressionReport.cs — The score after each goal (MatchScore), for the live timeline —
counted forwards because that list runs newest first
HealthReport.cs — Whether a booted container is actually serving: the /health payload and
Expand All @@ -73,21 +73,22 @@ Services/
renders, GetTodaysMatchAsync for the home-page banner (in-progress
first, else today's fixture, upcoming or finished). Writing to one is
the three services below, split by what happens on the touchline
MatchClockService.cs — The clock and the run of play: kick-off, ending a period, starting or
rolling into the next one, the final whistle. The arithmetic a
season's statistics are built from. There is no pause — the clock
runs from kick-off to the whistle and only a period boundary stops it
MatchClockService.cs — The clock and the run of play: kick-off, half time, starting the next
half, the final whistle. A match is two halves whatever its line-ups
were planned in. The arithmetic a season's statistics are built from.
There is no pause — the clock runs from kick-off to the whistle and
only half time stops it
MatchGoalService.cs — Goals logged live: the live minute added here, storage and the
recounted scoreline delegated to GameService, which writes the two in
one save (see patterns.md, "When two rows have to agree")
MatchSubstitutionService.cs — The slot swap and the record of it, in one SaveChanges, plus undoing
the most recent one of a period, plus SwapPositionsAsync — two players
the most recent one of a half, plus SwapPositionsAsync — two players
already on trading slots, which writes no substitution row (so the undo
reads the slot back off the pitch, not off the row)
LiveMatchOperation.cs — The write shape those three share: RunAdminAsync plus, on success, one
LiveMatchNotifier call naming the game that changed
LiveMatchQueries.cs — The tracked load they all start from (the game with its periods, via
GameQueries) and the one "game not found" message
LiveMatchQueries.cs — The tracked load they all start from (the game with its planned
line-ups, via GameQueries) and the one "game not found" message
LiveMatchNotifier.cs — Singleton: fans live match changes out to every open circuit
MatchPreferencesService.cs — Per-season prefs: GetAsync(seasonId)/SaveAsync,
GetNextMatchDateAsync(seasonId)
Expand Down Expand Up @@ -115,6 +116,8 @@ Pages/
LiveGoalDialog.razor(.cs) — Dialog: scorer, assister, own-goal toggle
LiveSubDialog.razor(.cs)(.css) — Dialog: for a player tapped on the pitch, either a replacement
from the bench or a position swap with someone already on
PlannedChangesDialog.razor — Dialog: the changes still planned for the middle of this half, as a
reference to work through by tapping the pitch. Writes nothing
SeasonDialog.razor(.cs) — Dialog: season name, start date, end date
Settings.razor(.cs) — /settings — Match preferences, own password, season management
Users.razor(.cs) — /users — Accounts: add, edit, reset password, delete (Admin only)
Expand All @@ -128,7 +131,7 @@ Components/
OnPlayerClicked for the live screen, Size for chip scale
PlayerLabel.razor — A player as one line of text: "#7 Jasper"
PlannedChangesList.razor(.css) — What the next line-up does, as a team sheet, for the live
screen's "Changes at half-way" card
screen's PlannedChangesDialog
CancellableComponent.cs — Base for any component that reads: owns the CancellationToken its
service reads take, tripped when the component is disposed
SeasonAwarePage.cs — Base for pages that follow the season picker (a CancellableComponent)
Expand Down
31 changes: 16 additions & 15 deletions docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,22 +363,23 @@ Avoid repeating these mistakes:
5, swapping that player to slot 0 and then undoing seated two players in slot 5 and emptied
slot 0; it now reads the slot off the player coming off instead. And `GameMinutesReport` seeds
from the lineup as it finally stands, so a swap credits **the position moved into** for the whole
period, earlier minutes included — the opposite of what its comment used to claim. Totals are
half, earlier minutes included — the opposite of what its comment used to claim. Totals are
right either way; only the split by position is affected, and a test pins it.
- **Advancing a period rewrites the next period's stored line-up.** `AdvancePeriodAsync` is not
purely a clock move: where a live substitution has already answered one of the next line-up's
swaps, it keeps the player who came on and benches the arrival the plan named — otherwise an
injury replacement is pulled straight back off at the quarter boundary. So the line-up the
formation builder shows for Q2 after a match has been run is not necessarily the one that was
saved, and that is deliberate rather than a lost edit. The rule is `PlannedChangesReport.Swaps`,
shared with the live screen's "Changes at half-way" card so the list and the button cannot part
ways; change one and the other follows.
- **Removing a control does not remove the state it could leave behind.** Deleting pause/resume
left `MatchState=InProgress` + a live period + `ClockRunningSince=null` unreachable going
forward, but still storable by a row an older build wrote. `AdvancePeriodAsync` deliberately
leaves the anchor alone — so on such a row it would roll on to the next line-up with the clock
still frozen, banking no minutes for the rest of the half while the screen said it kept running.
It now restarts a stopped anchor, which is a no-op for every game that was never paused.
- **A quarters match only ever kicks off two of its four periods.** The live match knows halves
and nothing else: `Game.NextHalf()` skips a line-up whose half has already been played, so the
second half opens at Q3. Q2 and Q4 keep their planned line-ups and never get `StartedAtSeconds`,
which is exactly what `GameMinutesReport` needs — a line-up that was never kicked off contributes
nothing, so the half is credited to the line-up that played it plus the substitutions made during
it. Q2 and Q4 reach the touchline only as `Game.MidHalfPlan()`, behind the live screen's
`Changes (n)` pop-up. Do not "fix" a Q2 with no timings, and do not read `PeriodCount` as a count
of stages the clock stops for.
- **A goal's minute is not one number.** `GameGoal.Minute` stops at the end of the half and
`AdditionalMinute` counts the overrun beside it, because the two together are what orders a
timeline: counted on into a single number, a goal at 35+2 reads 37 and sorts after a goal in the
36th minute of the second half, which happened a minute later. Rows written before the split have
`AdditionalMinute = 0` and keep whatever number they were given — the migration deliberately does
not backfill, because nothing left in the row says whether a 37 was stoppage time or typed in by
hand.

## General
- **Port already in use**: Kill orphaned process with `taskkill //PID <pid> //F`.
Expand Down
31 changes: 22 additions & 9 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ always had, and keeps games referencing a since-departed player rendering sensib
| MatchState | MatchState | NotStarted / InProgress / Finished. Driven by the live match screen |
| ClockRunningSince | DateTime? | UTC anchor; null whenever the clock is stopped |
| ClockAccumulatedSeconds | int | Seconds banked from earlier running stretches |
| LivePeriodId | int? | The period on the pitch. Null before kick-off, at the break and after full time |
| LivePeriodId | int? | The line-up on the pitch — the row that opened the half being played. Null before kick-off, at half time and after full time |
| Substitutions | List\<GameSubstitution\> | Cascade delete |
| Comments | List\<GameComment\> | Cascade delete. Never eager-loaded — see GameComment |

Expand All @@ -149,10 +149,13 @@ would shift while it is still being played. More computed members support the re
| `PeriodDurationSeconds` | How long one period lasts on an even split. **Seconds, not minutes** — a duration that splits into fractions of a minute (50 in quarters is 4 × 12.5) still splits exactly into seconds, so the periods add back up to the full match length. Every planned-minutes calculation reads this one |
| `PeriodDurationMinutes` | The same length as a `decimal`, fractional when it has to be. Display only |
| `HasLineup` | Does any period have someone on the pitch? Needs `PlayerPositions` loaded |
| `HasActualTimings` | Was any period actually kicked off, i.e. are there real timings to prefer over the plan? |
| `HasActualTimings` | Was any half actually kicked off, i.e. are there real timings to prefer over the plan? |
| `PlayedDurationSeconds` | The same sum in seconds, without the fallback — the denominator for a share of one game's playing time, where truncating to minutes would let an ever-present player round past 100% |
| `PlayedDurationMinutes` | How long the match really lasted, summed over the periods played out; falls back to `GameDurationMinutes`. The denominator for utilisation, so a match that over-ran cannot push anyone past 100% |
| `CurrentOrLastPeriod()` | The period the match is *about*: the live one, else the last played, else the first — so the live screen is never blank |
| `CurrentOrLastHalf()` | The half the match is *about*, as the line-up it is played with: the live one, else the last played, else the one the match opens with — so the live screen is never blank |
| `LiveHalf()` | The half on the pitch, or null before kick-off, at half time and after full time. What a substitution may touch |
| `NextHalf()` | The half the clock goes to next, as the line-up opening it. Skips a line-up planned for the middle of a half already played, so a quarters second half opens at Q3 |
| `MidHalfPlan(half)` | The line-up planned to take over partway through that half, or null. Only a quarters game has one, and the clock never stops for it — the live screen offers it as a reference |

A game's season is resolved in `GameService.CreateAsync`: `SeasonId == 0` means "auto by date"
(the game dialog's default) and is looked up via `SeasonService.GetOrCreateForDateAsync`, creating
Expand All @@ -175,14 +178,19 @@ season's squad — so a player who was a guest one year and a regular the next i
each. `PlayerStatsReport.Build` and `SeasonStatsReport.Build` both take `SeasonSquads` for this reason.

## GamePeriod
One **planned line-up**, for a half or for a quarter. The match itself is only ever two halves, so
the row that opens a half is the one the live screen plays, times and records against, while a row
planned for the middle of a half stays a plan and is never kicked off. `PeriodType.Half()` maps one
to the other.

| Property | Type | Notes |
|---|---|---|
| Id | int | PK |
| GameId | int | FK → Game (cascade delete) |
| PeriodType | PeriodType | FirstHalf, SecondHalf, FirstQuarter..FourthQuarter |
| FormationTypeOverride | FormationType? | Null = use game's formation |
| StartedAtSeconds | int? | Match-clock second it kicked off. Null unless run live |
| EndedAtSeconds | int? | Match-clock second it was whistled off |
| StartedAtSeconds | int? | Match-clock second the half this opens kicked off. Null unless run live, and always null for a plan for the middle of a half |
| EndedAtSeconds | int? | Match-clock second that half was whistled off |
| PlayerPositions | List\<GamePlayerPosition\> | |

## GamePlayerPosition
Expand All @@ -205,7 +213,8 @@ bench, never both and never twice.
| GameId | int | FK → Game (cascade delete) |
| ScorerId | int? | FK → Player, **SetNull**. Null for an opponent goal — we don't track their players |
| AssisterId | int? | FK → Player, SetNull |
| Minute | int? | Free-typed on `/result`; stamped from the clock on `/live` |
| Minute | int? | Free-typed on `/result`; stamped from the scoreboard clock on `/live`, and never past the end of the half |
| AdditionalMinute | int | Minutes into stoppage time, from 1; 0 in normal play. Stored apart from `Minute` so 35+2 sorts before 36 — see `MatchMinute` |
| IsOwnGoal | bool | One of ours into our own net. Counts for the opponent |
| IsOpponentGoal | bool | The opponent scored. Counts for them, and has no scorer |
| RecordedAt | DateTime | UTC entry time — orders events that share a minute |
Expand All @@ -220,9 +229,13 @@ bench, never both and never twice.
| AtSeconds | int | Match-clock second of the change |
| SlotIndex | int? | The pitch slot that changed hands |
| Position | PlayerPosition | The position that changed hands |
| Minute | int | Computed: `AtSeconds / 60 + 1` — a timeline's first minute is 1', not 0' |
| RecordedAt | DateTime | UTC entry time — orders events that share a minute |

A substitution has no stored minute: `MatchClockReport.MinuteOf` derives it from `AtSeconds` and
the half the change belongs to, so it reads off the same scoreboard clock a goal was stamped from
rather than the raw elapsed time. A goal cannot be derived that way — one typed in on `/result` has
no clock behind it at all — which is why its minute is stored, both halves of it.

`RecordedAt` exists on both `GameGoal` and `GameSubstitution` because the minute alone cannot order
a timeline: a goal and the substitution that followed it routinely share one, and several events in
the opening minute is the normal case, not the edge case. The live timeline sorts by minute, then by
Expand All @@ -234,15 +247,15 @@ Ids from the two tables are not comparable with each other, so a goal and a subs
both minute and `RecordedAt` keep an arbitrary (but stable) order.

The lineup stays the source of truth for *who stands where*; this records **when** the swap
happened, which the period lineup alone cannot express. `MatchSubstitutionService.SubstituteAsync` writes
happened, which the line-up alone cannot express. `MatchSubstitutionService.SubstituteAsync` writes
both in one `SaveChangesAsync`, so they cannot diverge — and it updates the lineup **in place**
rather than going through `GameService.SavePeriodLineupAsync`, which is delete-and-reinsert.

Both player legs are `Restrict`, not `Cascade`: two cascading paths from `Players` to the same row
is the shape SQLite rejects, and neither leg is nullable, so deleting a player who was substituted
fails loudly instead of silently rewriting match history.

Only the **most recent** substitution of a period can be undone (`RemoveSubstitutionAsync`);
Only the **most recent** substitution of a half can be undone (`RemoveSubstitutionAsync`);
reversing an older swap would fight every change made on that slot since. "Most recent" is
`AtSeconds` then `Id`: a double substitution puts two rows in the same second, and the id is what
says which of them came second. `GameMinutesReport` walks them in that same order — see
Expand Down
Loading