diff --git a/packages/backend/src/fantasy/fantasy.controller.spec.ts b/packages/backend/src/fantasy/fantasy.controller.spec.ts index 42f38133..38c8f7e4 100644 --- a/packages/backend/src/fantasy/fantasy.controller.spec.ts +++ b/packages/backend/src/fantasy/fantasy.controller.spec.ts @@ -61,7 +61,16 @@ describe('fantasyController', () => { expect(response.status).toBe(200); expect(response.body.league.league_id).toBe('999'); - expect(getOverview).toHaveBeenCalledWith('U1', 'T1', '999'); + expect(getOverview).toHaveBeenCalledWith('U1', 'T1', '999', false); + }); + + it('refreshes cached recommendations when explicitly requested', async () => { + getOverview.mockResolvedValue({ league: { league_id: '999' } }); + + const response = await request(app).get('/leagues/999?refresh=true'); + + expect(response.status).toBe(200); + expect(getOverview).toHaveBeenCalledWith('U1', 'T1', '999', true); }); it('returns 404 when the linked roster is unavailable', async () => { diff --git a/packages/backend/src/fantasy/fantasy.controller.ts b/packages/backend/src/fantasy/fantasy.controller.ts index 3369e05c..a424b733 100644 --- a/packages/backend/src/fantasy/fantasy.controller.ts +++ b/packages/backend/src/fantasy/fantasy.controller.ts @@ -51,7 +51,7 @@ fantasyController.get('/leagues/:leagueId', (req: RequestWithAuthSession, res) = return; } fantasyService - .getOverview(session.userId, session.teamId, req.params.leagueId) + .getOverview(session.userId, session.teamId, req.params.leagueId, req.query.refresh === 'true') .then((overview) => { if (!overview) { res.status(404).json({ error: 'League or linked Sleeper roster was not found.' }); diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index 75b94eb2..f53d1e30 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -2,6 +2,7 @@ import Axios from 'axios'; import { getRepository } from 'typeorm'; import type { OpenAIClientLike } from '../lib/resilientOpenAIClient'; import type { + AITradeAnalysis, FantasyPlayer, FantasyTeam, LineupRecommendation, @@ -65,6 +66,11 @@ const aiResponse = { }; type FantasyServiceInternals = { + getTradeAnalysis: ( + key: string, + refresh: boolean, + generate: () => Promise, + ) => Promise; buildLineupRecommendation: ( week: number, league: SleeperLeague, @@ -139,6 +145,36 @@ describe('FantasyService', () => { expect(Axios.get).not.toHaveBeenCalled(); }); + it('caches AI analysis for 24 hours and bypasses the cache on refresh', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-09T12:00:00.000Z')); + const internals = service as unknown as FantasyServiceInternals; + const analysis: AITradeAnalysis = { + teamHealth: { percentage: 80, summary: 'Healthy roster.' }, + tradeInsights: [], + suggestions: [], + waiverSuggestions: [], + lineupSummary: 'Use the projected starters.', + }; + const generate = vi.fn().mockResolvedValue(analysis); + + try { + await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toBe(analysis); + vi.advanceTimersByTime(24 * 60 * 60 * 1000 - 1); + await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toBe(analysis); + expect(generate).toHaveBeenCalledOnce(); + + await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', true, generate)).resolves.toBe(analysis); + expect(generate).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + await expect(internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate)).resolves.toBe(analysis); + expect(generate).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + it('builds a league overview with AI trade analysis and roster-relevant games', async () => { findOne.mockResolvedValue({ slackId: 'U1', teamId: 'T1', sleeperUserId: '123' }); (Axios.get as Mock).mockImplementation((url: string) => { diff --git a/packages/backend/src/fantasy/fantasy.service.ts b/packages/backend/src/fantasy/fantasy.service.ts index de55ce75..8d90a9f7 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -41,6 +41,7 @@ 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 AI_ANALYSIS_CACHE_MS = 24 * 60 * 60 * 1000; const WAIVER_MARKET_CACHE_MS = 6 * 60 * 60 * 1000; const WAIVER_HISTORY_SEASONS = 3; const NFL_REGULAR_SEASON_WEEKS = 18; @@ -115,6 +116,7 @@ export class FantasyService { private playerRequest: Promise> | null = null; private waiverMarketCache = new Map(); private projectionCache = new Map(); + private analysisCache = new Map(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -146,7 +148,12 @@ export class FantasyService { return { sleeperUser, leagues, season: state.season }; } - public async getOverview(slackId: string, teamId: string, leagueId: string): Promise { + public async getOverview( + slackId: string, + teamId: string, + leagueId: string, + refresh = false, + ): Promise { if (!SLEEPER_ID_PATTERN.test(leagueId)) { throw new FantasyValidationError('Invalid league ID.'); } @@ -228,7 +235,7 @@ export class FantasyService { try { const [matchups, projections] = await Promise.all([ this.get(`/league/${leagueId}/matchups/${Math.max(state.week, 1)}`), - this.getProjections(state), + this.getProjections(state, refresh), ]); currentProjections = projections; lineupRecommendation = this.buildLineupRecommendation( @@ -266,15 +273,20 @@ export class FantasyService { let analysis: AITradeAnalysis; let aiStatus: FantasyOverview['aiStatus'] = 'ready'; try { - analysis = await this.generateTradeAnalysis( - league, - roster, - teams, - pendingTransactions, - waiverCandidates, - remainingWaiverBudget, - lineupRecommendation, - waiverBidGuidance, + analysis = await this.getTradeAnalysis( + `${teamId}:${slackId}:${leagueId}:${roster.rosterId}:${state.season}:${state.week}`, + refresh, + () => + this.generateTradeAnalysis( + league, + roster, + teams, + pendingTransactions, + waiverCandidates, + remainingWaiverBudget, + lineupRecommendation, + waiverBidGuidance, + ), ); } catch (error) { logError(this.serviceLogger, 'Failed to generate fantasy trade analysis', error, { @@ -344,14 +356,43 @@ export class FantasyService { return this.get(`/user/${encodeURIComponent(userId)}/leagues/nfl/${encodeURIComponent(season)}`); } - private getProjections(state: NflState): Promise { + private getProjections(state: NflState, refresh: boolean): Promise { + const key = `current:${state.season}:${state.season_type}:${Math.max(state.week, 1)}`; + const cached = this.projectionCache.get(key); + if (!refresh && cached && cached.expiresAt > Date.now()) { + return Promise.resolve(cached.projections); + } 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); + ).then((response) => { + this.projectionCache.set(key, { + expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, + projections: response.data, + }); + return response.data; + }); + } + + private async getTradeAnalysis( + key: string, + refresh: boolean, + generate: () => Promise, + ): Promise { + const cached = this.analysisCache.get(key); + if (!refresh && cached && cached.expiresAt > Date.now()) { + return cached.analysis; + } + + const analysis = await generate(); + this.analysisCache.set(key, { + expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, + analysis, + }); + return analysis; } private getSeasonProjections(season: string, week: number): Promise { diff --git a/packages/frontend/src/hooks/useFantasy.ts b/packages/frontend/src/hooks/useFantasy.ts index ae8a887d..489c6627 100644 --- a/packages/frontend/src/hooks/useFantasy.ts +++ b/packages/frontend/src/hooks/useFantasy.ts @@ -10,8 +10,8 @@ interface UseFantasyReturn { error: string | null; selectedLeagueId: string | null; selectLeague: (leagueId: string) => void; - refreshingSuggestions: 'trades' | 'waivers' | null; - refreshSuggestions: (kind: 'trades' | 'waivers') => Promise; + refreshingSuggestions: 'lineup' | 'trades' | 'waivers' | null; + refreshSuggestions: (kind: 'lineup' | 'trades' | 'waivers') => Promise; } export function useFantasy(onLogout: () => void): UseFantasyReturn { @@ -21,7 +21,7 @@ export function useFantasy(onLogout: () => void): UseFantasyReturn { const [isLandingLoading, setIsLandingLoading] = useState(true); const [isOverviewLoading, setIsOverviewLoading] = useState(false); const [error, setError] = useState(null); - const [refreshingSuggestions, setRefreshingSuggestions] = useState<'trades' | 'waivers' | null>(null); + const [refreshingSuggestions, setRefreshingSuggestions] = useState<'lineup' | 'trades' | 'waivers' | null>(null); const onLogoutRef = useRef(onLogout); onLogoutRef.current = onLogout; @@ -89,17 +89,17 @@ export function useFantasy(onLogout: () => void): UseFantasyReturn { }, [request, selectedLeagueId]); const refreshSuggestions = useCallback( - async (kind: 'trades' | 'waivers') => { + async (kind: 'lineup' | 'trades' | 'waivers') => { if (!selectedLeagueId || refreshingSuggestions) return; setRefreshingSuggestions(kind); setError(null); try { const data = await request( - `/fantasy/leagues/${encodeURIComponent(selectedLeagueId)}?refresh=${Date.now()}`, + `/fantasy/leagues/${encodeURIComponent(selectedLeagueId)}?refresh=true`, ); setOverview(data); } catch (err) { - setError(err instanceof Error ? err.message : `Failed to refresh ${kind} suggestions.`); + setError(err instanceof Error ? err.message : `Failed to refresh ${kind} recommendations.`); } finally { setRefreshingSuggestions(null); } diff --git a/packages/frontend/src/pages/FantasyPage.spec.tsx b/packages/frontend/src/pages/FantasyPage.spec.tsx index 038c71dc..ead1374b 100644 --- a/packages/frontend/src/pages/FantasyPage.spec.tsx +++ b/packages/frontend/src/pages/FantasyPage.spec.tsx @@ -178,22 +178,27 @@ describe('FantasyPage', () => { expect(screen.getByText('0 rostered players')).toBeInTheDocument(); }); - it('refreshes AI trade and waiver suggestions', async () => { + it('refreshes lineup, AI trade, and waiver recommendations', async () => { mockFetch .mockResolvedValueOnce({ ok: true, status: 200, json: async () => landing }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => overview }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => overview }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => overview }) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => overview }); render(); await screen.findByText('Balances your lineup.'); - fireEvent.click(screen.getByRole('button', { name: /refresh ideas/i })); + fireEvent.click(screen.getByRole('button', { name: /refresh lineup/i })); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3)); - expect(mockFetch.mock.calls[2]?.[0]).toMatch(/\/fantasy\/leagues\/999\?refresh=\d+/); + expect(mockFetch.mock.calls[2]?.[0]).toMatch(/\/fantasy\/leagues\/999\?refresh=true/); - fireEvent.click(screen.getByRole('button', { name: /refresh proposals/i })); + fireEvent.click(screen.getByRole('button', { name: /refresh ideas/i })); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(4)); - expect(mockFetch.mock.calls[3]?.[0]).toMatch(/\/fantasy\/leagues\/999\?refresh=\d+/); + expect(mockFetch.mock.calls[3]?.[0]).toMatch(/\/fantasy\/leagues\/999\?refresh=true/); + + fireEvent.click(screen.getByRole('button', { name: /refresh proposals/i })); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(5)); + expect(mockFetch.mock.calls[4]?.[0]).toMatch(/\/fantasy\/leagues\/999\?refresh=true/); }); }); diff --git a/packages/frontend/src/pages/FantasyPage.tsx b/packages/frontend/src/pages/FantasyPage.tsx index c128022c..d44e6007 100644 --- a/packages/frontend/src/pages/FantasyPage.tsx +++ b/packages/frontend/src/pages/FantasyPage.tsx @@ -163,10 +163,24 @@ export function FantasyPage({ onLogout }: FantasyPageProps) { )}
-

-

+
+

+

+ +
{overview.lineupStatus === 'unavailable' ? (