From de7b712d4a2184cb7b773e32c6b67469c58b7980 Mon Sep 17 00:00:00 2001 From: frizat Date: Sun, 4 Oct 2026 18:55:02 -0500 Subject: [PATCH 1/3] One live push per team listing each league's picks A member of two leagues got two separate pushes for the same team with different user counts. Live pushes are now collected across leagues and sent once per (recipient, team, state), one line per league: "Alpha: your pick + 1 other" / "Beta: 3 users picked". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DAWjtJgXaMYbzZXYJjTEYs --- Client.React/CHANGELOG.md | 1 + .../LivePickTransitionServiceTests.cs | 61 ++++++++++++++++ Server/Services/LivePickTransitionService.cs | 69 ++++++++++++++----- 3 files changed, 112 insertions(+), 19 deletions(-) diff --git a/Client.React/CHANGELOG.md b/Client.React/CHANGELOG.md index 29dacec5..81b1053b 100644 --- a/Client.React/CHANGELOG.md +++ b/Client.React/CHANGELOG.md @@ -6,6 +6,7 @@ Format: `## ` release headings and single-line `- ` bullets only — no bold, li ## 2026-10-04 +- 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/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 From 2b60dd62c9e7044fec5445fcbee3a69c25b03770 Mon Sep 17 00:00:00 2001 From: frizat Date: Sun, 4 Oct 2026 22:02:32 -0500 Subject: [PATCH 2/3] Hide the field graphic when ESPN reports no possession Between plays (kickoff after a score, timeouts) ESPN sends a situation with no possession and a stale yardLine; the card drew a ball and an away-team arrow from it. No possessor now means no situation, so the field graphic hides until the next play (NFL and CFB share this DTO). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DAWjtJgXaMYbzZXYJjTEYs --- Client.React/CHANGELOG.md | 1 + Server.UnitTests/LiveGameDtoTests.cs | 14 ++++++++++++++ Shared/Models/Data/Dtos/LiveGameDto.cs | 9 ++++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Client.React/CHANGELOG.md b/Client.React/CHANGELOG.md index 81b1053b..ff2b4d19 100644 --- a/Client.React/CHANGELOG.md +++ b/Client.React/CHANGELOG.md @@ -6,6 +6,7 @@ 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 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/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, From 1308e3db83415c8a275c36718bfcdd9a68fe88a7 Mon Sep 17 00:00:00 2001 From: frizat Date: Sun, 4 Oct 2026 22:41:46 -0500 Subject: [PATCH 3/3] Remove Codecov upload; gate frontend coverage in-repo Codecov's expired certificate (codecov/codecov-action#1975) failed the required Frontend check on every run since 2026-10-05 00:03 UTC, blocking all merges. It was report-only and unused. Frontend coverage is now enforced by Vitest thresholds instead (backend already gated at 30%). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DAWjtJgXaMYbzZXYJjTEYs --- .github/workflows/ci.yml | 7 ------- Client.React/vite.config.ts | 3 +++ 2 files changed, 3 insertions(+), 7 deletions(-) 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/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',