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
62 changes: 62 additions & 0 deletions packages/backend/src/fantasy/fantasy.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const aiResponse = {
};

type FantasyServiceInternals = {
analysisCache: Map<string, { expiresAt: number; analysis: AITradeAnalysis }>;
getTradeAnalysis: (
key: string,
refresh: boolean,
Expand Down Expand Up @@ -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<AITradeAnalysis>((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) => {
Expand Down
33 changes: 25 additions & 8 deletions packages/backend/src/fantasy/fantasy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class FantasyService {
private waiverMarketCache = new Map<string, { expiresAt: number; samples: WaiverMarketSample[] }>();
private projectionCache = new Map<string, { expiresAt: number; projections: SleeperProjection[] }>();
private analysisCache = new Map<string, { expiresAt: number; analysis: AITradeAnalysis }>();
private analysisRequests = new Map<string, Promise<AITradeAnalysis>>();
private readonly openAi: OpenAIClientLike;
private readonly serviceLogger = logger.child({ module: 'FantasyService' });

Expand Down Expand Up @@ -383,16 +384,32 @@ export class FantasyService {
generate: () => Promise<AITradeAnalysis>,
): Promise<AITradeAnalysis> {
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<SleeperProjection[]> {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/hooks/useFantasy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function useFantasy(onLogout: () => void): UseFantasyReturn {
try {
const data = await request<FantasyOverview>(
`/fantasy/leagues/${encodeURIComponent(selectedLeagueId)}?refresh=true`,
{ cache: 'no-store' },
);
setOverview(data);
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions packages/frontend/src/pages/FantasyPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Loading