diff --git a/src/__tests__/ai/context.test.ts b/src/__tests__/ai/context.test.ts new file mode 100644 index 0000000..b7aa504 --- /dev/null +++ b/src/__tests__/ai/context.test.ts @@ -0,0 +1,51 @@ +import { getDb, resetDb } from '../../db'; +import { insertSession } from '../../db/sessions'; +import { insertScheduleEntry, updateScheduleStatus } from '../../db/schedule'; +import { setSetting } from '../../db/settings'; +import { buildContext } from '../../ai/context'; + +beforeEach(() => { resetDb(); }); + +describe('buildContext', () => { + it('returns empty history when no data exists', () => { + const db = getDb(); + const ctx = buildContext(db, '2026-05-20'); + expect(ctx.recentSessions).toHaveLength(0); + expect(ctx.upcomingSchedule).toHaveLength(0); + expect(ctx.currentStreak).toBe(0); + }); + + it('includes recent sessions', () => { + const db = getDb(); + insertSession(db, '2026-05-19', 60, 'good session'); + const ctx = buildContext(db, '2026-05-20'); + expect(ctx.recentSessions).toHaveLength(1); + expect(ctx.recentSessions[0]!.duration_min).toBe(60); + }); + + it('includes upcoming schedule', () => { + const db = getDb(); + insertScheduleEntry(db, '2026-05-21', 60, null); + const ctx = buildContext(db, '2026-05-20'); + expect(ctx.upcomingSchedule).toHaveLength(1); + }); + + it('reads training goal from settings', () => { + const db = getDb(); + setSetting(db, 'goal', 'wear 2 hours comfortably'); + const ctx = buildContext(db, '2026-05-20'); + expect(ctx.goal).toBe('wear 2 hours comfortably'); + }); + + it('calculates current streak', () => { + const db = getDb(); + insertSession(db, '2026-05-18', 60, null); + insertSession(db, '2026-05-19', 60, null); + insertScheduleEntry(db, '2026-05-18', 60, null); + updateScheduleStatus(db, '2026-05-18', 'completed', null); + insertScheduleEntry(db, '2026-05-19', 60, null); + updateScheduleStatus(db, '2026-05-19', 'completed', null); + const ctx = buildContext(db, '2026-05-20'); + expect(ctx.currentStreak).toBe(2); + }); +}); diff --git a/src/ai/context.ts b/src/ai/context.ts new file mode 100644 index 0000000..a5c76cb --- /dev/null +++ b/src/ai/context.ts @@ -0,0 +1,56 @@ +import type { Database } from 'better-sqlite3'; +import { getRecentSessions, type Session } from '../db/sessions'; +import { getUpcomingSchedule, type ScheduleEntry } from '../db/schedule'; +import { getSetting } from '../db/settings'; + +export interface AIContext { + today: string; + goal: string; + recentSessions: Session[]; + upcomingSchedule: ScheduleEntry[]; + currentStreak: number; + recentSkips: Array<{ date: string; reason: string | null }>; +} + +function calculateStreak(db: Database): number { + const rows = db.prepare( + "SELECT date FROM schedule WHERE status = 'completed' ORDER BY date DESC" + ).all() as Array<{ date: string }>; + + if (rows.length === 0) return 0; + + let streak = 0; + let prev: string | null = null; + + for (const { date } of rows) { + if (prev === null) { + streak = 1; + prev = date; + continue; + } + const prevDate = new Date(prev + 'T12:00:00'); + const currDate = new Date(date + 'T12:00:00'); + const diffDays = Math.round((prevDate.getTime() - currDate.getTime()) / 86400000); + if (diffDays <= 2) { + streak++; + prev = date; + } else { + break; + } + } + + return streak; +} + +export function buildContext(db: Database, today: string): AIContext { + const recentSessions = getRecentSessions(db, 30); + const upcomingSchedule = getUpcomingSchedule(db, today); + const goal = getSetting(db, 'goal') ?? 'Not set'; + const currentStreak = calculateStreak(db); + + const recentSkips = (db.prepare( + "SELECT date, skip_reason as reason FROM schedule WHERE status = 'skipped' ORDER BY date DESC LIMIT 10" + ).all() as Array<{ date: string; reason: string | null }>); + + return { today, goal, recentSessions, upcomingSchedule, currentStreak, recentSkips }; +}