diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d9bc4c3..63126844 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,13 +104,6 @@ jobs: # instead so tests run once per PR, not twice. run: npm run test -- --run --coverage - - name: Upload coverage - uses: codecov/codecov-action@v4 - with: - files: ./Client.React/coverage/coverage-final.json - flags: frontend - fail_ci_if_error: false - - name: Build run: npm run build env: diff --git a/Client.React/CHANGELOG.md b/Client.React/CHANGELOG.md index 29dacec5..ff2b4d19 100644 --- a/Client.React/CHANGELOG.md +++ b/Client.React/CHANGELOG.md @@ -6,6 +6,8 @@ Format: `## ` release headings and single-line `- ` bullets only — no bold, li ## 2026-10-04 +- Fixed: right after a score or during a timeout, live game cards showed the ball at a stale spot with a possession arrow even though nobody had the ball — the field graphic now hides until the next play +- Improved: live covering/bloody alerts now come as one notification per team that lists each of your leagues and how many picked it there (e.g. "Alpha: your pick + 1 other") instead of a separate notification per league - Changed: the NFL and college apps now each have their own notification settings, and each app only gets its own sport's alerts — turn on push in the NFL app for NFL alerts and in the college app for college alerts (your existing settings were copied to both) - Fixed: NFL notifications also arrived on the college app (and vice versa) when both were installed — each app now only gets its own sport's alerts - Fixed: the first week-result notification run of the season sent a push for every already-finished week (Weeks 1-4) — a week's result can now only be pushed within 48 hours of that week ending, never later diff --git a/Client.React/vite.config.ts b/Client.React/vite.config.ts index 6f47ec22..8ef34a43 100644 --- a/Client.React/vite.config.ts +++ b/Client.React/vite.config.ts @@ -34,6 +34,9 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], + // CI gate (npm run test -- --run --coverage): fails the build if frontend coverage drops. + // Set a few points under the 2026-10-04 baseline (79.8/80.5/73.9/82.3) — raise as it grows. + thresholds: { statements: 75, branches: 75, functions: 70, lines: 78 }, exclude: [ 'node_modules/', 'src/setupTests.ts', diff --git a/Server.UnitTests/LiveGameDtoTests.cs b/Server.UnitTests/LiveGameDtoTests.cs index 67d19906..d5366bd5 100644 --- a/Server.UnitTests/LiveGameDtoTests.cs +++ b/Server.UnitTests/LiveGameDtoTests.cs @@ -111,6 +111,20 @@ public void FromCompetition_SituationIsNull_WhenCompetitionHasNoSituation() Assert.Null(dto.Situation); } + // Between plays — kickoff after a score, timeouts, end of quarter — ESPN sends a situation with + // no possession (and a stale yardLine). Showing a ball and an arrow then is wrong (owner report: + // DET@CAR right after a CAR field goal), so no possession means no field graphic at all. + [Fact] + public void FromCompetition_SituationIsNull_WhenEspnReportsNoPossession() + { + var competition = BuildCompetition(possessionId: null); + competition.Situation = new EspnSitutation { Possession = null, YardLine = 35, Down = -1, IsRedZone = true }; + + var dto = LiveGameDto.FromCompetition(competition); + + Assert.Null(dto.Situation); + } + // ── IsRedZone ───────────────────────────────────────────────────────────── [Fact] diff --git a/Server.UnitTests/LivePickTransitionServiceTests.cs b/Server.UnitTests/LivePickTransitionServiceTests.cs index 3077fea8..daa9a6c2 100644 --- a/Server.UnitTests/LivePickTransitionServiceTests.cs +++ b/Server.UnitTests/LivePickTransitionServiceTests.cs @@ -93,6 +93,11 @@ private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider { // Takes the already-built "only this flag true" DTO rather than a setter lambda — a setter // lambda's assignment expression can't appear inside the Arg.Is expression tree at the call site. + public LivePickTransitionServiceTests() + { + _leagueRepository.GetLeaguesByTypeAsync(Arg.Any()).Returns(new List()); + } + private static bool PredicateMatches(Func predicate, NotificationPreferencesDto whenTrue) => predicate(whenTrue) && !predicate(new NotificationPreferencesDto()); @@ -104,6 +109,10 @@ private void SetUpNflHappyPath(Dictionary? prior _leagueRepository.GetNflSpreadsAsync(2026, 5).Returns([KcSpread]); _leagueRepository.GetLeagueJuiceMappingAsync(1, 2026).Returns(NoJuice); _leagueRepository.GetLeagueUserMappingsAsync(1).Returns(Members); + _leagueRepository.GetLeaguesByTypeAsync(LeagueType.Nfl).Returns([ + new LeagueInfo { Id = 1, LeagueName = "Alpha", OwnerUserId = "o", LeagueType = LeagueType.Nfl }, + new LeagueInfo { Id = 2, LeagueName = "Beta", OwnerUserId = "o", LeagueType = LeagueType.Nfl }, + ]); _stateService.GetStatesAsync(LeagueType.Nfl, Arg.Any>()).Returns(priorStates ?? []); _teamStateService.GetStatesAsync(LeagueType.Nfl, Arg.Any(), Arg.Any(), Arg.Any>()) .Returns([]); @@ -254,6 +263,58 @@ await _stateService.Received(1).UpsertStatesAsync(Arg.Is>()); } + // A member of two leagues gets ONE push per team, listing each league and how many picked it + // there — not one push per league with different counts (owner report, 2026-10-04). + private void SetUpTwoLeaguesKcBloodyTransition(List picks, List league1, List league2) + { + SetUpNflHappyPath(picks: picks); + _leagueRepository.GetLeagueJuiceMappingAsync(2, 2026).Returns(NoJuice); + _leagueRepository.GetLeagueUserMappingsAsync(1).Returns(league1); + _leagueRepository.GetLeagueUserMappingsAsync(2).Returns(league2); + _stateService.GetStatesAsync(LeagueType.Nfl, Arg.Any>()).Returns( + picks.ToDictionary(p => p.Id, p => new PickLiveNotificationState { Sport = LeagueType.Nfl, PickId = p.Id, LeagueId = p.LeagueId, LastNotifiedCovering = true, LastNotifiedAt = FakeNow.AddMinutes(-10) })); + foreach (var league in new[] { 1, 2 }) + _teamStateService.GetStatesAsync(LeagueType.Nfl, league, 5, Arg.Any>()).Returns( + new Dictionary<(string, PickType), TeamLiveNotificationState> { + [("KC", PickType.Spread)] = new() { Sport = LeagueType.Nfl, LeagueId = league, Team = "KC", PickType = PickType.Spread, Period = 5, LastNotifiedCovering = true, LastNotifiedAt = FakeNow.AddMinutes(-10) }, + }); + _espnCache.GetScoresAsync().Returns(NflScores(20, 20, TypeName.StatusInProgress, KickoffLongAgo)); // KC stops covering + } + + private static NflPicks Kc(int id, string userId, int leagueId) => new() { Id = id, UserId = userId, LeagueId = leagueId, Team = "KC", Pick = PickType.Spread, NflWeek = 5, Season = 2026 }; + + [Fact] + public async Task RecomputeAsync_MemberOfTwoLeagues_GetsOnePushListingEachLeaguesPickCount() + { + SetUpTwoLeaguesKcBloodyTransition( + [Kc(101, "user-2", 1), Kc(201, "user-4", 2), Kc(202, "user-5", 2)], + [new() { UserId = "user-1" }, new() { UserId = "user-2" }], + [new() { UserId = "user-1" }, new() { UserId = "user-4" }, new() { UserId = "user-5" }]); + + await BuildService().RecomputeAsync(LeagueType.Nfl); + + await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any>(), + Arg.Is(p => p.Body.Contains("Kansas City Chiefs are bloody") && p.Body.Contains("Alpha: 1 user picked") && p.Body.Contains("Beta: 2 users picked")), + Arg.Any()); + await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any>(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RecomputeAsync_OwnPickInOneLeague_AndOthersInAnother_MergeIntoOnePush() + { + SetUpTwoLeaguesKcBloodyTransition( + [Kc(101, "user-1", 1), Kc(102, "user-2", 1), Kc(201, "user-4", 2)], + [new() { UserId = "user-1" }, new() { UserId = "user-2" }], + [new() { UserId = "user-1" }, new() { UserId = "user-4" }]); + + await BuildService().RecomputeAsync(LeagueType.Nfl); + + await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any>(), Arg.Any(), Arg.Any()); + await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any>(), + Arg.Is(p => p.Body.Contains("Alpha: your pick + 1 other") && p.Body.Contains("Beta: 1 user picked")), + Arg.Any()); + } + [Fact] public async Task RecomputeAsync_Others_FirstReadAfterQuietWindow_EstablishesSilentBaseline_NoPush() { diff --git a/Server/Services/LivePickTransitionService.cs b/Server/Services/LivePickTransitionService.cs index 976012b0..99113af5 100644 --- a/Server/Services/LivePickTransitionService.cs +++ b/Server/Services/LivePickTransitionService.cs @@ -119,6 +119,11 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio // could leave "others" state committed for an earlier league while "mine" state for that // same earlier league is lost, letting the two channels silently diverge. var newTeamStates = new List(); + // Collected across every league and sent once at the end, so someone in two leagues gets + // ONE push per team listing each league ("Alpha: your pick + 1 other / Beta: 3 users + // picked") instead of a separate push per league. + var outbox = new List(); + var leagueNames = (await leagueRepository.GetLeaguesByTypeAsync(sport)).ToDictionary(l => l.Id, l => l.LeagueName); // Grouped by league so each league's own tease (JuiceTiers) is applied — "covering" is a // league-specific answer, not a single global one, exactly like LeaderboardEngine.Build. @@ -131,6 +136,7 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio var calculator = new SpreadCalculator(spreads, JuiceTiers.For(sport, period.JuiceTierNumber, juiceMapping)); var members = await leagueRepository.GetLeagueUserMappingsAsync(byLeague.Key); var betResults = new Dictionary<(string Team, PickType PickType), BetResult>(); + var mineEvents = new List<(string UserId, (string Team, PickType PickType) Bet, bool Covering, bool AtFinal)>(); foreach (var pick in byLeague) { @@ -165,11 +171,11 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio }); } if (mineTransition.NotifyTransition) - await SafeDispatchAsync(pick.UserId, MinePreference(covering, false), MinePayload(sport, NameOf(pick.Pick.Team), covering, atFinal: false)); + mineEvents.Add((pick.UserId, (pick.Pick.Team, pick.Pick.PickType), covering, false)); // Independent of the above — fires once at final regardless of recent transition // history, even if this tick's status flip happens to also be a cover-state flip. if (mineTransition.NotifyFinal) - await SafeDispatchAsync(pick.UserId, MinePreference(covering, true), MinePayload(sport, NameOf(pick.Pick.Team), covering, atFinal: true)); + mineEvents.Add((pick.UserId, (pick.Pick.Team, pick.Pick.PickType), covering, true)); // Record for the per-bet "others" pass below — every picker on the same // (team, pickType) computes this exact same covering/isFinal, so only the picker @@ -179,6 +185,10 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio else betResults[key] = new BetResult(covering, isFinal, pick.UserId); } + // Picker counts are only known once every pick in this league is tallied. + foreach (var e in mineEvents) + outbox.Add(new PushLine(e.UserId, e.Bet.Team, e.Covering, e.AtFinal, byLeague.Key, Mine: true, OtherPickers: betResults[e.Bet].PickerUserIds.Count - 1)); + if (betResults.Count == 0) continue; // "Others" — exactly one aggregated push per (team, pickType) bet that actually @@ -198,34 +208,55 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio } var otherMembers = members.Where(m => !result.PickerUserIds.Contains(m.UserId)).ToList(); - if (teamTransition.NotifyTransition) - await NotifyOthersAsync(sport, otherMembers, NameOf(bet.Team), result.Covering, result.PickerUserIds.Count, atFinal: false); - if (teamTransition.NotifyFinal) - await NotifyOthersAsync(sport, otherMembers, NameOf(bet.Team), result.Covering, result.PickerUserIds.Count, atFinal: true); + foreach (var atFinal in new[] { false, true }) + { + if (atFinal ? !teamTransition.NotifyFinal : !teamTransition.NotifyTransition) continue; + outbox.AddRange(otherMembers.Select(m => + new PushLine(m.UserId, bet.Team, result.Covering, atFinal, byLeague.Key, Mine: false, OtherPickers: result.PickerUserIds.Count))); + } } } + foreach (var push in outbox.GroupBy(l => (l.UserId, l.Team, l.Covering, l.AtFinal))) + await SafeDispatchAsync(push.Key.UserId, PreferenceFor(push), BuildPayload(sport, NameOf(push.Key.Team), push, leagueNames)); + if (newStates.Count > 0) await stateService.UpsertStatesAsync(newStates); if (newTeamStates.Count > 0) await teamStateService.UpsertStatesAsync(newTeamStates); } - private async Task NotifyOthersAsync(LeagueType sport, List otherMembers, string team, bool covering, int pickerCount, bool atFinal) + // One line per league for a single (recipient, team, covering, final) push. Mine = the recipient + // picked it in that league; OtherPickers = how many others in that league did. + private sealed record PushLine(string UserId, string Team, bool Covering, bool AtFinal, int LeagueId, bool Mine, int OtherPickers); + + private static PushPayload BuildPayload(LeagueType sport, string team, IEnumerable lines, IReadOnlyDictionary leagueNames) { - if (otherMembers.Count == 0) return; - var payload = OthersPayload(team, covering, pickerCount, atFinal) with { Sport = sport }; - await Task.WhenAll(otherMembers.Select(m => SafeDispatchAsync(m.UserId, OthersPreference(covering, atFinal), payload))); + var first = lines.First(); + var headline = first.AtFinal + ? (first.Covering ? $"{team} covered! Final 🏆" : $"{team} didn't cover. Final 💀") + : (first.Covering ? $"{team} are covering 🟢" : $"{team} are bloody 🔴"); + var leagueLines = lines + .GroupBy(l => l.LeagueId) + .Select(g => { + var name = leagueNames.GetValueOrDefault(g.Key, "Your league"); + var others = g.Max(l => l.OtherPickers); + var mine = g.Any(l => l.Mine); + var detail = mine + ? (others == 0 ? "your pick" : $"your pick + {others} other{(others == 1 ? "" : "s")}") + : $"{others} user{(others == 1 ? "" : "s")} picked"; + return $"{name}: {detail}"; + }) + .OrderBy(l => l); + return new PushPayload("IV League", $"{headline}\n{string.Join("\n", leagueLines)}", Sport: sport); } - private static PushPayload MinePayload(LeagueType sport, string team, bool covering, bool atFinal) => (atFinal - ? new PushPayload("IV League", covering ? $"{team} covered! Final. 🏆" : $"{team} didn't cover. Final. 💀") - : new PushPayload("IV League", covering ? $"{team} are covering! 🟢" : $"{team} are bloody right now 🔴")) with { Sport = sport }; - - private static PushPayload OthersPayload(string team, bool covering, int pickerCount, bool atFinal) + // The push goes out if the recipient wants any kind of line it contains ("my games" for their + // own picks, "league activity" for others'). + private static Func PreferenceFor(IGrouping<(string UserId, string Team, bool Covering, bool AtFinal), PushLine> push) { - var usersLabel = pickerCount == 1 ? "1 user picked" : $"{pickerCount} users picked"; - return atFinal - ? new PushPayload("IV League", covering ? $"{team} covered! Final — {usersLabel} 🏆" : $"{team} didn't cover! Final — {usersLabel} 💀") - : new PushPayload("IV League", covering ? $"{team} are Covering — {usersLabel} 🟢" : $"{team} are Bloody — {usersLabel} 🔴"); + var (_, _, covering, atFinal) = push.Key; + var mine = push.Any(l => l.Mine) ? MinePreference(covering, atFinal) : null; + var others = push.Any(l => !l.Mine) ? OthersPreference(covering, atFinal) : null; + return p => (mine?.Invoke(p) ?? false) || (others?.Invoke(p) ?? false); } private static Func MinePreference(bool covering, bool atFinal) => atFinal diff --git a/Shared/Models/Data/Dtos/LiveGameDto.cs b/Shared/Models/Data/Dtos/LiveGameDto.cs index d909be90..abcf7386 100644 --- a/Shared/Models/Data/Dtos/LiveGameDto.cs +++ b/Shared/Models/Data/Dtos/LiveGameDto.cs @@ -48,16 +48,19 @@ public class GameSituationDto public bool IsRedZone { get; set; } public string DownDistanceText { get; set; } = string.Empty; - internal static GameSituationDto FromSituation(EspnSitutation situation, Competitor home, Competitor away) + // Null when nobody has the ball (kickoff after a score, timeouts, end of quarter) — ESPN still + // sends a situation then, with a stale yardLine, and drawing a ball + arrow from it is wrong. + internal static GameSituationDto? FromSituation(EspnSitutation situation, Competitor home, Competitor away) { var possessor = situation.Possession == home.Id ? home : situation.Possession == away.Id ? away : null; + if (possessor is null) return null; return new GameSituationDto { - PossessionTeam = possessor?.Team.Abbreviation, - IsHomePossession = possessor?.HomeAway == HomeAway.Home, + PossessionTeam = possessor.Team.Abbreviation, + IsHomePossession = possessor.HomeAway == HomeAway.Home, YardLine = situation.YardLine, Down = situation.Down, Distance = situation.Distance,