diff --git a/apps/web/src/features/standings/LeagueStandings.test.tsx b/apps/web/src/features/standings/LeagueStandings.test.tsx index 2e048465..059ae121 100644 --- a/apps/web/src/features/standings/LeagueStandings.test.tsx +++ b/apps/web/src/features/standings/LeagueStandings.test.tsx @@ -24,11 +24,46 @@ const mockState = { homeRecord: '5-1', awayRecord: '5-2', }, + { + rank: 4, + teamId: 'team-4', + teamName: 'Milwaukee Frost Line', + teamIcon: 'mil', + wins: 7, + losses: 6, + ties: 0, + pct: 0.538, + pointsFor: 288, + pointsAgainst: 282, + pointDifferential: 6, + streak: -3, + homeRecord: '4-2', + awayRecord: '3-4', + }, + { + rank: 8, + teamId: 'team-8', + teamName: 'Canton Factory Lights', + teamIcon: 'can', + wins: 3, + losses: 10, + ties: 0, + pct: 0.231, + pointsFor: 210, + pointsAgainst: 320, + pointDifferential: -110, + streak: 2, + homeRecord: '2-5', + awayRecord: '1-5', + }, ], }, ], playoffPicture: { - afc: [{ seed: 1, teamId: 'team-1', teamName: 'Chicago City of Broad Shoulders Deep-Dish', teamIcon: 'chi', divisionWinner: true, indicator: 'X' }], + afc: [ + { seed: 1, teamId: 'team-1', teamName: 'Chicago City of Broad Shoulders Deep-Dish', teamIcon: 'chi', divisionWinner: true, indicator: 'X' }, + { seed: 4, teamId: 'team-4', teamName: 'Milwaukee Frost Line', teamIcon: 'mil', divisionWinner: false, indicator: '' }, + ], nfc: [{ seed: 1, teamId: 'team-9', teamName: 'Seattle Emerald City Grunge', teamIcon: 'sea', divisionWinner: true, indicator: '' }], }, statLeaders: { @@ -60,4 +95,41 @@ describe('LeagueStandings', () => { expect(markup).toContain('STAT LEADERS'); expect(markup).toContain('Jay Stone'); }); + + it('renders division-leader laurel for the top team in a division', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-division-leader-row="true"'); + expect(markup).toContain('data-standings-signal="division_leader"'); + }); + + it('renders locked seed signal for top-three playoff seeds', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="seed_locked"'); + expect(markup).toContain('Playoff seed locked'); + }); + + it('renders bubble seed signal for seeds four through seven', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="seed_bubble"'); + expect(markup).toContain('Playoff bubble'); + }); + + it('renders out signal for non-playoff teams', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="seed_out"'); + expect(markup).toContain('Outside playoff picture'); + }); + + it('renders streak signals without removing the text streak label', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="fire"'); + expect(markup).toContain('data-standings-signal="ice"'); + expect(markup).toContain('W4'); + expect(markup).toContain('L3'); + }); }); diff --git a/apps/web/src/features/standings/LeagueStandings.tsx b/apps/web/src/features/standings/LeagueStandings.tsx index 526fc82a..e8572b6e 100644 --- a/apps/web/src/features/standings/LeagueStandings.tsx +++ b/apps/web/src/features/standings/LeagueStandings.tsx @@ -13,6 +13,11 @@ import { PlayerNameLink, } from '../shared/pixelUi'; import { TeamLogo } from '../shared/TeamLogo'; +import { StandingsSignalSvg, StreakSignalSvg, type StandingsSignalKind } from './standingsSignalSvg'; + +type SeedSignalKind = Extract; +type PlayoffSeed = { seed: number; teamId: string; indicator?: string }; +type PlayoffPicture = { afc: PlayoffSeed[]; nfc: PlayoffSeed[] }; function streakLabel(streak: number): string { if (streak > 0) return `W${streak}`; @@ -20,7 +25,27 @@ function streakLabel(streak: number): string { return 'EVEN'; } -function standingsColumns(userTeamId: string | null): ColumnDef[] { +function buildSeedSignals(playoffPicture: PlayoffPicture): Map { + const signals = new Map(); + + for (const seed of [...playoffPicture.afc, ...playoffPicture.nfc]) { + if (seed.indicator === 'X' || seed.indicator === 'Y' || seed.seed <= 3) { + signals.set(seed.teamId, 'seed_locked'); + } else if (seed.seed <= 7) { + signals.set(seed.teamId, 'seed_bubble'); + } + } + + return signals; +} + +function seedSignalTitle(kind: SeedSignalKind): string { + if (kind === 'seed_locked') return 'Playoff seed locked'; + if (kind === 'seed_bubble') return 'Playoff bubble'; + return 'Outside playoff picture'; +} + +function standingsColumns(userTeamId: string | null, seedSignals: Map): ColumnDef[] { return [ { accessorKey: 'rank', @@ -30,21 +55,32 @@ function standingsColumns(userTeamId: string | null): ColumnDef ( -
- - - {row.original.teamName} - -
- ), + cell: ({ row }) => { + const isUserTeam = row.original.teamId === userTeamId; + const isDivisionLeader = row.original.rank === 1; + const seedSignal = seedSignals.get(row.original.teamId) ?? 'seed_out'; + return ( +
+ {isDivisionLeader ? : null} + + + + {row.original.teamName} + +
+ ); + }, }, { id: 'record', @@ -75,7 +111,15 @@ function standingsColumns(userTeamId: string | null): ColumnDef streakLabel(getValue() as number), + cell: ({ getValue }) => { + const streak = getValue() as number; + return ( + + {streakLabel(streak)} + + + ); + }, }, { accessorKey: 'homeRecord', @@ -93,7 +137,8 @@ export function LeagueStandings() { const playoffPicture = useGameStore(selectPlayoffPicture); const statLeaders = useGameStore(selectStatLeaders); const userTeam = useGameStore(selectUserTeam); - const columns = standingsColumns(userTeam?.id ?? null); + const seedSignals = buildSeedSignals(playoffPicture); + const columns = standingsColumns(userTeam?.id ?? null, seedSignals); return (
@@ -130,7 +175,7 @@ export function LeagueStandings() { { label: 'NFC', seeds: playoffPicture.nfc }, ].map((conference) => (
-
+
{conference.label}
{conference.seeds.map((seed) => ( @@ -138,10 +183,10 @@ export function LeagueStandings() {
-
+
#{seed.seed} {seed.teamName}
-
+
{seed.divisionWinner ? 'Division winner' : 'Wildcard'}
@@ -165,7 +210,7 @@ export function LeagueStandings() { ['INTs', statLeaders.defINT], ] as const).map(([label, leaders]) => (
-
{label.toUpperCase()}
+
{label.toUpperCase()}
{leaders.map((leader, index) => (
@@ -173,7 +218,7 @@ export function LeagueStandings() { {index + 1}.
-
{leader.teamName}
+
{leader.teamName}
{leader.value}
diff --git a/apps/web/src/features/standings/standingsSignalSvg.test.tsx b/apps/web/src/features/standings/standingsSignalSvg.test.tsx new file mode 100644 index 00000000..633fe9b0 --- /dev/null +++ b/apps/web/src/features/standings/standingsSignalSvg.test.tsx @@ -0,0 +1,52 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { StandingsSignalSvg, StreakSignalSvg } from './standingsSignalSvg'; + +describe('StandingsSignalSvg', () => { + it('renders fire variant for streak W4+', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="fire"'); + expect(markup).toContain('Hot streak (W4+)'); + }); + + it('renders ice variant for streak L3+', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="ice"'); + expect(markup).toContain('Cold streak (L3+)'); + }); + + it('renders nothing for short streaks', () => { + expect(renderToStaticMarkup()).toBe(''); + expect(renderToStaticMarkup()).toBe(''); + }); + + it('renders locked playoff seed signal', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="seed_locked"'); + expect(markup).toContain('Playoff seed locked'); + }); + + it('renders bubble playoff seed signal', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="seed_bubble"'); + expect(markup).toContain('Playoff bubble'); + }); + + it('renders out-of-picture seed signal', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="seed_out"'); + expect(markup).toContain('Outside playoff picture'); + }); + + it('renders division-leader laurel signal', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-standings-signal="division_leader"'); + expect(markup).toContain('Division leader'); + }); +}); diff --git a/apps/web/src/features/standings/standingsSignalSvg.tsx b/apps/web/src/features/standings/standingsSignalSvg.tsx new file mode 100644 index 00000000..3963b56a --- /dev/null +++ b/apps/web/src/features/standings/standingsSignalSvg.tsx @@ -0,0 +1,117 @@ +export type StandingsSignalKind = + | 'fire' + | 'ice' + | 'seed_locked' + | 'seed_bubble' + | 'seed_out' + | 'division_leader'; + +interface StandingsSignalSvgProps { + kind: StandingsSignalKind; + title: string; + size?: number; +} + +export function StandingsSignalSvg({ kind, title, size = 16 }: StandingsSignalSvgProps) { + return ( + + + {title} + {renderSignal(kind)} + + + ); +} + +export function StreakSignalSvg({ streak }: { streak: number }) { + if (streak >= 4) return ; + if (streak <= -3) return ; + return null; +} + +function renderSignal(kind: StandingsSignalKind) { + switch (kind) { + case 'fire': + return ( + <> + + + + + + + + + + ); + case 'ice': + return ( + + + + + + + + + + ); + case 'seed_locked': + return ( + + + + + ); + case 'seed_bubble': + return ( + + ); + case 'seed_out': + return ( + + + + + + ); + case 'division_leader': + return ( + + + + + + + + + + + + ); + } +}