From 6938262d7adbbcc4908501c4136ae89891917a96 Mon Sep 17 00:00:00 2001 From: KevinBigham Date: Thu, 30 Apr 2026 12:56:46 -0500 Subject: [PATCH] Sprint 46: Add weather forecast hub Surfaces existing schedule weather through a league forecast route without changing engine or save state. --- apps/web/src/app/App.test.tsx | 6 + apps/web/src/app/App.tsx | 13 + .../features/league/WeatherForecast.test.tsx | 112 +++++++++ .../src/features/league/WeatherForecast.tsx | 228 ++++++++++++++++++ .../features/league/weatherGlyphSvg.test.tsx | 45 ++++ .../src/features/league/weatherGlyphSvg.tsx | 123 ++++++++++ 6 files changed, 527 insertions(+) create mode 100644 apps/web/src/features/league/WeatherForecast.test.tsx create mode 100644 apps/web/src/features/league/WeatherForecast.tsx create mode 100644 apps/web/src/features/league/weatherGlyphSvg.test.tsx create mode 100644 apps/web/src/features/league/weatherGlyphSvg.tsx diff --git a/apps/web/src/app/App.test.tsx b/apps/web/src/app/App.test.tsx index 116173ce..122b1f0e 100644 --- a/apps/web/src/app/App.test.tsx +++ b/apps/web/src/app/App.test.tsx @@ -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]);'); + }); }); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 49ae27cb..2e2820f9 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -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 })); @@ -1806,6 +1807,16 @@ const achievementsRoute = createRoute({ ), }); +const weatherForecastRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/league/weather', + component: () => ( + + + + ), +}); + const routeTree = rootRoute.addChildren([ indexRoute, rosterRoute, lockerRoomRoute, contractsRoute, capLabRoute, frontOfficeRoute, endorsementsRoute, tradesRoute, tradeBlockRoute, watchListRoute, @@ -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 }); diff --git a/apps/web/src/features/league/WeatherForecast.test.tsx b/apps/web/src/features/league/WeatherForecast.test.tsx new file mode 100644 index 00000000..d51be2ff --- /dev/null +++ b/apps/web/src/features/league/WeatherForecast.test.tsx @@ -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(); + + expect(markup).toContain('FORECAST'); + expect(markup).toContain('Week 8 forecast across the league.'); + }); + + it('renders one card per upcoming game', () => { + const markup = renderToStaticMarkup(); + + expect(markup.match(/data-weather-game-card=/g)).toHaveLength(3); + }); + + it('renders the weather glyph for each game condition', () => { + const markup = renderToStaticMarkup(); + + 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(); + + expect(markup).toContain('Austin Comets'); + expect(markup).not.toContain('Miami Lights'); + }); + + it('filters Domes Only games', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('Dallas Wranglers'); + expect(markup).not.toContain('Chicago Blaze'); + }); + + it('marks impact-tier badges for game changers', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-impact-tier="game_changer"'); + expect(markup).toContain('Game Changer'); + }); + + it('filters Outdoor Only games', () => { + const markup = renderToStaticMarkup(); + + 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(); + + expect(markup).toContain('No games scheduled this week.'); + }); +}); diff --git a/apps/web/src/features/league/WeatherForecast.tsx b/apps/web/src/features/league/WeatherForecast.tsx new file mode 100644 index 00000000..f183d3c2 --- /dev/null +++ b/apps/web/src/features/league/WeatherForecast.tsx @@ -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 = { + SUNNY: 'Sunny', + PARTLY_CLOUDY: 'Partly Cloudy', + CLOUDY: 'Cloudy', + RAIN: 'Rain', + SNOW: 'Snow', + WIND: 'Wind', + DOME: 'Dome', + HEAT_WAVE: 'Heat Wave', +}; + +const IMPACT_VARIANTS: Record = { + 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 { + 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; + 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(initialFilter); + const visibleGames = useMemo(() => filterGames(games, filter), [filter, games]); + + return ( +
+ + {games.length} games + {games.filter((game) => game.impactTier !== 'minor').length} weather alerts + + )} + /> + + +
+ {FILTERS.map((entry) => ( + setFilter(entry.id)}> + {entry.label} + + ))} +
+
+ + {visibleGames.length === 0 ? ( + +
+ No games scheduled this week. +
+
+ ) : ( +
+ {visibleGames.map((game) => ( +
+
+
+
+ WEEK {game.week} +
+
+ {game.awayTeamName} @ {game.homeTeamName} +
+
+ +
+
+ {game.conditionLabel} + + {game.impactLabel} + + {game.dome ? 'Dome' : 'Outdoor'} +
+
+ {game.temperatureF}F + Wind {game.windMph} MPH +
+
+ ))} +
+ )} +
+ ); +} + +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 ; +} diff --git a/apps/web/src/features/league/weatherGlyphSvg.test.tsx b/apps/web/src/features/league/weatherGlyphSvg.test.tsx new file mode 100644 index 00000000..f38a54ff --- /dev/null +++ b/apps/web/src/features/league/weatherGlyphSvg.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { WEATHER_GLYPH_VARIANTS, WeatherGlyphSvg } from './weatherGlyphSvg'; + +describe('WeatherGlyphSvg', () => { + it('renders all 8 weather glyph variants', () => { + for (const variant of WEATHER_GLYPH_VARIANTS) { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(`data-weather-glyph="${variant}"`); + } + }); + + it('renders sun rays for SUNNY', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-sun-rays="true"'); + }); + + it('renders raindrops for RAIN', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-raindrops="true"'); + }); + + it('renders snowflakes for SNOW', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-snowflakes="true"'); + }); + + it('renders the dome shield for DOME', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-dome-shield="true"'); + }); + + it('renders distinct SVG path data across every variant', () => { + const signatures = WEATHER_GLYPH_VARIANTS.map((variant) => + renderToStaticMarkup().match(/d="[^"]+"/g)?.join('|') ?? '', + ); + + expect(new Set(signatures)).toHaveLength(WEATHER_GLYPH_VARIANTS.length); + }); +}); diff --git a/apps/web/src/features/league/weatherGlyphSvg.tsx b/apps/web/src/features/league/weatherGlyphSvg.tsx new file mode 100644 index 00000000..c7934eda --- /dev/null +++ b/apps/web/src/features/league/weatherGlyphSvg.tsx @@ -0,0 +1,123 @@ +export const WEATHER_GLYPH_VARIANTS = [ + 'SUNNY', + 'PARTLY_CLOUDY', + 'CLOUDY', + 'RAIN', + 'SNOW', + 'WIND', + 'DOME', + 'HEAT_WAVE', +] as const; + +export type WeatherGlyphVariant = typeof WEATHER_GLYPH_VARIANTS[number]; + +export function WeatherGlyphSvg({ + variant, + label, +}: { + variant: WeatherGlyphVariant; + label: string; +}) { + const sun = ( + + + + + + + + + + + + ); + const cloud = ( + + ); + + return ( + + {variant === 'SUNNY' ? sun : null} + {variant === 'PARTLY_CLOUDY' ? ( + <> + {sun} + {cloud} + + ) : null} + {variant === 'CLOUDY' ? ( + <> + {cloud} + {cloud} + + ) : null} + {variant === 'RAIN' ? ( + <> + {cloud} + + + + + + + ) : null} + {variant === 'SNOW' ? ( + <> + {cloud} + + + + + + + + + + + + + + ) : null} + {variant === 'WIND' ? ( + + + + + + ) : null} + {variant === 'DOME' ? ( + + + + + + + + ) : null} + {variant === 'HEAT_WAVE' ? ( + <> + {sun} + + + + + + + ) : null} + + ); +}