diff --git a/packages/backend/src/fantasy/fantasy.service.spec.ts b/packages/backend/src/fantasy/fantasy.service.spec.ts index f53d1e30..b6c2526b 100644 --- a/packages/backend/src/fantasy/fantasy.service.spec.ts +++ b/packages/backend/src/fantasy/fantasy.service.spec.ts @@ -66,6 +66,7 @@ const aiResponse = { }; type FantasyServiceInternals = { + analysisCache: Map; getTradeAnalysis: ( key: string, refresh: boolean, @@ -175,6 +176,67 @@ describe('FantasyService', () => { } }); + it('coalesces concurrent AI analysis requests for the same key', async () => { + const internals = service as unknown as FantasyServiceInternals; + const analysis: AITradeAnalysis = { + teamHealth: null, + tradeInsights: [], + suggestions: [], + waiverSuggestions: [], + lineupSummary: null, + }; + let resolveGenerate: ((value: AITradeAnalysis) => void) | undefined; + const generate = vi.fn( + () => + new Promise((resolve) => { + resolveGenerate = resolve; + }), + ); + + const first = internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate); + const second = internals.getTradeAnalysis('U1:T1:999:1:2026:1', false, generate); + + expect(generate).toHaveBeenCalledOnce(); + resolveGenerate?.(analysis); + + await expect(first).resolves.toBe(analysis); + await expect(second).resolves.toBe(analysis); + }); + + it('deletes expired AI analysis entries before generating new analysis', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-09T12:00:00.000Z')); + const internals = service as unknown as FantasyServiceInternals; + const key = 'U1:T1:999:1:2026:1'; + const staleAnalysis: AITradeAnalysis = { + teamHealth: null, + tradeInsights: [], + suggestions: [], + waiverSuggestions: [], + lineupSummary: null, + }; + const freshAnalysis: AITradeAnalysis = { + teamHealth: { percentage: 81, summary: 'Updated analysis.' }, + tradeInsights: [], + suggestions: [], + waiverSuggestions: [], + lineupSummary: 'Updated lineup summary.', + }; + internals.analysisCache.set(key, { + expiresAt: Date.now() - 1, + analysis: staleAnalysis, + }); + const generate = vi.fn().mockResolvedValue(freshAnalysis); + + try { + await expect(internals.getTradeAnalysis(key, false, generate)).resolves.toBe(freshAnalysis); + expect(generate).toHaveBeenCalledOnce(); + expect(internals.analysisCache.get(key)?.analysis).toBe(freshAnalysis); + } 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 8d90a9f7..400bbcdc 100644 --- a/packages/backend/src/fantasy/fantasy.service.ts +++ b/packages/backend/src/fantasy/fantasy.service.ts @@ -117,6 +117,7 @@ export class FantasyService { private waiverMarketCache = new Map(); private projectionCache = new Map(); private analysisCache = new Map(); + private analysisRequests = new Map>(); private readonly openAi: OpenAIClientLike; private readonly serviceLogger = logger.child({ module: 'FantasyService' }); @@ -383,16 +384,32 @@ export class FantasyService { generate: () => Promise, ): Promise { const cached = this.analysisCache.get(key); - if (!refresh && cached && cached.expiresAt > Date.now()) { - return cached.analysis; + if (!refresh && cached) { + if (cached.expiresAt > Date.now()) { + return cached.analysis; + } + this.analysisCache.delete(key); } - const analysis = await generate(); - this.analysisCache.set(key, { - expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, - analysis, - }); - return analysis; + const inFlight = this.analysisRequests.get(key); + if (inFlight) { + return inFlight; + } + + const request = generate() + .then((analysis) => { + this.analysisCache.set(key, { + expiresAt: Date.now() + AI_ANALYSIS_CACHE_MS, + analysis, + }); + return analysis; + }) + .finally(() => { + this.analysisRequests.delete(key); + }); + + this.analysisRequests.set(key, request); + return request; } private getSeasonProjections(season: string, week: number): Promise { diff --git a/packages/frontend/src/hooks/useFantasy.ts b/packages/frontend/src/hooks/useFantasy.ts index 489c6627..d9ff8e77 100644 --- a/packages/frontend/src/hooks/useFantasy.ts +++ b/packages/frontend/src/hooks/useFantasy.ts @@ -96,6 +96,7 @@ export function useFantasy(onLogout: () => void): UseFantasyReturn { try { const data = await request( `/fantasy/leagues/${encodeURIComponent(selectedLeagueId)}?refresh=true`, + { cache: 'no-store' }, ); setOverview(data); } catch (err) { diff --git a/packages/frontend/src/pages/FantasyPage.spec.tsx b/packages/frontend/src/pages/FantasyPage.spec.tsx index ead1374b..2536bc43 100644 --- a/packages/frontend/src/pages/FantasyPage.spec.tsx +++ b/packages/frontend/src/pages/FantasyPage.spec.tsx @@ -192,13 +192,16 @@ describe('FantasyPage', () => { 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=true/); + expect(mockFetch.mock.calls[2]?.[1]).toMatchObject({ cache: 'no-store' }); 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=true/); + expect(mockFetch.mock.calls[3]?.[1]).toMatchObject({ cache: 'no-store' }); 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/); + expect(mockFetch.mock.calls[4]?.[1]).toMatchObject({ cache: 'no-store' }); }); });