What is wrong
The two kinds of match event are stored differently, and only one of them records where in the match it happened.
|
GameSubstitution |
GameGoal |
| Which half |
GamePeriodId → period → Half() |
nothing |
| When |
AtSeconds (real elapsed clock) |
nothing |
| Minute |
derived at display time |
Minute + AdditionalMinute, frozen when it was written |
A goal's half is only implied. Minute is capped at the end of the half it was scored in, so on a 70-minute game 1–35 reads as the first half and 36–70 as the second. That is inference from a display value, not a fact on the row, and it has two consequences:
- It moves under stored data. Editing
GameDurationMinutes shifts the half boundary, and goals already on file are silently reinterpreted — a goal written 36 stops meaning what it meant.
- The minute cannot be corrected. A substitution's minute is derived from
AtSeconds and its period, so fixing a period's StartedAtSeconds fixes the substitutions with it. A goal's minute is frozen, so the same correction leaves the goals wrong and the two kinds of event disagreeing on the same timeline.
AdditionalMinute and the pair-comparison ordering that goes with it (MatchMinute.CompareTo) exist only because the goal row has no real clock reading to sort on.
What to do instead
Give a goal the same two facts a substitution already carries:
GamePeriodId — nullable FK to GamePeriod, set when the goal is logged from /live
AtSeconds — nullable, the real elapsed match clock, same source as GameSubstitution.AtSeconds
Both nullable because a goal typed by hand on /result has neither a clock nor a period behind it. Minute stays as the fallback for exactly those rows, rather than being presentation stored on every goal.
Then:
- the displayed minute derives through
MatchClockReport for both event types, from one code path
- ordering is by real elapsed seconds instead of comparing a
(minute, additional) pair
GameGoal.AdditionalMinute and MatchMinute.CompareTo can go
- correcting a period's timings corrects the goals in it, as it already does for substitutions
Scope
- Migration: add
GamePeriodId (nullable FK, cascade like GameSubstitution) and AtSeconds (nullable int) to GameGoals; drop AdditionalMinute. No backfill — nothing left in an old row says whether a stored 37 was stoppage time or a minute typed in by hand, so existing goals keep reading and sorting exactly as they do today, off Minute alone.
MatchGoalService.LogGoalAsync — write the period and the clock reading instead of the minute pair
MatchClockReport — one MinuteOf path covering goals and substitutions, plus the fallback for rows with no clock
ScoreProgressionReport, LiveMatch.Timeline, MatchResult goal list — order on elapsed seconds, falling back to Minute for rows without one
- Docs:
models.md (the GameGoal table and the note under GameSubstitution), ui_components.md (the MatchMinute bullet), known_issues.md (the "a goal's minute is not one number" entry describes the shape this replaces)
Background
The 35+2 display and the pair ordering landed in "Run the live match in halves, and write a minute the way football does" (#80 follow-up branch). That fixed the visible bug — first-half stoppage events sorting after the restart — but stored the fix as a display value on the goal rather than recording where the goal actually happened. This issue is the model change that makes the same behaviour fall out of the data.
What is wrong
The two kinds of match event are stored differently, and only one of them records where in the match it happened.
GameSubstitutionGameGoalGamePeriodId→ period →Half()AtSeconds(real elapsed clock)Minute+AdditionalMinute, frozen when it was writtenA goal's half is only implied.
Minuteis capped at the end of the half it was scored in, so on a 70-minute game 1–35 reads as the first half and 36–70 as the second. That is inference from a display value, not a fact on the row, and it has two consequences:GameDurationMinutesshifts the half boundary, and goals already on file are silently reinterpreted — a goal written 36 stops meaning what it meant.AtSecondsand its period, so fixing a period'sStartedAtSecondsfixes the substitutions with it. A goal's minute is frozen, so the same correction leaves the goals wrong and the two kinds of event disagreeing on the same timeline.AdditionalMinuteand the pair-comparison ordering that goes with it (MatchMinute.CompareTo) exist only because the goal row has no real clock reading to sort on.What to do instead
Give a goal the same two facts a substitution already carries:
GamePeriodId— nullable FK toGamePeriod, set when the goal is logged from/liveAtSeconds— nullable, the real elapsed match clock, same source asGameSubstitution.AtSecondsBoth nullable because a goal typed by hand on
/resulthas neither a clock nor a period behind it.Minutestays as the fallback for exactly those rows, rather than being presentation stored on every goal.Then:
MatchClockReportfor both event types, from one code path(minute, additional)pairGameGoal.AdditionalMinuteandMatchMinute.CompareTocan goScope
GamePeriodId(nullable FK, cascade likeGameSubstitution) andAtSeconds(nullable int) toGameGoals; dropAdditionalMinute. No backfill — nothing left in an old row says whether a stored37was stoppage time or a minute typed in by hand, so existing goals keep reading and sorting exactly as they do today, offMinutealone.MatchGoalService.LogGoalAsync— write the period and the clock reading instead of the minute pairMatchClockReport— oneMinuteOfpath covering goals and substitutions, plus the fallback for rows with no clockScoreProgressionReport,LiveMatch.Timeline,MatchResultgoal list — order on elapsed seconds, falling back toMinutefor rows without onemodels.md(theGameGoaltable and the note underGameSubstitution),ui_components.md(theMatchMinutebullet),known_issues.md(the "a goal's minute is not one number" entry describes the shape this replaces)Background
The
35+2display and the pair ordering landed in "Run the live match in halves, and write a minute the way football does" (#80 follow-up branch). That fixed the visible bug — first-half stoppage events sorting after the restart — but stored the fix as a display value on the goal rather than recording where the goal actually happened. This issue is the model change that makes the same behaviour fall out of the data.