From 41575005ca74885b4117d0ef3a9a7e002d667bd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:31:19 +0000 Subject: [PATCH 1/2] Run the live match in halves, and write a minute the way football does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both on the live screen. "Volgende opstelling" is gone: the button, the service call behind it and the line-up rewrite it did. What it was for was never clear at the touchline, and it was the only thing that made a quarter boundary a moment the clock had to be told about. The clock now runs in halves however the line-ups were planned — Game.NextPeriod() skips the quarter left behind inside a half already played, so the second half opens at Q3 and Q2 is never kicked off. A period with no timings costs GameMinutesReport nothing, so the half is credited to the line-up that actually played it plus the substitutions made during it. The "Changes at half-way" card stays, and is now purely a list to work through by tapping the players on the pitch above it. The order of match events could be wrong across half time. The scoreboard clock stops at the end of a half, but the minute written down counted straight on, so a goal two minutes into first-half stoppage was stored as 37 and sorted after a goal in the 36th minute of the second half — which happened a minute later. Substitutions were worse: their minute came off the raw elapsed time and ignored the scoreboard clock entirely. A minute is now a MatchMinute pair, 35 or 35+2, and comparing the two parts is chronological across the whole match. A goal stores both halves of it (new nullable-free AdditionalMinute column, no backfill — nothing left in an old row says whether a 37 was stoppage time or typed in by hand); a substitution derives its own from AtSeconds and the period it belongs to. The timeline, the result page's goal list and ScoreProgressionReport all sort on the pair. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfrvWcNR8hMjw3ny6dMemX --- CLAUDE.md | 2 +- docs/architecture.md | 9 +- docs/known_issues.md | 28 +- docs/models.md | 9 +- docs/ui_components.md | 51 +- ...062317_AddGoalAdditionalMinute.Designer.cs | 594 ++++++++++++++++++ .../20260813062317_AddGoalAdditionalMinute.cs | 37 ++ .../Migrations/AppDbContextModelSnapshot.cs | 3 + src/FootballFormation.Core/Models/Game.cs | 24 +- src/FootballFormation.Core/Models/GameGoal.cs | 13 + .../Models/GamePeriod.cs | 9 - .../Models/GameSubstitution.cs | 6 - .../Models/MatchMinute.cs | 26 + .../Reporting/MatchClockReport.cs | 56 +- .../Reporting/PlannedChangesReport.cs | 38 +- .../Reporting/ScoreProgressionReport.cs | 7 +- .../Services/MatchClockService.cs | 108 +--- .../Services/MatchGoalService.cs | 7 +- .../Pages/LiveMatch.razor | 33 +- .../Pages/LiveMatch.razor.cs | 37 +- .../Pages/MatchResult.razor | 6 +- src/FootballFormation.UI/Strings.nl.resx | 2 - src/FootballFormation.Web/wwwroot/app.css | 7 - .../FootballFormation.Core.Tests/GameTests.cs | 34 +- .../LiveMatchNotificationTests.cs | 5 +- .../MatchClockReportTests.cs | 65 +- .../MatchClockServiceTests.cs | 142 +---- .../MatchGoalServiceTests.cs | 19 + .../MatchSubstitutionServiceTests.cs | 1 - .../ScoreProgressionReportTests.cs | 22 +- tests/ui/helpers.js | 2 +- tests/ui/specs/match-day.spec.js | 30 +- 32 files changed, 988 insertions(+), 444 deletions(-) create mode 100644 src/FootballFormation.Core/Migrations/20260813062317_AddGoalAdditionalMinute.Designer.cs create mode 100644 src/FootballFormation.Core/Migrations/20260813062317_AddGoalAdditionalMinute.cs create mode 100644 src/FootballFormation.Core/Models/MatchMinute.cs diff --git a/CLAUDE.md b/CLAUDE.md index aa6cdf3..c97fd2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index ec4f68a..6eb2969 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -51,11 +51,10 @@ 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 + MatchClockReport.cs — Derives the live clock and period state from the stored anchor + banked + total, and the MatchMinute an event is written down against 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) + 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 diff --git a/docs/known_issues.md b/docs/known_issues.md index f36adde..b681c1d 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -347,20 +347,20 @@ Avoid repeating these mistakes: 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. -- **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 clock runs in halves, so + `Game.NextPeriod()` skips a period whose half has already been played and 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 period 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. Do not + "fix" a Q2 with no timings, and do not read `PeriodCount` as a count of periods 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 //F`. diff --git a/docs/models.md b/docs/models.md index 5cff422..aad6434 100644 --- a/docs/models.md +++ b/docs/models.md @@ -201,7 +201,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 | @@ -216,9 +217,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 period 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 diff --git a/docs/ui_components.md b/docs/ui_components.md index 5d86f4a..3983251 100644 --- a/docs/ui_components.md +++ b/docs/ui_components.md @@ -106,14 +106,15 @@ watches the same URL read-only. Every control sits in an ` +using System; +using FootballFormation.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FootballFormation.Core.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260813062317_AddGoalAdditionalMinute")] + partial class AddGoalAdditionalMinute + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("FootballFormation.Core.Models.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClockAccumulatedSeconds") + .HasColumnType("INTEGER"); + + b.Property("ClockRunningSince") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("FormationType") + .HasColumnType("INTEGER"); + + b.Property("GameDurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("GuestPlayerIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsHomeGame") + .HasColumnType("INTEGER"); + + b.Property("LivePeriodId") + .HasColumnType("INTEGER"); + + b.Property("MatchState") + .HasColumnType("INTEGER"); + + b.Property("MatchType") + .HasColumnType("INTEGER"); + + b.Property("Opponent") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ScoreAway") + .HasColumnType("INTEGER"); + + b.Property("ScoreHome") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.Property("SplitType") + .HasColumnType("INTEGER"); + + b.Property("UnavailablePlayerIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GameComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EditedAt") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("GameId", "CreatedAt"); + + b.ToTable("GameComments"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GameGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalMinute") + .HasColumnType("INTEGER"); + + b.Property("AssisterId") + .HasColumnType("INTEGER"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("IsOpponentGoal") + .HasColumnType("INTEGER"); + + b.Property("IsOwnGoal") + .HasColumnType("INTEGER"); + + b.Property("Minute") + .HasColumnType("INTEGER"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("ScorerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AssisterId"); + + b.HasIndex("GameId"); + + b.HasIndex("ScorerId"); + + b.ToTable("GameGoals"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GamePeriod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndedAtSeconds") + .HasColumnType("INTEGER"); + + b.Property("FormationTypeOverride") + .HasColumnType("INTEGER"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("PeriodType") + .HasColumnType("INTEGER"); + + b.Property("StartedAtSeconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GamePeriods"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GamePlayerPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GamePeriodId") + .HasColumnType("INTEGER"); + + b.Property("IsSubstitute") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SlotIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("GamePeriodId", "PlayerId") + .IsUnique(); + + b.ToTable("GamePlayerPositions"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GameSubstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AtSeconds") + .HasColumnType("INTEGER"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("GamePeriodId") + .HasColumnType("INTEGER"); + + b.Property("PlayerOffId") + .HasColumnType("INTEGER"); + + b.Property("PlayerOnId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("RecordedAt") + .HasColumnType("TEXT"); + + b.Property("SlotIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("GamePeriodId"); + + b.HasIndex("PlayerOffId"); + + b.HasIndex("PlayerOnId"); + + b.ToTable("GameSubstitutions"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.MatchPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DefaultFormation") + .HasColumnType("INTEGER"); + + b.Property("DefaultSplitType") + .HasColumnType("INTEGER"); + + b.Property("GameDurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("MatchDay") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeasonId") + .IsUnique(); + + b.ToTable("MatchPreferences"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AlternativePositions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("PreferredPosition") + .HasColumnType("INTEGER"); + + b.Property("ShirtNumber") + .HasColumnType("INTEGER"); + + b.Property("Surname") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StartDate") + .IsUnique(); + + b.ToTable("Seasons"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.SeasonSquadMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsGuest") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("SeasonId", "PlayerId") + .IsUnique(); + + b.ToTable("SeasonSquadMembers"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.Game", b => + { + b.HasOne("FootballFormation.Core.Models.Season", "Season") + .WithMany("Games") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GameComment", b => + { + b.HasOne("FootballFormation.Core.Models.AppUser", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("FootballFormation.Core.Models.Game", "Game") + .WithMany("Comments") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GameGoal", b => + { + b.HasOne("FootballFormation.Core.Models.Player", "Assister") + .WithMany() + .HasForeignKey("AssisterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("FootballFormation.Core.Models.Game", "Game") + .WithMany("Goals") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FootballFormation.Core.Models.Player", "Scorer") + .WithMany() + .HasForeignKey("ScorerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Assister"); + + b.Navigation("Game"); + + b.Navigation("Scorer"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GamePeriod", b => + { + b.HasOne("FootballFormation.Core.Models.Game", "Game") + .WithMany("Periods") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GamePlayerPosition", b => + { + b.HasOne("FootballFormation.Core.Models.GamePeriod", "GamePeriod") + .WithMany("PlayerPositions") + .HasForeignKey("GamePeriodId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FootballFormation.Core.Models.Player", "Player") + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GamePeriod"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GameSubstitution", b => + { + b.HasOne("FootballFormation.Core.Models.Game", "Game") + .WithMany("Substitutions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FootballFormation.Core.Models.GamePeriod", "GamePeriod") + .WithMany() + .HasForeignKey("GamePeriodId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FootballFormation.Core.Models.Player", "PlayerOff") + .WithMany() + .HasForeignKey("PlayerOffId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FootballFormation.Core.Models.Player", "PlayerOn") + .WithMany() + .HasForeignKey("PlayerOnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("GamePeriod"); + + b.Navigation("PlayerOff"); + + b.Navigation("PlayerOn"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.MatchPreferences", b => + { + b.HasOne("FootballFormation.Core.Models.Season", "Season") + .WithMany() + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.SeasonSquadMember", b => + { + b.HasOne("FootballFormation.Core.Models.Player", "Player") + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FootballFormation.Core.Models.Season", "Season") + .WithMany("SquadMembers") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.Game", b => + { + b.Navigation("Comments"); + + b.Navigation("Goals"); + + b.Navigation("Periods"); + + b.Navigation("Substitutions"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.GamePeriod", b => + { + b.Navigation("PlayerPositions"); + }); + + modelBuilder.Entity("FootballFormation.Core.Models.Season", b => + { + b.Navigation("Games"); + + b.Navigation("SquadMembers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FootballFormation.Core/Migrations/20260813062317_AddGoalAdditionalMinute.cs b/src/FootballFormation.Core/Migrations/20260813062317_AddGoalAdditionalMinute.cs new file mode 100644 index 0000000..376eade --- /dev/null +++ b/src/FootballFormation.Core/Migrations/20260813062317_AddGoalAdditionalMinute.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FootballFormation.Core.Migrations +{ + /// + /// Splits stoppage time out of a goal's minute, so the timeline can order 35+2 before 36. + /// + /// Deliberately not backfilled. A goal already on file stored the minute counted straight on + /// past the end of the half, and nothing left in the row says whether a 37 in a 35-minute half + /// was stoppage time or a minute typed in by hand on the result page. Zero leaves those goals + /// reading and sorting exactly as they do today; only goals logged from here on carry the split. + /// + /// + public partial class AddGoalAdditionalMinute : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AdditionalMinute", + table: "GameGoals", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AdditionalMinute", + table: "GameGoals"); + } + } +} diff --git a/src/FootballFormation.Core/Migrations/AppDbContextModelSnapshot.cs b/src/FootballFormation.Core/Migrations/AppDbContextModelSnapshot.cs index 38e2e3a..87b3827 100644 --- a/src/FootballFormation.Core/Migrations/AppDbContextModelSnapshot.cs +++ b/src/FootballFormation.Core/Migrations/AppDbContextModelSnapshot.cs @@ -162,6 +162,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AdditionalMinute") + .HasColumnType("INTEGER"); + b.Property("AssisterId") .HasColumnType("INTEGER"); diff --git a/src/FootballFormation.Core/Models/Game.cs b/src/FootballFormation.Core/Models/Game.cs index 7393274..713bfac 100644 --- a/src/FootballFormation.Core/Models/Game.cs +++ b/src/FootballFormation.Core/Models/Game.cs @@ -165,9 +165,27 @@ public List SelectRoster(IEnumerable allPlayers, SeasonSquad squ public GamePeriod? LivePeriod() => LivePeriodId is null ? null : Periods.FirstOrDefault(p => p.Id == LivePeriodId); - /// The first period that has not been kicked off yet, in playing order. - public GamePeriod? NextPeriod() => - Periods.OrderBy(p => p.PeriodType).FirstOrDefault(p => p.StartedAtSeconds is null); + /// + /// Where the clock goes next: the first period not yet kicked off, skipping any whose half has + /// already been played. + /// + /// A quarters game is planned as two line-ups per half but played as two halves. The second + /// line-up of a half is a plan the coach carries out by hand, one substitution at a time — the + /// clock never stops for it — so once the first half has run, the next period the clock knows + /// about is the one that opens the second half, not the quarter left behind inside the first. + /// + /// + public GamePeriod? NextPeriod() + { + var halvesPlayed = Periods + .Where(p => p.StartedAtSeconds is not null) + .Select(p => p.PeriodType.Half()) + .ToHashSet(); + + return Periods + .OrderBy(p => p.PeriodType) + .FirstOrDefault(p => p.StartedAtSeconds is null && !halvesPlayed.Contains(p.PeriodType.Half())); + } /// /// The match clock in seconds at . Callers that only need a settled diff --git a/src/FootballFormation.Core/Models/GameGoal.cs b/src/FootballFormation.Core/Models/GameGoal.cs index 0a0da03..e35dab0 100644 --- a/src/FootballFormation.Core/Models/GameGoal.cs +++ b/src/FootballFormation.Core/Models/GameGoal.cs @@ -13,8 +13,21 @@ public class GameGoal public int? AssisterId { get; set; } public Player? Assister { get; set; } + /// + /// The minute on the scoreboard clock, which never runs past the end of the half — the overrun + /// is . Null on a goal recorded with no minute at all, which the + /// result page allows. + /// public int? Minute { get; set; } + /// + /// Minutes into stoppage time, counted from 1, or zero for a goal in normal play. Stored beside + /// the minute rather than added into it because the two together are what orders the timeline: + /// a goal at 35+2 belongs before one at 36, and a single number that had counted on to 37 would + /// put it after. Always zero on a goal typed in by hand, which has no clock behind it. + /// + public int AdditionalMinute { get; set; } + /// One of ours put it in our own net. Counts for the opponent. public bool IsOwnGoal { get; set; } diff --git a/src/FootballFormation.Core/Models/GamePeriod.cs b/src/FootballFormation.Core/Models/GamePeriod.cs index 4b21469..7537b42 100644 --- a/src/FootballFormation.Core/Models/GamePeriod.cs +++ b/src/FootballFormation.Core/Models/GamePeriod.cs @@ -43,15 +43,6 @@ public static class PeriodTypeExtensions _ => period.ToString() }; - /// - /// Whether play actually stops after this period. A quarters game is still two halves: the - /// teams roll straight from Q1 into Q2 and from Q3 into Q4 without leaving the pitch, and the - /// only real break is half time. This is what stops the live screen offering a whistle after - /// every quarter. - /// - public static bool IsFollowedByBreak(this PeriodType period) => - period is PeriodType.FirstHalf or PeriodType.SecondQuarter; - /// /// The half this period is played in. Quarters are a planning device — a way to write two /// line-ups per half — but a match is only ever two halves, so anything shown to someone diff --git a/src/FootballFormation.Core/Models/GameSubstitution.cs b/src/FootballFormation.Core/Models/GameSubstitution.cs index 39e0d09..9048003 100644 --- a/src/FootballFormation.Core/Models/GameSubstitution.cs +++ b/src/FootballFormation.Core/Models/GameSubstitution.cs @@ -35,10 +35,4 @@ public class GameSubstitution /// this breaks ties against goals in the same minute. See . /// public DateTime RecordedAt { get; set; } = DateTime.UtcNow; - - /// - /// The minute as a football timeline writes it: the first minute of play is 1', not 0'. Goals - /// are stamped the same way in MatchGoalService.LogGoalAsync, so the two line up. - /// - public int Minute => (AtSeconds / 60) + 1; } diff --git a/src/FootballFormation.Core/Models/MatchMinute.cs b/src/FootballFormation.Core/Models/MatchMinute.cs new file mode 100644 index 0000000..3163b60 --- /dev/null +++ b/src/FootballFormation.Core/Models/MatchMinute.cs @@ -0,0 +1,26 @@ +namespace FootballFormation.Core.Models; + +/// +/// The minute an event is written down against, the way football writes one: 35, or 35+2 once the +/// half has been played out and the clock is into stoppage time. +/// +/// The pair is also what puts events in order, which a single number cannot do. A goal in first-half +/// stoppage time and one just after the restart are barely a minute apart, but a clock that counted +/// straight on would write the first as 37 and the second as 36 and list them the wrong way round. +/// Comparing on first and second is +/// chronological across the whole match, because a half's clock stops at the half and the half that +/// follows starts above it. +/// +/// +/// The minute on the clock, never past the end of the half being played. +/// Minutes into stoppage time, counted from 1; zero during normal play. +public readonly record struct MatchMinute(int Minute, int Additional) : IComparable +{ + public bool IsAdditional => Additional > 0; + + public int CompareTo(MatchMinute other) => Minute != other.Minute + ? Minute.CompareTo(other.Minute) + : Additional.CompareTo(other.Additional); + + public override string ToString() => IsAdditional ? $"{Minute}+{Additional}" : $"{Minute}"; +} diff --git a/src/FootballFormation.Core/Reporting/MatchClockReport.cs b/src/FootballFormation.Core/Reporting/MatchClockReport.cs index 49c7545..fe36db4 100644 --- a/src/FootballFormation.Core/Reporting/MatchClockReport.cs +++ b/src/FootballFormation.Core/Reporting/MatchClockReport.cs @@ -8,21 +8,12 @@ namespace FootballFormation.Core.Reporting; /// /// The reading on the clock, capped at the end of the half being played. /// How far past the end of the half play has gone, counted separately. -public record MatchClock(int Seconds, int AdditionalSeconds) +/// The minute an event at this instant is written down against. +public record MatchClock(int Seconds, int AdditionalSeconds, MatchMinute Minute) { - public static readonly MatchClock BeforeKickOff = new(0, 0); + public static readonly MatchClock BeforeKickOff = new(0, 0, new MatchMinute(1, 0)); public bool IsInAdditionalTime => AdditionalSeconds > 0; - - /// - /// The clock running on past the cap — the number a goal is written down against. Football - /// counts a stoppage-time goal into the following minutes rather than pinning it to the cap, - /// and several goals in stoppage time must not all land on the same minute. - /// - public int TotalSeconds => Seconds + AdditionalSeconds; - - /// The minute this instant falls in. The first minute of play is 1', as scorelines are written. - public int Minute => (TotalSeconds / 60) + 1; } /// @@ -32,6 +23,8 @@ public record MatchClock(int Seconds, int AdditionalSeconds) /// of the half and the overrun is reported as additional time. /// The second half starts at half the match duration whatever the first half actually cost, /// so an over-running first half does not push the whole second half out of step. +/// The minute an event is written down against follows that same reading, as a +/// — 35+2 rather than a 37 that would sort after the restart. /// /// This is presentation only. What is stored stays the real elapsed time, so playing time, /// substitution timings and the season statistics are unaffected. @@ -53,19 +46,48 @@ public static MatchClock Build(Game game, GamePeriod? displayPeriod, int elapsed var plannedStart = half == PeriodType.FirstHalf ? 0 : halfSeconds; if (HalfKickedOffAt(game, half) is not { } actualStart) - return new MatchClock(plannedStart, 0); + return new MatchClock(plannedStart, 0, PlainMinute(plannedStart)); var intoHalf = Math.Max(0, elapsedSeconds - actualStart); // A game with no duration on file has nothing to cap against; showing the time as it runs // beats reporting the whole half as additional time. - if (halfSeconds <= 0) return new MatchClock(intoHalf, 0); + if (halfSeconds <= 0) return new MatchClock(intoHalf, 0, PlainMinute(intoHalf)); + + // Once the half is played out the clock stands still at the cap, so the minute stands still + // with it and the overrun is counted alongside as 35+1, 35+2 — the reading a scoreboard + // shows, and the only way several stoppage-time events keep their order. + if (intoHalf >= halfSeconds) + { + return new MatchClock( + plannedStart + halfSeconds, + intoHalf - halfSeconds, + new MatchMinute((plannedStart + halfSeconds) / 60, ((intoHalf - halfSeconds) / 60) + 1)); + } - return new MatchClock( - plannedStart + Math.Min(intoHalf, halfSeconds), - Math.Max(0, intoHalf - halfSeconds)); + return new MatchClock(plannedStart + intoHalf, 0, PlainMinute(plannedStart + intoHalf)); } + /// + /// The minute a substitution is written down against: the reading the clock showed when it was + /// made, which is the half's reading and not the raw elapsed time. Falls back to the raw minute + /// for a substitution whose period was not loaded — a wrong-looking minute beats claiming 1'. + /// + public static MatchMinute MinuteOf(Game game, GameSubstitution substitution) => + game.Periods.FirstOrDefault(p => p.Id == substitution.GamePeriodId) is { } period + ? Build(game, period, substitution.AtSeconds).Minute + : PlainMinute(substitution.AtSeconds); + + /// + /// The minute a goal was written down against, or null for one recorded without a minute — + /// which the result page allows and the timeline then has nothing to place. + /// + public static MatchMinute? MinuteOf(GameGoal goal) => + goal.Minute is { } minute ? new MatchMinute(minute, goal.AdditionalMinute) : null; + + /// The minute a plain clock reading falls in. The first minute of play is 1'. + private static MatchMinute PlainMinute(int seconds) => new((seconds / 60) + 1, 0); + /// /// The real clock reading when this half kicked off — the earliest of its periods to start. /// A quarters game has two periods per half and only the first of them opens the half. diff --git a/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs b/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs index 54d0ca6..bf800ff 100644 --- a/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs +++ b/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs @@ -12,21 +12,10 @@ public record PlannedSubstitution(Player? PlayerOff, Player? PlayerOn, PlayerPos public record PlannedMove(Player Player, PlayerPosition From, PlayerPosition To); /// -/// The same swap in terms of the line-up rows rather than the players, which is what carrying one -/// out needs: a slot and a position change hands, and neither is a property of a name. +/// The same swap in terms of the line-up rows rather than the players: a slot and a position +/// change hands, and neither is a property of a name. /// -public record PlannedSwap(GamePlayerPosition? Off, GamePlayerPosition? On); - -/// -/// The swaps the next line-up implies, split by whether play has already answered them. -/// -/// is the ones it has: the player the plan takes off went off live and -/// somebody came on for them, so the plan's arrival is no longer wanted and the slot's occupant is -/// no longer the player the plan meant to withdraw. is never null there — a -/// swap with nobody named to come off has nothing for play to overtake. -/// -/// -public record PlannedSwaps(List Viable, List Overtaken); +internal record PlannedSwap(GamePlayerPosition? Off, GamePlayerPosition? On); /// What the next line-up does: who is swapped, and who shifts position. public record PlannedChanges(List Substitutions, List Moves) @@ -49,8 +38,6 @@ public record PlannedChanges(List Substitutions, List is the same walk without the names, and MatchClockService applies -/// what it calls overtaken rather than deciding again, so the card and the button cannot part ways. /// /// public static class PlannedChangesReport @@ -71,23 +58,11 @@ public static PlannedChanges Build( var after = StartersBySlot(next); return new PlannedChanges( - [.. PairUp(before, after, KickOffStarters(before.Values, liveChanges)).Viable + [.. PairUp(before, after, KickOffStarters(before.Values, liveChanges)) .Select(swap => Name(swap, findPlayer))], Moves(before, after, findPlayer)); } - /// - /// The same swaps as line-up rows, for the caller that has to carry them out rather than - /// print them. See for what the two halves mean. - /// - public static PlannedSwaps Swaps( - GamePeriod current, GamePeriod next, IEnumerable liveChanges) - { - var before = StartersBySlot(current); - - return PairUp(before, StartersBySlot(next), KickOffStarters(before.Values, liveChanges)); - } - /// /// A swap as the screen says it. The position is the one being taken over, which for a player /// coming off with nobody named to replace them is the one they are vacating. @@ -133,7 +108,7 @@ private static HashSet KickOffStarters( /// are taking, which is the swap a coach would call out; when that player is staying on the /// pitch — a shuffle rather than a straight swap — the next unpaired departure is used instead. /// - private static PlannedSwaps PairUp( + private static List PairUp( Dictionary before, Dictionary after, HashSet kickOffStarters) @@ -165,8 +140,7 @@ private static PlannedSwaps PairUp( // Anyone left over comes off with nobody named to replace them. swaps.AddRange(unpaired.Select(off => new PlannedSwap(off, null))); - var byViability = swaps.ToLookup(swap => IsStillViable(swap, kickOffStarters)); - return new PlannedSwaps([.. byViability[true]], [.. byViability[false]]); + return [.. swaps.Where(swap => IsStillViable(swap, kickOffStarters))]; } private static List Moves( diff --git a/src/FootballFormation.Core/Reporting/ScoreProgressionReport.cs b/src/FootballFormation.Core/Reporting/ScoreProgressionReport.cs index c3b2f4c..e2e5dac 100644 --- a/src/FootballFormation.Core/Reporting/ScoreProgressionReport.cs +++ b/src/FootballFormation.Core/Reporting/ScoreProgressionReport.cs @@ -25,10 +25,11 @@ public static IReadOnlyDictionary Build(IEnumerable g var us = 0; var them = 0; - // The order the live screen shows events in, read forwards: the match minute first, then - // the moment it was entered, then the id — see LiveMatch.Timeline for why all three. + // The order the live screen shows events in, read forwards: the match minute first — both + // halves of it, so a stoppage-time goal stays inside the half it was scored in — then the + // moment it was entered, then the id. See LiveMatch.Timeline for why all three. var chronological = goals - .OrderBy(g => g.Minute ?? 0) + .OrderBy(g => MatchClockReport.MinuteOf(g) ?? default) .ThenBy(g => g.RecordedAt) .ThenBy(g => g.Id); diff --git a/src/FootballFormation.Core/Services/MatchClockService.cs b/src/FootballFormation.Core/Services/MatchClockService.cs index da5f74f..4e48179 100644 --- a/src/FootballFormation.Core/Services/MatchClockService.cs +++ b/src/FootballFormation.Core/Services/MatchClockService.cs @@ -1,6 +1,5 @@ using FootballFormation.Core.Data; using FootballFormation.Core.Models; -using FootballFormation.Core.Reporting; using FootballFormation.Core.Security; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -86,6 +85,10 @@ public Task> EndPeriodAsync(int gameId, CancellationToken cancellat return Result.Success(game); }); + /// + /// Kicks off the half after the break. decides which period that + /// is, and it skips the second line-up of a half already played — the clock runs in halves. + /// public Task> StartNextPeriodAsync(int gameId, CancellationToken cancellationToken = default) => LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "start the next period", cancellationToken, async () => @@ -116,109 +119,6 @@ public Task> StartNextPeriodAsync(int gameId, CancellationToken can return Result.Success(game); }); - /// - /// Rolls straight from the current period into the next one without stopping the clock, for the - /// quarter boundaries that are not a real break (see ). - /// The lineup changes over, the running time does not — minus the swaps play has already - /// answered, which drops. - /// - public Task> AdvancePeriodAsync(int gameId, CancellationToken cancellationToken = default) => - LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "start the next period", - cancellationToken, async () => - { - await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); - - var game = await db.LoadWithPeriodsAsync(gameId, cancellationToken); - if (game is null) return NotFound(gameId); - - var current = game.LivePeriod(); - if (current is null) return Result.Failure("No period is currently being played"); - - var next = game.NextPeriod(); - if (next is null) - return Result.Failure("Every period has been played — finish the match instead"); - - await db.Entry(current).Collection(p => p.PlayerPositions).LoadAsync(cancellationToken); - await db.Entry(next).Collection(p => p.PlayerPositions).LoadAsync(cancellationToken); - var liveChanges = await db.GameSubstitutions - .Where(s => s.GamePeriodId == current.Id) - .ToListAsync(cancellationToken); - - KeepLiveArrivalsOn(current, next, liveChanges); - - // Both ends read the same instant, so no seconds fall between the two periods. The - // clock anchor is deliberately left alone: it must keep running through the change. - var elapsed = game.ElapsedSecondsAt(UtcNow); - current.EndedAtSeconds = elapsed; - next.StartedAtSeconds = elapsed; - next.EndedAtSeconds = null; - game.LivePeriodId = next.Id; - - // Nothing this app still does can stop a live period's clock, but a row stored by a - // build that had a pause button can be in exactly that state — and rolling on to the - // next line-up while the clock stayed frozen would bank no minutes for the rest of the - // half. Restarting the anchor from the banked total is what "the clock keeps running" - // means, and it is a no-op for every game that was not left paused. - game.ClockRunningSince ??= UtcNow; - - await db.SaveChangesAsync(cancellationToken); - logger.LogInformation("Game {GameId} rolled from period {From} into {To} at {Seconds}s", - gameId, current.Id, next.Id, elapsed); - return Result.Success(game); - }); - - /// - /// Keeps the players brought on during the period that is ending on the pitch for the next one. - /// - /// The line-up for was written before the match. Where play has already - /// answered one of its swaps — the player it takes off went off live, and somebody came on for - /// them — carrying it out would pull that substitute straight back off for an arrival nobody is - /// waiting for, and an injury replacement would last exactly one quarter. So the swap is - /// dropped: the player who came on takes the place the plan's arrival was to have, and that - /// arrival goes to the bench. - /// - /// - /// Which swaps those are is 's answer, not a second opinion - /// formed here — the live screen lists exactly the ones it does not drop, directly above the - /// button that calls this, and a card promising one thing while the button does another is - /// worse than either behaviour on its own. - /// - /// - private static void KeepLiveArrivalsOn( - GamePeriod current, GamePeriod next, List liveChanges) - { - foreach (var swap in PlannedChangesReport.Swaps(current, next, liveChanges).Overtaken) - { - // Never null in this half of the split — see PlannedSwaps. - var stayingOn = swap.Off!; - - // The slot the plan's arrival was taking, or the one the substitute already holds when - // the next line-up names nobody for it. - var slot = swap.On?.SlotIndex ?? stayingOn.SlotIndex; - var position = swap.On?.Position ?? stayingOn.Position; - - foreach (var displaced in next.PlayerPositions - .Where(p => !p.IsSubstitute && p.SlotIndex == slot).ToList()) - { - displaced.SlotIndex = null; - displaced.IsSubstitute = true; - } - - var entry = next.PlayerPositions.FirstOrDefault(p => p.PlayerId == stayingOn.PlayerId); - if (entry is null) - { - // Someone who was not in the next line-up at all — a late arrival, or a bench that - // was only filled in for the first quarter. - entry = new GamePlayerPosition { GamePeriodId = next.Id, PlayerId = stayingOn.PlayerId }; - next.PlayerPositions.Add(entry); - } - - entry.SlotIndex = slot; - entry.Position = position; - entry.IsSubstitute = false; - } - } - public Task> FinishMatchAsync(int gameId, CancellationToken cancellationToken = default) => LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "finish the match", cancellationToken, async () => diff --git a/src/FootballFormation.Core/Services/MatchGoalService.cs b/src/FootballFormation.Core/Services/MatchGoalService.cs index f8aca26..16ea11c 100644 --- a/src/FootballFormation.Core/Services/MatchGoalService.cs +++ b/src/FootballFormation.Core/Services/MatchGoalService.cs @@ -50,9 +50,10 @@ public Task> LogGoalAsync( ScorerId = scorerId, AssisterId = assisterId, // The minute the clock showed, so an over-running first half does not push every - // second-half goal out by the overrun. Stoppage time counts on past the cap rather - // than pinning several goals to the same minute. - Minute = clock.Minute, + // second-half goal out by the overrun. Stoppage time is kept in the second half of + // the pair — see GameGoal.AdditionalMinute for why it is not folded into the first. + Minute = clock.Minute.Minute, + AdditionalMinute = clock.Minute.Additional, IsOwnGoal = isOwnGoal, IsOpponentGoal = isOpponentGoal }; diff --git a/src/FootballFormation.UI/Pages/LiveMatch.razor b/src/FootballFormation.UI/Pages/LiveMatch.razor index 4924488..a8ce863 100644 --- a/src/FootballFormation.UI/Pages/LiveMatch.razor +++ b/src/FootballFormation.UI/Pages/LiveMatch.razor @@ -59,8 +59,8 @@ else if (GameData.MatchState == MatchState.InProgress) {
- @* A quarter boundary is not a stoppage but a planned line-up change, so it is - carried out from the card that lists what it changes rather than from here. *@ + @* The clock knows halves only. The line-up change partway through a half is + made by hand from the pitch, so it has no control of its own here. *@ @if (BreakFollowsCurrentPeriod) { @{ var planned = PlannedChanges; } - @* An empty list is still worth a card while the change can be made — otherwise the only - way on to the next line-up would vanish exactly when nobody needs swapping. *@ - @if (!planned.IsEmpty || CanAdvanceLineup) + @if (!planned.IsEmpty) {
@L["Changes at half-way"]
- - @if (planned.IsEmpty) - { - - @L["Nobody changes — the next line-up is the one already on the pitch."] - - } - else - { - - } - - @if (CanAdvanceLineup) - { - - @L["Next line-up"] - - } +
} diff --git a/src/FootballFormation.UI/Pages/LiveMatch.razor.cs b/src/FootballFormation.UI/Pages/LiveMatch.razor.cs index 6ba6d68..1436684 100644 --- a/src/FootballFormation.UI/Pages/LiveMatch.razor.cs +++ b/src/FootballFormation.UI/Pages/LiveMatch.razor.cs @@ -13,6 +13,8 @@ namespace FootballFormation.UI.Pages; /// /// One entry on the match timeline — a goal or a substitution — so both can be listed together. +/// is the scoreboard reading rather than the raw elapsed time, which is +/// what keeps a first-half stoppage entry above the restart instead of below it. /// orders events that share a minute, which the minute alone cannot. /// settles the rest: two entries of the same kind entered in one instant /// share a , and rows older than that column all read @@ -21,8 +23,8 @@ namespace FootballFormation.UI.Pages; /// is the scoreline as it stood after a goal, and null for a substitution. /// public record MatchEvent( - int Minute, DateTime RecordedAt, int Id, bool IsGoal, GameGoal? Goal, GameSubstitution? Substitution, - MatchScore? Score = null); + MatchMinute Minute, DateTime RecordedAt, int Id, bool IsGoal, GameGoal? Goal, + GameSubstitution? Substitution, MatchScore? Score = null); /// /// The sideline screen. An admin runs the clock and records what happens; everyone else sees the @@ -127,11 +129,11 @@ private bool IsInRoster(int playerId) => private string? NextHalfLabel => NextPeriod?.PeriodType.HalfDisplayName(); /// - /// Whether the period being played ends in a real stoppage. Only half time does; a quarter - /// boundary rolls straight on, so the screen offers the line-up change rather than a whistle. + /// Whether whistling the period off leads to a break rather than to the end of the match. The + /// clock runs in halves, so before full time there is exactly one stoppage — and after it + /// is null and the only control left is the final whistle. /// - private bool BreakFollowsCurrentPeriod => - DisplayPeriod is { } period && IsLivePeriod && period.PeriodType.IsFollowedByBreak(); + private bool BreakFollowsCurrentPeriod => IsLivePeriod && NextPeriod is not null; /// /// The period whose line-up takes over partway through the half on screen, if there is one. @@ -155,7 +157,8 @@ private GamePeriod? MidHalfSuccessor /// /// The swaps the planned line-ups imply for the middle of this half, measured against who is - /// on the pitch right now — so a live substitution already made drops out of the list. + /// on the pitch right now — so a live substitution already made drops out of the list. They are + /// carried out by hand, one tap on the pitch at a time; nothing here rolls them on at once. /// private PlannedChanges PlannedChanges => GameData is { } game && DisplayPeriod is { } current && MidHalfSuccessor is { } next @@ -163,12 +166,6 @@ GameData is { } game && DisplayPeriod is { } current && MidHalfSuccessor is { } game.Substitutions.Where(s => s.GamePeriodId == current.Id)) : PlannedChanges.None; - /// - /// Whether the next line-up can be rolled on. Only during play: before kick-off the changes - /// are worth reading but there is no period running to advance out of. - /// - private bool CanAdvanceLineup => IsLivePeriod && MidHalfSuccessor is not null; - /// What the match is doing right now, in one phrase under the clock. private string StatusLabel => GameData?.MatchState switch { @@ -264,10 +261,11 @@ private List Timeline // newest first, so a total accumulated while rendering it would count down. var progression = ScoreProgressionReport.Build(GameData.Goals); - var goals = GameData.Goals.Select(g => - new MatchEvent(g.Minute ?? 0, g.RecordedAt, g.Id, true, g, null, progression[g.Id])); + var goals = GameData.Goals.Select(g => new MatchEvent( + MatchClockReport.MinuteOf(g) ?? default, g.RecordedAt, g.Id, true, g, null, progression[g.Id])); IEnumerable subs = ShowSubstitutions - ? GameData.Substitutions.Select(s => new MatchEvent(s.Minute, s.RecordedAt, s.Id, false, null, s)) + ? GameData.Substitutions.Select(s => new MatchEvent( + MatchClockReport.MinuteOf(GameData, s), s.RecordedAt, s.Id, false, null, s)) : []; // A goal and the sub that followed it commonly share a minute; the entry time keeps @@ -348,13 +346,6 @@ private async Task EndPeriod() => private async Task StartNextPeriod() => Snackbar.Report(L, await ClockService.StartNextPeriodAsync(GameId), L["Next period started"]); - /// - /// Rolls the next planned line-up onto the pitch. Asks nothing first: the button sits under - /// the list of exactly the changes it makes, which is what a confirmation would have said. - /// - private async Task AdvancePeriod() => - Snackbar.Report(L, await ClockService.AdvancePeriodAsync(GameId), L["Next period started"]); - private async Task FinishMatch() { var confirmed = await DialogService.ConfirmAsync( diff --git a/src/FootballFormation.UI/Pages/MatchResult.razor b/src/FootballFormation.UI/Pages/MatchResult.razor index edd3183..66b5d0b 100644 --- a/src/FootballFormation.UI/Pages/MatchResult.razor +++ b/src/FootballFormation.UI/Pages/MatchResult.razor @@ -74,12 +74,14 @@ @if (GameData.Goals.Count > 0) {
- @foreach (var goal in GameData.Goals.OrderBy(g => g.Minute)) + @* Both halves of the minute, so a goal in first-half stoppage time keeps its place + above the restart rather than sorting past it — see MatchMinute. *@ + @foreach (var goal in GameData.Goals.OrderBy(g => MatchClockReport.MinuteOf(g) ?? default)) { @* Own goals and opponent goals both count against us, so they share the styling. *@
- @(goal.Minute.HasValue ? $"{goal.Minute}'" : "—") + @(MatchClockReport.MinuteOf(goal) is { } at ? $"{at}'" : "—")
diff --git a/src/FootballFormation.UI/Strings.nl.resx b/src/FootballFormation.UI/Strings.nl.resx index 6a2225b..69e4200 100644 --- a/src/FootballFormation.UI/Strings.nl.resx +++ b/src/FootballFormation.UI/Strings.nl.resx @@ -192,9 +192,7 @@ Wedstrijd starten Rust {0} starten - Volgende opstelling Wissels halverwege - Er verandert niemand — de volgende opstelling is die al in het veld staat. Positiewissels Wedstrijd afsluiten Uitslag aanpassen diff --git a/src/FootballFormation.Web/wwwroot/app.css b/src/FootballFormation.Web/wwwroot/app.css index dde38ca..6baaf87 100644 --- a/src/FootballFormation.Web/wwwroot/app.css +++ b/src/FootballFormation.Web/wwwroot/app.css @@ -775,13 +775,6 @@ html, body { } } -/* Carrying out the changes listed above it, so it is spaced off the list rather than crowding it. - A MudButton's root element is a child component's, which scoped CSS cannot reach. */ -.live-advance-btn { - margin-top: 14px; - min-height: 48px; -} - /* On a phone what just happened matters more than where everyone stands, so the line-up card drops below the timeline, taking the minutes table with it. Every other child of .live-layout keeps the default order of 0 and so stays in source order above them. */ diff --git a/tests/FootballFormation.Core.Tests/GameTests.cs b/tests/FootballFormation.Core.Tests/GameTests.cs index e6d6be5..c846c8c 100644 --- a/tests/FootballFormation.Core.Tests/GameTests.cs +++ b/tests/FootballFormation.Core.Tests/GameTests.cs @@ -225,16 +225,30 @@ public void An_own_goal_counts_for_the_opponent_and_not_for_us() Assert.Equal(2, Game.CountTheirGoals(goals)); } - [Theory] - [InlineData(PeriodType.FirstHalf, true)] - [InlineData(PeriodType.SecondQuarter, true)] - // A quarters game is still two halves — the teams roll straight from Q1 into Q2. - [InlineData(PeriodType.FirstQuarter, false)] - [InlineData(PeriodType.ThirdQuarter, false)] - [InlineData(PeriodType.SecondHalf, false)] - [InlineData(PeriodType.FourthQuarter, false)] - public void Only_half_time_is_a_real_break(PeriodType period, bool expected) => - Assert.Equal(expected, period.IsFollowedByBreak()); + [Fact] + public void The_clock_goes_from_one_half_to_the_next_rather_than_from_quarter_to_quarter() + { + var game = QuartersGame(); + + // Before kick-off the next period is simply the first one. + Assert.Equal(PeriodType.FirstQuarter, game.NextPeriod()!.PeriodType); + + // The first half has been played, so the line-up planned for the rest of it is behind the + // clock — the whistle hands over to the half that follows. + game.Periods.Single(p => p.PeriodType == PeriodType.FirstQuarter).StartedAtSeconds = 0; + Assert.Equal(PeriodType.ThirdQuarter, game.NextPeriod()!.PeriodType); + + game.Periods.Single(p => p.PeriodType == PeriodType.ThirdQuarter).StartedAtSeconds = 1800; + Assert.Null(game.NextPeriod()); + } + + private static Game QuartersGame() => new() + { + Opponent = "X", + SplitType = GameSplitType.Quarters, + Periods = [.. PeriodTypeExtensions.ForSplitType(GameSplitType.Quarters) + .Select(type => new GamePeriod { PeriodType = type })] + }; [Fact] public void Split_period_count_is_derived_from_the_period_table_itself() diff --git a/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs b/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs index be2e351..313b7aa 100644 --- a/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs +++ b/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs @@ -34,13 +34,12 @@ public async Task Every_touchline_write_names_the_game_it_changed() Assert.True((await Subs.SwapPositionsAsync(game.Id, players[0].Id, players[1].Id)).IsSuccess); - Assert.True((await MatchClock.AdvancePeriodAsync(game.Id)).IsSuccess); Assert.True((await MatchClock.EndPeriodAsync(game.Id)).IsSuccess); Assert.True((await MatchClock.StartNextPeriodAsync(game.Id)).IsSuccess); Assert.True((await MatchClock.FinishMatchAsync(game.Id)).IsSuccess); - // Ten writes, ten announcements, each naming this match. - Assert.Equal(10, _announced.Count); + // Nine writes, nine announcements, each naming this match. + Assert.Equal(9, _announced.Count); Assert.All(_announced, id => Assert.Equal(game.Id, id)); } diff --git a/tests/FootballFormation.Core.Tests/MatchClockReportTests.cs b/tests/FootballFormation.Core.Tests/MatchClockReportTests.cs index 35379e4..98405b3 100644 --- a/tests/FootballFormation.Core.Tests/MatchClockReportTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchClockReportTests.cs @@ -129,9 +129,12 @@ public void An_odd_duration_does_not_lose_the_half_minute() Assert.Equal(0, atFullTime.AdditionalSeconds); } - /// Goals are written against the clock that ran on, not the one that stopped. + /// + /// A half that has been played out stops counting minutes and starts counting them alongside, + /// the way football writes 30+2 — so the minute never runs into the numbers the next half uses. + /// [Fact] - public void The_minute_counts_on_through_additional_time() + public void The_minute_stops_with_the_clock_and_additional_time_is_counted_beside_it() { var game = QuartersGame(); Period(game, PeriodType.FirstQuarter).StartedAtSeconds = 0; @@ -140,7 +143,27 @@ public void The_minute_counts_on_through_additional_time() var clock = MatchClockReport.Build(game, Period(game, PeriodType.SecondQuarter), 31 * 60 + 30); Assert.Equal(30 * 60, clock.Seconds); - Assert.Equal(32, clock.Minute); + Assert.Equal(new MatchMinute(30, 2), clock.Minute); + Assert.Equal("30+2", clock.Minute.ToString()); + } + + /// + /// The whole point of the pair: a goal in first-half stoppage time and one just after the + /// restart are a minute apart, and a single number would have written them 32 and 31. + /// + [Fact] + public void A_stoppage_time_minute_comes_before_the_first_minute_of_the_next_half() + { + var game = QuartersGame(); + Period(game, PeriodType.FirstQuarter).StartedAtSeconds = 0; + Period(game, PeriodType.ThirdQuarter).StartedAtSeconds = 32 * 60; + + var stoppage = MatchClockReport.Build(game, Period(game, PeriodType.FirstQuarter), 31 * 60); + var afterTheBreak = MatchClockReport.Build(game, Period(game, PeriodType.ThirdQuarter), 32 * 60); + + Assert.Equal(new MatchMinute(30, 2), stoppage.Minute); + Assert.Equal(new MatchMinute(31, 0), afterTheBreak.Minute); + Assert.True(stoppage.Minute.CompareTo(afterTheBreak.Minute) < 0); } [Fact] @@ -149,9 +172,39 @@ public void The_first_minute_of_play_is_the_first_minute() var game = QuartersGame(); Period(game, PeriodType.FirstQuarter).StartedAtSeconds = 0; - Assert.Equal(1, MatchClockReport.Build(game, Period(game, PeriodType.FirstQuarter), 0).Minute); - Assert.Equal(1, MatchClockReport.Build(game, Period(game, PeriodType.FirstQuarter), 59).Minute); - Assert.Equal(2, MatchClockReport.Build(game, Period(game, PeriodType.FirstQuarter), 60).Minute); + MatchMinute MinuteAt(int seconds) => + MatchClockReport.Build(game, Period(game, PeriodType.FirstQuarter), seconds).Minute; + + Assert.Equal(new MatchMinute(1, 0), MinuteAt(0)); + Assert.Equal(new MatchMinute(1, 0), MinuteAt(59)); + Assert.Equal(new MatchMinute(2, 0), MinuteAt(60)); + Assert.Equal("2", MinuteAt(60).ToString()); + } + + [Fact] + public void A_substitution_is_written_against_the_clock_its_own_half_was_showing() + { + var game = QuartersGame(); + Period(game, PeriodType.FirstQuarter).StartedAtSeconds = 0; + var secondHalf = Period(game, PeriodType.ThirdQuarter); + secondHalf.StartedAtSeconds = 32 * 60; + secondHalf.Id = 7; + + // Two minutes into a second half that kicked off 32 real minutes in: 33', not 35'. + var sub = new GameSubstitution { GamePeriodId = 7, AtSeconds = 34 * 60 }; + Assert.Equal(new MatchMinute(33, 0), MatchClockReport.MinuteOf(game, sub)); + + // A substitution whose period is not loaded still says something better than 1'. + var orphan = new GameSubstitution { GamePeriodId = 99, AtSeconds = 34 * 60 }; + Assert.Equal(new MatchMinute(35, 0), MatchClockReport.MinuteOf(game, orphan)); + } + + [Fact] + public void A_goal_recorded_without_a_minute_has_none_to_show() + { + Assert.Null(MatchClockReport.MinuteOf(new GameGoal())); + Assert.Equal(new MatchMinute(35, 2), + MatchClockReport.MinuteOf(new GameGoal { Minute = 35, AdditionalMinute = 2 })); } [Fact] diff --git a/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs b/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs index 70e49f1..4cbc875 100644 --- a/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs @@ -1,5 +1,4 @@ using FootballFormation.Core.Models; -using Microsoft.EntityFrameworkCore; namespace FootballFormation.Core.Tests; @@ -133,143 +132,44 @@ public async Task The_next_period_cannot_start_before_the_current_one_ends() Assert.Equal("End the current period first", result.Error); } - [Fact] - public async Task Advancing_between_quarters_loses_no_seconds() - { - // Q1 → Q2 is not a real break: the clock must run straight through the changeover. - var game = await SeedGameAsync(GameSplitType.Quarters); - await MatchClock.StartMatchAsync(game.Id); - - Time.Advance(TimeSpan.FromMinutes(15)); - await MatchClock.AdvancePeriodAsync(game.Id); - - var advanced = await ReloadAsync(game.Id); - var periods = advanced.Periods.OrderBy(p => p.PeriodType).ToList(); - - Assert.Equal(900, periods[0].EndedAtSeconds); - Assert.Equal(900, periods[1].StartedAtSeconds); // both ends read the same instant - Assert.Equal(periods[1].Id, advanced.LivePeriodId); - Assert.True(advanced.IsClockRunning); - } - - [Fact] - public async Task Advancing_restarts_a_clock_that_an_older_build_left_stopped() - { - // Nothing here can stop a live period's clock any more, but a row written while there was - // a pause button can be in exactly that state. Rolling on to the next line-up while the - // anchor stayed null would bank no minutes for the rest of the half. - var game = await SeedGameAsync(GameSplitType.Quarters); - await MatchClock.StartMatchAsync(game.Id); - - Time.Advance(TimeSpan.FromMinutes(15)); - - // Through ReloadAsync, which clears the change tracker first: the copy this context has - // held since the seed still reads ClockRunningSince = null, so assigning null to *that* - // is not a change EF would detect, and the anchor the service wrote would survive. - var paused = await ReloadAsync(game.Id); - paused.ClockAccumulatedSeconds = 900; - paused.ClockRunningSince = null; - await Db.SaveChangesAsync(); - - Assert.True((await MatchClock.AdvancePeriodAsync(game.Id)).IsSuccess); - - var advanced = await ReloadAsync(game.Id); - Assert.True(advanced.IsClockRunning); - Assert.Equal(900, advanced.Periods.OrderBy(p => p.PeriodType).ToList()[1].StartedAtSeconds); - - Time.Advance(TimeSpan.FromMinutes(5)); - Assert.Equal(1200, advanced.ElapsedSecondsAt(Time.GetUtcNow().UtcDateTime)); - } - /// - /// The plan for the next quarter was written before the match. If it still takes off a player - /// who has already gone off, carrying it out pulls their replacement straight back off — so an - /// injury replacement would last exactly one quarter. The live screen drops that swap from - /// "Changes at half-way"; this is the half that makes the button agree with the card. + /// A quarters game is planned as two line-ups per half but played as two halves. The second + /// quarter's line-up is a plan the coach works through by hand — the clock never stops for it — + /// so the whistle after the first half hands over to the third quarter, not the second. /// [Fact] - public async Task Advancing_keeps_a_player_brought_on_live_rather_than_carrying_out_the_swap_they_answered() + public async Task The_second_half_of_a_quarters_game_starts_at_the_third_quarter() { - var game = await SeedQuartersWithASwapAsync(); - var players = await PlayersAsync(); - + var game = await SeedGameAsync(GameSplitType.Quarters); await MatchClock.StartMatchAsync(game.Id); - Time.Advance(TimeSpan.FromMinutes(5)); - // Not P3, who Q2 was going to bring on — an injury, and whoever was warm goes on. - Assert.True((await Subs.SubstituteAsync(game.Id, players[1].Id, players[3].Id)).IsSuccess); - Time.Advance(TimeSpan.FromMinutes(10)); - Assert.True((await MatchClock.AdvancePeriodAsync(game.Id)).IsSuccess); + Time.Advance(TimeSpan.FromMinutes(30)); + await MatchClock.EndPeriodAsync(game.Id); + Assert.True((await MatchClock.StartNextPeriodAsync(game.Id)).IsSuccess); - var q2 = await LineupAsync(game.Id, PeriodType.SecondQuarter); - var stayedOn = Assert.Single(q2, p => p.PlayerId == players[3].Id); - Assert.False(stayedOn.IsSubstitute); - Assert.Equal(5, stayedOn.SlotIndex); - Assert.Equal(PlayerPosition.CM, stayedOn.Position); + var second = await ReloadAsync(game.Id); + var periods = second.Periods.OrderBy(p => p.PeriodType).ToList(); - // And the arrival the plan named is on the bench rather than in the same slot. - Assert.True(q2.Single(p => p.PlayerId == players[2].Id).IsSubstitute); - Assert.Single(q2, p => p.SlotIndex == 5); + Assert.Equal(periods[2].Id, second.LivePeriodId); + Assert.Equal(1800, periods[2].StartedAtSeconds); + // The first half ran as one period, so its second line-up was never kicked off — which is + // what keeps GameMinutesReport from crediting it a quarter nobody played. + Assert.Null(periods[1].StartedAtSeconds); } [Fact] - public async Task Advancing_carries_out_a_swap_the_match_has_not_already_answered() - { - var game = await SeedQuartersWithASwapAsync(); - var players = await PlayersAsync(); - - await MatchClock.StartMatchAsync(game.Id); - Time.Advance(TimeSpan.FromMinutes(15)); - Assert.True((await MatchClock.AdvancePeriodAsync(game.Id)).IsSuccess); - - // Nothing overtook it, so the planned line-up rolls on untouched. - var q2 = await LineupAsync(game.Id, PeriodType.SecondQuarter); - Assert.Equal(5, q2.Single(p => p.PlayerId == players[2].Id).SlotIndex); - Assert.True(q2.Single(p => p.PlayerId == players[1].Id).IsSubstitute); - } - - /// - /// A quarters game whose second quarter plans one swap: P2 comes off at CM for P3. Every - /// period is seeded with the same line-up, so the second one is rewritten here. - /// - private async Task SeedQuartersWithASwapAsync() + public async Task A_quarters_game_has_no_third_half_left_to_start() { var game = await SeedGameAsync(GameSplitType.Quarters); - var players = await PlayersAsync(); - - var q2 = game.Periods.Single(p => p.PeriodType == PeriodType.SecondQuarter); - await Db.Entry(q2).Collection(p => p.PlayerPositions).LoadAsync(); - - var comingOff = q2.PlayerPositions.Single(p => p.PlayerId == players[1].Id); - var comingOn = q2.PlayerPositions.Single(p => p.PlayerId == players[2].Id); - - (comingOff.SlotIndex, comingOff.IsSubstitute) = (null, true); - (comingOn.SlotIndex, comingOn.IsSubstitute) = (5, false); - comingOn.Position = PlayerPosition.CM; - - await Db.SaveChangesAsync(); - return game; - } - - private async Task> LineupAsync(int gameId, PeriodType period) - { - Db.ChangeTracker.Clear(); - - return await Db.GamePlayerPositions - .Where(p => p.GamePeriod.GameId == gameId && p.GamePeriod.PeriodType == period) - .ToListAsync(); - } - - [Fact] - public async Task Advancing_past_the_last_period_is_refused() - { - var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); + Time.Advance(TimeSpan.FromMinutes(30)); - await MatchClock.AdvancePeriodAsync(game.Id); + await MatchClock.EndPeriodAsync(game.Id); + await MatchClock.StartNextPeriodAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(30)); + await MatchClock.EndPeriodAsync(game.Id); - var result = await MatchClock.AdvancePeriodAsync(game.Id); + var result = await MatchClock.StartNextPeriodAsync(game.Id); Assert.True(result.IsFailure); Assert.Equal("Every period has been played — finish the match instead", result.Error); diff --git a/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs b/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs index ce2f7f9..bf47311 100644 --- a/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs @@ -46,6 +46,25 @@ public async Task A_second_half_goal_is_stamped_off_the_scoreboard_clock_not_the Assert.Equal(36, goal.Value!.Minute); } + /// + /// A goal after the half has been played out is written 30+2, not 32. The two halves of the + /// minute are stored apart because that is what keeps it above the restart on the timeline. + /// + [Fact] + public async Task A_goal_in_stoppage_time_is_stamped_with_the_minute_it_is_added_to() + { + var game = await SeedGameAsync(); + await MatchClock.StartMatchAsync(game.Id); + var players = await PlayersAsync(); + + Time.Advance(TimeSpan.FromMinutes(31)); // one minute past a 30-minute half + + var goal = await Goals.LogGoalAsync(game.Id, players[1].Id, null, false, false); + + Assert.Equal(30, goal.Value!.Minute); + Assert.Equal(2, goal.Value.AdditionalMinute); + } + [Fact] public async Task A_goal_for_us_needs_a_scorer() { diff --git a/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs b/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs index f7cd2a8..21b5cb2 100644 --- a/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs @@ -21,7 +21,6 @@ public async Task A_substitution_hands_the_slot_and_position_over() Assert.True(result.IsSuccess); Assert.Equal(720, result.Value!.AtSeconds); - Assert.Equal(13, result.Value.Minute); Assert.Equal(PlayerPosition.CM, result.Value.Position); Assert.Equal(5, result.Value.SlotIndex); diff --git a/tests/FootballFormation.Core.Tests/ScoreProgressionReportTests.cs b/tests/FootballFormation.Core.Tests/ScoreProgressionReportTests.cs index d058119..2e858ac 100644 --- a/tests/FootballFormation.Core.Tests/ScoreProgressionReportTests.cs +++ b/tests/FootballFormation.Core.Tests/ScoreProgressionReportTests.cs @@ -10,11 +10,13 @@ namespace FootballFormation.Core.Tests; ///
public class ScoreProgressionReportTests { - private static GameGoal Goal(int id, int minute, bool ownGoal = false, bool opponentGoal = false) => + private static GameGoal Goal( + int id, int minute, int additional = 0, bool ownGoal = false, bool opponentGoal = false) => new() { Id = id, Minute = minute, + AdditionalMinute = additional, IsOwnGoal = ownGoal, IsOpponentGoal = opponentGoal, RecordedAt = new DateTime(2026, 8, 11, 14, 0, minute, DateTimeKind.Utc) @@ -85,6 +87,24 @@ public void The_last_goals_score_is_the_final_score() Assert.Equal(Game.CountTheirGoals(goals), final.Them); } + /// + /// A goal in first-half stoppage time was scored before one just after the restart, and the + /// running total has to follow that — a minute counted straight on would have made 30+2 read + /// as 32 and put it after the 31st minute of the second half. + /// + [Fact] + public void A_stoppage_time_goal_is_counted_inside_the_half_it_was_scored_in() + { + var stoppage = Goal(1, 30, additional: 2); + var afterTheBreak = Goal(2, 31, opponentGoal: true); + afterTheBreak.RecordedAt = stoppage.RecordedAt.AddMinutes(16); + + var progression = ScoreProgressionReport.Build([afterTheBreak, stoppage]); + + Assert.Equal(new MatchScore(1, 0), progression[1]); + Assert.Equal(new MatchScore(1, 1), progression[2]); + } + [Fact] public void A_match_with_no_goals_has_nothing_to_report() { diff --git a/tests/ui/helpers.js b/tests/ui/helpers.js index 9314465..7a93987 100644 --- a/tests/ui/helpers.js +++ b/tests/ui/helpers.js @@ -169,7 +169,7 @@ export async function createMatch(page, { opponent, venue, matchType, split } = if (venue) await chooseOption(page, panel, 'Venue', venue); if (matchType) await chooseOption(page, panel, 'Match Type', matchType); // "Quarters" is the split that gives a half two line-ups, and so the only one whose live screen - // ever offers "Next line-up". + // has changes to list partway through a half. if (split) await chooseOption(page, panel, 'Game Split', split); await submitDialog(page); diff --git a/tests/ui/specs/match-day.spec.js b/tests/ui/specs/match-day.spec.js index 8027b21..dcce784 100644 --- a/tests/ui/specs/match-day.spec.js +++ b/tests/ui/specs/match-day.spec.js @@ -146,8 +146,8 @@ test('tapping a player on the pitch offers a substitution and a position swap', await expect(page.locator('.live-event')).toHaveCount(0); }); -test('the next line-up is rolled on from the card that lists what it changes', async ({ page }) => { - // Quarters, so the first half is planned as two line-ups and the mid-half control appears. +test('a quarters half lists the changes due in it and is run as one half', async ({ page }) => { + // Quarters, so the first half is planned as two line-ups and the changes card has something in it. const id = await matchWithId(page, 'FC Kwarten', { split: 'Quarters' }); const available = page.locator('.draggable-player'); @@ -155,7 +155,7 @@ test('the next line-up is rolled on from the card that lists what it changes', a const chips = page.locator('.pitch .pitch-player'); // Q1 takes the front of the squad list and Q2 the back, so the two line-ups genuinely differ and - // the dialog has changes to list. + // the card has changes to list. await expect(available.first()).toBeVisible(); for (let i = 0; i < 3; i++) { await available.first().dragTo(emptySlots.first()); @@ -173,29 +173,25 @@ test('the next line-up is rolled on from the card that lists what it changes', a ); await goto(page, `/games/${id}/live`); - const nextLineup = page.getByRole('button', { name: 'Next line-up' }); - // The changes are worth reading before kick-off, but there is no period running to advance out - // of yet, so the button that carries them out is not there. + // The changes are worth reading before kick-off too, and they are all the screen says about the + // quarter boundary: there is no control that rolls the next line-up on, only the pitch above. await expect(page.locator('.planned-row').first()).toBeVisible(); - await expect(nextLineup).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Next line-up' })).toHaveCount(0); await clickFor( page.getByRole('button', { name: 'Start match' }), - () => expect(nextLineup).toBeVisible(), + () => expect(page.getByRole('button', { name: 'Finish match' })).toBeVisible(), ); - // It belongs to the card, not to the clock controls: the tap is made while reading the list it - // sits under, which is why it no longer asks in a dialog first. - await expect(page.locator('.live-controls').getByRole('button', { name: 'Next line-up' })).toHaveCount(0); + // The clock runs in halves however the line-ups were planned, so the control on offer during the + // first quarter is already half time — the second quarter is never a period the clock stops for. + const controls = page.locator('.live-controls'); + await expect(controls.getByRole('button', { name: 'Next line-up' })).toHaveCount(0); await clickFor( - nextLineup, - () => expect(page.getByText('Next period started', { exact: false })).toBeVisible(), + controls.getByRole('button', { name: 'Half time' }), + () => expect(controls.getByRole('button', { name: 'Start 2nd Half' })).toBeVisible(), ); - await expect(page.locator('.mud-dialog')).toHaveCount(0); - - // The second quarter is the last of the half, so the control it now offers is half time. - await expect(page.locator('.live-controls').getByRole('button', { name: 'Half time' })).toBeVisible(); }); test('the timeline can be narrowed to the goals', async ({ page }) => { From db1b2162c80485a5c2f2a39edf2038a34cbeb8c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:12:34 +0000 Subject: [PATCH 2/2] Let the live match know halves only, and make the plan a reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The touchline screen dealt in periods: a quarters game's second line-up of a half showed up as a card beside the pitch, and the clock, the services and the model all still spoke of periods even though nothing stopped for one. A period row is now what it always was in practice — a planned line-up. The row that opens a half is the one the match is played, timed and recorded with; the row planned for the middle of a half is a plan, and nothing else. The model, the clock service and the live screen say so: CurrentOrLastHalf, LiveHalf, NextHalf, MidHalfPlan, EndHalfAsync, StartNextHalfAsync, and messages that talk about halves rather than periods. The changes due partway through a half move out of the screen and into a pop-up behind a "Changes (n)" button on the line-up card. Standing beside the live line-up, a plan reads as the state of play and invites being asked about before it is carried out; behind a button it is looked up, worked through by tapping the pitch, and dismissed. It is still admin-only, still stripped of the swaps play has overtaken, and still readable before kick-off. No schema change: what is stored is what was stored, and the minutes a season is built from are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfrvWcNR8hMjw3ny6dMemX --- docs/architecture.md | 28 +++-- docs/known_issues.md | 17 +-- docs/models.md | 24 ++-- docs/patterns.md | 15 +-- docs/project_overview.md | 2 +- docs/ui_components.md | 56 +++++---- src/FootballFormation.Core/Models/Game.cs | 56 ++++++--- .../Models/GamePeriod.cs | 13 ++- .../Reporting/GameMinutesReport.cs | 20 ++-- .../Reporting/LiveMinutesReport.cs | 2 +- .../Reporting/MatchClockReport.cs | 24 ++-- .../Reporting/PlannedChangesReport.cs | 24 ++-- .../Services/LiveMatchQueries.cs | 6 +- .../Services/MatchClockService.cs | 56 +++++---- .../Services/MatchGoalService.cs | 4 +- .../Services/MatchSubstitutionService.cs | 48 ++++---- .../Pages/LiveMatch.razor | 52 ++++----- .../Pages/LiveMatch.razor.cs | 109 +++++++++--------- .../Pages/LiveSubDialog.razor | 2 +- .../Pages/PlannedChangesDialog.razor | 40 +++++++ src/FootballFormation.UI/Strings.nl.resx | 29 ++--- src/FootballFormation.Web/wwwroot/app.css | 22 ++++ .../FootballFormation.Core.Tests/GameTests.cs | 32 ++++- .../LiveMatchNotificationTests.cs | 6 +- .../MatchClockServiceTests.cs | 44 +++---- .../MatchGoalServiceTests.cs | 4 +- .../MatchSubstitutionServiceTests.cs | 12 +- tests/ui/specs/match-day.spec.js | 20 +++- 28 files changed, 461 insertions(+), 306 deletions(-) create mode 100644 src/FootballFormation.UI/Pages/PlannedChangesDialog.razor diff --git a/docs/architecture.md b/docs/architecture.md index 6eb2969..7457650 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,10 +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, and the MatchMinute an event is written down against - PlannedChangesReport.cs — What the next period changes versus the one on the pitch, minus the - swaps play has already overtaken, for UI/Components/PlannedChangesList + 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 @@ -72,20 +73,21 @@ 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: 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, 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) @@ -113,6 +115,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) @@ -126,7 +130,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) diff --git a/docs/known_issues.md b/docs/known_issues.md index b681c1d..8f2e041 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -345,15 +345,16 @@ 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. -- **A quarters match only ever kicks off two of its four periods.** The clock runs in halves, so - `Game.NextPeriod()` skips a period whose half has already been played and 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 period 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. Do not - "fix" a Q2 with no timings, and do not read `PeriodCount` as a count of periods the clock stops - for. +- **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 diff --git a/docs/models.md b/docs/models.md index aad6434..1db0a80 100644 --- a/docs/models.md +++ b/docs/models.md @@ -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\ | Cascade delete | | Comments | List\ | Cascade delete. Never eager-loaded — see GameComment | @@ -145,10 +145,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 @@ -171,14 +174,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 @@ -220,7 +228,7 @@ bench, never both and never twice. | 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 period the change belongs to, so it reads off the same scoreboard clock a goal was stamped from +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. @@ -235,7 +243,7 @@ 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. @@ -243,7 +251,7 @@ Both player legs are `Restrict`, not `Cascade`: two cascading paths from `Player 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 diff --git a/docs/patterns.md b/docs/patterns.md index bfcc591..bc9573b 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -108,9 +108,9 @@ 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, 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 | +| `MatchClockService` | Kick-off, half time, starting the next half, the final whistle — and `BankClock`, the only thing that moves seconds about. No pause: only half time 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 | +| `MatchSubstitutionService` | The slot swap and the record of it, in one `SaveChanges`, and undoing the most recent one of a half | What made the cut worth making was not the line count: the clock arithmetic and the substitution slot-swapping shared a type, a `UtcNow` and a set of private helpers, so reading either meant paging @@ -121,11 +121,12 @@ followed correctly throughout. Three things fall out of a split like this, and they are the parts worth copying: - **Pure helpers over an entity move onto the entity.** `CurrentPeriod` and `NextPeriod` were - private statics over a `Game`; they are `Game.LivePeriod()` and `Game.NextPeriod()` now, beside - `Game.CurrentOrLastPeriod()`, and the live page reads its "next period" from the same one. A - helper that *mutates*, like `BankClock`, stays with the service that owns the writing. + private statics over a `Game`; they are `Game.LiveHalf()` and `Game.NextHalf()` now, beside + `Game.CurrentOrLastHalf()` and `Game.MidHalfPlan()`, and the live page reads its "next half" and + the plan it offers as a reference from the same ones. A helper that *mutates*, like `BankClock`, + stays with the service that owns the writing. - **What every piece still shares gets named once.** `LiveMatchQueries` holds the tracked load they - all start from (the game with its periods, shaped by `GameQueries.WithPeriods`) and the single + all start from (the game with its planned line-ups, shaped by `GameQueries.WithPeriods`) and the single "game not found" message. - **Anything every method had to remember becomes part of the operation shape.** Each write used to end with `notifier.Notify(gameId)`; three services each remembering that is worse than one, so @@ -140,7 +141,7 @@ split was cut along the wrong line. ## Domain logic on the model Anything computable without the database lives on the entity, not in a service or a page: `Game.PeriodCount`, `Game.PeriodDurationSeconds`, `Game.IsInRoster`, `Game.SelectRoster`, -`Game.LivePeriod()`, `Game.NextPeriod()`, +`Game.LiveHalf()`, `Game.NextHalf()`, `Game.MidHalfPlan()`, `GameSplitTypeExtensions.PeriodCount()/PeriodDurationSeconds()/PeriodLabel()`. `PeriodCount` derives from `PeriodTypeExtensions.ForSplitType`, so the count can never drift from the periods actually created. diff --git a/docs/project_overview.md b/docs/project_overview.md index f2d7256..988a787 100644 --- a/docs/project_overview.md +++ b/docs/project_overview.md @@ -40,7 +40,7 @@ calculation live in Core, not the UI, so they are testable and would come along **Match day** - Formation builder with drag-and-drop onto a visual pitch, per-period lineups (halves or quarters) - Playing time overview (% of game time per player, with position-fit colours) -- Live match mode (`/games/{id}/live`): a running clock, period transitions, timestamped +- Live match mode (`/games/{id}/live`): a running clock in two halves, timestamped substitutions and goals. The admin drives it; everyone else watches the same URL read-only, with the clock derived from one stored anchor rather than pushed each second - Match results: final score, scorers, assists, own and opponent goals diff --git a/docs/ui_components.md b/docs/ui_components.md index 3983251..4c85103 100644 --- a/docs/ui_components.md +++ b/docs/ui_components.md @@ -81,7 +81,7 @@ there, so a lineup can never be laid out one way on one screen and another way o - `OnPlayerClicked` is **optional**. Unset (and not draggable), the pitch is inert — which is what the overview and every spectator wants. Set, occupied slots gain `.pitch-clickable` (pointer cursor, press feedback) and tapping one raises the player id. The live match screen wires it only - when the viewer is an admin *and* a period is actually being played. + when the viewer is an admin *and* a half is actually being played. - The five fit colors are tokens in `theme.css` (`--fit-*`), shared with the builder's legend and its playing-time dots — one definition, three consumers. @@ -106,36 +106,36 @@ watches the same URL read-only. Every control sits in an ` *` 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. The bench strip under it is - always drawn. +- The pitch shows the half being played; at half time and after full time the last one played, and + before kick-off the half the match opens with — so it is never blank when a lineup exists. The + bench strip under it is always drawn. - **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** + stands, so after a swap the whole half 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. @@ -150,7 +150,7 @@ watches the same URL read-only. Every control sits in an `A pop-up rather than a card because the plan is not the match: standing beside the live + line-up it reads as the state of play, and a shared screen invites being asked about a change + before it is made. Behind a button it is looked up, acted on and dismissed, and the count on the + button says whether opening it is worth the tap. It is there before kick-off too, as something to + read; nothing left to change means no button. - **Only viable changes are listed.** The report is handed the substitutions already made in the - period so it can rewind to the line-up that kicked off. A swap whose outgoing player has since + half so it can rewind to the line-up that kicked off. A swap whose outgoing player has since been taken off is dropped: the difference between the line-ups still names their slot, but it now proposes withdrawing whoever came on for them, which nobody planned. An injury replacement therefore stays on for the rest of the half rather than being listed to come straight back off. diff --git a/src/FootballFormation.Core/Models/Game.cs b/src/FootballFormation.Core/Models/Game.cs index 713bfac..0d33956 100644 --- a/src/FootballFormation.Core/Models/Game.cs +++ b/src/FootballFormation.Core/Models/Game.cs @@ -27,6 +27,12 @@ public class Game /// The opponent's score. Not tied to venue — see . public int? ScoreAway { get; set; } + /// + /// The line-ups this game is planned in. A match is played in two halves whatever the split + /// says; a period row is a planned line-up for a stretch of one, and a quarters game + /// simply plans two per half. The row that opens a half is the one the live match plays it + /// with, and the row after it inside the same half is a plan the coach carries out by hand. + /// public List Periods { get; set; } = []; public List Goals { get; set; } = []; public List Substitutions { get; set; } = []; @@ -51,7 +57,11 @@ public class Game /// Seconds banked from earlier running stretches, excluding the current one. public int ClockAccumulatedSeconds { get; set; } - /// The period currently on the pitch. Null before kick-off, at the break, and after the final whistle. + /// + /// The line-up currently on the pitch — the row that opened the half being played. Null before + /// kick-off, at half time and after the final whistle, which are exactly the moments nothing + /// may be recorded against a half. + /// public int? LivePeriodId { get; set; } /// Squad players opted out of this game. @@ -140,14 +150,14 @@ public List SelectRoster(IEnumerable allPlayers, SeasonSquad squ public bool IsClockRunning => ClockRunningSince is not null; /// - /// The period the match is currently about: the one being played; at a break and after the - /// final whistle the last one that was; and before kick-off the first one. Shared by the live - /// screen and the goal log so the minute written down is the one that was on screen. + /// The half the match is currently about, as the line-up it is played with: the one on the + /// pitch; at half time and after the final whistle the last one played; and before kick-off the + /// half the match opens with. Shared by the live screen and the goal log so the minute written + /// down is the one that was on screen. /// - public GamePeriod? CurrentOrLastPeriod() + public GamePeriod? CurrentOrLastHalf() { - if (LivePeriodId is { } liveId - && Periods.FirstOrDefault(p => p.Id == liveId) is { } live) return live; + if (LiveHalf() is { } live) return live; var lastPlayed = Periods .Where(p => p.StartedAtSeconds is not null) @@ -158,24 +168,23 @@ public List SelectRoster(IEnumerable allPlayers, SeasonSquad squ } /// - /// The period actually being played, or null before kick-off, at a break and after the final - /// whistle. Stricter than , which always names a period if - /// there is one to name: this is the one a substitution or a period change may touch. + /// The half being played, as the line-up on the pitch, or null before kick-off, at half time + /// and after the final whistle. Stricter than , which always + /// names a half if there is one to name: this is the one a substitution may touch. /// - public GamePeriod? LivePeriod() => + public GamePeriod? LiveHalf() => LivePeriodId is null ? null : Periods.FirstOrDefault(p => p.Id == LivePeriodId); /// - /// Where the clock goes next: the first period not yet kicked off, skipping any whose half has - /// already been played. + /// The half the clock goes to next, as the line-up it opens with — the first line-up not yet + /// kicked off whose half has not been played. Null once both halves have run. /// - /// A quarters game is planned as two line-ups per half but played as two halves. The second - /// line-up of a half is a plan the coach carries out by hand, one substitution at a time — the - /// clock never stops for it — so once the first half has run, the next period the clock knows - /// about is the one that opens the second half, not the quarter left behind inside the first. + /// A quarters game is planned as two line-ups per half but played as two halves, so once the + /// first half has run the next half to kick off opens with the third quarter's line-up, not + /// with the second quarter's plan left behind inside the half just played. /// /// - public GamePeriod? NextPeriod() + public GamePeriod? NextHalf() { var halvesPlayed = Periods .Where(p => p.StartedAtSeconds is not null) @@ -187,6 +196,17 @@ public List SelectRoster(IEnumerable allPlayers, SeasonSquad squ .FirstOrDefault(p => p.StartedAtSeconds is null && !halvesPlayed.Contains(p.PeriodType.Half())); } + /// + /// The line-up planned to take over partway through , or null when the + /// half is played out with the one it kicked off with. The clock never stops for it: it is a + /// plan the coach works through by hand, which is what makes it a reference rather than a step. + /// + public GamePeriod? MidHalfPlan(GamePeriod half) => + Periods + .OrderBy(p => p.PeriodType) + .FirstOrDefault(p => p.PeriodType > half.PeriodType + && p.PeriodType.Half() == half.PeriodType.Half()); + /// /// The match clock in seconds at . Callers that only need a settled /// value (a stopped clock, a finished match) can pass any instant. diff --git a/src/FootballFormation.Core/Models/GamePeriod.cs b/src/FootballFormation.Core/Models/GamePeriod.cs index 7537b42..6d08aff 100644 --- a/src/FootballFormation.Core/Models/GamePeriod.cs +++ b/src/FootballFormation.Core/Models/GamePeriod.cs @@ -1,5 +1,11 @@ namespace FootballFormation.Core.Models; +/// +/// One planned line-up, for a half or for a quarter. The match itself is only ever two halves — +/// see — so the row that opens a half carries that half's +/// timings and everything the live screen records, while a row planned for the middle of a half +/// stays a plan and is never kicked off. +/// public class GamePeriod { public int Id { get; set; } @@ -9,12 +15,13 @@ public class GamePeriod public FormationType? FormationTypeOverride { get; set; } /// - /// Match-clock second this period kicked off, set by the live match screen. Null for periods - /// that were never run live — the lineup builder does not need it. + /// Match-clock second the half this line-up opens kicked off, set by the live match screen. + /// Null for a line-up that was never run live — a plan for the middle of a half, or a game + /// never played from the touchline. The lineup builder does not need it. /// public int? StartedAtSeconds { get; set; } - /// Match-clock second this period was whistled off. Null while it is still running. + /// Match-clock second that half was whistled off. Null while it is still running. public int? EndedAtSeconds { get; set; } public List PlayerPositions { get; set; } = []; diff --git a/src/FootballFormation.Core/Reporting/GameMinutesReport.cs b/src/FootballFormation.Core/Reporting/GameMinutesReport.cs index 5d94b4f..fa35d96 100644 --- a/src/FootballFormation.Core/Reporting/GameMinutesReport.cs +++ b/src/FootballFormation.Core/Reporting/GameMinutesReport.cs @@ -11,7 +11,7 @@ public class GameMinutes /// Everyone named in a lineup or a substitution, including players with zero seconds. public required IReadOnlySet PlayerIds { get; init; } - /// Who is on the pitch right now. Only populated while a period is live. + /// Who is on the pitch right now. Only populated while a half is being played. public required IReadOnlySet OnPitchNow { get; init; } /// @@ -34,21 +34,22 @@ public int SecondsFor(int playerId) => /// a game's minutes come from what actually happened or from what was planned. /// /// A game that was run live carries the truth: and -/// say when each period ran, and the +/// say when each half ran, and the /// rows say who swapped with whom, when, and into which position. /// The lineup alone cannot express any of that — MatchSubstitutionService rewrites it in /// place, so afterwards it only shows the final occupants. A game that was never run live /// has no timings at all, and there the planned lineup is the only answer available. /// /// -/// The choice is made per game, not per period, on : once a -/// match has been run live, a period with no kick-off is one that was never played, and crediting -/// its lineup a full period's minutes would invent playing time. +/// The choice is made per game, not per line-up, on : once a +/// match has been run live, a line-up with no kick-off is one the coach worked towards by hand +/// inside a half that is already accounted for, and crediting it a full period's minutes would +/// invent playing time. /// /// /// Known limitation: only a substitution records a position change. The walk below starts from the /// lineup as it finally 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, +/// position mid-half without one is credited the position they ended in for the whole half, /// the minutes before the shift included. The live screen's position swap /// (MatchSubstitutionService.SwapPositionsAsync) is exactly that case: it rewrites the /// lineup and writes nothing down, because a would say someone left @@ -58,7 +59,7 @@ public int SecondsFor(int playerId) => /// public static class GameMinutesReport { - /// The match clock right now, which closes off a running period. + /// The match clock right now, which closes off the running half. /// Irrelevant for a settled game — any value will do. public static GameMinutes Build(Game game, int elapsedSeconds = 0) { @@ -81,7 +82,8 @@ public static GameMinutes Build(Game game, int elapsedSeconds = 0) continue; } - // A period that was never kicked off contributes no time — only a planned lineup. + // A line-up that was never kicked off contributes no time — it is a plan for the + // middle of a half whose minutes the half's own line-up already accounts for. if (period.StartedAtSeconds is not { } start) continue; var isLive = game.LivePeriodId == period.Id; @@ -96,7 +98,7 @@ public static GameMinutes Build(Game game, int elapsedSeconds = 0) .ThenBy(s => s.Id) .ToList(); - // The lineup records where everyone stands *now*. Rewinding this period's + // The lineup records where everyone stands *now*. Rewinding this half's // substitutions recovers who stood where when it kicked off, which is the only point // the forward walk below can start from. GameSubstitution.Position is the position // that changed hands, so it hands the slot back to the player who came off. diff --git a/src/FootballFormation.Core/Reporting/LiveMinutesReport.cs b/src/FootballFormation.Core/Reporting/LiveMinutesReport.cs index 4f21849..f1a159b 100644 --- a/src/FootballFormation.Core/Reporting/LiveMinutesReport.cs +++ b/src/FootballFormation.Core/Reporting/LiveMinutesReport.cs @@ -16,7 +16,7 @@ public record LiveMinutesRow(Player Player, int Seconds, bool IsOnPitch) /// public static class LiveMinutesReport { - /// The match clock right now, which closes off the running period. + /// The match clock right now, which closes off the running half. /// Resolves an id to a player; rows for unknown ids are dropped. public static List Build(Game game, int elapsedSeconds, Func findPlayer) { diff --git a/src/FootballFormation.Core/Reporting/MatchClockReport.cs b/src/FootballFormation.Core/Reporting/MatchClockReport.cs index fe36db4..93ff336 100644 --- a/src/FootballFormation.Core/Reporting/MatchClockReport.cs +++ b/src/FootballFormation.Core/Reporting/MatchClockReport.cs @@ -31,18 +31,18 @@ public record MatchClock(int Seconds, int AdditionalSeconds, MatchMinute Minute) ///
public static class MatchClockReport { - /// The period on screen; its half decides where the clock starts - /// and where it stops. Null before there is anything to show. + /// The half on screen, as the line-up it is played with. It decides + /// where the clock starts and where it stops. Null before there is anything to show. /// The real match clock right now. - public static MatchClock Build(Game game, GamePeriod? displayPeriod, int elapsedSeconds) + public static MatchClock Build(Game game, GamePeriod? displayHalf, int elapsedSeconds) { - if (displayPeriod is null) return MatchClock.BeforeKickOff; + if (displayHalf is null) return MatchClock.BeforeKickOff; - // Always halves, whatever the game is split into — a quarters game is still two halves, - // and the scoreboard counts in halves. + // Always halves, whatever the line-ups were planned in — a quarters game is still two + // halves, and the scoreboard counts in halves. var halfSeconds = GameSplitType.Halves.PeriodDurationSeconds(game.GameDurationMinutes); - var half = displayPeriod.PeriodType.Half(); + var half = displayHalf.PeriodType.Half(); var plannedStart = half == PeriodType.FirstHalf ? 0 : halfSeconds; if (HalfKickedOffAt(game, half) is not { } actualStart) @@ -71,11 +71,11 @@ public static MatchClock Build(Game game, GamePeriod? displayPeriod, int elapsed /// /// The minute a substitution is written down against: the reading the clock showed when it was /// made, which is the half's reading and not the raw elapsed time. Falls back to the raw minute - /// for a substitution whose period was not loaded — a wrong-looking minute beats claiming 1'. + /// for a substitution whose half was not loaded — a wrong-looking minute beats claiming 1'. /// public static MatchMinute MinuteOf(Game game, GameSubstitution substitution) => - game.Periods.FirstOrDefault(p => p.Id == substitution.GamePeriodId) is { } period - ? Build(game, period, substitution.AtSeconds).Minute + game.Periods.FirstOrDefault(p => p.Id == substitution.GamePeriodId) is { } half + ? Build(game, half, substitution.AtSeconds).Minute : PlainMinute(substitution.AtSeconds); /// @@ -89,8 +89,8 @@ public static MatchMinute MinuteOf(Game game, GameSubstitution substitution) => private static MatchMinute PlainMinute(int seconds) => new((seconds / 60) + 1, 0); /// - /// The real clock reading when this half kicked off — the earliest of its periods to start. - /// A quarters game has two periods per half and only the first of them opens the half. + /// The real clock reading when this half kicked off — the earliest of its line-ups to start. + /// A quarters game plans two line-ups per half and only the first of them opens the half. /// private static int? HalfKickedOffAt(Game game, PeriodType half) { diff --git a/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs b/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs index bf800ff..44fb0c3 100644 --- a/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs +++ b/src/FootballFormation.Core/Reporting/PlannedChangesReport.cs @@ -28,7 +28,7 @@ public record PlannedChanges(List Substitutions, List /// The changes the planned line-ups imply. A quarters game is planned as two line-ups per half, /// and the difference between them is exactly what is due midway through that half — so the live -/// screen can announce it without ever mentioning a quarter. +/// screen can offer it as a reference without ever mentioning a quarter. /// /// Substitutions and position moves are kept apart on purpose. Rewriting a back four commonly /// touches every slot while only one player actually leaves the pitch, and a flat list of slot @@ -42,20 +42,20 @@ public record PlannedChanges(List Substitutions, List public static class PlannedChangesReport { - /// The period being played. Live substitutions have already been - /// applied to it, so the changes shown stay true to who is actually on the pitch. - /// The period whose line-up takes over. + /// The line-up the half is being played with. Live substitutions have + /// already been applied to it, so the changes shown stay true to who is on the pitch. + /// The line-up planned to take over partway through that half. /// Resolves an id to a player; unknown ids come back as null. - /// The substitutions already made in . + /// The substitutions already made in . /// They decide which swaps are still worth showing — see . public static PlannedChanges Build( - GamePeriod current, - GamePeriod next, + GamePeriod half, + GamePeriod plan, Func findPlayer, IEnumerable liveChanges) { - var before = StartersBySlot(current); - var after = StartersBySlot(next); + var before = StartersBySlot(half); + var after = StartersBySlot(plan); return new PlannedChanges( [.. PairUp(before, after, KickOffStarters(before.Values, liveChanges)) @@ -82,7 +82,7 @@ private static bool IsStillViable(PlannedSwap swap, HashSet kickOffStarters swap.Off is null || kickOffStarters.Contains(swap.Off.PlayerId); /// - /// Who was on the pitch when the period kicked off. The line-up records where everyone stands + /// Who was on the pitch when the half kicked off. The line-up records where everyone stands /// now, so rewinding the substitutions made since is the only way back to the eleven /// the plan was written against — the same walk makes. /// @@ -163,11 +163,11 @@ private static List Moves( /// saved by an older build is not guaranteed to honour that, so the first entry wins rather /// than the lookup throwing on data that is already stored. /// - private static Dictionary StartersBySlot(GamePeriod period) + private static Dictionary StartersBySlot(GamePeriod lineup) { var bySlot = new Dictionary(); - foreach (var position in period.PlayerPositions.Where(p => !p.IsSubstitute && p.SlotIndex is not null)) + foreach (var position in lineup.PlayerPositions.Where(p => !p.IsSubstitute && p.SlotIndex is not null)) bySlot.TryAdd(position.SlotIndex!.Value, position); return bySlot; diff --git a/src/FootballFormation.Core/Services/LiveMatchQueries.cs b/src/FootballFormation.Core/Services/LiveMatchQueries.cs index 710e6cb..d615f72 100644 --- a/src/FootballFormation.Core/Services/LiveMatchQueries.cs +++ b/src/FootballFormation.Core/Services/LiveMatchQueries.cs @@ -6,9 +6,9 @@ namespace FootballFormation.Core.Services; /// /// The load every touchline write starts from, described once for the three services that share -/// it: the game with its periods, tracked so it can be written back. The clock does its arithmetic -/// on them, a goal takes its minute from the period being played, and a substitution moves a slot -/// inside one. +/// it: the game with its planned line-ups, tracked so it can be written back. The clock marks the +/// half's line-up as it starts and ends, a goal takes its minute from the half being played, and a +/// substitution moves a slot inside that half's line-up. /// internal static class LiveMatchQueries { diff --git a/src/FootballFormation.Core/Services/MatchClockService.cs b/src/FootballFormation.Core/Services/MatchClockService.cs index 4e48179..dc5ea21 100644 --- a/src/FootballFormation.Core/Services/MatchClockService.cs +++ b/src/FootballFormation.Core/Services/MatchClockService.cs @@ -7,15 +7,20 @@ namespace FootballFormation.Core.Services; /// -/// The clock and the run of play: kick-off, the period changes and the final whistle. +/// The clock and the run of play: kick-off, half time and the final whistle. /// -/// There is no pause: the clock runs from kick-off until the period is whistled off, and only a -/// period boundary stops it. A youth match is not paused at the touchline, and a clock that could -/// be stopped by a stray tap is a clock the season's minutes cannot be trusted from. +/// A match is two halves, whether its line-ups were planned in halves or in quarters. A line-up +/// planned for the middle of a half never reaches this service — the coach works through it by +/// hand while the clock runs — so the only stoppage here is half time. +/// +/// +/// There is no pause: the clock runs from kick-off until the half is whistled off. A youth match +/// is not paused at the touchline, and a clock that could be stopped by a stray tap is a clock the +/// season's minutes cannot be trusted from. /// /// /// This is where the arithmetic a season's statistics are built on lives — the banked seconds and -/// the started/ended marks on each period are what GameMinutesReport later credits players +/// the started/ended marks on each half are what GameMinutesReport later credits players /// with — so it is the piece of the live match kept on its own and driven to exact instants under /// test. /// @@ -29,7 +34,7 @@ public class MatchClockService( { /// /// The clock every match-time decision reads. Injected rather than taken straight from - /// so the period arithmetic can be driven to an exact instant + /// so the half arithmetic can be driven to an exact instant /// under test — it is the part of the live match most likely to be silently wrong, and a /// season's statistics depend on it. /// @@ -48,7 +53,7 @@ public Task> StartMatchAsync(int gameId, CancellationToken cancella return Result.Failure("This match has already been started"); var first = game.Periods.OrderBy(p => p.PeriodType).FirstOrDefault(); - if (first is null) return Result.Failure("This game has no periods to play"); + if (first is null) return Result.Failure("This game has no line-up to play"); game.MatchState = MatchState.InProgress; game.ClockAccumulatedSeconds = 0; @@ -58,13 +63,14 @@ public Task> StartMatchAsync(int gameId, CancellationToken cancella first.EndedAtSeconds = null; await db.SaveChangesAsync(cancellationToken); - logger.LogInformation("Started live match {GameId} at period {PeriodId}", gameId, first.Id); + logger.LogInformation("Started live match {GameId} in the {Half} with line-up {PeriodId}", + gameId, first.PeriodType.Half(), first.Id); return Result.Success(game); }); - /// Whistles the current period off. The clock stops and no period is live until the next one starts. - public Task> EndPeriodAsync(int gameId, CancellationToken cancellationToken = default) => - LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "end the period", + /// Whistles the half off. The clock stops and no half is live until the next kicks off. + public Task> EndHalfAsync(int gameId, CancellationToken cancellationToken = default) => + LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "end the half", cancellationToken, async () => { await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); @@ -72,25 +78,25 @@ public Task> EndPeriodAsync(int gameId, CancellationToken cancellat var game = await db.LoadWithPeriodsAsync(gameId, cancellationToken); if (game is null) return NotFound(gameId); - var current = game.LivePeriod(); - if (current is null) return Result.Failure("No period is currently being played"); + var current = game.LiveHalf(); + if (current is null) return Result.Failure("No half is being played"); BankClock(game); current.EndedAtSeconds = game.ClockAccumulatedSeconds; game.LivePeriodId = null; await db.SaveChangesAsync(cancellationToken); - logger.LogInformation("Ended period {PeriodId} of game {GameId} at {Seconds}s", - current.Id, gameId, current.EndedAtSeconds); + logger.LogInformation("Ended the {Half} of game {GameId} at {Seconds}s", + current.PeriodType.Half(), gameId, current.EndedAtSeconds); return Result.Success(game); }); /// - /// Kicks off the half after the break. decides which period that - /// is, and it skips the second line-up of a half already played — the clock runs in halves. + /// Kicks off the half after the break. decides which line-up opens + /// it, skipping any planned for the middle of the half just played. /// - public Task> StartNextPeriodAsync(int gameId, CancellationToken cancellationToken = default) => - LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "start the next period", + public Task> StartNextHalfAsync(int gameId, CancellationToken cancellationToken = default) => + LiveMatchOperation.RunAdminAsync(notifier, gameId, currentUser, logger, "start the next half", cancellationToken, async () => { await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); @@ -101,11 +107,11 @@ public Task> StartNextPeriodAsync(int gameId, CancellationToken can if (game.MatchState != MatchState.InProgress) return Result.Failure("This match is not in progress"); if (game.LivePeriodId is not null) - return Result.Failure("End the current period first"); + return Result.Failure("End the current half first"); - var next = game.NextPeriod(); + var next = game.NextHalf(); if (next is null) - return Result.Failure("Every period has been played — finish the match instead"); + return Result.Failure("Both halves have been played — finish the match instead"); BankClock(game); next.StartedAtSeconds = game.ClockAccumulatedSeconds; @@ -114,8 +120,8 @@ public Task> StartNextPeriodAsync(int gameId, CancellationToken can game.ClockRunningSince = UtcNow; await db.SaveChangesAsync(cancellationToken); - logger.LogInformation("Started period {PeriodId} of game {GameId} at {Seconds}s", - next.Id, gameId, next.StartedAtSeconds); + logger.LogInformation("Started the {Half} of game {GameId} at {Seconds}s", + next.PeriodType.Half(), gameId, next.StartedAtSeconds); return Result.Success(game); }); @@ -133,7 +139,7 @@ public Task> FinishMatchAsync(int gameId, CancellationToken cancell BankClock(game); - var current = game.LivePeriod(); + var current = game.LiveHalf(); if (current is not null) current.EndedAtSeconds = game.ClockAccumulatedSeconds; game.LivePeriodId = null; diff --git a/src/FootballFormation.Core/Services/MatchGoalService.cs b/src/FootballFormation.Core/Services/MatchGoalService.cs index 16ea11c..d3e5b70 100644 --- a/src/FootballFormation.Core/Services/MatchGoalService.cs +++ b/src/FootballFormation.Core/Services/MatchGoalService.cs @@ -33,7 +33,7 @@ public Task> LogGoalAsync( { await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); - // Periods included: the minute follows the scoreboard clock, which is measured from + // Line-ups included: the minute follows the scoreboard clock, which is measured from // the half being played rather than from kick-off. var game = await db.LoadWithPeriodsAsync(gameId, cancellationToken); if (game is null) return LiveMatchQueries.GameNotFound(gameId); @@ -42,7 +42,7 @@ public Task> LogGoalAsync( return Result.Failure("A goal for us needs a scorer"); var clock = MatchClockReport.Build( - game, game.CurrentOrLastPeriod(), game.ElapsedSecondsAt(UtcNow)); + game, game.CurrentOrLastHalf(), game.ElapsedSecondsAt(UtcNow)); var goal = new GameGoal { diff --git a/src/FootballFormation.Core/Services/MatchSubstitutionService.cs b/src/FootballFormation.Core/Services/MatchSubstitutionService.cs index b697dc6..a4b76cf 100644 --- a/src/FootballFormation.Core/Services/MatchSubstitutionService.cs +++ b/src/FootballFormation.Core/Services/MatchSubstitutionService.cs @@ -28,7 +28,7 @@ public class MatchSubstitutionService( private DateTime UtcNow => time.GetUtcNow().UtcDateTime; /// - /// Brings on for in the period + /// Brings on for in the half /// currently being played: the outgoing player's slot and position change hands, and the swap /// is recorded with the minute it happened. /// @@ -45,13 +45,13 @@ public Task> SubstituteAsync( var game = await db.LoadWithPeriodsAsync(gameId, cancellationToken); if (game is null) return LiveMatchQueries.GameNotFound(gameId); - var period = game.LivePeriod(); - if (period is null) - return Result.Failure("No period is currently being played"); + var half = game.LiveHalf(); + if (half is null) + return Result.Failure("No half is being played"); - await db.Entry(period).Collection(p => p.PlayerPositions).LoadAsync(cancellationToken); + await db.Entry(half).Collection(p => p.PlayerPositions).LoadAsync(cancellationToken); - var off = period.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerOffId); + var off = half.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerOffId); if (off is null || off.IsSubstitute) return Result.Failure("That player is not on the pitch"); @@ -61,13 +61,13 @@ public Task> SubstituteAsync( off.SlotIndex = null; off.IsSubstitute = true; - var on = period.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerOnId); + var on = half.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerOnId); if (on is null) { - // Not benched for this period — someone who turned up late, or a lineup that was + // Not benched for this half — someone who turned up late, or a lineup that was // never filled in. Adding them is friendlier than refusing the change mid-match. - on = new GamePlayerPosition { GamePeriodId = period.Id, PlayerId = playerOnId }; - period.PlayerPositions.Add(on); + on = new GamePlayerPosition { GamePeriodId = half.Id, PlayerId = playerOnId }; + half.PlayerPositions.Add(on); } else if (!on.IsSubstitute) { @@ -81,7 +81,7 @@ public Task> SubstituteAsync( var sub = new GameSubstitution { GameId = gameId, - GamePeriodId = period.Id, + GamePeriodId = half.Id, PlayerOffId = playerOffId, PlayerOnId = playerOnId, AtSeconds = game.ElapsedSecondsAt(UtcNow), @@ -100,8 +100,8 @@ public Task> SubstituteAsync( await db.Entry(sub).Reference(s => s.PlayerOff).LoadAsync(cancellationToken); await db.Entry(sub).Reference(s => s.PlayerOn).LoadAsync(cancellationToken); - logger.LogInformation("Game {GameId}: {Off} off, {On} on at {Seconds}s in period {PeriodId}", - gameId, playerOffId, playerOnId, sub.AtSeconds, period.Id); + logger.LogInformation("Game {GameId}: {Off} off, {On} on at {Seconds}s in the {Half}", + gameId, playerOffId, playerOnId, sub.AtSeconds, half.PeriodType.Half()); return Result.Success(sub); }); @@ -113,7 +113,7 @@ public Task> SubstituteAsync( /// /// What it costs is the position half of the minutes report. GameMinutesReport reads the /// lineup as it finally stands and rewinds only substitution rows, so after a swap each player - /// is credited the position they moved into for the whole period — including the + /// is credited the position they moved into for the whole half — including the /// minutes before the swap. Totals are unaffected; only the split by position is. /// /// @@ -130,14 +130,14 @@ public Task SwapPositionsAsync( var game = await db.LoadWithPeriodsAsync(gameId, cancellationToken); if (game is null) return LiveMatchQueries.GameNotFound(gameId); - var period = game.LivePeriod(); - if (period is null) - return Result.Failure("No period is currently being played"); + var half = game.LiveHalf(); + if (half is null) + return Result.Failure("No half is being played"); - await db.Entry(period).Collection(p => p.PlayerPositions).LoadAsync(cancellationToken); + await db.Entry(half).Collection(p => p.PlayerPositions).LoadAsync(cancellationToken); - var a = period.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerAId); - var b = period.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerBId); + var a = half.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerAId); + var b = half.PlayerPositions.FirstOrDefault(pp => pp.PlayerId == playerBId); if (a is null || a.IsSubstitute || b is null || b.IsSubstitute) return Result.Failure("Both players have to be on the pitch to swap positions"); @@ -147,14 +147,14 @@ public Task SwapPositionsAsync( await db.SaveChangesAsync(cancellationToken); - logger.LogInformation("Game {GameId}: {A} and {B} swapped positions in period {PeriodId}", - gameId, playerAId, playerBId, period.Id); + logger.LogInformation("Game {GameId}: {A} and {B} swapped positions in the {Half}", + gameId, playerAId, playerBId, half.PeriodType.Half()); return Result.Success(gameId); }); /// - /// Undoes a substitution. Only the most recent one in its period can go, because reversing an + /// Undoes a substitution. Only the most recent one in its half can go, because reversing an /// older swap would fight every change made on that slot since. /// public Task RemoveSubstitutionAsync(int subId, CancellationToken cancellationToken = default) => @@ -175,7 +175,7 @@ public Task RemoveSubstitutionAsync(int subId, CancellationToken cancell || (s.AtSeconds == sub.AtSeconds && s.Id > sub.Id)), cancellationToken); if (!isNewest) - return Result.Failure("Only the most recent substitution of a period can be undone"); + return Result.Failure("Only the most recent substitution of a half can be undone"); var positions = await db.GamePlayerPositions .Where(pp => pp.GamePeriodId == sub.GamePeriodId) diff --git a/src/FootballFormation.UI/Pages/LiveMatch.razor b/src/FootballFormation.UI/Pages/LiveMatch.razor index a8ce863..579aae0 100644 --- a/src/FootballFormation.UI/Pages/LiveMatch.razor +++ b/src/FootballFormation.UI/Pages/LiveMatch.razor @@ -60,18 +60,19 @@ {
@* The clock knows halves only. The line-up change partway through a half is - made by hand from the pitch, so it has no control of its own here. *@ - @if (BreakFollowsCurrentPeriod) + made by hand from the pitch, so it has no control of its own here — it is a + reference, behind the button on the line-up card below. *@ + @if (HalfTimeFollows) { + StartIcon="@Icons.Material.Filled.SportsScore" OnClick="EndHalf"> @L["Half time"] } - else if (!IsLivePeriod && NextHalfLabel is { } startingNext) + else if (!IsHalfInPlay && NextHalfLabel is { } startingNext) { + StartIcon="@Icons.Material.Filled.PlayArrow" OnClick="StartNextHalf"> @L["Start {0}", L[startingNext]] } @@ -94,14 +95,27 @@ @* .live-lineup drops this card below the timeline on a phone — see app.css. *@ -
- @(DisplayHalfLabel is { } half ? L[half] : L["Line-up"]) + @* The half's name and, for the coach only, the way into what the plan still holds for it. + The button carries the count so the plan can be ignored at a glance when it is empty. *@ +
+
+ @(DisplayHalfLabel is { } half ? L[half] : L["Line-up"]) +
+ + @if (PlannedChangeCount > 0) + { + + @L["Changes ({0})", PlannedChangeCount] + + } +
@if (DisplayLineup.Count == 0) { - @L["No line-up for this period yet — build one first."] + @L["No line-up for this half yet — build one first."] } else @@ -113,7 +127,7 @@ } - @* OnPlayerClicked is left unset for spectators and between periods, which keeps the pitch inert. *@ + @* OnPlayerClicked is left unset for spectators and at half time, which keeps the pitch inert. *@ @@ -141,24 +155,6 @@ } - @* The quarters the line-up was planned in are never named on this screen, but the difference - between them is exactly the set of changes due halfway through the half — which is the part - worth knowing at the touchline. Measured against the pitch as it stands, and stripped of the - swaps play has already overtaken, so what is left is a list to work through by tapping the - players on the pitch above. - Admin only, like the minutes table: this is what the coach is about to do, not what has - happened, and a plan on a shared screen invites being asked about before it is carried out. *@ - - @{ var planned = PlannedChanges; } - @if (!planned.IsEmpty) - { - -
@L["Changes at half-way"]
- -
- } -
- @* Nothing can be scored before kick-off, so the buttons stay out of the way entirely rather than sitting there greyed out above the start button. *@ @if (GameData.MatchState != MatchState.NotStarted) @@ -262,7 +258,7 @@ @if (MinutesPlayed.Count == 0) { - @L["No line-up for this period yet — build one first."] + @L["No line-up for this half yet — build one first."] } else diff --git a/src/FootballFormation.UI/Pages/LiveMatch.razor.cs b/src/FootballFormation.UI/Pages/LiveMatch.razor.cs index 1436684..bc4771f 100644 --- a/src/FootballFormation.UI/Pages/LiveMatch.razor.cs +++ b/src/FootballFormation.UI/Pages/LiveMatch.razor.cs @@ -78,7 +78,7 @@ public partial class LiveMatch ///
private MatchClock Clock => GameData is null ? MatchClock.BeforeKickOff - : MatchClockReport.Build(GameData, DisplayPeriod, ElapsedSeconds); + : MatchClockReport.Build(GameData, DisplayHalf, ElapsedSeconds); private static string Mmss(int seconds) => $"{seconds / 60:D2}:{seconds % 60:D2}"; @@ -87,19 +87,20 @@ public partial class LiveMatch private string AdditionalDisplay => Mmss(Clock.AdditionalSeconds); /// - /// The period on screen: the one being played; at the break and after the whistle the last one - /// that was; and before kick-off the first one, so the pitch is never blank when a line-up exists. + /// The half on screen, as the line-up it is played with: the one being played; at half time and + /// after the whistle the last one that was; and before kick-off the half the match opens with, + /// so the pitch is never blank when a line-up exists. /// - private GamePeriod? DisplayPeriod => GameData?.CurrentOrLastPeriod(); + private GamePeriod? DisplayHalf => GameData?.CurrentOrLastHalf(); - private bool IsLivePeriod => GameData?.LivePeriodId is not null; + private bool IsHalfInPlay => GameData?.LivePeriodId is not null; - private List DisplayLineup => DisplayPeriod?.PlayerPositions ?? []; + private List DisplayLineup => DisplayHalf?.PlayerPositions ?? []; private List OnPitch => [.. DisplayLineup.Where(p => !p.IsSubstitute)]; /// - /// The bench for this period. A lineup can outlive the roster it was built from — someone + /// The bench for this half. A lineup can outlive the roster it was built from — someone /// marked unavailable, or dropped from the squad, keeps their saved substitute row — and /// listing them as a sub would offer a player who is not at the match. /// @@ -110,76 +111,71 @@ private bool IsInRoster(int playerId) => GameData is not null && FindPlayer(playerId) is { } player && GameData.IsInRoster(player, Squad); private FormationType DisplayFormation => - DisplayPeriod?.FormationTypeOverride ?? GameData?.FormationType ?? FormationType.F442; + DisplayHalf?.FormationTypeOverride ?? GameData?.FormationType ?? FormationType.F442; - /// Whether the sub controls can do anything — needs an admin and a period in play. - private bool CanSubstitute => _isAdmin && IsLivePeriod; + /// Whether the sub controls can do anything — needs an admin and a half in play. + private bool CanSubstitute => _isAdmin && IsHalfInPlay; - /// The first period not yet kicked off — where the clock goes next, if anywhere. - private GamePeriod? NextPeriod => GameData?.NextPeriod(); + /// The half not yet kicked off — where the clock goes next, if anywhere. + private GamePeriod? NextHalf => GameData?.NextHalf(); /// - /// The half on the clock, which is the only division this screen names. Quarters exist to - /// plan two line-ups per half; nobody standing at the pitch thinks in them, so Q1 and Q2 both - /// read as the first half here and the line-up change between them is announced on its own. + /// The half on the clock, which is the only division this screen names. Quarters exist to plan + /// two line-ups per half; nobody standing at the pitch thinks in them, so the second line-up of + /// a half never appears here as a stage of the match — only behind + /// , as a plan to work through. /// - private string? DisplayHalfLabel => DisplayPeriod?.PeriodType.HalfDisplayName(); + private string? DisplayHalfLabel => DisplayHalf?.PeriodType.HalfDisplayName(); - /// Half the buttons would kick off, or null once every period has been played. - private string? NextHalfLabel => NextPeriod?.PeriodType.HalfDisplayName(); + /// Half the buttons would kick off, or null once both have been played. + private string? NextHalfLabel => NextHalf?.PeriodType.HalfDisplayName(); /// - /// Whether whistling the period off leads to a break rather than to the end of the match. The - /// clock runs in halves, so before full time there is exactly one stoppage — and after it - /// is null and the only control left is the final whistle. + /// Whether whistling the half off leads to half time rather than to the end of the match. A + /// match is two halves, so before full time there is exactly one stoppage — and after it + /// is null and the only control left is the final whistle. /// - private bool BreakFollowsCurrentPeriod => IsLivePeriod && NextPeriod is not null; - - /// - /// The period whose line-up takes over partway through the half on screen, if there is one. - /// Read from the period order rather than the clock, so the changes due can be looked up - /// before kick-off as well as during play. Null once the match is over. - /// - private GamePeriod? MidHalfSuccessor - { - get - { - if (GameData is null || GameData.MatchState == MatchState.Finished) return null; - if (DisplayPeriod is not { } current) return null; - - var next = GameData.Periods - .OrderBy(p => p.PeriodType) - .FirstOrDefault(p => p.PeriodType > current.PeriodType); - - return next?.PeriodType.Half() == current.PeriodType.Half() ? next : null; - } - } + private bool HalfTimeFollows => IsHalfInPlay && NextHalf is not null; /// /// The swaps the planned line-ups imply for the middle of this half, measured against who is /// on the pitch right now — so a live substitution already made drops out of the list. They are /// carried out by hand, one tap on the pitch at a time; nothing here rolls them on at once. + /// + /// Looked up from the planned line-ups rather than from the clock, so the changes due can be + /// read before kick-off as well as during play. Empty once the match is over — there is + /// nothing left to plan for. + /// /// private PlannedChanges PlannedChanges => - GameData is { } game && DisplayPeriod is { } current && MidHalfSuccessor is { } next - ? PlannedChangesReport.Build(current, next, FindPlayer, - game.Substitutions.Where(s => s.GamePeriodId == current.Id)) + GameData is { MatchState: not MatchState.Finished } game + && DisplayHalf is { } half + && game.MidHalfPlan(half) is { } plan + ? PlannedChangesReport.Build(half, plan, FindPlayer, + game.Substitutions.Where(s => s.GamePeriodId == half.Id)) : PlannedChanges.None; + /// + /// How many changes the plan still holds. It is what the button opening the plan says, and + /// whether it is shown at all — a count is enough to know if the tap is worth making. + /// + private int PlannedChangeCount => + PlannedChanges.Substitutions.Count + PlannedChanges.Moves.Count; + /// What the match is doing right now, in one phrase under the clock. private string StatusLabel => GameData?.MatchState switch { null or MatchState.NotStarted => L["Not started"], MatchState.Finished => L["Full time"], - _ when !IsLivePeriod => L["Break"], + _ when !IsHalfInPlay => L["Half time"], // The half is played out and play has not stopped — the thing to say is how much longer. _ when Clock.IsInAdditionalTime => L["Additional time"], _ => L[DisplayHalfLabel ?? "In progress"] }; /// - /// Drives the colour of the status chip. A live period always has a running clock — nothing - /// stops one short of the whistle — so the third arm here is the break between two periods. + /// Drives the colour of the status chip. A half being played always has a running clock — + /// nothing stops one short of the whistle — so the third arm here is half time. /// private string StatusCssClass => GameData?.MatchState switch { @@ -212,7 +208,7 @@ private List GoalCandidates } /// - /// Who can come on: the bench for this period, plus anyone in the roster with no lineup entry + /// Who can come on: the bench for this half, plus anyone in the roster with no lineup entry /// at all — a late arrival should not be locked out of a match already under way. /// private List SubCandidates @@ -340,11 +336,18 @@ private async Task ReloadAsync() private async Task StartMatch() => Snackbar.Report(L, await ClockService.StartMatchAsync(GameId), L["Match started"]); - private async Task EndPeriod() => - Snackbar.Report(L, await ClockService.EndPeriodAsync(GameId), L["Period ended"], Severity.Info); + private async Task EndHalf() => + Snackbar.Report(L, await ClockService.EndHalfAsync(GameId), L["Half ended"], Severity.Info); + + private async Task StartNextHalf() => + Snackbar.Report(L, await ClockService.StartNextHalfAsync(GameId), L["Next half started"]); - private async Task StartNextPeriod() => - Snackbar.Report(L, await ClockService.StartNextPeriodAsync(GameId), L["Next period started"]); + /// Opens the plan for the middle of this half. Nothing here writes anything. + private Task ShowPlannedChanges() => + DialogService.ShowAsync( + L["Changes to make"], + new DialogParameters { { x => x.Changes, PlannedChanges } }, + UiFeedback.LockedDialog); private async Task FinishMatch() { diff --git a/src/FootballFormation.UI/Pages/LiveSubDialog.razor b/src/FootballFormation.UI/Pages/LiveSubDialog.razor index d3b052d..3ddbc84 100644 --- a/src/FootballFormation.UI/Pages/LiveSubDialog.razor +++ b/src/FootballFormation.UI/Pages/LiveSubDialog.razor @@ -15,7 +15,7 @@ @* The placeholder only ever explains an empty list. MudSelect shows it whenever nothing is chosen, so a standing "nobody is on the bench" would greet a full bench too. *@ @foreach (var player in Bench) { diff --git a/src/FootballFormation.UI/Pages/PlannedChangesDialog.razor b/src/FootballFormation.UI/Pages/PlannedChangesDialog.razor new file mode 100644 index 0000000..9fbd30e --- /dev/null +++ b/src/FootballFormation.UI/Pages/PlannedChangesDialog.razor @@ -0,0 +1,40 @@ +@* + The changes planned for the middle of the half, as a reference the coach opens and closes. + + Deliberately a dialog rather than a card on the live screen: the plan is not the match. What is + on the pitch is recorded by tapping players on the pitch itself, and a standing list of what + *ought* to happen next to it invites being read as the state of play. Here it is looked up, + acted on by hand, and dismissed. +*@ +@inject IStringLocalizer L + + + + @if (Changes.IsEmpty) + { + + @L["Nothing more is planned for this half."] + + } + else + { + + @L["Tap the players on the pitch to make these changes."] + + + } + + + @L["Close"] + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter, EditorRequired] + public PlannedChanges Changes { get; set; } = PlannedChanges.None; + + private void Close() => MudDialog.Cancel(); +} diff --git a/src/FootballFormation.UI/Strings.nl.resx b/src/FootballFormation.UI/Strings.nl.resx index 69e4200..09ba82f 100644 --- a/src/FootballFormation.UI/Strings.nl.resx +++ b/src/FootballFormation.UI/Strings.nl.resx @@ -186,18 +186,16 @@ Live Nog niet begonnen Bezig - Rust Extra tijd Einde wedstrijd Wedstrijd starten Rust {0} starten - Wissels halverwege Positiewissels Wedstrijd afsluiten Uitslag aanpassen Opstelling - Nog geen opstelling voor dit deel — maak er eerst een. + Nog geen opstelling voor deze helft — maak er eerst een. Tik op een speler om te wisselen of van positie te veranderen. Tegen doelpunt Tijdlijn @@ -213,8 +211,13 @@ Verwijderen Ongedaan maken Wedstrijd gestart - Speeldeel beëindigd - Volgend speeldeel gestart + Wijzigingen ({0}) + Nog door te voeren wijzigingen + Er is niets meer gepland voor deze helft. + Tik op de spelers in het veld om deze wijzigingen te maken. + Sluiten + Helft beëindigd + Volgende helft gestart Wedstrijd afgesloten Wedstrijd afsluiten en de eindstand opslaan? Je kunt de uitslag daarna nog aanpassen. Doelpunt @@ -229,7 +232,7 @@ Wissel ongedaan gemaakt Wissel doorvoeren Komt erin - Er zit niemand op de bank voor dit speeldeel. + Er zit niemand op de bank voor deze helft. Wisselt van positie met Er staat niemand anders op het veld. Bij een positiewissel blijven beide spelers op het veld; het is geen wissel. @@ -447,15 +450,15 @@ Beide spelers moeten op het veld staan om van positie te wisselen Een selectie kan niet naar zichzelf gekopieerd worden Opmerking niet gevonden - Beëindig eerst de huidige periode - Alle periodes zijn gespeeld — beëindig de wedstrijd + Beëindig eerst de huidige helft + Beide helften zijn gespeeld — beëindig de wedstrijd Wedstrijd niet gevonden Wedstrijd met ID {0} niet gevonden Doelpunt niet gevonden - Er wordt op dit moment geen periode gespeeld + Er wordt op dit moment geen helft gespeeld Geen seizoen geselecteerd Nog geen seizoenen ingesteld - Alleen de laatste wissel van een periode kan ongedaan gemaakt worden + Alleen de laatste wissel van een helft kan ongedaan gemaakt worden Speler zit niet in deze selectie Speler niet gevonden Speler met ID {0} niet gevonden @@ -468,7 +471,7 @@ Die speler staat niet op het veld De einddatum moet na de startdatum liggen Deze datums overlappen seizoen {0} - Deze wedstrijd heeft geen periodes om te spelen + Deze wedstrijd heeft geen opstelling om te spelen Dit laat een gat na seizoen {0} — het zou moeten beginnen op {1} Dit laat een gat vóór seizoen {0} — het zou moeten eindigen op {1} Deze wedstrijd is al gestart @@ -492,7 +495,7 @@ seizoen aanmaken gebruiker aanmaken gebruiker verwijderen - periode beëindigen + helft beëindigen vorig seizoen zoeken seizoen bij die datum zoeken wedstrijd van vandaag zoeken @@ -521,7 +524,7 @@ speler uit de selectie verwijderen opstelling opslaan wachtwoord instellen - volgende periode starten + volgende helft starten posities wisselen van seizoen wisselen wissel ongedaan maken diff --git a/src/FootballFormation.Web/wwwroot/app.css b/src/FootballFormation.Web/wwwroot/app.css index 6baaf87..181d861 100644 --- a/src/FootballFormation.Web/wwwroot/app.css +++ b/src/FootballFormation.Web/wwwroot/app.css @@ -766,6 +766,28 @@ html, body { grid-column: 1 / -1; } +/* The line-up card's heading row: the half on the left, the way into the plan for it on the right. + .card-label carries its own bottom margin, so the row takes it over and the label gives it up — + otherwise the button sits 16px above the pitch and the label doesn't. */ +.live-lineup-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 16px; +} + +.live-lineup-head .card-label { + margin-bottom: 0; +} + +.live-plan-btn { + flex: none; + /* Small on a desktop, still a thumb target on the phone this is opened on. */ + min-height: 44px; + white-space: nowrap; +} + /* The timeline's "Show substitutions" checkbox is dense, which is right on a desktop and under the 44px floor on the phone this screen is actually used on. Its label is the target, so the height goes there. */ diff --git a/tests/FootballFormation.Core.Tests/GameTests.cs b/tests/FootballFormation.Core.Tests/GameTests.cs index c846c8c..e436fdf 100644 --- a/tests/FootballFormation.Core.Tests/GameTests.cs +++ b/tests/FootballFormation.Core.Tests/GameTests.cs @@ -231,15 +231,41 @@ public void The_clock_goes_from_one_half_to_the_next_rather_than_from_quarter_to var game = QuartersGame(); // Before kick-off the next period is simply the first one. - Assert.Equal(PeriodType.FirstQuarter, game.NextPeriod()!.PeriodType); + Assert.Equal(PeriodType.FirstQuarter, game.NextHalf()!.PeriodType); // The first half has been played, so the line-up planned for the rest of it is behind the // clock — the whistle hands over to the half that follows. game.Periods.Single(p => p.PeriodType == PeriodType.FirstQuarter).StartedAtSeconds = 0; - Assert.Equal(PeriodType.ThirdQuarter, game.NextPeriod()!.PeriodType); + Assert.Equal(PeriodType.ThirdQuarter, game.NextHalf()!.PeriodType); game.Periods.Single(p => p.PeriodType == PeriodType.ThirdQuarter).StartedAtSeconds = 1800; - Assert.Null(game.NextPeriod()); + Assert.Null(game.NextHalf()); + } + + /// + /// The plan the live screen offers as a reference. It is the line-up that would take over + /// partway through the half, which only a quarters game has — and it is looked up from the + /// plan rather than the clock, so it reads the same before kick-off as during play. + /// + [Fact] + public void Only_a_half_planned_in_two_line_ups_has_a_plan_for_its_middle() + { + var quarters = QuartersGame(); + var firstHalf = quarters.Periods.Single(p => p.PeriodType == PeriodType.FirstQuarter); + var secondHalf = quarters.Periods.Single(p => p.PeriodType == PeriodType.ThirdQuarter); + + Assert.Equal(PeriodType.SecondQuarter, quarters.MidHalfPlan(firstHalf)!.PeriodType); + Assert.Equal(PeriodType.FourthQuarter, quarters.MidHalfPlan(secondHalf)!.PeriodType); + + // The plan itself has nothing planned after it — the half ends there. + Assert.Null(quarters.MidHalfPlan(quarters.MidHalfPlan(firstHalf)!)); + + var halves = TestData.Game(); + halves.AddPeriod(PeriodType.FirstHalf); + halves.AddPeriod(PeriodType.SecondHalf); + + // The second half is not a change due inside the first — it is the next half. + Assert.Null(halves.MidHalfPlan(halves.Periods[0])); } private static Game QuartersGame() => new() diff --git a/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs b/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs index 313b7aa..6031953 100644 --- a/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs +++ b/tests/FootballFormation.Core.Tests/LiveMatchNotificationTests.cs @@ -34,8 +34,8 @@ public async Task Every_touchline_write_names_the_game_it_changed() Assert.True((await Subs.SwapPositionsAsync(game.Id, players[0].Id, players[1].Id)).IsSuccess); - Assert.True((await MatchClock.EndPeriodAsync(game.Id)).IsSuccess); - Assert.True((await MatchClock.StartNextPeriodAsync(game.Id)).IsSuccess); + Assert.True((await MatchClock.EndHalfAsync(game.Id)).IsSuccess); + Assert.True((await MatchClock.StartNextHalfAsync(game.Id)).IsSuccess); Assert.True((await MatchClock.FinishMatchAsync(game.Id)).IsSuccess); // Nine writes, nine announcements, each naming this match. @@ -51,7 +51,7 @@ public async Task A_refused_write_says_nothing() _announced.Clear(); // A rule broken and an unknown row, across all three services. - Assert.True((await MatchClock.StartNextPeriodAsync(game.Id)).IsFailure); + Assert.True((await MatchClock.StartNextHalfAsync(game.Id)).IsFailure); Assert.True((await Goals.LogGoalAsync(game.Id, null, null, false, false)).IsFailure); Assert.True((await Goals.RemoveGoalAsync(game.Id, 999)).IsFailure); Assert.True((await Subs.SubstituteAsync(game.Id, 999, 998)).IsFailure); diff --git a/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs b/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs index 4cbc875..e1c6af6 100644 --- a/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchClockServiceTests.cs @@ -3,15 +3,15 @@ namespace FootballFormation.Core.Tests; /// -/// The clock and period arithmetic — the numbers a season's statistics are later built from, and -/// the reason the match is driven to exact instants here rather than to a wall clock. +/// The clock and the two halves it runs — the numbers a season's statistics are later built from, +/// and the reason the match is driven to exact instants here rather than to a wall clock. /// public class MatchClockServiceTests : LiveMatchTestBase { // ---- Starting and stopping ------------------------------------------------------------- [Fact] - public async Task Starting_a_match_puts_the_first_period_on_the_pitch_with_a_zeroed_clock() + public async Task Starting_a_match_puts_the_first_half_on_the_pitch_with_a_zeroed_clock() { var game = await SeedGameAsync(); @@ -39,7 +39,7 @@ public async Task A_match_cannot_be_started_twice() } [Fact] - public async Task A_game_with_no_periods_cannot_kick_off() + public async Task A_game_with_no_line_up_at_all_cannot_kick_off() { var season = Season.CreateFor(KickOff); Db.Seasons.Add(season); @@ -52,7 +52,7 @@ public async Task A_game_with_no_periods_cannot_kick_off() var result = await MatchClock.StartMatchAsync(game.Id); Assert.True(result.IsFailure); - Assert.Equal("This game has no periods to play", result.Error); + Assert.Equal("This game has no line-up to play", result.Error); } [Fact] @@ -67,7 +67,7 @@ public async Task An_unknown_game_fails_rather_than_throwing() // ---- The clock ------------------------------------------------------------------------- [Fact] - public async Task The_clock_runs_from_kick_off_until_the_period_is_whistled_off() + public async Task The_clock_runs_from_kick_off_until_the_half_is_whistled_off() { var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); @@ -81,16 +81,16 @@ public async Task The_clock_runs_from_kick_off_until_the_period_is_whistled_off( Assert.Equal(420, running.ElapsedSecondsAt(Time.GetUtcNow().UtcDateTime)); } - // ---- Periods --------------------------------------------------------------------------- + // ---- Halves ---------------------------------------------------------------------------- [Fact] - public async Task Ending_a_period_stops_the_clock_and_leaves_nothing_live() + public async Task Ending_a_half_stops_the_clock_and_leaves_nothing_live() { var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(30)); - await MatchClock.EndPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); var ended = await ReloadAsync(game.Id); Assert.Null(ended.LivePeriodId); @@ -105,10 +105,10 @@ public async Task The_second_half_starts_where_the_first_left_off_and_the_break_ await MatchClock.StartMatchAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(30)); - await MatchClock.EndPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(15)); // half time - await MatchClock.StartNextPeriodAsync(game.Id); + await MatchClock.StartNextHalfAsync(game.Id); var second = await ReloadAsync(game.Id); var periods = second.Periods.OrderBy(p => p.PeriodType).ToList(); @@ -121,15 +121,15 @@ public async Task The_second_half_starts_where_the_first_left_off_and_the_break_ } [Fact] - public async Task The_next_period_cannot_start_before_the_current_one_ends() + public async Task The_next_half_cannot_start_before_the_current_one_ends() { var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); - var result = await MatchClock.StartNextPeriodAsync(game.Id); + var result = await MatchClock.StartNextHalfAsync(game.Id); Assert.True(result.IsFailure); - Assert.Equal("End the current period first", result.Error); + Assert.Equal("End the current half first", result.Error); } /// @@ -144,8 +144,8 @@ public async Task The_second_half_of_a_quarters_game_starts_at_the_third_quarter await MatchClock.StartMatchAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(30)); - await MatchClock.EndPeriodAsync(game.Id); - Assert.True((await MatchClock.StartNextPeriodAsync(game.Id)).IsSuccess); + await MatchClock.EndHalfAsync(game.Id); + Assert.True((await MatchClock.StartNextHalfAsync(game.Id)).IsSuccess); var second = await ReloadAsync(game.Id); var periods = second.Periods.OrderBy(p => p.PeriodType).ToList(); @@ -164,21 +164,21 @@ public async Task A_quarters_game_has_no_third_half_left_to_start() await MatchClock.StartMatchAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(30)); - await MatchClock.EndPeriodAsync(game.Id); - await MatchClock.StartNextPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); + await MatchClock.StartNextHalfAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(30)); - await MatchClock.EndPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); - var result = await MatchClock.StartNextPeriodAsync(game.Id); + var result = await MatchClock.StartNextHalfAsync(game.Id); Assert.True(result.IsFailure); - Assert.Equal("Every period has been played — finish the match instead", result.Error); + Assert.Equal("Both halves have been played — finish the match instead", result.Error); } // ---- Finishing ------------------------------------------------------------------------- [Fact] - public async Task Finishing_closes_the_running_period_and_writes_the_score_from_the_goals() + public async Task Finishing_closes_the_running_half_and_writes_the_score_from_the_goals() { var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); diff --git a/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs b/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs index bf47311..c105818 100644 --- a/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchGoalServiceTests.cs @@ -35,8 +35,8 @@ public async Task A_second_half_goal_is_stamped_off_the_scoreboard_clock_not_the var players = await PlayersAsync(); Time.Advance(TimeSpan.FromMinutes(33)); // a first half that ran three minutes long - await MatchClock.EndPeriodAsync(game.Id); - await MatchClock.StartNextPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); + await MatchClock.StartNextHalfAsync(game.Id); Time.Advance(TimeSpan.FromMinutes(5)); // five minutes into the second half diff --git a/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs b/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs index 21b5cb2..507beda 100644 --- a/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs +++ b/tests/FootballFormation.Core.Tests/MatchSubstitutionServiceTests.cs @@ -85,13 +85,13 @@ public async Task A_substitution_needs_a_period_to_be_running() { var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); - await MatchClock.EndPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); var players = await PlayersAsync(); var result = await Subs.SubstituteAsync(game.Id, players[1].Id, players[2].Id); Assert.True(result.IsFailure); - Assert.Equal("No period is currently being played", result.Error); + Assert.Equal("No half is being played", result.Error); } [Fact] @@ -167,13 +167,13 @@ public async Task A_position_swap_needs_a_period_to_be_running() { var game = await SeedGameAsync(); await MatchClock.StartMatchAsync(game.Id); - await MatchClock.EndPeriodAsync(game.Id); + await MatchClock.EndHalfAsync(game.Id); var players = await PlayersAsync(); var result = await Subs.SwapPositionsAsync(game.Id, players[0].Id, players[1].Id); Assert.True(result.IsFailure); - Assert.Equal("No period is currently being played", result.Error); + Assert.Equal("No half is being played", result.Error); } [Fact] @@ -254,7 +254,7 @@ public async Task Only_the_most_recent_substitution_of_a_period_can_be_undone() var result = await Subs.RemoveSubstitutionAsync(first.Value!.Id); Assert.True(result.IsFailure); - Assert.Equal("Only the most recent substitution of a period can be undone", result.Error); + Assert.Equal("Only the most recent substitution of a half can be undone", result.Error); } [Fact] @@ -273,7 +273,7 @@ public async Task Of_two_substitutions_in_the_same_second_only_the_later_one_can var refused = await Subs.RemoveSubstitutionAsync(first.Value.Id); Assert.True(refused.IsFailure); - Assert.Equal("Only the most recent substitution of a period can be undone", refused.Error); + Assert.Equal("Only the most recent substitution of a half can be undone", refused.Error); Assert.True((await Subs.RemoveSubstitutionAsync(second.Value.Id)).IsSuccess); diff --git a/tests/ui/specs/match-day.spec.js b/tests/ui/specs/match-day.spec.js index dcce784..7ac0479 100644 --- a/tests/ui/specs/match-day.spec.js +++ b/tests/ui/specs/match-day.spec.js @@ -146,8 +146,8 @@ test('tapping a player on the pitch offers a substitution and a position swap', await expect(page.locator('.live-event')).toHaveCount(0); }); -test('a quarters half lists the changes due in it and is run as one half', async ({ page }) => { - // Quarters, so the first half is planned as two line-ups and the changes card has something in it. +test('a quarters half keeps its changes in a pop-up and is run as one half', async ({ page }) => { + // Quarters, so the first half is planned as two line-ups and the plan has something in it. const id = await matchWithId(page, 'FC Kwarten', { split: 'Quarters' }); const available = page.locator('.draggable-player'); @@ -174,11 +174,21 @@ test('a quarters half lists the changes due in it and is run as one half', async await goto(page, `/games/${id}/live`); - // The changes are worth reading before kick-off too, and they are all the screen says about the - // quarter boundary: there is no control that rolls the next line-up on, only the pitch above. - await expect(page.locator('.planned-row').first()).toBeVisible(); + // The plan is a reference, not part of the screen: nothing about the quarter boundary is on the + // page until it is asked for, and there is no control that rolls the next line-up on either. + await expect(page.locator('.planned-row')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Next line-up' })).toHaveCount(0); + // Worth reading before kick-off too, so the button is there from the start. + await clickFor( + page.getByRole('button', { name: /^Changes \(\d+\)$/ }), + () => expect(page.locator('.mud-dialog .planned-row').first()).toBeVisible(), + ); + await clickFor( + page.locator('.mud-dialog').getByRole('button', { name: 'Close' }), + () => expect(page.locator('.mud-dialog')).toHaveCount(0), + ); + await clickFor( page.getByRole('button', { name: 'Start match' }), () => expect(page.getByRole('button', { name: 'Finish match' })).toBeVisible(),