From b9cdaa505b4f4d3f2bc0d387ac8153d0369c9218 Mon Sep 17 00:00:00 2001 From: Steve Freeman Date: Wed, 9 Sep 2026 12:45:09 -0400 Subject: [PATCH 1/2] Add weekly lineup optimizer Use Sleeper matchup and projection data to rank complete lineups against the weekly opponent, then explain recommended start/sit changes with AI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/backend/src/fantasy/fantasy.model.ts | 35 +++ .../src/fantasy/fantasy.service.spec.ts | 52 +++- .../backend/src/fantasy/fantasy.service.ts | 233 +++++++++++++++++- packages/frontend/src/app.model.ts | 18 ++ .../frontend/src/pages/FantasyPage.spec.tsx | 26 ++ packages/frontend/src/pages/FantasyPage.tsx | 81 ++++++ 6 files changed, 439 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/fantasy/fantasy.model.ts b/packages/backend/src/fantasy/fantasy.model.ts index 305e266f..8ff3245b 100644 --- a/packages/backend/src/fantasy/fantasy.model.ts +++ b/packages/backend/src/fantasy/fantasy.model.ts @@ -15,6 +15,8 @@ export interface SleeperLeague { settings?: { waiver_budget?: number; }; + roster_positions?: string[]; + scoring_settings?: Record; } export interface SleeperRoster { @@ -63,15 +65,30 @@ export interface SleeperPlayer { position: string | null; team: string | null; injury_status: string | null; + fantasy_positions?: string[] | null; search_rank?: number | null; } +export interface SleeperMatchup { + roster_id: number; + matchup_id: number | null; + players: string[] | null; + starters: string[] | null; +} + +export interface SleeperProjection { + player_id: string; + opponent?: string | null; + stats?: Record; +} + export interface FantasyPlayer { id: string; name: string; position: string | null; team: string | null; injuryStatus: string | null; + fantasyPositions: string[]; } export interface FantasyTeam { @@ -128,6 +145,21 @@ export interface TeamHealth { summary: string; } +export interface ProjectedFantasyPlayer extends FantasyPlayer { + projectedPoints: number; +} + +export interface LineupRecommendation { + week: number; + opponentOwnerName: string | null; + recommendedStarters: ProjectedFantasyPlayer[]; + start: ProjectedFantasyPlayer[]; + sit: ProjectedFantasyPlayer[]; + userPotential: { min: number; max: number }; + opponentPotential: { min: number; max: number } | null; + summary: string; +} + export interface GameToWatch { id: string; startsAt: string; @@ -146,6 +178,8 @@ export interface FantasyOverview { gamesToWatch: GameToWatch[]; tradeSuggestions: TradeSuggestion[]; waiverSuggestions: WaiverSuggestion[]; + lineupRecommendation: LineupRecommendation | null; + lineupStatus: 'ready' | 'unavailable' | 'no_matchup'; teamHealth: TeamHealth | null; aiStatus: 'ready' | 'unavailable'; sleeperUrl: string; @@ -180,4 +214,5 @@ export interface AITradeAnalysis { priority: 'high' | 'medium' | 'low'; recommendedBid: number; }>; + lineupSummary: string; } diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index 1e5866c1..b4a9a076 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -45,6 +45,7 @@ const aiResponse = { recommendedBid: 17, }, ], + lineupSummary: 'Start Drew Runner to maximize your matchup ceiling this week.', }), }, ], @@ -101,6 +102,8 @@ describe('FantasyService', () => { status: 'in_season', avatar: null, total_rosters: 2, + roster_positions: ['RB'], + scoring_settings: { rush_yd: 0.1, rush_td: 6 }, }, ], }); @@ -108,7 +111,7 @@ describe('FantasyService', () => { if (url.endsWith('/league/999/rosters')) { return Promise.resolve({ data: [ - { roster_id: 1, owner_id: '123', players: ['p1'], starters: ['p1'] }, + { roster_id: 1, owner_id: '123', players: ['p1', 'p4'], starters: ['p1'] }, { roster_id: 2, owner_id: '456', players: ['p2'], starters: ['p2'] }, ], }); @@ -121,6 +124,24 @@ describe('FantasyService', () => { ], }); } + if (url.endsWith('/league/999/matchups/1')) { + return Promise.resolve({ + data: [ + { roster_id: 1, matchup_id: 7, players: ['p1', 'p4'], starters: ['p1'] }, + { roster_id: 2, matchup_id: 7, players: ['p2'], starters: ['p2'] }, + ], + }); + } + if (url.includes('api.sleeper.com/projections/nfl/2026/1')) { + return Promise.resolve({ + data: [ + { player_id: 'p1', stats: { rush_yd: 20, rush_td: 0 } }, + { player_id: 'p2', stats: { rush_yd: 50, rush_td: 1 } }, + { player_id: 'p3', stats: { rush_yd: 80, rush_td: 1 } }, + { player_id: 'p4', stats: { rush_yd: 80, rush_td: 1 } }, + ], + }); + } if (url.endsWith('/league/999/transactions/1')) { return Promise.resolve({ data: [ @@ -157,6 +178,7 @@ describe('FantasyService', () => { position: 'WR', team: 'BUF', injury_status: null, + fantasy_positions: ['RB'], }, p2: { player_id: 'p2', @@ -165,16 +187,27 @@ describe('FantasyService', () => { position: 'RB', team: 'NYJ', injury_status: null, + fantasy_positions: ['RB'], }, p3: { player_id: 'p3', first_name: 'Casey', last_name: 'Waiver', - position: 'WR', + position: 'RB', team: 'DAL', injury_status: null, + fantasy_positions: ['RB'], search_rank: 10, }, + p4: { + player_id: 'p4', + first_name: 'Drew', + last_name: 'Runner', + position: 'RB', + team: 'DAL', + injury_status: null, + fantasy_positions: ['RB'], + }, }, }); } @@ -239,6 +272,15 @@ describe('FantasyService', () => { summary: 'Strong starters and balanced depth make this roster a contender.', }); expect(result?.aiStatus).toBe('ready'); + expect(result?.lineupRecommendation).toMatchObject({ + week: 1, + opponentOwnerName: 'Bob', + userPotential: { min: 2, max: 14 }, + opponentPotential: { min: 11, max: 11 }, + start: [{ id: 'p4' }], + sit: [{ id: 'p1' }], + summary: 'Start Drew Runner to maximize your matchup ceiling this week.', + }); }); it('returns league data with fallback insights when AI is unavailable', async () => { @@ -273,6 +315,10 @@ describe('FantasyService', () => { if (url.endsWith('/league/999/users')) { return Promise.resolve({ data: [] }); } + if (url.endsWith('/league/999/matchups/2')) return Promise.resolve({ data: [] }); + if (url.includes('api.sleeper.com/projections/nfl/2026/2')) { + return Promise.reject(new Error('Projections unavailable')); + } if (url.endsWith('/league/999/transactions/2')) { return Promise.resolve({ data: [ @@ -331,6 +377,8 @@ describe('FantasyService', () => { const result = await service.getOverview('U1', 'T1', '999'); expect(result?.aiStatus).toBe('unavailable'); + expect(result?.lineupStatus).toBe('unavailable'); + expect(result?.lineupRecommendation).toBeNull(); expect(result?.teamHealth).toBeNull(); expect(result?.waiverSuggestions).toEqual([]); expect(result?.pendingTrades[0]).toMatchObject({ diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index 6769c554..d6ea79ff 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -15,6 +15,7 @@ import { logger } from '../shared/logger/logger'; import type { AITradeAnalysis, FantasyLandingResponse, + LineupRecommendation, FantasyOverview, FantasyPlayer, FantasyTeam, @@ -23,7 +24,9 @@ import type { PendingTrade, SleeperLeague, SleeperLeagueUser, + SleeperMatchup, SleeperPlayer, + SleeperProjection, SleeperRoster, SleeperTransaction, SleeperUser, @@ -34,6 +37,7 @@ import type { } from './fantasy.model'; const SLEEPER_API_URL = 'https://api.sleeper.app/v1'; +const SLEEPER_PROJECTIONS_URL = 'https://api.sleeper.com/projections/nfl'; const ESPN_SCOREBOARD_URL = 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard'; const PLAYER_CACHE_MS = 24 * 60 * 60 * 1000; const SLEEPER_ID_PATTERN = /^\d{1,32}$/; @@ -206,6 +210,30 @@ export class FantasyService { const waiverBudget = Math.max(0, league.settings?.waiver_budget ?? 100); const waiverBudgetUsed = Math.max(0, ownRoster.settings?.waiver_budget_used ?? 0); const remainingWaiverBudget = Math.max(0, waiverBudget - waiverBudgetUsed); + let lineupRecommendation: LineupRecommendation | null = null; + let lineupStatus: FantasyOverview['lineupStatus']; + try { + const [matchups, projections] = await Promise.all([ + this.get(`/league/${leagueId}/matchups/${Math.max(state.week, 1)}`), + this.getProjections(state), + ]); + lineupRecommendation = this.buildLineupRecommendation( + Math.max(state.week, 1), + league, + roster, + teams, + matchups, + projections, + ); + lineupStatus = lineupRecommendation ? 'ready' : 'no_matchup'; + } catch (error) { + logError(this.serviceLogger, 'Failed to build weekly lineup recommendation', error, { + leagueId, + rosterId: roster.rosterId, + week: state.week, + }); + lineupStatus = 'unavailable'; + } let analysis: AITradeAnalysis; let aiStatus: FantasyOverview['aiStatus'] = 'ready'; try { @@ -216,6 +244,7 @@ export class FantasyService { pendingTransactions, waiverCandidates, remainingWaiverBudget, + lineupRecommendation, ); } catch (error) { logError(this.serviceLogger, 'Failed to generate fantasy trade analysis', error, { @@ -227,6 +256,7 @@ export class FantasyService { tradeInsights: [], suggestions: [], waiverSuggestions: [], + lineupSummary: '', }; aiStatus = 'unavailable'; } @@ -243,6 +273,14 @@ export class FantasyService { gamesToWatch: this.buildGames(scoreboard, roster.players), tradeSuggestions, waiverSuggestions, + lineupRecommendation: lineupRecommendation + ? { + ...lineupRecommendation, + summary: + aiStatus === 'ready' ? analysis.lineupSummary : this.buildLineupFallbackSummary(lineupRecommendation), + } + : null, + lineupStatus, teamHealth: aiStatus === 'ready' ? this.buildTeamHealth(analysis) : null, aiStatus, sleeperUrl: `https://sleeper.com/leagues/${leagueId}`, @@ -270,6 +308,16 @@ export class FantasyService { return this.get(`/user/${encodeURIComponent(userId)}/leagues/nfl/${encodeURIComponent(season)}`); } + private getProjections(state: NflState): Promise { + return Axios.get( + `${SLEEPER_PROJECTIONS_URL}/${encodeURIComponent(state.season)}/${Math.max(state.week, 1)}`, + { + params: { season_type: state.season_type }, + timeout: 10000, + }, + ).then((response) => response.data); + } + private async getPlayers(): Promise> { if (this.playerCache && this.playerCache.expiresAt > Date.now()) { return this.playerCache.players; @@ -309,9 +357,178 @@ export class FantasyService { position: player?.position ?? null, team: player?.team ?? null, injuryStatus: player?.injury_status ?? null, + fantasyPositions: player?.fantasy_positions?.length + ? player.fantasy_positions + : player?.position + ? [player.position] + : [], }; } + private buildLineupRecommendation( + week: number, + league: SleeperLeague, + ownRoster: FantasyTeam, + teams: FantasyTeam[], + matchups: SleeperMatchup[], + projections: SleeperProjection[], + ): LineupRecommendation | null { + const ownMatchup = matchups.find((matchup) => matchup.roster_id === ownRoster.rosterId); + if (!ownMatchup || ownMatchup.matchup_id === null) { + return null; + } + const opponentMatchup = matchups.find( + (matchup) => matchup.matchup_id === ownMatchup.matchup_id && matchup.roster_id !== ownMatchup.roster_id, + ); + const opponent = opponentMatchup ? teams.find((team) => team.rosterId === opponentMatchup.roster_id) : undefined; + const slots = (league.roster_positions ?? []).filter((slot) => !['BN', 'IR', 'TAXI'].includes(slot)); + if (!slots.length) { + return null; + } + + const projectionsByPlayer = new Map(projections.map((projection) => [projection.player_id, projection])); + const scorePlayers = (team: FantasyTeam, matchup: SleeperMatchup) => { + const activePlayerIds = new Set(matchup.players ?? []); + return team.players.flatMap((player) => { + if (!activePlayerIds.has(player.id)) return []; + const projectedPoints = this.projectedPoints( + projectionsByPlayer.get(player.id)?.stats, + league.scoring_settings, + ); + return projectedPoints === null ? [] : [{ ...player, projectedPoints }]; + }); + }; + const ownPlayers = scorePlayers(ownRoster, ownMatchup); + if (!ownPlayers.length) { + throw new Error('Sleeper returned no usable weekly projections for the user roster.'); + } + const maximum = this.optimizeLineup(ownPlayers, slots, 'max'); + const minimum = this.optimizeLineup(ownPlayers, slots, 'min'); + if (!maximum.length || !minimum.length) { + return null; + } + const currentStarterIds = new Set(ownRoster.starters); + const recommendedIds = new Set(maximum.map((player) => player.id)); + const opponentPlayers = opponent && opponentMatchup ? scorePlayers(opponent, opponentMatchup) : []; + if (opponent && !opponentPlayers.length) { + throw new Error('Sleeper returned no usable weekly projections for the opponent roster.'); + } + const opponentMaximum = opponent ? this.optimizeLineup(opponentPlayers, slots, 'max') : []; + const opponentMinimum = opponent ? this.optimizeLineup(opponentPlayers, slots, 'min') : []; + + return { + week, + opponentOwnerName: opponent?.ownerName ?? null, + recommendedStarters: maximum, + start: maximum.filter((player) => !currentStarterIds.has(player.id)), + sit: ownPlayers.filter((player) => currentStarterIds.has(player.id) && !recommendedIds.has(player.id)), + userPotential: { + min: this.lineupTotal(minimum), + max: this.lineupTotal(maximum), + }, + opponentPotential: + opponentMaximum.length && opponentMinimum.length + ? { + min: this.lineupTotal(opponentMinimum), + max: this.lineupTotal(opponentMaximum), + } + : null, + summary: '', + }; + } + + private projectedPoints( + stats: Record | undefined, + scoringSettings: Record | undefined, + ): number | null { + if (!stats) return null; + if (scoringSettings && Object.keys(scoringSettings).length) { + const scoringEntries = Object.entries(scoringSettings).filter(([stat]) => stats[stat] !== undefined); + if (!scoringEntries.length) return null; + return scoringEntries.reduce((total, [stat, multiplier]) => total + (stats[stat] ?? 0) * multiplier, 0); + } + return stats.pts_ppr ?? stats.pts_half_ppr ?? stats.pts_std ?? null; + } + + private optimizeLineup( + players: LineupRecommendation['recommendedStarters'], + slots: string[], + direction: 'min' | 'max', + ): LineupRecommendation['recommendedStarters'] { + const orderedSlots = [...slots].sort( + (left, right) => + players.filter((player) => this.isEligible(player, left)).length - + players.filter((player) => this.isEligible(player, right)).length, + ); + type OptimizedLineup = { + score: number; + players: LineupRecommendation['recommendedStarters']; + }; + const memo = new Map(); + const solve = (slotIndex: number, usedPlayers: bigint): OptimizedLineup => { + if (slotIndex === orderedSlots.length) { + return { score: 0, players: [] }; + } + const key = `${slotIndex}:${usedPlayers}`; + const cached = memo.get(key); + if (cached) return cached; + + const eligible = players + .map((player, index) => ({ player, index })) + .filter( + ({ player, index }) => + (usedPlayers & (1n << BigInt(index))) === 0n && this.isEligible(player, orderedSlots[slotIndex]), + ); + if (!eligible.length) { + const result = solve(slotIndex + 1, usedPlayers); + memo.set(key, result); + return result; + } + + let best: OptimizedLineup | null = null; + for (const { player, index } of eligible) { + const remaining = solve(slotIndex + 1, usedPlayers | (1n << BigInt(index))); + const candidate = { + score: player.projectedPoints + remaining.score, + players: [player, ...remaining.players], + }; + if ( + best === null || + (direction === 'max' && candidate.score > best.score) || + (direction === 'min' && candidate.score < best.score) + ) { + best = candidate; + } + } + const result = best ?? { score: 0, players: [] }; + memo.set(key, result); + return result; + }; + + return solve(0, 0n).players; + } + + private isEligible(player: FantasyPlayer, slot: string): boolean { + const eligiblePositions: Record = { + FLEX: ['RB', 'WR', 'TE'], + SUPER_FLEX: ['QB', 'RB', 'WR', 'TE'], + REC_FLEX: ['WR', 'TE'], + WRRB_FLEX: ['WR', 'RB'], + IDP_FLEX: ['DL', 'LB', 'DB'], + }; + const positions = eligiblePositions[slot] ?? [slot]; + return player.fantasyPositions.some((position) => positions.includes(position)); + } + + private lineupTotal(players: LineupRecommendation['recommendedStarters']): number { + return Math.round(players.reduce((total, player) => total + player.projectedPoints, 0) * 10) / 10; + } + + private buildLineupFallbackSummary(recommendation: LineupRecommendation): string { + const opponent = recommendation.opponentOwnerName ? ` against ${recommendation.opponentOwnerName}` : ''; + return `The highest-projected lineup for week ${recommendation.week}${opponent} is ${recommendation.userPotential.max.toFixed(1)} points.`; + } + private toFantasyTeam( roster: SleeperRoster, ownerNames: Map, @@ -513,6 +730,7 @@ export class FantasyService { pendingTransactions: SleeperTransaction[], waiverCandidates: FantasyPlayer[], remainingWaiverBudget: number, + lineupRecommendation: LineupRecommendation | null, ): Promise { const compactTeams = teams.map((team) => ({ rosterId: team.rosterId, @@ -529,14 +747,15 @@ export class FantasyService { model: GPT_MODEL, reasoning: { effort: 'low' }, instructions: - 'You are a fantasy football analyst. Return only valid JSON with keys teamHealth, tradeInsights, suggestions, and waiverSuggestions. ' + + 'You are a fantasy football analyst. Return only valid JSON with keys teamHealth, tradeInsights, suggestions, waiverSuggestions, and lineupSummary. ' + 'teamHealth must assess the user roster relative to the supplied league with an integer percentage from 0 to 100 and a concise summary. ' + 'tradeInsights must include exactly one item per pending transaction with transactionId, a concise insight, ' + 'and recommendation of accept, decline, or negotiate. suggestions must contain up to 3 realistic options ' + 'with targetRosterId, givePlayerIds, receivePlayerIds, and rationale. waiverSuggestions must contain up to 3 ' + 'add/drop proposals using only the supplied waiver candidate and user roster IDs, with rationale and a high, ' + 'medium, or low priority, plus an integer recommendedBid in dollars that does not exceed remainingWaiverBudget. ' + - 'Waivers process Wednesday and Sunday. Use only supplied IDs and rosters.', + 'Waivers process Wednesday and Sunday. lineupSummary must briefly explain the supplied deterministic weekly lineup recommendation, ' + + 'including its overall potential versus the opponent; do not change or invent player IDs or projections. Use only supplied IDs and rosters.', input: JSON.stringify({ league: { name: league.name, season: league.season }, userRosterId: ownRoster.rosterId, @@ -544,6 +763,7 @@ export class FantasyService { pendingTransactions, waiverCandidates, remainingWaiverBudget, + lineupRecommendation, }), text: { format: { @@ -553,7 +773,7 @@ export class FantasyService { schema: { type: 'object', additionalProperties: false, - required: ['teamHealth', 'tradeInsights', 'suggestions', 'waiverSuggestions'], + required: ['teamHealth', 'tradeInsights', 'suggestions', 'waiverSuggestions', 'lineupSummary'], properties: { teamHealth: { type: 'object', @@ -608,6 +828,7 @@ export class FantasyService { }, }, }, + lineupSummary: { type: 'string' }, }, }, }, @@ -636,12 +857,15 @@ export class FantasyService { const tradeInsights = Reflect.get(value, 'tradeInsights'); const suggestions = Reflect.get(value, 'suggestions'); const waiverSuggestions = Reflect.get(value, 'waiverSuggestions'); + const lineupSummary = Reflect.get(value, 'lineupSummary'); if ( !teamHealth || typeof teamHealth !== 'object' || !Array.isArray(tradeInsights) || !Array.isArray(suggestions) || - !Array.isArray(waiverSuggestions) + !Array.isArray(waiverSuggestions) || + typeof lineupSummary !== 'string' || + !lineupSummary.trim() ) { throw new Error('AI returned invalid trade analysis.'); } @@ -727,6 +951,7 @@ export class FantasyService { tradeInsights: validatedInsights, suggestions: validatedSuggestions, waiverSuggestions: validatedWaiverSuggestions, + lineupSummary: lineupSummary.trim(), }; } } diff --git a/packages/frontend/src/app.model.ts b/packages/frontend/src/app.model.ts index a82c2f86..5280a4ec 100644 --- a/packages/frontend/src/app.model.ts +++ b/packages/frontend/src/app.model.ts @@ -131,6 +131,22 @@ export interface FantasyPlayer { position: string | null; team: string | null; injuryStatus: string | null; + fantasyPositions: string[]; +} + +export interface ProjectedFantasyPlayer extends FantasyPlayer { + projectedPoints: number; +} + +export interface LineupRecommendation { + week: number; + opponentOwnerName: string | null; + recommendedStarters: ProjectedFantasyPlayer[]; + start: ProjectedFantasyPlayer[]; + sit: ProjectedFantasyPlayer[]; + userPotential: { min: number; max: number }; + opponentPotential: { min: number; max: number } | null; + summary: string; } export interface PendingTrade { @@ -201,6 +217,8 @@ export interface FantasyOverview { gamesToWatch: GameToWatch[]; tradeSuggestions: TradeSuggestion[]; waiverSuggestions: WaiverSuggestion[]; + lineupRecommendation: LineupRecommendation | null; + lineupStatus: 'ready' | 'unavailable' | 'no_matchup'; teamHealth: TeamHealth | null; aiStatus: 'ready' | 'unavailable'; sleeperUrl: string; diff --git a/packages/frontend/src/pages/FantasyPage.spec.tsx b/packages/frontend/src/pages/FantasyPage.spec.tsx index 19cd9962..038c71dc 100644 --- a/packages/frontend/src/pages/FantasyPage.spec.tsx +++ b/packages/frontend/src/pages/FantasyPage.spec.tsx @@ -74,6 +74,27 @@ const overview = { sleeperUrl: 'https://sleeper.com/leagues/999', }, ], + lineupRecommendation: { + week: 1, + opponentOwnerName: 'Bob', + recommendedStarters: [ + { + id: 'p3', + name: 'Casey Waiver', + position: 'WR', + team: 'DAL', + injuryStatus: null, + fantasyPositions: ['WR'], + projectedPoints: 18.4, + }, + ], + start: [], + sit: [], + userPotential: { min: 8.2, max: 18.4 }, + opponentPotential: { min: 9.1, max: 16.7 }, + summary: 'This lineup gives you the strongest overall projection against Bob.', + }, + lineupStatus: 'ready', teamHealth: { percentage: 82, rating: 'good', @@ -117,6 +138,9 @@ describe('FantasyPage', () => { expect(screen.getByText('82%')).toBeInTheDocument(); expect(screen.getByText('Strong starters and balanced depth make this roster a contender.')).toBeInTheDocument(); expect(screen.getByText('$17')).toBeInTheDocument(); + expect(screen.getByText('Best projected lineup vs. Bob')).toBeInTheDocument(); + expect(screen.getByText('8.2–18.4')).toBeInTheDocument(); + expect(screen.getByText('9.1–16.7')).toBeInTheDocument(); expect(screen.getByText(/waivers process wednesday and sunday/i)).toBeInTheDocument(); const headings = screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent?.trim()); expect(headings.indexOf('Trade ideas')).toBeLessThan(headings.indexOf('Games to watch')); @@ -137,6 +161,8 @@ describe('FantasyPage', () => { waiverSuggestions: [], teamHealth: null, aiStatus: 'unavailable', + lineupRecommendation: null, + lineupStatus: 'no_matchup', }; mockFetch .mockResolvedValueOnce({ ok: true, status: 200, json: async () => landing }) diff --git a/packages/frontend/src/pages/FantasyPage.tsx b/packages/frontend/src/pages/FantasyPage.tsx index eeefbc9c..c128022c 100644 --- a/packages/frontend/src/pages/FantasyPage.tsx +++ b/packages/frontend/src/pages/FantasyPage.tsx @@ -7,6 +7,7 @@ import { Sparkles, Trophy, Tv, + Users, UserPlus, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -161,6 +162,86 @@ export function FantasyPage({ onLogout }: FantasyPageProps) { )} +
+

+

+ {overview.lineupStatus === 'unavailable' ? ( + + + Weekly Sleeper projections are temporarily unavailable, so lineup potential could not be + calculated. + + + ) : !overview.lineupRecommendation ? ( + + + No head-to-head matchup or configurable starting slots were found for this week. + + + ) : ( + + + + Best projected lineup + {overview.lineupRecommendation.opponentOwnerName + ? ` vs. ${overview.lineupRecommendation.opponentOwnerName}` + : ''} + + {overview.lineupRecommendation.summary} + + +
+
+

Your overall lineup potential

+

+ {overview.lineupRecommendation.userPotential.min.toFixed(1)}– + {overview.lineupRecommendation.userPotential.max.toFixed(1)} +

+
+ {overview.lineupRecommendation.opponentPotential && ( +
+

Opponent lineup potential

+

+ {overview.lineupRecommendation.opponentPotential.min.toFixed(1)}– + {overview.lineupRecommendation.opponentPotential.max.toFixed(1)} +

+
+ )} +
+
+

Recommended starters

+
+ {overview.lineupRecommendation.recommendedStarters.map((player) => ( +
+ + {player.name} + {player.position ? ` · ${player.position}` : ''} + + {player.projectedPoints.toFixed(1)} +
+ ))} +
+
+ {(overview.lineupRecommendation.start.length > 0 || + overview.lineupRecommendation.sit.length > 0) && ( +
+
+

Move into lineup

+ +
+
+

Move to bench

+ +
+
+ )} +
+
+ )} +
+