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
11 changes: 10 additions & 1 deletion packages/backend/src/fantasy/fantasy.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/fantasy/fantasy.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' });
Expand Down
36 changes: 36 additions & 0 deletions packages/backend/src/fantasy/fantasy.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Axios from 'axios';
import { getRepository } from 'typeorm';
import type { OpenAIClientLike } from '../lib/resilientOpenAIClient';
import type {
AITradeAnalysis,
FantasyPlayer,
FantasyTeam,
LineupRecommendation,
Expand Down Expand Up @@ -65,6 +66,11 @@ const aiResponse = {
};

type FantasyServiceInternals = {
getTradeAnalysis: (
key: string,
refresh: boolean,
generate: () => Promise<AITradeAnalysis>,
) => Promise<AITradeAnalysis>;
buildLineupRecommendation: (
week: number,
league: SleeperLeague,
Expand Down Expand Up @@ -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) => {
Expand Down
67 changes: 54 additions & 13 deletions packages/backend/src/fantasy/fantasy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,6 +116,7 @@ export class FantasyService {
private playerRequest: Promise<Record<string, SleeperPlayer | undefined>> | null = null;
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 readonly openAi: OpenAIClientLike;
Comment on lines 118 to 120
private readonly serviceLogger = logger.child({ module: 'FantasyService' });

Expand Down Expand Up @@ -146,7 +148,12 @@ export class FantasyService {
return { sleeperUser, leagues, season: state.season };
}

public async getOverview(slackId: string, teamId: string, leagueId: string): Promise<FantasyOverview | null> {
public async getOverview(
slackId: string,
teamId: string,
leagueId: string,
refresh = false,
): Promise<FantasyOverview | null> {
if (!SLEEPER_ID_PATTERN.test(leagueId)) {
throw new FantasyValidationError('Invalid league ID.');
}
Expand Down Expand Up @@ -228,7 +235,7 @@ export class FantasyService {
try {
const [matchups, projections] = await Promise.all([
this.get<SleeperMatchup[]>(`/league/${leagueId}/matchups/${Math.max(state.week, 1)}`),
this.getProjections(state),
this.getProjections(state, refresh),
]);
currentProjections = projections;
lineupRecommendation = this.buildLineupRecommendation(
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -344,14 +356,43 @@ export class FantasyService {
return this.get<SleeperLeague[]>(`/user/${encodeURIComponent(userId)}/leagues/nfl/${encodeURIComponent(season)}`);
}

private getProjections(state: NflState): Promise<SleeperProjection[]> {
private getProjections(state: NflState, refresh: boolean): Promise<SleeperProjection[]> {
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<SleeperProjection[]>(
`${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<AITradeAnalysis>,
): Promise<AITradeAnalysis> {
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<SleeperProjection[]> {
Expand Down
12 changes: 6 additions & 6 deletions packages/frontend/src/hooks/useFantasy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
refreshingSuggestions: 'lineup' | 'trades' | 'waivers' | null;
refreshSuggestions: (kind: 'lineup' | 'trades' | 'waivers') => Promise<void>;
}

export function useFantasy(onLogout: () => void): UseFantasyReturn {
Expand All @@ -21,7 +21,7 @@ export function useFantasy(onLogout: () => void): UseFantasyReturn {
const [isLandingLoading, setIsLandingLoading] = useState(true);
const [isOverviewLoading, setIsOverviewLoading] = useState(false);
const [error, setError] = useState<string | null>(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;

Expand Down Expand Up @@ -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<FantasyOverview>(
`/fantasy/leagues/${encodeURIComponent(selectedLeagueId)}?refresh=${Date.now()}`,
`/fantasy/leagues/${encodeURIComponent(selectedLeagueId)}?refresh=true`,
);
Comment on lines 97 to 99
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);
}
Expand Down
15 changes: 10 additions & 5 deletions packages/frontend/src/pages/FantasyPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FantasyPage onLogout={vi.fn()} />);
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/);
});
});
22 changes: 18 additions & 4 deletions packages/frontend/src/pages/FantasyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,24 @@ export function FantasyPage({ onLogout }: FantasyPageProps) {
)}

<section>
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
<Users className="h-5 w-5 text-primary" aria-hidden="true" /> Week{' '}
{overview.lineupRecommendation?.week ?? ''} lineup optimizer
</h2>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Users className="h-5 w-5 text-primary" aria-hidden="true" /> Week{' '}
{overview.lineupRecommendation?.week ?? ''} lineup optimizer
</h2>
<Button
variant="outline"
size="sm"
disabled={refreshingSuggestions !== null}
onClick={() => void refreshSuggestions('lineup')}
>
<RefreshCw
className={refreshingSuggestions === 'lineup' ? 'animate-spin' : ''}
aria-hidden="true"
/>
{refreshingSuggestions === 'lineup' ? 'Refreshing…' : 'Refresh lineup'}
</Button>
</div>
{overview.lineupStatus === 'unavailable' ? (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Expand Down
Loading