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
4 changes: 4 additions & 0 deletions Client.React/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Notable changes to IV League, most recent first. For admins — see the version

Format: `## ` release headings and single-line `- ` bullets only — no bold, links, or multi-line/nested bullets. The admin Changelog page renders this with a small hand-rolled parser (`parseChangelog.ts`), not a full Markdown engine; anything outside this subset renders as literal syntax instead of being formatted. A test guards this file against drifting outside the subset.

## 2026-10-05

- Added: tapping a live game notification opens the Scores page scrolled to that game, briefly outlined; a week-result notification opens the Leaderboard

## 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
Expand Down
6 changes: 5 additions & 1 deletion Client.React/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ self.addEventListener('notificationclick', (event) => {

event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// App already open (on any page): take that window to the game rather than just focusing
// whatever page it was on. Falls back to opening a new window.
for (const client of clientList) {
if (client.url.includes(targetUrl) && 'focus' in client) return client.focus();
if ('navigate' in client && 'focus' in client) {
return client.navigate(targetUrl).then((c) => (c || client).focus()).catch(() => client.focus());
}
}
if (self.clients.openWindow) return self.clients.openWindow(targetUrl);
return undefined;
Expand Down
15 changes: 15 additions & 0 deletions Client.React/src/__tests__/gameHelpers.findGameForTeam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { findGameIdForTeam } from '../utils/gameHelpers';

// Push notification deep links (/scores?team=KC) name a team, not a game — find its game card.
describe('findGameIdForTeam', () => {
const games = [
{ id: 'g1', homeTeam: 'BUF', awayTeam: 'MIA' },
{ id: 'g2', homeTeam: 'DAL', awayTeam: 'NYG' },
];

it('matches the home team', () => expect(findGameIdForTeam(games, 'DAL')).toBe('g2'));
it('matches the away team', () => expect(findGameIdForTeam(games, 'MIA')).toBe('g1'));
it('is case-insensitive', () => expect(findGameIdForTeam(games, 'nyg')).toBe('g2'));
it('returns null when the team has no game this week', () => expect(findGameIdForTeam(games, 'KC')).toBeNull());
it('returns null with no team', () => expect(findGameIdForTeam(games, null)).toBeNull());
});
21 changes: 19 additions & 2 deletions Client.React/src/__tests__/scores.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ const setupDefaults = async (options?: {
mockLeagueJuiceEmpty(mockedGetLeagueJuice);
};

const renderWithClient = (ui: React.ReactElement, client?: QueryClient) => {
const renderWithClient = (ui: React.ReactElement, client?: QueryClient, route = '/scores') => {
const queryClient = client ?? new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
// MemoryRouter — useCurrentWeekNav (shared by PicksPage/ScoresPage) reads useLocation() to
// reset back to the current week when AppLayout's nav-link click passes resetToCurrent state.
return { ...render(<QueryClientProvider client={queryClient}><MemoryRouter>{ui}</MemoryRouter></QueryClientProvider>), queryClient };
return { ...render(<QueryClientProvider client={queryClient}><MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter></QueryClientProvider>), queryClient };
};

const renderPage = async () => {
Expand Down Expand Up @@ -151,6 +151,23 @@ describe('ScoresPage', () => {
expect(screen.getByTestId('week-year-selector-container')).toBeInTheDocument();
});

// Tapping a live push opens /scores?team=XXX (issue #461): the page scrolls to that team's game
// and briefly highlights it, whether the team is home or away.
it('scrolls to and highlights the game for ?team= from a notification tap', async () => {
await setupDefaults();
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;

renderWithClient(<ScoresPage adapter={createNflAdapter()} />, undefined, '/scores?team=MIA');

// Generous timeout: under a full parallel run the page's data + rAF scroll can exceed 1s.
await waitFor(() => expect(document.querySelector('[data-focused="true"]')).not.toBeNull(), { timeout: 5000 });
const focused = document.querySelector('[data-focused="true"]') as HTMLElement;
expect(within(focused).getAllByText('MIA').length).toBeGreaterThan(0);
await waitFor(() => expect(scrollIntoView).toHaveBeenCalled(), { timeout: 5000 });
expect(document.querySelectorAll('[data-focused="true"]')).toHaveLength(1);
});

// frizat-u66: a league with StartWeek > 1 excludes weeks before it from scoring — the Scores
// page must say so clearly instead of showing an ordinary score grid that implies the week
// counts toward standings when it doesn't.
Expand Down
33 changes: 30 additions & 3 deletions Client.React/src/pages/ScoresPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Badge, Box, Button, Grid,
IconButton, Paper, Stack, Typography,
Expand All @@ -21,7 +22,7 @@ import PickDialog from '../components/PickDialog';
import FieldPosition from '../components/FieldPosition';
import { useSession } from '../services/session';
import { useAuth } from '../services/auth';
import { isGameDecided, isGameFinal, isGameLive, isConsistentRedZone, spreadLabel, isWeekExcludedFromSeason } from '../utils/gameHelpers';
import { isGameDecided, isGameFinal, isGameLive, isConsistentRedZone, spreadLabel, isWeekExcludedFromSeason, findGameIdForTeam } from '../utils/gameHelpers';
import type { SportAdapter, GameView, WeekState, PickType } from '../services/sportAdapter';
import { sortGamesByTimeThenRank } from '../services/sportAdapter';
import { useLeagueMinSeason } from '../utils/useLeagueMinSeason';
Expand Down Expand Up @@ -173,6 +174,25 @@ export default function ScoresPage({ adapter }: ScoresPageProps) {
// that don't actually change data.games — memoized like matrixSpreads below.
const sortedGames = useMemo(() => sortGamesByTimeThenRank(data?.games ?? []), [data?.games]);

// Push notification deep link (/scores?team=KC, issue #461): once that team's game is on the
// page, scroll to it and outline it briefly. Only once per link, not on every live poll.
const [searchParams] = useSearchParams();
const focusTeam = searchParams.get('team');
const [focusedGameId, setFocusedGameId] = useState<string | null>(null);
const focusedForTeam = useRef<string | null>(null);
useEffect(() => {
const id = findGameIdForTeam(sortedGames, focusTeam);
if (!id || focusedForTeam.current === focusTeam) return;
focusedForTeam.current = focusTeam;
setFocusedGameId(id);
requestAnimationFrame(() => document.getElementById(`game-card-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
}, [sortedGames, focusTeam]);
useEffect(() => {
if (!focusedGameId) return;
const t = setTimeout(() => setFocusedGameId(null), 4000);
return () => clearTimeout(t);
}, [focusedGameId]);

/** Build spread result map for UserPicksMatrix from GameView cover data */
const matrixSpreads = useMemo(() => {
const result: Record<string, { isWinner: boolean; isOverWinner: boolean; isUnderWinner: boolean; spread: number | null; over: number | null; under: number | null }> = {};
Expand Down Expand Up @@ -300,6 +320,7 @@ export default function ScoresPage({ adapter }: ScoresPageProps) {
)}

{data?.hasOdds && games.map(game => {
const isFocused = game.id === focusedGameId;
const isFinal = isGameFinal(game.gameStatus);
const isLive = isGameLive(game.gameStatus);
const hc = game.homeCovers ?? null;
Expand All @@ -311,9 +332,15 @@ export default function ScoresPage({ adapter }: ScoresPageProps) {
return (
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={game.id}>
<Paper
id={`game-card-${game.id}`}
data-testid={`game-card-${game.id}`}
data-redzone={String(isCardRedZone)}
sx={[{ p: 2 }, isCardRedZone && { outline: '3px solid', outlineColor: 'error.main', outlineOffset: -1 }]}
data-focused={String(isFocused)}
sx={[
{ p: 2, scrollMarginTop: 80 },
isCardRedZone && { outline: '3px solid', outlineColor: 'error.main', outlineOffset: -1 },
isFocused && { outline: '3px solid', outlineColor: 'secondary.main', outlineOffset: -1 },
]}
>
{/* Score header */}
<Stack direction="row" alignItems="center" justifyContent="space-between">
Expand Down
10 changes: 10 additions & 0 deletions Client.React/src/utils/gameHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,13 @@ function isRecordType(value: EspnRecordType, expected: 'total' | 'road' | 'home'
}
return value === expected;
}

/** Push notification deep links (/scores?team=KC) name a team; returns the id of its game card. */
export function findGameIdForTeam<T extends { id: string; homeTeam: string; awayTeam: string }>(
games: readonly T[],
team: string | null | undefined,
): string | null {
if (!team) return null;
const t = team.toUpperCase();
return games.find((g) => g.homeTeam.toUpperCase() === t || g.awayTeam.toUpperCase() === t)?.id ?? null;
}
2 changes: 1 addition & 1 deletion Server.UnitTests/LivePickTransitionServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ public async Task RecomputeAsync_TransitionAfterBaseline_FiresExactlyOnePush_ToO

await _dispatcher.Received(1).DispatchAsync("user-1",
Arg.Is<Func<NotificationPreferencesDto, bool>>(f => PredicateMatches(f, new NotificationPreferencesDto { NotifyMineBloodyDuringGame = true })),
Arg.Is<PushPayload>(p => p.Body.StartsWith("Kansas City Chiefs ") && p.Sport == LeagueType.Nfl), Arg.Any<CancellationToken>());
Arg.Is<PushPayload>(p => p.Body.StartsWith("Kansas City Chiefs ") && p.Sport == LeagueType.Nfl && p.Url == "/scores?team=KC"), Arg.Any<CancellationToken>());
await _dispatcher.Received(1).DispatchAsync("user-2",
Arg.Is<Func<NotificationPreferencesDto, bool>>(f => PredicateMatches(f, new NotificationPreferencesDto { NotifyOthersBloodyDuringGame = true })),
Arg.Is<PushPayload>(p => p.Body.Contains("Kansas City Chiefs") && !p.Body.Contains("KC ") && p.Body.Contains("1 user picked") && p.Sport == LeagueType.Nfl),
Expand Down
2 changes: 1 addition & 1 deletion Server.UnitTests/WeekResultNotificationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ public async Task CheckNflWeekResultsAsync_UsesWeekLabel_NotSlateLabel_InThePayl
await BuildService().CheckNflWeekResultsAsync(2026);

await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any<Func<NotificationPreferencesDto, bool>>(),
Arg.Is<PushPayload>(p => p.Body.StartsWith("NFL Week 5:") && p.Sport == LeagueType.Nfl), Arg.Any<CancellationToken>());
Arg.Is<PushPayload>(p => p.Body.StartsWith("NFL Week 5:") && p.Sport == LeagueType.Nfl && p.Url == "/leaderboard"), Arg.Any<CancellationToken>());
}

[Fact]
Expand Down
9 changes: 6 additions & 3 deletions Server/Services/LivePickTransitionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio
}

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));
await SafeDispatchAsync(push.Key.UserId, PreferenceFor(push), BuildPayload(sport, push.Key.Team, NameOf(push.Key.Team), push, leagueNames));

if (newStates.Count > 0) await stateService.UpsertStatesAsync(newStates);
if (newTeamStates.Count > 0) await teamStateService.UpsertStatesAsync(newTeamStates);
Expand All @@ -228,7 +228,7 @@ public async Task RecomputeAsync(LeagueType sport, CancellationToken cancellatio
// 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)
private static PushPayload BuildPayload(LeagueType sport, string teamAbbr, string team, IEnumerable<PushLine> lines, IReadOnlyDictionary<int, string> leagueNames)
{
var first = lines.First();
var headline = first.AtFinal
Expand All @@ -246,7 +246,10 @@ private static PushPayload BuildPayload(LeagueType sport, string team, IEnumerab
return $"{name}: {detail}";
})
.OrderBy(l => l);
return new PushPayload("IV League", $"{headline}\n{string.Join("\n", leagueLines)}", Sport: sport);
// Tapping it opens the Scores page scrolled to this team's game (sport-correct host: the push
// only ever reaches that sport's app).
return new PushPayload("IV League", $"{headline}\n{string.Join("\n", leagueLines)}",
Url: $"/scores?team={Uri.EscapeDataString(teamAbbr)}", Sport: sport);
}

// The push goes out if the recipient wants any kind of line it contains ("my games" for their
Expand Down
6 changes: 3 additions & 3 deletions Server/Services/WeekResultNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ private async Task<int> NotifyDecidedWeeksAsync(LeagueType sport, int leagueId,

var label = periodLabel(weekResult.Week);
var payload = weekResult.WeekResult == WeekResult.Won
? new PushPayload("IV League", $"{label}: You Won the Week in {leagueName}! 🏆", Sport: sport)
? new PushPayload("IV League", $"{label}: You Won the Week in {leagueName}! 🏆", Url: "/leaderboard", Sport: sport)
: BuildLostPayload(sport, leagueName, label, weekResult.LosingTeams.Select(t => teamNames.GetValueOrDefault(t, t)).ToList());

// /code-review: dedup rows used to be recorded in one batch after this whole
Expand Down Expand Up @@ -127,6 +127,6 @@ private async Task<int> NotifyDecidedWeeksAsync(LeagueType sport, int leagueId,

private static PushPayload BuildLostPayload(LeagueType sport, string leagueName, string label, IReadOnlyList<string> losingTeams) =>
losingTeams.Count > 0
? new PushPayload("IV League", $"{label}: You Lost {losingTeams[0]} — You Lost the Week in {leagueName}! 💀", Sport: sport)
: new PushPayload("IV League", $"{label}: You Lost the Week in {leagueName}! 💀", Sport: sport);
? new PushPayload("IV League", $"{label}: You Lost {losingTeams[0]} — You Lost the Week in {leagueName}! 💀", Url: "/leaderboard", Sport: sport)
: new PushPayload("IV League", $"{label}: You Lost the Week in {leagueName}! 💀", Url: "/leaderboard", Sport: sport);
}
Loading