diff --git a/Client.React/CHANGELOG.md b/Client.React/CHANGELOG.md
index ff2b4d19..ae7ce8e2 100644
--- a/Client.React/CHANGELOG.md
+++ b/Client.React/CHANGELOG.md
@@ -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
diff --git a/Client.React/public/sw.js b/Client.React/public/sw.js
index b66bb358..d1059b4d 100644
--- a/Client.React/public/sw.js
+++ b/Client.React/public/sw.js
@@ -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;
diff --git a/Client.React/src/__tests__/gameHelpers.findGameForTeam.test.ts b/Client.React/src/__tests__/gameHelpers.findGameForTeam.test.ts
new file mode 100644
index 00000000..75b79338
--- /dev/null
+++ b/Client.React/src/__tests__/gameHelpers.findGameForTeam.test.ts
@@ -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());
+});
diff --git a/Client.React/src/__tests__/scores.test.tsx b/Client.React/src/__tests__/scores.test.tsx
index 705af378..a9c9ada2 100644
--- a/Client.React/src/__tests__/scores.test.tsx
+++ b/Client.React/src/__tests__/scores.test.tsx
@@ -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({ui}), queryClient };
+ return { ...render({ui}), queryClient };
};
const renderPage = async () => {
@@ -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(, 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.
diff --git a/Client.React/src/pages/ScoresPage.tsx b/Client.React/src/pages/ScoresPage.tsx
index bdaea63c..67be3427 100644
--- a/Client.React/src/pages/ScoresPage.tsx
+++ b/Client.React/src/pages/ScoresPage.tsx
@@ -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,
@@ -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';
@@ -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(null);
+ const focusedForTeam = useRef(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 = {};
@@ -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;
@@ -311,9 +332,15 @@ export default function ScoresPage({ adapter }: ScoresPageProps) {
return (
{/* Score header */}
diff --git a/Client.React/src/utils/gameHelpers.ts b/Client.React/src/utils/gameHelpers.ts
index e30149dc..0f6a9ff7 100644
--- a/Client.React/src/utils/gameHelpers.ts
+++ b/Client.React/src/utils/gameHelpers.ts
@@ -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(
+ 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;
+}
diff --git a/Server.UnitTests/LivePickTransitionServiceTests.cs b/Server.UnitTests/LivePickTransitionServiceTests.cs
index daa9a6c2..67238219 100644
--- a/Server.UnitTests/LivePickTransitionServiceTests.cs
+++ b/Server.UnitTests/LivePickTransitionServiceTests.cs
@@ -190,7 +190,7 @@ public async Task RecomputeAsync_TransitionAfterBaseline_FiresExactlyOnePush_ToO
await _dispatcher.Received(1).DispatchAsync("user-1",
Arg.Is>(f => PredicateMatches(f, new NotificationPreferencesDto { NotifyMineBloodyDuringGame = true })),
- Arg.Is(p => p.Body.StartsWith("Kansas City Chiefs ") && p.Sport == LeagueType.Nfl), Arg.Any());
+ Arg.Is(p => p.Body.StartsWith("Kansas City Chiefs ") && p.Sport == LeagueType.Nfl && p.Url == "/scores?team=KC"), Arg.Any());
await _dispatcher.Received(1).DispatchAsync("user-2",
Arg.Is>(f => PredicateMatches(f, new NotificationPreferencesDto { NotifyOthersBloodyDuringGame = true })),
Arg.Is(p => p.Body.Contains("Kansas City Chiefs") && !p.Body.Contains("KC ") && p.Body.Contains("1 user picked") && p.Sport == LeagueType.Nfl),
diff --git a/Server.UnitTests/WeekResultNotificationServiceTests.cs b/Server.UnitTests/WeekResultNotificationServiceTests.cs
index d402b4f2..7faccb2f 100644
--- a/Server.UnitTests/WeekResultNotificationServiceTests.cs
+++ b/Server.UnitTests/WeekResultNotificationServiceTests.cs
@@ -231,7 +231,7 @@ public async Task CheckNflWeekResultsAsync_UsesWeekLabel_NotSlateLabel_InThePayl
await BuildService().CheckNflWeekResultsAsync(2026);
await _dispatcher.Received(1).DispatchAsync("user-1", Arg.Any>(),
- Arg.Is(p => p.Body.StartsWith("NFL Week 5:") && p.Sport == LeagueType.Nfl), Arg.Any());
+ Arg.Is(p => p.Body.StartsWith("NFL Week 5:") && p.Sport == LeagueType.Nfl && p.Url == "/leaderboard"), Arg.Any());
}
[Fact]
diff --git a/Server/Services/LivePickTransitionService.cs b/Server/Services/LivePickTransitionService.cs
index 99113af5..1f297a3f 100644
--- a/Server/Services/LivePickTransitionService.cs
+++ b/Server/Services/LivePickTransitionService.cs
@@ -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);
@@ -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 lines, IReadOnlyDictionary leagueNames)
+ private static PushPayload BuildPayload(LeagueType sport, string teamAbbr, string team, IEnumerable lines, IReadOnlyDictionary leagueNames)
{
var first = lines.First();
var headline = first.AtFinal
@@ -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
diff --git a/Server/Services/WeekResultNotificationService.cs b/Server/Services/WeekResultNotificationService.cs
index b7be4785..686b8d8b 100644
--- a/Server/Services/WeekResultNotificationService.cs
+++ b/Server/Services/WeekResultNotificationService.cs
@@ -97,7 +97,7 @@ private async Task 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
@@ -127,6 +127,6 @@ private async Task NotifyDecidedWeeksAsync(LeagueType sport, int leagueId,
private static PushPayload BuildLostPayload(LeagueType sport, string leagueName, string label, IReadOnlyList 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);
}