Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions Client.React/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Client.React/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions Server.UnitTests/LiveGameDtoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
61 changes: 61 additions & 0 deletions Server.UnitTests/LivePickTransitionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LeagueType>()).Returns(new List<LeagueInfo>());
}

private static bool PredicateMatches(Func<NotificationPreferencesDto, bool> predicate, NotificationPreferencesDto whenTrue) =>
predicate(whenTrue) && !predicate(new NotificationPreferencesDto());

Expand All @@ -104,6 +109,10 @@ private void SetUpNflHappyPath(Dictionary<int, PickLiveNotificationState>? 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<IReadOnlyCollection<int>>()).Returns(priorStates ?? []);
_teamStateService.GetStatesAsync(LeagueType.Nfl, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<IReadOnlyCollection<(string, PickType)>>())
.Returns([]);
Expand Down Expand Up @@ -254,6 +263,58 @@ await _stateService.Received(1).UpsertStatesAsync(Arg.Is<IReadOnlyCollection<Pic
await _teamStateService.Received(1).UpsertStatesAsync(Arg.Any<IReadOnlyCollection<TeamLiveNotificationState>>());
}

// 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<NflPicks> picks, List<LeagueUserMapping> league1, List<LeagueUserMapping> 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<IReadOnlyCollection<int>>()).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<IReadOnlyCollection<(string, PickType)>>()).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<Func<NotificationPreferencesDto, bool>>(),
Arg.Is<PushPayload>(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<CancellationToken>());
await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any<Func<NotificationPreferencesDto, bool>>(), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
}

[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<Func<NotificationPreferencesDto, bool>>(), Arg.Any<PushPayload>(), Arg.Any<CancellationToken>());
await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any<Func<NotificationPreferencesDto, bool>>(),
Arg.Is<PushPayload>(p => p.Body.Contains("Alpha: your pick + 1 other") && p.Body.Contains("Beta: 1 user picked")),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task RecomputeAsync_Others_FirstReadAfterQuietWindow_EstablishesSilentBaseline_NoPush()
{
Expand Down
69 changes: 50 additions & 19 deletions Server/Services/LivePickTransitionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TeamLiveNotificationState>();
// 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<PushLine>();
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.
Expand All @@ -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)
{
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<LeagueUserMapping> 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<PushLine> lines, IReadOnlyDictionary<int, string> 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<NotificationPreferencesDto, bool> 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<NotificationPreferencesDto, bool> MinePreference(bool covering, bool atFinal) => atFinal
Expand Down
9 changes: 6 additions & 3 deletions Shared/Models/Data/Dtos/LiveGameDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading