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
74 changes: 73 additions & 1 deletion apps/web/src/features/standings/LeagueStandings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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(<LeagueStandings />);

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(<LeagueStandings />);

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(<LeagueStandings />);

expect(markup).toContain('data-standings-signal="seed_bubble"');
expect(markup).toContain('Playoff bubble');
});

it('renders out signal for non-playoff teams', () => {
const markup = renderToStaticMarkup(<LeagueStandings />);

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(<LeagueStandings />);

expect(markup).toContain('data-standings-signal="fire"');
expect(markup).toContain('data-standings-signal="ice"');
expect(markup).toContain('W4');
expect(markup).toContain('L3');
});
});
91 changes: 68 additions & 23 deletions apps/web/src/features/standings/LeagueStandings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,39 @@ import {
PlayerNameLink,
} from '../shared/pixelUi';
import { TeamLogo } from '../shared/TeamLogo';
import { StandingsSignalSvg, StreakSignalSvg, type StandingsSignalKind } from './standingsSignalSvg';

type SeedSignalKind = Extract<StandingsSignalKind, 'seed_locked' | 'seed_bubble' | 'seed_out'>;
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}`;
if (streak < 0) return `L${Math.abs(streak)}`;
return 'EVEN';
}

function standingsColumns(userTeamId: string | null): ColumnDef<StandingsRow, unknown>[] {
function buildSeedSignals(playoffPicture: PlayoffPicture): Map<string, SeedSignalKind> {
const signals = new Map<string, SeedSignalKind>();

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<string, SeedSignalKind>): ColumnDef<StandingsRow, unknown>[] {
return [
{
accessorKey: 'rank',
Expand All @@ -30,21 +55,32 @@ function standingsColumns(userTeamId: string | null): ColumnDef<StandingsRow, un
{
accessorKey: 'teamName',
header: 'Team',
cell: ({ row }) => (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: row.original.teamId === userTeamId ? '2px 6px' : 0,
border: row.original.teamId === userTeamId ? '3px solid var(--mfd-gold)' : 'none',
}}
>
<TeamLogo icon={row.original.teamIcon} size={22} />
<span style={{ ...mono, color: row.original.teamId === userTeamId ? 'var(--mfd-gold)' : '#fff' }}>
{row.original.teamName}
</span>
</div>
),
cell: ({ row }) => {
const isUserTeam = row.original.teamId === userTeamId;
const isDivisionLeader = row.original.rank === 1;
const seedSignal = seedSignals.get(row.original.teamId) ?? 'seed_out';
return (
<div
data-division-leader-row={isDivisionLeader ? 'true' : undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: isUserTeam || isDivisionLeader ? '2px 6px' : 0,
border: isUserTeam ? '3px solid var(--mfd-gold)' : 'none',
boxShadow: isDivisionLeader ? 'inset 0 0 0 1px var(--mfd-gold)' : undefined,
borderRadius: isDivisionLeader ? 'var(--mfd-rad-sm)' : undefined,
}}
>
{isDivisionLeader ? <StandingsSignalSvg kind="division_leader" title="Division leader" /> : null}
<StandingsSignalSvg kind={seedSignal} title={seedSignalTitle(seedSignal)} />
<TeamLogo icon={row.original.teamIcon} size={22} />
<span style={{ ...mono, color: isUserTeam ? 'var(--mfd-gold)' : 'var(--mfd-text)' }}>
{row.original.teamName}
</span>
</div>
);
},
},
{
id: 'record',
Expand Down Expand Up @@ -75,7 +111,15 @@ function standingsColumns(userTeamId: string | null): ColumnDef<StandingsRow, un
{
accessorKey: 'streak',
header: 'Strk',
cell: ({ getValue }) => streakLabel(getValue() as number),
cell: ({ getValue }) => {
const streak = getValue() as number;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<span>{streakLabel(streak)}</span>
<StreakSignalSvg streak={streak} />
</span>
);
},
},
{
accessorKey: 'homeRecord',
Expand All @@ -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 (
<div style={screenStackStyle}>
Expand Down Expand Up @@ -130,18 +175,18 @@ export function LeagueStandings() {
{ label: 'NFC', seeds: playoffPicture.nfc },
].map((conference) => (
<div key={conference.label} style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ ...display, fontSize: '22px', color: '#fff', lineHeight: 1 }}>
<div style={{ ...display, fontSize: '22px', color: 'var(--mfd-text)', lineHeight: 1 }}>
{conference.label}
</div>
{conference.seeds.map((seed) => (
<div key={seed.teamId} style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<TeamLogo icon={seed.teamIcon} size={24} />
<div>
<div style={{ ...mono, color: seed.teamId === userTeam?.id ? 'var(--mfd-gold)' : '#fff' }}>
<div style={{ ...mono, color: seed.teamId === userTeam?.id ? 'var(--mfd-gold)' : 'var(--mfd-text)' }}>
#{seed.seed} {seed.teamName}
</div>
<div style={{ ...monoSm, color: '#999' }}>
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
{seed.divisionWinner ? 'Division winner' : 'Wildcard'}
</div>
</div>
Expand All @@ -165,15 +210,15 @@ export function LeagueStandings() {
['INTs', statLeaders.defINT],
] as const).map(([label, leaders]) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: '6px', marginBottom: '12px' }}>
<div style={{ ...pixelSm, color: '#666' }}>{label.toUpperCase()}</div>
<div style={{ ...pixelSm, color: 'var(--mfd-text-faint)' }}>{label.toUpperCase()}</div>
{leaders.map((leader, index) => (
<div key={leader.playerId} style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
<div>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ ...mono, color: 'var(--mfd-text)' }}>{index + 1}.</span>
<PlayerNameLink playerId={leader.playerId} name={leader.playerName} style={{ ...mono }} />
</div>
<div style={{ ...monoSm, color: '#999' }}>{leader.teamName}</div>
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>{leader.teamName}</div>
</div>
<PixelBadge variant="green">{leader.value}</PixelBadge>
</div>
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/features/standings/standingsSignalSvg.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StreakSignalSvg streak={4} />);

expect(markup).toContain('data-standings-signal="fire"');
expect(markup).toContain('Hot streak (W4+)');
});

it('renders ice variant for streak L3+', () => {
const markup = renderToStaticMarkup(<StreakSignalSvg streak={-3} />);

expect(markup).toContain('data-standings-signal="ice"');
expect(markup).toContain('Cold streak (L3+)');
});

it('renders nothing for short streaks', () => {
expect(renderToStaticMarkup(<StreakSignalSvg streak={2} />)).toBe('');
expect(renderToStaticMarkup(<StreakSignalSvg streak={-2} />)).toBe('');
});

it('renders locked playoff seed signal', () => {
const markup = renderToStaticMarkup(<StandingsSignalSvg kind="seed_locked" title="Playoff seed locked" />);

expect(markup).toContain('data-standings-signal="seed_locked"');
expect(markup).toContain('Playoff seed locked');
});

it('renders bubble playoff seed signal', () => {
const markup = renderToStaticMarkup(<StandingsSignalSvg kind="seed_bubble" title="Playoff bubble" />);

expect(markup).toContain('data-standings-signal="seed_bubble"');
expect(markup).toContain('Playoff bubble');
});

it('renders out-of-picture seed signal', () => {
const markup = renderToStaticMarkup(<StandingsSignalSvg kind="seed_out" title="Outside playoff picture" />);

expect(markup).toContain('data-standings-signal="seed_out"');
expect(markup).toContain('Outside playoff picture');
});

it('renders division-leader laurel signal', () => {
const markup = renderToStaticMarkup(<StandingsSignalSvg kind="division_leader" title="Division leader" />);

expect(markup).toContain('data-standings-signal="division_leader"');
expect(markup).toContain('Division leader');
});
});
Loading
Loading