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); }