# Training Assistant Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Telegram bot that tracks anal training sessions, sends configurable reminders, and uses an AI coach via OpenRouter to adaptively manage the schedule and provide progress feedback. **Architecture:** Single TypeScript process with four components — Telegraf bot handler, SQLite session/schedule store (better-sqlite3), node-cron reminder jobs, and an OpenRouter AI coach using the OpenAI-compatible client. Business logic is separated from Telegraf handlers so it can be unit tested with an in-memory SQLite database. **Tech Stack:** Node.js 20, TypeScript 5, Telegraf 4, better-sqlite3, openai (pointed at OpenRouter), node-cron, Jest + ts-jest, Docker (multi-stage build). --- ## File Map ``` src/ config.ts # env loading and validation index.ts # entry point: starts bot + cron jobs bot/ index.ts # Telegraf setup, command registration commands/ log.ts # /log handler skip.ts # /skip handler snooze.ts # /snooze handler status.ts # /status handler history.ts # /history handler streak.ts # /streak handler setup.ts # /setup onboarding handler settings-cmd.ts # /settings handler progress.ts # /progress handler (AI) next.ts # /next handler (AI) reschedule.ts # /reschedule handler (AI) chat.ts # /chat handler (AI) db/ index.ts # database connection singleton schema.ts # CREATE TABLE statements + migration sessions.ts # session queries schedule.ts # schedule queries settings.ts # settings queries scheduler/ index.ts # creates and exports all cron jobs reminders.ts # morning + evening reminder logic ai/ client.ts # OpenRouter client singleton context.ts # builds history context object from DB prompts.ts # prompt template functions coach.ts # AI calls: progress, next, reschedule, chat parser.ts # parse AI JSON schedule output utils/ duration.ts # parse "2h" / "90m" / "45min" → minutes date.ts # YYYY-MM-DD helpers src/__tests__/ duration.test.ts date.test.ts db/ sessions.test.ts schedule.test.ts settings.test.ts ai/ context.test.ts parser.test.ts Dockerfile docker-compose.yml .dockerignore .env.example package.json tsconfig.json jest.config.ts ``` --- ## Task 1: Project Scaffolding **Files:** - Create: `package.json` - Create: `tsconfig.json` - Create: `jest.config.ts` - Create: `.env.example` - [ ] **Step 1: Initialise the project** ```bash cd /home/sjoerd/Work/assistant npm init -y ``` - [ ] **Step 2: Install runtime dependencies** ```bash npm install telegraf better-sqlite3 openai node-cron dotenv ``` - [ ] **Step 3: Install dev dependencies** ```bash npm install -D typescript @types/node @types/better-sqlite3 @types/node-cron tsx jest ts-jest @types/jest ``` - [ ] **Step 4: Write `tsconfig.json`** ```json { "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 5: Write `jest.config.ts`** ```typescript import type { Config } from 'jest'; const config: Config = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/src/__tests__/**/*.test.ts'], clearMocks: true, }; export default config; ``` - [ ] **Step 6: Add scripts to `package.json`** Open `package.json` and replace the `scripts` section: ```json "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", "test": "jest", "test:watch": "jest --watch" } ``` - [ ] **Step 7: Write `.env.example`** ``` TELEGRAM_TOKEN=your_telegram_bot_token TELEGRAM_CHAT_ID=your_numeric_chat_id OPENROUTER_API_KEY=your_openrouter_key DB_PATH=./data/assistant.db MORNING_REMINDER=05:30 EVENING_REMINDER=16:30 DEFAULT_MODEL=google/gemini-2.5-flash-preview TZ=Europe/Amsterdam ``` - [ ] **Step 8: Create directory structure** ```bash mkdir -p src/bot/commands src/db src/scheduler src/ai src/utils src/__tests__/db src/__tests__/ai data ``` - [ ] **Step 9: Commit** ```bash git init echo "node_modules/\ndist/\ndata/\n.env" > .gitignore git add . git commit -m "chore: project scaffolding" ``` --- ## Task 2: Config Module **Files:** - Create: `src/config.ts` - [ ] **Step 1: Write `src/config.ts`** ```typescript import 'dotenv/config'; function require_env(key: string): string { const val = process.env[key]; if (!val) throw new Error(`Missing required env var: ${key}`); return val; } export const config = { telegramToken: require_env('TELEGRAM_TOKEN'), telegramChatId: require_env('TELEGRAM_CHAT_ID'), openrouterApiKey: require_env('OPENROUTER_API_KEY'), dbPath: process.env['DB_PATH'] ?? './data/assistant.db', morningReminder: process.env['MORNING_REMINDER'] ?? '05:30', eveningReminder: process.env['EVENING_REMINDER'] ?? '16:30', defaultModel: process.env['DEFAULT_MODEL'] ?? 'google/gemini-2.5-flash-preview', }; ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/config.ts git commit -m "feat: config module" ``` --- ## Task 3: Database Connection and Schema **Files:** - Create: `src/db/index.ts` - Create: `src/db/schema.ts` - [ ] **Step 1: Write `src/db/schema.ts`** ```typescript import type { Database } from 'better-sqlite3'; export function applySchema(db: Database): void { db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, duration_min INTEGER NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL UNIQUE, planned_min INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', skip_reason TEXT, ai_rationale TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); } ``` - [ ] **Step 2: Write `src/db/index.ts`** ```typescript import Database from 'better-sqlite3'; import { applySchema } from './schema'; let _db: Database.Database | null = null; export function getDb(path: string = ':memory:'): Database.Database { if (_db) return _db; _db = new Database(path); _db.pragma('journal_mode = WAL'); _db.pragma('foreign_keys = ON'); applySchema(_db); return _db; } export function resetDb(): void { _db = null; } ``` - [ ] **Step 3: Commit** ```bash git add src/db/ git commit -m "feat: database schema and connection" ``` --- ## Task 4: Settings Queries + Tests **Files:** - Create: `src/db/settings.ts` - Create: `src/__tests__/db/settings.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // src/__tests__/db/settings.test.ts import { getDb, resetDb } from '../../db'; import { getSetting, setSetting, getAllSettings } from '../../db/settings'; beforeEach(() => { resetDb(); }); describe('settings', () => { it('returns null for a missing key', () => { const db = getDb(); expect(getSetting(db, 'missing')).toBeNull(); }); it('sets and gets a value', () => { const db = getDb(); setSetting(db, 'model', 'gpt-4o-mini'); expect(getSetting(db, 'model')).toBe('gpt-4o-mini'); }); it('overwrites an existing value', () => { const db = getDb(); setSetting(db, 'model', 'gpt-4o-mini'); setSetting(db, 'model', 'gemini-flash'); expect(getSetting(db, 'model')).toBe('gemini-flash'); }); it('returns all settings as an object', () => { const db = getDb(); setSetting(db, 'a', '1'); setSetting(db, 'b', '2'); expect(getAllSettings(db)).toEqual({ a: '1', b: '2' }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash npm test -- settings.test.ts ``` Expected: FAIL — `getSetting` not found. - [ ] **Step 3: Write `src/db/settings.ts`** ```typescript import type { Database } from 'better-sqlite3'; export function getSetting(db: Database, key: string): string | null { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; return row?.value ?? null; } export function setSetting(db: Database, key: string, value: string): void { db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); } export function getAllSettings(db: Database): Record { const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{ key: string; value: string }>; return Object.fromEntries(rows.map(r => [r.key, r.value])); } ``` - [ ] **Step 4: Run test to verify it passes** ```bash npm test -- settings.test.ts ``` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/db/settings.ts src/__tests__/db/settings.test.ts git commit -m "feat: settings queries" ``` --- ## Task 5: Session Queries + Tests **Files:** - Create: `src/db/sessions.ts` - Create: `src/__tests__/db/sessions.test.ts` - [ ] **Step 1: Write the failing tests** ```typescript // src/__tests__/db/sessions.test.ts import { getDb, resetDb } from '../../db'; import { insertSession, getSessionByDate, getSessionsInRange, getRecentSessions } from '../../db/sessions'; beforeEach(() => { resetDb(); }); describe('sessions', () => { it('inserts a session and retrieves it by date', () => { const db = getDb(); insertSession(db, '2026-05-20', 60, null); const s = getSessionByDate(db, '2026-05-20'); expect(s).not.toBeNull(); expect(s!.duration_min).toBe(60); expect(s!.notes).toBeNull(); }); it('inserts a session with notes', () => { const db = getDb(); insertSession(db, '2026-05-20', 45, 'felt good'); const s = getSessionByDate(db, '2026-05-20'); expect(s!.notes).toBe('felt good'); }); it('returns null for a date with no session', () => { const db = getDb(); expect(getSessionByDate(db, '2026-05-20')).toBeNull(); }); it('returns sessions in date range ordered by date asc', () => { const db = getDb(); insertSession(db, '2026-05-22', 60, null); insertSession(db, '2026-05-20', 45, null); const rows = getSessionsInRange(db, '2026-05-20', '2026-05-22'); expect(rows).toHaveLength(2); expect(rows[0]!.date).toBe('2026-05-20'); }); it('returns the N most recent sessions ordered by date desc', () => { const db = getDb(); insertSession(db, '2026-05-18', 30, null); insertSession(db, '2026-05-19', 45, null); insertSession(db, '2026-05-20', 60, null); const rows = getRecentSessions(db, 2); expect(rows).toHaveLength(2); expect(rows[0]!.date).toBe('2026-05-20'); expect(rows[1]!.date).toBe('2026-05-19'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash npm test -- sessions.test.ts ``` Expected: FAIL — `insertSession` not found. - [ ] **Step 3: Write `src/db/sessions.ts`** ```typescript import type { Database } from 'better-sqlite3'; export interface Session { id: number; date: string; duration_min: number; notes: string | null; created_at: string; } export function insertSession(db: Database, date: string, duration_min: number, notes: string | null): Session { const result = db.prepare( 'INSERT INTO sessions (date, duration_min, notes) VALUES (?, ?, ?)' ).run(date, duration_min, notes); return getSessionById(db, result.lastInsertRowid as number)!; } function getSessionById(db: Database, id: number): Session | null { return (db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined) ?? null; } export function getSessionByDate(db: Database, date: string): Session | null { return (db.prepare('SELECT * FROM sessions WHERE date = ?').get(date) as Session | undefined) ?? null; } export function getSessionsInRange(db: Database, from: string, to: string): Session[] { return db.prepare( 'SELECT * FROM sessions WHERE date >= ? AND date <= ? ORDER BY date ASC' ).all(from, to) as Session[]; } export function getRecentSessions(db: Database, limit: number): Session[] { return db.prepare( 'SELECT * FROM sessions ORDER BY date DESC LIMIT ?' ).all(limit) as Session[]; } ``` - [ ] **Step 4: Run test to verify it passes** ```bash npm test -- sessions.test.ts ``` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/db/sessions.ts src/__tests__/db/sessions.test.ts git commit -m "feat: session queries" ``` --- ## Task 6: Schedule Queries + Tests **Files:** - Create: `src/db/schedule.ts` - Create: `src/__tests__/db/schedule.test.ts` - [ ] **Step 1: Write the failing tests** ```typescript // src/__tests__/db/schedule.test.ts import { getDb, resetDb } from '../../db'; import { insertScheduleEntry, getScheduleByDate, updateScheduleStatus, getUpcomingSchedule, getPendingDates, } from '../../db/schedule'; beforeEach(() => { resetDb(); }); describe('schedule', () => { it('inserts and retrieves a schedule entry', () => { const db = getDb(); insertScheduleEntry(db, '2026-05-22', 60, 'Continuing current level'); const e = getScheduleByDate(db, '2026-05-22'); expect(e).not.toBeNull(); expect(e!.planned_min).toBe(60); expect(e!.status).toBe('pending'); }); it('updates status to completed', () => { const db = getDb(); insertScheduleEntry(db, '2026-05-22', 60, null); updateScheduleStatus(db, '2026-05-22', 'completed', null); expect(getScheduleByDate(db, '2026-05-22')!.status).toBe('completed'); }); it('updates status to skipped with reason', () => { const db = getDb(); insertScheduleEntry(db, '2026-05-22', 60, null); updateScheduleStatus(db, '2026-05-22', 'skipped', 'work trip'); const e = getScheduleByDate(db, '2026-05-22')!; expect(e.status).toBe('skipped'); expect(e.skip_reason).toBe('work trip'); }); it('returns upcoming entries from today onwards ordered by date', () => { const db = getDb(); insertScheduleEntry(db, '2026-05-21', 60, null); insertScheduleEntry(db, '2026-05-23', 75, null); const rows = getUpcomingSchedule(db, '2026-05-21'); expect(rows).toHaveLength(2); expect(rows[0]!.date).toBe('2026-05-21'); }); it('returns only pending dates', () => { const db = getDb(); insertScheduleEntry(db, '2026-05-21', 60, null); insertScheduleEntry(db, '2026-05-22', 60, null); updateScheduleStatus(db, '2026-05-21', 'completed', null); expect(getPendingDates(db)).toEqual(['2026-05-22']); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash npm test -- schedule.test.ts ``` Expected: FAIL. - [ ] **Step 3: Write `src/db/schedule.ts`** ```typescript import type { Database } from 'better-sqlite3'; export type ScheduleStatus = 'pending' | 'completed' | 'skipped'; export interface ScheduleEntry { id: number; date: string; planned_min: number; status: ScheduleStatus; skip_reason: string | null; ai_rationale: string | null; created_at: string; } export function insertScheduleEntry( db: Database, date: string, planned_min: number, ai_rationale: string | null ): ScheduleEntry { db.prepare( 'INSERT OR IGNORE INTO schedule (date, planned_min, ai_rationale) VALUES (?, ?, ?)' ).run(date, planned_min, ai_rationale); return getScheduleByDate(db, date)!; } export function getScheduleByDate(db: Database, date: string): ScheduleEntry | null { return (db.prepare('SELECT * FROM schedule WHERE date = ?').get(date) as ScheduleEntry | undefined) ?? null; } export function updateScheduleStatus( db: Database, date: string, status: ScheduleStatus, skip_reason: string | null ): void { db.prepare( 'UPDATE schedule SET status = ?, skip_reason = ? WHERE date = ?' ).run(status, skip_reason, date); } export function getUpcomingSchedule(db: Database, fromDate: string): ScheduleEntry[] { return db.prepare( 'SELECT * FROM schedule WHERE date >= ? ORDER BY date ASC' ).all(fromDate) as ScheduleEntry[]; } export function getPendingDates(db: Database): string[] { const rows = db.prepare( "SELECT date FROM schedule WHERE status = 'pending' ORDER BY date ASC" ).all() as Array<{ date: string }>; return rows.map(r => r.date); } ``` - [ ] **Step 4: Run test to verify it passes** ```bash npm test -- schedule.test.ts ``` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/db/schedule.ts src/__tests__/db/schedule.test.ts git commit -m "feat: schedule queries" ``` --- ## Task 7: Duration Parser + Date Helpers + Tests **Files:** - Create: `src/utils/duration.ts` - Create: `src/utils/date.ts` - Create: `src/__tests__/duration.test.ts` - Create: `src/__tests__/date.test.ts` - [ ] **Step 1: Write failing duration tests** ```typescript // src/__tests__/duration.test.ts import { parseDuration, formatDuration } from '../utils/duration'; describe('parseDuration', () => { it('parses hours', () => expect(parseDuration('2h')).toBe(120)); it('parses hours with space', () => expect(parseDuration('2 h')).toBe(120)); it('parses minutes with m', () => expect(parseDuration('90m')).toBe(90)); it('parses minutes with min', () => expect(parseDuration('45min')).toBe(45)); it('parses plain number as minutes', () => expect(parseDuration('60')).toBe(60)); it('parses decimal hours', () => expect(parseDuration('1.5h')).toBe(90)); it('returns null for invalid input', () => expect(parseDuration('abc')).toBeNull()); it('returns null for zero', () => expect(parseDuration('0m')).toBeNull()); it('returns null for empty string', () => expect(parseDuration('')).toBeNull()); }); describe('formatDuration', () => { it('formats whole hours', () => expect(formatDuration(120)).toBe('2h')); it('formats minutes only', () => expect(formatDuration(45)).toBe('45m')); it('formats hours and minutes', () => expect(formatDuration(90)).toBe('1h 30m')); }); ``` - [ ] **Step 2: Write failing date tests** ```typescript // src/__tests__/date.test.ts import { toDateString, todayString, addDays } from '../utils/date'; describe('date utils', () => { it('formats a date as YYYY-MM-DD', () => { expect(toDateString(new Date('2026-05-20T10:00:00'))).toBe('2026-05-20'); }); it('addDays adds days correctly', () => { expect(addDays('2026-05-20', 3)).toBe('2026-05-23'); }); it('addDays handles month boundary', () => { expect(addDays('2026-05-30', 2)).toBe('2026-06-01'); }); }); ``` - [ ] **Step 3: Run tests to verify they fail** ```bash npm test -- duration.test.ts date.test.ts ``` Expected: FAIL. - [ ] **Step 4: Write `src/utils/duration.ts`** ```typescript export function parseDuration(input: string): number | null { const s = input.trim().toLowerCase(); if (!s) return null; const hoursMatch = s.match(/^(\d+(?:\.\d+)?)\s*h(?:ours?)?$/); if (hoursMatch) { const minutes = Math.round(parseFloat(hoursMatch[1]!) * 60); return minutes > 0 ? minutes : null; } const minutesMatch = s.match(/^(\d+)\s*m(?:in(?:utes?)?)?$/); if (minutesMatch) { const minutes = parseInt(minutesMatch[1]!, 10); return minutes > 0 ? minutes : null; } const plainMatch = s.match(/^(\d+)$/); if (plainMatch) { const minutes = parseInt(plainMatch[1]!, 10); return minutes > 0 ? minutes : null; } return null; } export function formatDuration(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; if (h === 0) return `${m}m`; if (m === 0) return `${h}h`; return `${h}h ${m}m`; } ``` - [ ] **Step 5: Write `src/utils/date.ts`** ```typescript export function toDateString(date: Date): string { return date.toISOString().slice(0, 10); } export function todayString(): string { return toDateString(new Date()); } export function addDays(dateStr: string, days: number): string { const d = new Date(dateStr + 'T12:00:00'); d.setDate(d.getDate() + days); return toDateString(d); } ``` - [ ] **Step 6: Run tests to verify they pass** ```bash npm test -- duration.test.ts date.test.ts ``` Expected: PASS (12 tests). - [ ] **Step 7: Commit** ```bash git add src/utils/ src/__tests__/duration.test.ts src/__tests__/date.test.ts git commit -m "feat: duration parser and date utilities" ``` --- ## Task 8: AI Schedule Parser + Tests **Files:** - Create: `src/ai/parser.ts` - Create: `src/__tests__/ai/parser.test.ts` - [ ] **Step 1: Write the failing tests** ```typescript // src/__tests__/ai/parser.test.ts import { parseScheduleJson } from '../../ai/parser'; describe('parseScheduleJson', () => { it('parses a valid JSON array', () => { const input = JSON.stringify([ { date: '2026-05-22', planned_min: 60, rationale: 'Continue level' }, { date: '2026-05-24', planned_min: 75, rationale: 'Step up' }, ]); const result = parseScheduleJson(input); expect(result).toHaveLength(2); expect(result[0]).toEqual({ date: '2026-05-22', planned_min: 60, rationale: 'Continue level' }); }); it('parses JSON wrapped in a markdown code block', () => { const input = '```json\n[{"date":"2026-05-22","planned_min":60,"rationale":"test"}]\n```'; const result = parseScheduleJson(input); expect(result).toHaveLength(1); expect(result[0]!.date).toBe('2026-05-22'); }); it('throws on invalid JSON', () => { expect(() => parseScheduleJson('not json')).toThrow(); }); it('throws if entries are missing required fields', () => { expect(() => parseScheduleJson(JSON.stringify([{ date: '2026-05-22' }]))).toThrow(); }); }); ``` - [ ] **Step 2: Run to verify it fails** ```bash npm test -- parser.test.ts ``` Expected: FAIL. - [ ] **Step 3: Write `src/ai/parser.ts`** ```typescript export interface ScheduleProposal { date: string; planned_min: number; rationale: string; } export function parseScheduleJson(raw: string): ScheduleProposal[] { const cleaned = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim(); const parsed = JSON.parse(cleaned); if (!Array.isArray(parsed)) throw new Error('Expected a JSON array'); return parsed.map((entry: unknown, i: number) => { if (typeof entry !== 'object' || entry === null) throw new Error(`Entry ${i} is not an object`); const e = entry as Record; if (typeof e['date'] !== 'string') throw new Error(`Entry ${i} missing "date"`); if (typeof e['planned_min'] !== 'number') throw new Error(`Entry ${i} missing "planned_min"`); if (typeof e['rationale'] !== 'string') throw new Error(`Entry ${i} missing "rationale"`); return { date: e['date'], planned_min: e['planned_min'], rationale: e['rationale'] }; }); } ``` - [ ] **Step 4: Run to verify it passes** ```bash npm test -- parser.test.ts ``` Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add src/ai/parser.ts src/__tests__/ai/parser.test.ts git commit -m "feat: AI schedule JSON parser" ``` --- ## Task 9: Context Builder + Tests **Files:** - Create: `src/ai/context.ts` - Create: `src/__tests__/ai/context.test.ts` - [ ] **Step 1: Write the failing tests** ```typescript // src/__tests__/ai/context.test.ts 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, 'continue'); 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); }); }); ``` - [ ] **Step 2: Run to verify it fails** ```bash npm test -- context.test.ts ``` Expected: FAIL. - [ ] **Step 3: Write `src/ai/context.ts`** ```typescript 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 }; } ``` - [ ] **Step 4: Run to verify it passes** ```bash npm test -- context.test.ts ``` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/ai/context.ts src/__tests__/ai/context.test.ts git commit -m "feat: AI context builder" ``` --- ## Task 10: AI Client and Prompts **Files:** - Create: `src/ai/client.ts` - Create: `src/ai/prompts.ts` - [ ] **Step 1: Write `src/ai/client.ts`** ```typescript import OpenAI from 'openai'; import { config } from '../config'; export function createAiClient(): OpenAI { return new OpenAI({ apiKey: config.openrouterApiKey, baseURL: 'https://openrouter.ai/api/v1', }); } ``` - [ ] **Step 2: Write `src/ai/prompts.ts`** ```typescript import type { AIContext } from './context'; import { formatDuration } from '../utils/duration'; function sessionLines(ctx: AIContext): string { if (ctx.recentSessions.length === 0) return 'No sessions recorded yet.'; return ctx.recentSessions .map(s => ` ${s.date}: ${formatDuration(s.duration_min)}${s.notes ? ` (${s.notes})` : ''}`) .join('\n'); } function scheduleLines(ctx: AIContext): string { if (ctx.upcomingSchedule.length === 0) return 'No upcoming schedule.'; return ctx.upcomingSchedule .map(e => ` ${e.date}: ${formatDuration(e.planned_min)} [${e.status}]`) .join('\n'); } function skipLines(ctx: AIContext): string { if (ctx.recentSkips.length === 0) return 'None.'; return ctx.recentSkips .map(s => ` ${s.date}${s.reason ? `: ${s.reason}` : ''}`) .join('\n'); } function systemBase(ctx: AIContext): string { return `You are a supportive personal training coach. You help the user track and improve their anal training practice. Today: ${ctx.today} Training goal: ${ctx.goal} Current streak: ${ctx.currentStreak} sessions Recent sessions (newest first): ${sessionLines(ctx)} Upcoming schedule: ${scheduleLines(ctx)} Recent skips: ${skipLines(ctx)} Be encouraging but honest. Always explain your reasoning. Flag if you think the pace is too fast or too slow.`; } export function progressPrompt(ctx: AIContext): { system: string; user: string } { return { system: systemBase(ctx), user: 'Analyse my recent training history. Give me a summary of my progress, highlight any patterns (positive or negative), and tell me how I\'m tracking toward my goal.', }; } export function nextStepPrompt(ctx: AIContext): { system: string; user: string } { return { system: systemBase(ctx), user: 'Based on my history, what should my next training step be? Should I increase duration, maintain, or take a rest? Explain your reasoning.', }; } export function reschedulePrompt(ctx: AIContext, note: string | null): { system: string; user: string } { const extra = note ? `\n\nAdditional context from the user: "${note}"` : ''; return { system: systemBase(ctx), user: `Generate a training schedule for the next 14 days starting from ${ctx.today}.${extra} Respond with ONLY a JSON array — no prose, no markdown explanation — in this exact format: [ { "date": "YYYY-MM-DD", "planned_min": 60, "rationale": "reason" } ] Only include days with planned sessions (rest days are omitted). Space sessions appropriately.`, }; } export function setupPrompt( currentLevel: string, availability: string, goal: string ): { system: string; user: string } { return { system: 'You are a supportive personal training coach helping a new user set up their anal training plan. Be warm, practical, and non-judgmental.', user: `New user setup: - Current level: ${currentLevel} - Typical availability: ${availability} - Goal: ${goal} Generate a 14-day starter schedule. Respond with ONLY a JSON array in this exact format: [ { "date": "YYYY-MM-DD", "planned_min": 30, "rationale": "Starting gently" } ]`, }; } ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/ai/client.ts src/ai/prompts.ts git commit -m "feat: AI client and prompt templates" ``` --- ## Task 11: AI Coach Functions **Files:** - Create: `src/ai/coach.ts` - [ ] **Step 1: Write `src/ai/coach.ts`** ```typescript import type { Database } from 'better-sqlite3'; import { createAiClient } from './client'; import { buildContext } from './context'; import { progressPrompt, nextStepPrompt, reschedulePrompt, setupPrompt } from './prompts'; import { parseScheduleJson, type ScheduleProposal } from './parser'; import { getSetting } from '../db/settings'; import { config } from '../config'; function getModel(db: Database): string { return getSetting(db, 'model') ?? config.defaultModel; } async function chat( db: Database, system: string, user: string, jsonMode = false ): Promise { const client = createAiClient(); const model = getModel(db); const response = await client.chat.completions.create({ model, messages: [ { role: 'system', content: system }, { role: 'user', content: user }, ], ...(jsonMode ? { response_format: { type: 'json_object' } } : {}), }); return response.choices[0]?.message.content ?? ''; } export async function getProgressAnalysis(db: Database, today: string): Promise { const ctx = buildContext(db, today); const { system, user } = progressPrompt(ctx); return chat(db, system, user); } export async function getNextStep(db: Database, today: string): Promise { const ctx = buildContext(db, today); const { system, user } = nextStepPrompt(ctx); return chat(db, system, user); } export async function generateSchedule( db: Database, today: string, note: string | null ): Promise { const ctx = buildContext(db, today); const { system, user } = reschedulePrompt(ctx, note); const raw = await chat(db, system, user); return parseScheduleJson(raw); } export async function generateSetupSchedule( db: Database, today: string, currentLevel: string, availability: string, goal: string ): Promise { const { system, user } = setupPrompt(currentLevel, availability, goal); const raw = await chat(db, system, user); return parseScheduleJson(raw); } export async function freeChat(db: Database, today: string, message: string): Promise { const ctx = buildContext(db, today); const { system } = progressPrompt(ctx); return chat(db, system, message); } ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/ai/coach.ts git commit -m "feat: AI coach functions" ``` --- ## Task 12: Logging and Skip Command Handlers **Files:** - Create: `src/bot/commands/log.ts` - Create: `src/bot/commands/skip.ts` - [ ] **Step 1: Write `src/bot/commands/log.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { parseDuration, formatDuration } from '../../utils/duration'; import { todayString } from '../../utils/date'; import { insertSession, getSessionByDate } from '../../db/sessions'; import { getScheduleByDate, updateScheduleStatus } from '../../db/schedule'; export function handleLog(db: Database) { return async (ctx: Context) => { const text = (ctx.message as { text?: string })?.text ?? ''; const args = text.split(/\s+/).slice(1).join(' '); if (!args) { await ctx.reply('Usage: /log e.g. /log 2h or /log 45m'); return; } const minutes = parseDuration(args); if (minutes === null) { await ctx.reply(`Could not parse "${args}". Try: /log 2h, /log 90m, /log 45min`); return; } const today = todayString(); const existing = getSessionByDate(db, today); if (existing) { await ctx.reply(`Already logged ${formatDuration(existing.duration_min)} for today. Each day supports one session.`); return; } insertSession(db, today, minutes, null); const scheduled = getScheduleByDate(db, today); if (scheduled && scheduled.status === 'pending') { updateScheduleStatus(db, today, 'completed', null); } await ctx.reply(`Logged ${formatDuration(minutes)} for today. Great work!`); }; } ``` - [ ] **Step 2: Write `src/bot/commands/skip.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { getScheduleByDate, updateScheduleStatus } from '../../db/schedule'; import { getSessionByDate } from '../../db/sessions'; export function handleSkip(db: Database) { return async (ctx: Context) => { const text = (ctx.message as { text?: string })?.text ?? ''; const reason = text.split(/\s+/).slice(1).join(' ') || null; const today = todayString(); const session = getSessionByDate(db, today); if (session) { await ctx.reply(`You already logged a session today (${session.duration_min}m). Nothing to skip.`); return; } const scheduled = getScheduleByDate(db, today); if (!scheduled) { await ctx.reply('No session scheduled for today anyway.'); return; } if (scheduled.status !== 'pending') { await ctx.reply(`Today's session is already marked as ${scheduled.status}.`); return; } updateScheduleStatus(db, today, 'skipped', reason); const msg = reason ? `Skipped today (${reason}). The AI will account for this next time you reschedule.` : 'Skipped today. The AI will account for this next time you reschedule.'; await ctx.reply(msg); }; } ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/bot/commands/log.ts src/bot/commands/skip.ts git commit -m "feat: /log and /skip command handlers" ``` --- ## Task 13: Status, History, and Streak Command Handlers **Files:** - Create: `src/bot/commands/status.ts` - Create: `src/bot/commands/history.ts` - Create: `src/bot/commands/streak.ts` - [ ] **Step 1: Write `src/bot/commands/status.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { getSessionByDate } from '../../db/sessions'; import { getScheduleByDate } from '../../db/schedule'; import { getSetting } from '../../db/settings'; import { formatDuration } from '../../utils/duration'; export function handleStatus(db: Database) { return async (ctx: Context) => { const today = todayString(); const session = getSessionByDate(db, today); const scheduled = getScheduleByDate(db, today); const morning = getSetting(db, 'morning_reminder') ?? '05:30'; const evening = getSetting(db, 'evening_reminder') ?? '16:30'; const lines: string[] = [`📅 Today: ${today}`]; if (session) { lines.push(`✅ Session logged: ${formatDuration(session.duration_min)}`); } else if (scheduled) { lines.push(`⏳ Planned: ${formatDuration(scheduled.planned_min)} (${scheduled.status})`); lines.push(`⏰ Reminders: ${morning} and ${evening}`); } else { lines.push('No session scheduled for today.'); } await ctx.reply(lines.join('\n')); }; } ``` - [ ] **Step 2: Write `src/bot/commands/history.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { getRecentSessions } from '../../db/sessions'; import { formatDuration } from '../../utils/duration'; export function handleHistory(db: Database) { return async (ctx: Context) => { const sessions = getRecentSessions(db, 7); if (sessions.length === 0) { await ctx.reply('No sessions recorded yet. Use /log to record your first session.'); return; } const lines = sessions.map(s => `${s.date}: ${formatDuration(s.duration_min)}${s.notes ? ` — ${s.notes}` : ''}` ); await ctx.reply(`Last ${sessions.length} sessions:\n\n${lines.join('\n')}`); }; } ``` - [ ] **Step 3: Write `src/bot/commands/streak.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; function getStreaks(db: Database): { current: number; longest: number } { const rows = db.prepare( "SELECT date FROM schedule WHERE status = 'completed' ORDER BY date ASC" ).all() as Array<{ date: string }>; if (rows.length === 0) return { current: 0, longest: 0 }; let current = 1, longest = 1, streak = 1; for (let i = 1; i < rows.length; i++) { const prev = new Date(rows[i - 1]!.date + 'T12:00:00'); const curr = new Date(rows[i]!.date + 'T12:00:00'); const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000); if (diff <= 2) { streak++; if (streak > longest) longest = streak; } else { streak = 1; } } current = streak; return { current, longest }; } export function handleStreak(db: Database) { return async (ctx: Context) => { const { current, longest } = getStreaks(db); await ctx.reply(`🔥 Current streak: ${current} sessions\n🏆 Longest streak: ${longest} sessions`); }; } ``` - [ ] **Step 4: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash git add src/bot/commands/status.ts src/bot/commands/history.ts src/bot/commands/streak.ts git commit -m "feat: /status, /history, /streak command handlers" ``` --- ## Task 14: Snooze and Settings Command Handlers **Files:** - Create: `src/bot/commands/snooze.ts` - Create: `src/bot/commands/settings-cmd.ts` - [ ] **Step 1: Write `src/bot/commands/snooze.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { getScheduleByDate } from '../../db/schedule'; import { getSessionByDate } from '../../db/sessions'; import { formatDuration } from '../../utils/duration'; export function handleSnooze(db: Database, telegramChatId: string) { return async (ctx: Context) => { const today = todayString(); const session = getSessionByDate(db, today); if (session) { await ctx.reply('Session already logged today — nothing to snooze.'); return; } const scheduled = getScheduleByDate(db, today); if (!scheduled || scheduled.status !== 'pending') { await ctx.reply('No pending session today to snooze.'); return; } await ctx.reply('Got it — I\'ll remind you again in 2 hours.'); setTimeout(async () => { try { await ctx.telegram.sendMessage( telegramChatId, `⏰ Snooze reminder: don't forget your session today (${formatDuration(scheduled.planned_min)}). Use /log when done or /skip to skip.` ); } catch { // best-effort; bot may have been restarted } }, 2 * 60 * 60 * 1000); }; } ``` - [ ] **Step 2: Write `src/bot/commands/settings-cmd.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { getSetting, setSetting, getAllSettings } from '../../db/settings'; const ALLOWED_KEYS = ['model', 'goal', 'morning_reminder', 'evening_reminder']; export function handleSettings(db: Database) { return async (ctx: Context) => { const text = (ctx.message as { text?: string })?.text ?? ''; const parts = text.split(/\s+/).slice(1); if (parts.length === 0) { const all = getAllSettings(db); const lines = ALLOWED_KEYS.map(k => `${k}: ${all[k] ?? '(not set)'}`); await ctx.reply(`Current settings:\n\n${lines.join('\n')}\n\nUsage: /settings `); return; } const key = parts[0]!; const value = parts.slice(1).join(' '); if (!ALLOWED_KEYS.includes(key)) { await ctx.reply(`Unknown setting "${key}". Allowed: ${ALLOWED_KEYS.join(', ')}`); return; } if (!value) { await ctx.reply(`Current value for "${key}": ${getSetting(db, key) ?? '(not set)'}`); return; } setSetting(db, key, value); await ctx.reply(`Updated ${key} = ${value}`); }; } ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/bot/commands/snooze.ts src/bot/commands/settings-cmd.ts git commit -m "feat: /snooze and /settings command handlers" ``` --- ## Task 15: AI Command Handlers **Files:** - Create: `src/bot/commands/progress.ts` - Create: `src/bot/commands/next.ts` - Create: `src/bot/commands/reschedule.ts` - Create: `src/bot/commands/chat.ts` - [ ] **Step 1: Write `src/bot/commands/progress.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { getProgressAnalysis } from '../../ai/coach'; export function handleProgress(db: Database) { return async (ctx: Context) => { await ctx.reply('Analysing your recent history…'); try { const result = await getProgressAnalysis(db, todayString()); await ctx.reply(result); } catch (err) { await ctx.reply(`AI unavailable: ${(err as Error).message}`); } }; } ``` - [ ] **Step 2: Write `src/bot/commands/next.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { getNextStep } from '../../ai/coach'; export function handleNext(db: Database) { return async (ctx: Context) => { await ctx.reply('Thinking about your next step…'); try { const result = await getNextStep(db, todayString()); await ctx.reply(result); } catch (err) { await ctx.reply(`AI unavailable: ${(err as Error).message}`); } }; } ``` - [ ] **Step 3: Write `src/bot/commands/reschedule.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { generateSchedule } from '../../ai/coach'; import { insertScheduleEntry } from '../../db/schedule'; import { formatDuration } from '../../utils/duration'; export function handleReschedule(db: Database) { return async (ctx: Context) => { const text = (ctx.message as { text?: string })?.text ?? ''; const note = text.split(/\s+/).slice(1).join(' ') || null; await ctx.reply('Generating new schedule…'); try { const proposals = await generateSchedule(db, todayString(), note); for (const p of proposals) { insertScheduleEntry(db, p.date, p.planned_min, p.rationale); } const lines = proposals.map(p => `${p.date}: ${formatDuration(p.planned_min)}`); await ctx.reply(`New schedule (${proposals.length} sessions):\n\n${lines.join('\n')}`); } catch (err) { await ctx.reply(`Failed to generate schedule: ${(err as Error).message}`); } }; } ``` - [ ] **Step 4: Write `src/bot/commands/chat.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../../utils/date'; import { freeChat } from '../../ai/coach'; export function handleChat(db: Database) { return async (ctx: Context) => { const text = (ctx.message as { text?: string })?.text ?? ''; const message = text.split(/\s+/).slice(1).join(' '); if (!message) { await ctx.reply('Usage: /chat '); return; } await ctx.reply('Thinking…'); try { const reply = await freeChat(db, todayString(), message); await ctx.reply(reply); } catch (err) { await ctx.reply(`AI unavailable: ${(err as Error).message}`); } }; } ``` - [ ] **Step 5: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 6: Commit** ```bash git add src/bot/commands/progress.ts src/bot/commands/next.ts src/bot/commands/reschedule.ts src/bot/commands/chat.ts git commit -m "feat: AI command handlers" ``` --- ## Task 16: Setup Command Handler (Onboarding) **Files:** - Create: `src/bot/commands/setup.ts` - [ ] **Step 1: Write `src/bot/commands/setup.ts`** ```typescript import type { Context } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { setSetting, getSetting } from '../../db/settings'; import { generateSetupSchedule } from '../../ai/coach'; import { insertScheduleEntry } from '../../db/schedule'; import { todayString } from '../../utils/date'; import { formatDuration } from '../../utils/duration'; type SetupState = { step: 'level' | 'availability' | 'goal'; level?: string; availability?: string; }; const sessions = new Map(); export function handleSetup(db: Database) { return async (ctx: Context) => { const chatId = ctx.from?.id; if (!chatId) return; const alreadySetup = getSetting(db, 'goal'); if (alreadySetup) { await ctx.reply('Already set up. Use /settings to change individual values, or /reschedule to regenerate your schedule.'); return; } sessions.set(chatId, { step: 'level' }); await ctx.reply( 'Welcome! Let\'s set up your training plan.\n\nWhat is your current level?\n\nExamples: "beginner", "can wear small plug for 30 minutes", "comfortable with medium plug for 1 hour"' ); }; } export function handleSetupReply(db: Database) { return async (ctx: Context) => { const chatId = ctx.from?.id; if (!chatId) return; const state = sessions.get(chatId); if (!state) return; const text = (ctx.message as { text?: string })?.text?.trim() ?? ''; if (!text) return; if (state.step === 'level') { state.level = text; state.step = 'availability'; await ctx.reply('How many days per week are you typically available for training? (e.g. "3 days", "every other day", "Mon/Wed/Fri")'); return; } if (state.step === 'availability') { state.availability = text; state.step = 'goal'; await ctx.reply('What is your training goal? (e.g. "wear large plug comfortably for 2 hours", "build up to daily wear")'); return; } if (state.step === 'goal') { sessions.delete(chatId); setSetting(db, 'goal', text); await ctx.reply('Got it! Generating your starter schedule…'); try { const proposals = await generateSetupSchedule(db, todayString(), state.level!, state.availability!, text); for (const p of proposals) { insertScheduleEntry(db, p.date, p.planned_min, p.rationale); } const lines = proposals.map(p => `${p.date}: ${formatDuration(p.planned_min)}`); await ctx.reply( `Setup complete! Here's your starter schedule (${proposals.length} sessions):\n\n${lines.join('\n')}\n\nUse /status to see today's plan, /log to record a session.` ); } catch (err) { await ctx.reply(`Setup saved but schedule generation failed: ${(err as Error).message}\n\nRun /reschedule to try again.`); } } }; } ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/bot/commands/setup.ts git commit -m "feat: /setup onboarding command" ``` --- ## Task 17: Bot Assembly **Files:** - Create: `src/bot/index.ts` - [ ] **Step 1: Write `src/bot/index.ts`** ```typescript import { Telegraf } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { handleLog } from './commands/log'; import { handleSkip } from './commands/skip'; import { handleSnooze } from './commands/snooze'; import { handleStatus } from './commands/status'; import { handleHistory } from './commands/history'; import { handleStreak } from './commands/streak'; import { handleSettings } from './commands/settings-cmd'; import { handleProgress } from './commands/progress'; import { handleNext } from './commands/next'; import { handleReschedule } from './commands/reschedule'; import { handleChat } from './commands/chat'; import { handleSetup, handleSetupReply } from './commands/setup'; export function createBot(token: string, chatId: string, db: Database): Telegraf { const bot = new Telegraf(token); bot.command('log', handleLog(db)); bot.command('skip', handleSkip(db)); bot.command('snooze', handleSnooze(db, chatId)); bot.command('status', handleStatus(db)); bot.command('history', handleHistory(db)); bot.command('streak', handleStreak(db)); bot.command('settings', handleSettings(db)); bot.command('progress', handleProgress(db)); bot.command('next', handleNext(db)); bot.command('reschedule', handleReschedule(db)); bot.command('chat', handleChat(db)); bot.command('setup', handleSetup(db)); const setupReply = handleSetupReply(db); bot.on('text', async (ctx) => { const text = (ctx.message as { text?: string })?.text ?? ''; if (text.startsWith('/')) return; await setupReply(ctx); }); bot.catch((err) => { console.error('Bot error:', err); }); return bot; } ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/bot/index.ts git commit -m "feat: bot assembly" ``` --- ## Task 18: Reminder Cron Jobs **Files:** - Create: `src/scheduler/reminders.ts` - Create: `src/scheduler/index.ts` - [ ] **Step 1: Write `src/scheduler/reminders.ts`** ```typescript import type { Telegraf } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { todayString } from '../utils/date'; import { getScheduleByDate } from '../db/schedule'; import { getSessionByDate } from '../db/sessions'; import { formatDuration } from '../utils/duration'; export async function sendMorningReminder(bot: Telegraf, db: Database, chatId: string): Promise { const today = todayString(); const scheduled = getScheduleByDate(db, today); if (!scheduled || scheduled.status !== 'pending') return; const session = getSessionByDate(db, today); if (session) return; await bot.telegram.sendMessage( chatId, `Good morning! You have a ${formatDuration(scheduled.planned_min)} session planned today.\n\nUse /log when done, /skip if you can't make it, or /snooze to remind you later.` ); } export async function sendEveningNudge(bot: Telegraf, db: Database, chatId: string): Promise { const today = todayString(); const scheduled = getScheduleByDate(db, today); if (!scheduled || scheduled.status !== 'pending') return; const session = getSessionByDate(db, today); if (session) return; await bot.telegram.sendMessage( chatId, `Still time for today's session (${formatDuration(scheduled.planned_min)})!\n\nUse /log to record it, or /skip to mark it as skipped.` ); } ``` - [ ] **Step 2: Write `src/scheduler/index.ts`** ```typescript import cron from 'node-cron'; import type { Telegraf } from 'telegraf'; import type { Database } from 'better-sqlite3'; import { getSetting } from '../db/settings'; import { config } from '../config'; import { sendMorningReminder, sendEveningNudge } from './reminders'; function parseCronTime(hhmm: string): string { const [hh, mm] = hhmm.split(':'); return `${mm} ${hh} * * *`; } export function startScheduler(bot: Telegraf, db: Database, chatId: string): void { const morningTime = getSetting(db, 'morning_reminder') ?? config.morningReminder; const eveningTime = getSetting(db, 'evening_reminder') ?? config.eveningReminder; cron.schedule(parseCronTime(morningTime), async () => { try { await sendMorningReminder(bot, db, chatId); } catch (err) { console.error('Morning reminder failed:', err); } }); cron.schedule(parseCronTime(eveningTime), async () => { try { await sendEveningNudge(bot, db, chatId); } catch (err) { console.error('Evening nudge failed:', err); } }); console.log(`Reminders scheduled: morning=${morningTime}, evening=${eveningTime}`); } ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/scheduler/ git commit -m "feat: cron reminder jobs" ``` --- ## Task 19: Entry Point **Files:** - Create: `src/index.ts` - [ ] **Step 1: Write `src/index.ts`** ```typescript import { config } from './config'; import { getDb } from './db'; import { createBot } from './bot'; import { startScheduler } from './scheduler'; async function main(): Promise { const db = getDb(config.dbPath); const bot = createBot(config.telegramToken, config.telegramChatId, db); startScheduler(bot, db, config.telegramChatId); await bot.launch(); console.log('Training assistant bot started.'); process.once('SIGINT', () => bot.stop('SIGINT')); process.once('SIGTERM', () => bot.stop('SIGTERM')); } main().catch((err) => { console.error('Fatal error:', err); process.exit(1); }); ``` - [ ] **Step 2: Verify full TypeScript compile** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Run the full test suite** ```bash npm test ``` Expected: all tests pass. - [ ] **Step 4: Commit** ```bash git add src/index.ts git commit -m "feat: entry point" ``` --- ## Task 20: Local Smoke Test - [ ] **Step 1: Create a `.env` file from the example** ```bash cp .env.example .env ``` Edit `.env` with your real Telegram token, chat ID, and OpenRouter key. - [ ] **Step 2: Start the bot in dev mode** ```bash npm run dev ``` Expected: `Training assistant bot started.` in the terminal with no errors. - [ ] **Step 3: Test core commands in Telegram** Send these to your bot in order: 1. `/setup` — complete onboarding (3 questions), verify schedule is generated 2. `/status` — verify today's plan appears 3. `/log 30m` — verify session is logged and schedule updates to completed 4. `/history` — verify the session appears 5. `/streak` — verify streak shows 1 6. `/progress` — verify AI responds (may take a few seconds) - [ ] **Step 4: Stop the bot** Press `Ctrl+C` in the terminal. --- ## Task 21: Dockerfile **Files:** - Create: `Dockerfile` - Create: `.dockerignore` - [ ] **Step 1: Write `Dockerfile`** ```dockerfile FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build FROM node:20-alpine AS runner WORKDIR /app RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist RUN mkdir -p /app/data && chown appuser:appgroup /app/data USER appuser CMD ["node", "dist/index.js"] ``` - [ ] **Step 2: Write `.dockerignore`** ``` node_modules/ dist/ data/ .env *.md src/ .git/ ``` - [ ] **Step 3: Verify the build succeeds** ```bash docker build -t training-assistant . ``` Expected: build completes without errors. - [ ] **Step 4: Commit** ```bash git add Dockerfile .dockerignore git commit -m "feat: Dockerfile" ``` --- ## Task 22: Docker Compose **Files:** - Create: `docker-compose.yml` - [ ] **Step 1: Write `docker-compose.yml`** ```yaml services: assistant: build: . restart: unless-stopped env_file: .env volumes: - ./data:/app/data environment: - TZ=${TZ:-Europe/Amsterdam} ``` - [ ] **Step 2: Start the container** ```bash docker compose up -d ``` Expected: container starts and stays running. - [ ] **Step 3: Verify it's running** ```bash docker compose logs -f ``` Expected: `Training assistant bot started.` in logs. Bot responds to commands in Telegram. - [ ] **Step 4: Test a reminder manually** ```bash docker compose exec assistant node -e " const { getDb } = require('./dist/db'); const { createBot } = require('./dist/bot'); const { sendMorningReminder } = require('./dist/scheduler/reminders'); const config = require('./dist/config').config; const db = getDb(config.dbPath); const bot = createBot(config.telegramToken, config.telegramChatId, db); sendMorningReminder(bot, db, config.telegramChatId).then(() => process.exit(0)); " ``` Expected: Telegram message received (only fires if you have a pending session scheduled for today). - [ ] **Step 5: Commit** ```bash git add docker-compose.yml git commit -m "feat: docker-compose" ``` --- ## Task 23: Update Procedure Documentation **Files:** - Create: `docs/update.md` - [ ] **Step 1: Write `docs/update.md`** ```markdown # Updating the Bot Pull latest code and rebuild: docker compose down git pull docker compose up -d --build The SQLite database in `./data/` is preserved across updates. ``` - [ ] **Step 2: Commit** ```bash git add docs/update.md git commit -m "docs: update procedure" ```