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
6 changes: 6 additions & 0 deletions apps/web/src/app/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,10 @@ describe('App Chip setup wiring', () => {
expect(content).toContain("path: '/franchise/achievements'");
expect(content).toContain('routeTree.addChildren([...(routeTree.children ?? []), achievementsRoute]);');
});

it('registers the Sprint 46 weather forecast route additively', () => {
expect(content).toContain("const LazyWeatherForecast = lazy(async () => ({ default: (await import('../features/league/WeatherForecast')).WeatherForecast }));");
expect(content).toContain("path: '/league/weather'");
expect(content).toContain('routeTree.addChildren([...(routeTree.children ?? []), weatherForecastRoute]);');
});
});
13 changes: 13 additions & 0 deletions apps/web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const LazyFilmRoom = lazy(async () => ({ default: (await import('../features/fil
const LazyGameBroadcast = lazy(async () => ({ default: (await import('../features/broadcast/GameBroadcast')).GameBroadcast }));
const LazyBroadcastPresentation = lazy(async () => ({ default: (await import('../features/broadcast/BroadcastPresentation')).default }));
const LazyLeaguePulse = lazy(async () => ({ default: (await import('../features/league/LeaguePulse')).default }));
const LazyWeatherForecast = lazy(async () => ({ default: (await import('../features/league/WeatherForecast')).WeatherForecast }));
const LazySocialFeed = lazy(async () => ({ default: (await import('../features/social/SocialFeed')).SocialFeed }));
const LazyTradeBlockTicker = lazy(async () => ({ default: (await import('../features/trades/TradeBlockTicker')).TradeBlockTicker }));
const LazyTradeDeadline = lazy(async () => ({ default: (await import('../features/trades/TradeDeadline')).TradeDeadline }));
Expand Down Expand Up @@ -1806,6 +1807,16 @@ const achievementsRoute = createRoute({
),
});

const weatherForecastRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/league/weather',
component: () => (
<LazyRouteFrame label="weather">
<LazyWeatherForecast />
</LazyRouteFrame>
),
});

const routeTree = rootRoute.addChildren([
indexRoute,
rosterRoute, lockerRoomRoute, contractsRoute, capLabRoute, frontOfficeRoute, endorsementsRoute, tradesRoute, tradeBlockRoute, watchListRoute,
Expand All @@ -1822,6 +1833,8 @@ routeTree.addChildren([...(routeTree.children ?? []), mvpPlaqueWallRoute]);

routeTree.addChildren([...(routeTree.children ?? []), achievementsRoute]);

routeTree.addChildren([...(routeTree.children ?? []), weatherForecastRoute]);

const hashHistory = createHashHistory();
const router = createRouter({ routeTree, history: hashHistory });

Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/features/league/WeatherForecast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { WeatherForecastView, type ForecastGame } from './WeatherForecast';

const GAMES: ForecastGame[] = [
{
id: 'austin-at-chicago',
week: 8,
awayTeamId: 'away',
awayTeamName: 'Austin Comets',
homeTeamId: 'user',
homeTeamName: 'Chicago Blaze',
condition: 'SNOW',
conditionLabel: 'Snow',
temperatureF: 24,
windMph: 18,
impactTier: 'game_changer',
impactLabel: 'Game Changer',
userTeamGame: true,
dome: false,
},
{
id: 'miami-at-dallas',
week: 8,
awayTeamId: 'mia',
awayTeamName: 'Miami Lights',
homeTeamId: 'dal',
homeTeamName: 'Dallas Wranglers',
condition: 'DOME',
conditionLabel: 'Dome',
temperatureF: 72,
windMph: 0,
impactTier: 'minor',
impactLabel: 'Minor',
userTeamGame: false,
dome: true,
},
{
id: 'seattle-at-bay',
week: 8,
awayTeamId: 'sea',
awayTeamName: 'Seattle Tempest',
homeTeamId: 'bay',
homeTeamName: 'Bay City Bridges',
condition: 'RAIN',
conditionLabel: 'Rain',
temperatureF: 51,
windMph: 12,
impactTier: 'notable',
impactLabel: 'Notable',
userTeamGame: false,
dome: false,
},
];

describe('WeatherForecast', () => {
it('renders the Forecast header', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} />);

expect(markup).toContain('FORECAST');
expect(markup).toContain('Week 8 forecast across the league.');
});

it('renders one card per upcoming game', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} />);

expect(markup.match(/data-weather-game-card=/g)).toHaveLength(3);
});

it('renders the weather glyph for each game condition', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} />);

expect(markup).toContain('data-weather-glyph="SNOW"');
expect(markup).toContain('data-weather-glyph="DOME"');
expect(markup).toContain('data-weather-glyph="RAIN"');
});

it('filters User Team Only games', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} initialFilter="user" />);

expect(markup).toContain('Austin Comets');
expect(markup).not.toContain('Miami Lights');
});

it('filters Domes Only games', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} initialFilter="domes" />);

expect(markup).toContain('Dallas Wranglers');
expect(markup).not.toContain('Chicago Blaze');
});

it('marks impact-tier badges for game changers', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} />);

expect(markup).toContain('data-impact-tier="game_changer"');
expect(markup).toContain('Game Changer');
});

it('filters Outdoor Only games', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={GAMES} week={8} initialFilter="outdoor" />);

expect(markup).toContain('Chicago Blaze');
expect(markup).toContain('Bay City Bridges');
expect(markup).not.toContain('Dallas Wranglers');
});

it('renders the empty state when no games are scheduled', () => {
const markup = renderToStaticMarkup(<WeatherForecastView games={[]} week={8} />);

expect(markup).toContain('No games scheduled this week.');
});
});
228 changes: 228 additions & 0 deletions apps/web/src/features/league/WeatherForecast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { useMemo, useState } from 'react';
import type { ScheduleWeek, Team, WeatherCondition } from '@mfd/engine';
import { PixelBadge, PixelButton, PixelPanel } from '@mfd/design-system/components';
import {
selectSchedule,
selectTeams,
selectUserTeam,
selectWeek,
useGameStore,
} from '../../app/store/game-store';
import {
PixelScreenHeader,
autoGrid,
monoSm,
pixelSm,
screenStackStyle,
} from '../shared/pixelUi';
import { WeatherGlyphSvg, type WeatherGlyphVariant } from './weatherGlyphSvg';

type ForecastFilter = 'all' | 'user' | 'outdoor' | 'domes';
export type ImpactTier = 'game_changer' | 'notable' | 'minor';

export interface ForecastGame {
id: string;
week: number;
awayTeamId: string;
awayTeamName: string;
homeTeamId: string;
homeTeamName: string;
condition: WeatherGlyphVariant;
conditionLabel: string;
temperatureF: number;
windMph: number;
impactTier: ImpactTier;
impactLabel: string;
userTeamGame: boolean;
dome: boolean;
}

const FILTERS: Array<{ id: ForecastFilter; label: string }> = [
{ id: 'all', label: 'All Games' },
{ id: 'user', label: 'User Team Only' },
{ id: 'outdoor', label: 'Outdoor Only' },
{ id: 'domes', label: 'Domes Only' },
];

const CONDITION_LABELS: Record<WeatherGlyphVariant, string> = {
SUNNY: 'Sunny',
PARTLY_CLOUDY: 'Partly Cloudy',
CLOUDY: 'Cloudy',
RAIN: 'Rain',
SNOW: 'Snow',
WIND: 'Wind',
DOME: 'Dome',
HEAT_WAVE: 'Heat Wave',
};

const IMPACT_VARIANTS: Record<ImpactTier, 'red' | 'gold' | 'cyan'> = {
game_changer: 'red',
notable: 'gold',
minor: 'cyan',
};

function conditionToVariant(condition: WeatherCondition | null | undefined, homeTeam: Team): WeatherGlyphVariant {
if (condition === 'dome' || homeTeam.stadiumType === 'dome') return 'DOME';
if (condition === 'rain') return 'RAIN';
if (condition === 'snow') return 'SNOW';
if (condition === 'wind') return 'WIND';
if (homeTeam.city === 'Miami' || homeTeam.city === 'Tampa Bay' || homeTeam.city === 'Jacksonville') return 'HEAT_WAVE';
return 'SUNNY';
}

function profileFor(variant: WeatherGlyphVariant): Pick<ForecastGame, 'temperatureF' | 'windMph' | 'impactTier' | 'impactLabel'> {
if (variant === 'SNOW') return { temperatureF: 24, windMph: 16, impactTier: 'game_changer', impactLabel: 'Game Changer' };
if (variant === 'WIND') return { temperatureF: 48, windMph: 24, impactTier: 'game_changer', impactLabel: 'Game Changer' };
if (variant === 'RAIN') return { temperatureF: 51, windMph: 12, impactTier: 'notable', impactLabel: 'Notable' };
if (variant === 'HEAT_WAVE') return { temperatureF: 92, windMph: 6, impactTier: 'notable', impactLabel: 'Notable' };
if (variant === 'DOME') return { temperatureF: 72, windMph: 0, impactTier: 'minor', impactLabel: 'Minor' };
if (variant === 'CLOUDY' || variant === 'PARTLY_CLOUDY') return { temperatureF: 58, windMph: 8, impactTier: 'minor', impactLabel: 'Minor' };
return { temperatureF: 64, windMph: 5, impactTier: 'minor', impactLabel: 'Minor' };
}

function filterGames(games: ForecastGame[], filter: ForecastFilter): ForecastGame[] {
if (filter === 'user') return games.filter((game) => game.userTeamGame);
if (filter === 'outdoor') return games.filter((game) => !game.dome);
if (filter === 'domes') return games.filter((game) => game.dome);
return games;
}

function forecastFromSchedule({
schedule,
teams,
userTeamId,
week,
}: {
schedule: ScheduleWeek[];
teams: Record<string, Team>;
userTeamId: string | null;
week: number;
}): ForecastGame[] {
const targetWeek = schedule.find((entry) => entry.week === week)
?? schedule.find((entry) => entry.week > week && entry.games.some((game) => !game.result));
if (!targetWeek) return [];

return targetWeek.games
.filter((game) => !game.result)
.map((game) => {
const homeTeam = teams[game.homeTeamId];
const awayTeam = teams[game.awayTeamId];
if (!homeTeam || !awayTeam) return null;
const condition = conditionToVariant(game.weather ?? null, homeTeam);
const profile = profileFor(condition);
return {
id: `${targetWeek.week}:${game.awayTeamId}@${game.homeTeamId}`,
week: targetWeek.week,
awayTeamId: awayTeam.id,
awayTeamName: `${awayTeam.city} ${awayTeam.name}`,
homeTeamId: homeTeam.id,
homeTeamName: `${homeTeam.city} ${homeTeam.name}`,
condition,
conditionLabel: CONDITION_LABELS[condition],
dome: condition === 'DOME',
userTeamGame: game.homeTeamId === userTeamId || game.awayTeamId === userTeamId,
...profile,
};
})
.filter((game): game is ForecastGame => game !== null);
}

export function WeatherForecastView({
games,
week,
initialFilter = 'all',
}: {
games: ForecastGame[];
week: number;
initialFilter?: ForecastFilter;
}) {
const [filter, setFilter] = useState<ForecastFilter>(initialFilter);
const visibleGames = useMemo(() => filterGames(games, filter), [filter, games]);

return (
<div style={screenStackStyle}>
<PixelScreenHeader
title="Forecast"
subtitle={`Week ${week} forecast across the league.`}
badges={(
<>
<PixelBadge variant="cyan">{games.length} games</PixelBadge>
<PixelBadge variant="gold">{games.filter((game) => game.impactTier !== 'minor').length} weather alerts</PixelBadge>
</>
)}
/>

<PixelPanel title="Filters" accent="cyan">
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{FILTERS.map((entry) => (
<PixelButton key={entry.id} accent={filter === entry.id ? 'gold' : 'default'} onClick={() => setFilter(entry.id)}>
{entry.label}
</PixelButton>
))}
</div>
</PixelPanel>

{visibleGames.length === 0 ? (
<PixelPanel title="Forecast Board" accent="default">
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)', lineHeight: 1.6 }}>
No games scheduled this week.
</div>
</PixelPanel>
) : (
<div style={autoGrid(300)}>
{visibleGames.map((game) => (
<article
key={game.id}
data-weather-game-card={game.id}
style={{
minHeight: '184px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '14px',
border: `2px solid ${game.impactTier === 'game_changer' ? 'var(--mfd-red)' : 'var(--mfd-border)'}`,
background: 'var(--mfd-bg-2)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
<div style={{ ...pixelSm, color: 'var(--mfd-cyan)' }}>
WEEK {game.week}
</div>
<div style={{ fontFamily: 'var(--mfd-font-display)', fontSize: '20px', color: 'var(--mfd-text)', lineHeight: 1.1 }}>
{game.awayTeamName} @ {game.homeTeamName}
</div>
</div>
<WeatherGlyphSvg variant={game.condition} label={game.conditionLabel} />
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<PixelBadge variant="cyan">{game.conditionLabel}</PixelBadge>
<span data-impact-tier={game.impactTier}>
<PixelBadge variant={IMPACT_VARIANTS[game.impactTier]}>{game.impactLabel}</PixelBadge>
</span>
<PixelBadge variant={game.dome ? 'gold' : 'default'}>{game.dome ? 'Dome' : 'Outdoor'}</PixelBadge>
</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<span style={{ ...monoSm, color: 'var(--mfd-text)' }}>{game.temperatureF}F</span>
<span style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>Wind {game.windMph} MPH</span>
</div>
</article>
))}
</div>
)}
</div>
);
}

export function WeatherForecast() {
const schedule = useGameStore(selectSchedule);
const teams = useGameStore(selectTeams);
const userTeam = useGameStore(selectUserTeam);
const week = useGameStore(selectWeek);
const games = useMemo(
() => forecastFromSchedule({ schedule, teams: teams ?? {}, userTeamId: userTeam?.id ?? null, week }),
[schedule, teams, userTeam?.id, week],
);

return <WeatherForecastView games={games} week={week} />;
}
Loading
Loading