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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ 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 # 374 tests, xUnit v3, real SQLite
dotnet test # 389 tests, xUnit v3, real SQLite
cd src/FootballFormation.Web && dotnet run # http://localhost:5228
cd tests/ui && npm test # 34 Playwright tests in a browser, ~1 min (npm install first)
cd tests/ui && npm test # 37 Playwright tests in a browser, ~1 min (npm install first)
scripts/visual-check.sh # screenshots every page, then measures every touch target
scripts/coverage.sh # coverage of the lines this branch changed, 80% floor
```
Expand Down Expand Up @@ -166,7 +166,7 @@ Test names are sentences:
There are **no component tests** (no bUnit). The UI is checked by driving the real app in a real
browser, in two places, both against a throwaway database:

- **`cd tests/ui && npm test`** — Playwright, ~34 tests, about a minute. Runs on every pull request
- **`cd tests/ui && npm test`** — Playwright, ~37 tests, about a minute. Runs on every pull request
as a job in `ci.yml`, advisory rather than the merge gate. Behaviour: the public/admin
split, the squad and match dialogs, the whole match-day journey from dragging a lineup to blowing
the final whistle, both languages, and the phone layout. Read
Expand Down
23 changes: 17 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ Reporting/
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
PlannedChangesReport.cs — What the next period changes versus the one on the pitch (rendered by
UI/Components/PlannedChangesList, shared by the live card and its dialog)
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
the rule that pending migrations mean unhealthy. Pure, so it is tested
Services/
Expand All @@ -68,13 +71,16 @@ 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, pause/resume, ending a period,
starting or rolling into the next one, the final whistle. The
arithmetic a season's statistics are built from
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
MatchGoalService.cs — Goals logged live: storage delegated to GameService, the live minute
and the recomputed scoreline added here
MatchSubstitutionService.cs — The slot swap and the record of it, in one SaveChanges, plus undoing
the most recent one of a period
the most recent one of a period, 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
Expand Down Expand Up @@ -104,7 +110,10 @@ Pages/
MatchResult.razor(.cs)(.css)— /games/{id}/result — Score and goal entry
LiveMatch.razor(.cs)(.css) — /games/{id}/live — Sideline screen: clock, subs, goals; admin drives, others watch
LiveGoalDialog.razor(.cs) — Dialog: scorer, assister, own-goal toggle
LiveSubDialog.razor(.cs)(.css) — Dialog: pick the replacement for a player tapped on the pitch
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
LiveNextLineupDialog.razor(.cs)(.css) — Dialog: what rolling on to the next line-up will change,
asked before it is done — advancing a period has no undo
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 @@ -117,6 +126,8 @@ Components/
Pitch.razor(.cs)(.css) — The pitch. Read-only by default; Draggable for the builder,
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; shared by the live
screen's card and the dialog that asks before applying it
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
25 changes: 25 additions & 0 deletions docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,31 @@ Avoid repeating these mistakes:
the identical markup on `/settings` — those buttons rendered as native browser chrome for as
long as nobody looked. There is no warning. Anything more than one page uses goes in `app.css`;
`.action-btn`, `.badge-*`, `.stat-tile*` and `.stacked-table` are there for this reason.
- **The same trap catches a rule that never leaves its own page: a child component's root element
has no scope attribute either.** `.live-control-row > *` sat in `LiveMatch.razor.css` and matched
nothing, because every child of that row is a `MudButton` and the `<button>` MudBlazor renders
carries no `b-<hash>`. The row looked deliberate and read as flex — the buttons simply never took
the width or the height it asked for, which is what "the buttons don't fill the box" turned out
to be. The tell: the *container* is styled and the *children* are not. Anything selecting past a
MudBlazor component's root goes in `app.css`, next to `.live-scoreboard` and `.live-action-btn`,
which are there for the same reason.

## Live match
- **A change to the pitch that writes no row is invisible to everything that reads the rows.**
`SwapPositionsAsync` moves two players between slots without a `GameSubstitution`, which is right
— nobody left the pitch — but two readers assumed the rows were the whole story.
`RemoveSubstitutionAsync` handed back the slot the *substitution* recorded, so subbing into slot
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
right either way; only the split by position is affected, and a test pins it.
- **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 dialog promised the opposite.
It now restarts a stopped anchor, which is a no-op for every game that was never paused.

## General
- **Port already in use**: Kill orphaned process with `taskkill //PID <pid> //F`.
Expand Down
2 changes: 1 addition & 1 deletion docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ is actually happening at the touchline rather than along a data-access seam:
| Service | Owns |
| --- | --- |
| `LiveMatchService` | Reading: `GetLiveAsync` for the live screen, `GetTodaysMatchAsync` for the home banner. Both public, like every other read |
| `MatchClockService` | Kick-off, pause/resume, ending a period, starting or rolling into the next, the final whistle — and `BankClock`, the only thing that moves seconds about |
| `MatchClockService` | Kick-off, ending a period, starting or rolling into the next, the final whistle — and `BankClock`, the only thing that moves seconds about. No pause: only a period boundary stops the clock |
| `MatchGoalService` | The live minute a goal is stamped with and the scoreline recomputed from the goals on file. Storage itself still delegates to `GameService` |
| `MatchSubstitutionService` | The slot swap and the record of it, in one `SaveChanges`, and undoing the most recent one of a period |

Expand Down
48 changes: 42 additions & 6 deletions docs/ui_components.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,50 @@ watches the same URL read-only. Every control sits in an `<AuthorizeView Roles="
the score, clock and state from its first load while newly inserted goals appear alongside them —
a live screen stuck at the old scoreline. Identity resolution keeps shared `Player` rows single.
- Controls are context-sensitive, and **only half time is a break**. A quarters game is still two
halves: Q1→Q2 and Q3→Q4 offer "Start Q2"/"Start Q4", which call `AdvancePeriodAsync` and roll
the lineup over *without stopping the clock*. Only after the first half or Q2 does the screen
offer "Half time" (`EndPeriodAsync`, which does stop it) followed by "Start Q3". The rule lives
in `PeriodTypeExtensions.IsFollowedByBreak`, not in the page.
halves: mid-half the screen offers **"Next line-up"**, which calls `AdvancePeriodAsync` and rolls
the *next quarter's planned lineup* onto the pitch without stopping the clock. It **asks first**
(`LiveNextLineupDialog`), listing the swaps and moves it is about to make: advancing a period has
no undo — no timeline event records it — and the changes used to be readable only from a card
further down the screen. Only after the first
half or Q2 does the screen offer "Half time" (`EndPeriodAsync`, which does stop it) followed by
"Start 2nd half". The rule lives in `PeriodTypeExtensions.IsFollowedByBreak`, not in the page.
- **There is no pause.** The clock runs from kick-off until the period is whistled off, and only
a period boundary stops it — `PauseClockAsync`/`ResumeClockAsync` are gone from
`MatchClockService` too, not just from the screen. A youth match is not paused at the touchline,
and a clock a stray tap can stop is a clock the season's minutes cannot be trusted from. So a
live period always has a running clock, which is why the status chip's third state is the break
between two periods (`.live-status-break`) rather than a paused one.
- **The controls are a two-column grid, laid out in `app.css`.** How many buttons the panel holds
depends on where the match is, so equal columns keep them the same size whichever set is showing,
and `:last-child:nth-child(odd)` spans the odd one out across the row. It has to be `app.css`:
the children are `MudButton`s, and the old scoped `.live-control-row > *` rule never matched
them — the classic CSS-isolation miss, and why they used not to fill the panel.
- The pitch shows the live period; at the break and after full time the last one played, and before
kick-off the first — so it is never blank when a lineup exists.
kick-off the first — so it is never blank when a lineup exists. A **"Show subs" checkbox** folds
the bench strip under it away; the state is per circuit and deliberately not stored.
- **Tapping a player offers two changes, one dropdown each** (`LiveSubDialog`): someone comes on for
them (`SubstituteAsync`), or they trade positions with a team-mate who stays on
(`SwapPositionsAsync`). Choosing in either list clears the other, so the single action button
always has exactly one change to make and says which — "Make substitution" or "Swap positions".
A position swap writes no `GameSubstitution`: nobody's minutes changed, and a row there would say
they did. The price is the *split by position* — `GameMinutesReport` reads the lineup as it finally
stands, so after a swap the whole period is credited to the position each player moved **into**
(pinned by `A_position_change_with_no_substitution_credits_the_position_it_ended_in`). Totals are
unaffected. Undoing a substitution therefore follows the slot rather than the recorded one: a swap
can have moved it since, and handing the recorded slot back would seat two players in it.
Each select's `Placeholder` is set **only** when its list is empty — MudSelect shows a
placeholder whenever nothing is chosen, so a standing "nobody is on the bench" greets a full bench.
- **Every goal on the timeline carries the score it made it** (`ScoreProgressionReport`), in the
scoreboard's order — home side first. It is counted forwards over the whole match and looked up
by goal id, because the timeline itself runs newest first and a total accumulated while rendering
would count down.
- Finishing asks for confirmation via `DialogPrompts.ConfirmAsync` (not `ConfirmDeleteAsync`,
whose button says "Delete").
whose button says "Delete"). "Next line-up" needs a list rather than a sentence, so it has its own
dialog and goes through `PromptValueAsync<…, bool>` instead.
- **`PlannedChangesList` is the one rendering of what the next line-up does.** The card and the
dialog show the same thing, so the markup and its `.planned-*` styling live in the component —
scoped CSS follows the file that owns the elements, so moving the markup and leaving the CSS
behind would have left both callers unstyled.
- **Minutes played is admin-only** (`LiveMinutesReport`), and shows exact time on the pitch rather
than the `periodsPlaying × periodDuration` estimate the planning screens use. It is a computed
property, so the running player's total climbs with the clock tick.
Expand Down
10 changes: 5 additions & 5 deletions src/FootballFormation.Core/Models/Game.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public class Game
public MatchState MatchState { get; set; } = MatchState.NotStarted;

/// <summary>
/// UTC instant the match clock was last started or resumed; null whenever the clock is not
/// running. The clock is stored as an anchor rather than a ticking value so every viewer
/// UTC instant the match clock was last started — at kick-off, or when a period took over from
/// a break; null whenever the clock is not running. The clock is stored as an anchor rather than a ticking value so every viewer
/// derives the same elapsed time without the server having to push each second.
/// </summary>
public DateTime? ClockRunningSince { get; set; }
Expand Down Expand Up @@ -164,7 +164,7 @@ public List<Player> SelectRoster(IEnumerable<Player> allPlayers, SeasonSquad squ

/// <summary>
/// The match clock in seconds at <paramref name="utcNow"/>. Callers that only need a settled
/// value (a paused clock, a finished match) can pass any instant.
/// value (a stopped clock, a finished match) can pass any instant.
/// </summary>
public int ElapsedSecondsAt(DateTime utcNow) => ClockAccumulatedSeconds +
(ClockRunningSince is null ? 0 : Math.Max(0, (int)(utcNow - ClockRunningSince.Value).TotalSeconds));
Expand All @@ -174,11 +174,11 @@ public int ElapsedSecondsAt(DateTime utcNow) => ClockAccumulatedSeconds +
/// so it is excluded here and included in <see cref="CountTheirGoals"/>.
/// </summary>
public static int CountOurGoals(IEnumerable<GameGoal> goals) =>
goals.Count(g => !g.IsOwnGoal && !g.IsOpponentGoal);
goals.Count(g => g.CountsForUs);

/// <summary>Their goals: everything the opponent scored, plus our own goals.</summary>
public static int CountTheirGoals(IEnumerable<GameGoal> goals) =>
goals.Count(g => g.IsOwnGoal || g.IsOpponentGoal);
goals.Count(g => !g.CountsForUs);
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/FootballFormation.Core/Models/GameGoal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,11 @@ public class GameGoal
/// moment they were typed, which is the best available answer and never reorders a live match.
/// </summary>
public DateTime RecordedAt { get; set; } = DateTime.UtcNow;

/// <summary>
/// Which end of the scoreline this goal lands on. The rule is stated once here because two
/// places count goals: <see cref="Game.CountOurGoals"/> totals a finished match, and
/// <c>ScoreProgressionReport</c> walks them one at a time for the live timeline.
/// </summary>
public bool CountsForUs => !IsOwnGoal && !IsOpponentGoal;
}
11 changes: 8 additions & 3 deletions src/FootballFormation.Core/Reporting/GameMinutesReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ public int SecondsFor(int playerId) =>
/// its lineup a full period's minutes would invent playing time.
/// </para>
/// <para>
/// Known limitation: the live screen only records a position change as part of a substitution, so
/// a player who shifts from one position to another mid-period without a swap keeps the earlier
/// position for those minutes. That is a gap in what gets recorded, not in this calculation.
/// Known limitation: only a substitution records a position change. The walk below starts from the
/// lineup as it <em>finally</em> stands and rewinds substitution rows, so a player who shifts
/// position mid-period without one is credited the position they ended in for the whole period,
/// the minutes before the shift included. The live screen's position swap
/// (<c>MatchSubstitutionService.SwapPositionsAsync</c>) is exactly that case: it rewrites the
/// lineup and writes nothing down, because a <see cref="GameSubstitution"/> would say someone left
/// the pitch. Totals stay right; only the split by position does. That is a gap in what gets
/// recorded, not in this calculation.
/// </para>
/// </summary>
public static class GameMinutesReport
Expand Down
Loading