Files
Training-Bot/docs/superpowers/plans/2026-05-20-training-assistant.md
T

59 KiB

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

cd /home/sjoerd/Work/assistant
npm init -y
  • Step 2: Install runtime dependencies
npm install telegraf better-sqlite3 openai node-cron dotenv
  • Step 3: Install dev dependencies
npm install -D typescript @types/node @types/better-sqlite3 @types/node-cron tsx jest ts-jest @types/jest
  • Step 4: Write tsconfig.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
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:

"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
mkdir -p src/bot/commands src/db src/scheduler src/ai src/utils src/__tests__/db src/__tests__/ai data
  • Step 9: Commit
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

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
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

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
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
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

// 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
npm test -- settings.test.ts

Expected: FAIL — getSetting not found.

  • Step 3: Write src/db/settings.ts
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<string, string> {
  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
npm test -- settings.test.ts

Expected: PASS (4 tests).

  • Step 5: Commit
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

// 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
npm test -- sessions.test.ts

Expected: FAIL — insertSession not found.

  • Step 3: Write src/db/sessions.ts
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
npm test -- sessions.test.ts

Expected: PASS (5 tests).

  • Step 5: Commit
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

// 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
npm test -- schedule.test.ts

Expected: FAIL.

  • Step 3: Write src/db/schedule.ts
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
npm test -- schedule.test.ts

Expected: PASS (5 tests).

  • Step 5: Commit
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

// 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
// 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
npm test -- duration.test.ts date.test.ts

Expected: FAIL.

  • Step 4: Write src/utils/duration.ts
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
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
npm test -- duration.test.ts date.test.ts

Expected: PASS (12 tests).

  • Step 7: Commit
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

// 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
npm test -- parser.test.ts

Expected: FAIL.

  • Step 3: Write src/ai/parser.ts
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<string, unknown>;
    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
npm test -- parser.test.ts

Expected: PASS (4 tests).

  • Step 5: Commit
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

// 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
npm test -- context.test.ts

Expected: FAIL.

  • Step 3: Write src/ai/context.ts
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
npm test -- context.test.ts

Expected: PASS (5 tests).

  • Step 5: Commit
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

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
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
npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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

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<string> {
  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<string> {
  const ctx = buildContext(db, today);
  const { system, user } = progressPrompt(ctx);
  return chat(db, system, user);
}

export async function getNextStep(db: Database, today: string): Promise<string> {
  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<ScheduleProposal[]> {
  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<ScheduleProposal[]> {
  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<string> {
  const ctx = buildContext(db, today);
  const { system } = progressPrompt(ctx);
  return chat(db, system, message);
}
  • Step 2: Verify TypeScript compiles
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

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 <duration>  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
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
npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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

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
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
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
npx tsc --noEmit

Expected: no errors.

  • Step 5: Commit
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

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
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 <key> <value>`);
      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
npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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

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
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
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
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 <your message>');
      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
npx tsc --noEmit

Expected: no errors.

  • Step 6: Commit
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

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<number, SetupState>();

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
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

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
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

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<void> {
  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<void> {
  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
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
npx tsc --noEmit

Expected: no errors.

  • Step 4: Commit
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

import { config } from './config';
import { getDb } from './db';
import { createBot } from './bot';
import { startScheduler } from './scheduler';

async function main(): Promise<void> {
  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
npx tsc --noEmit

Expected: no errors.

  • Step 3: Run the full test suite
npm test

Expected: all tests pass.

  • Step 4: Commit
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
cp .env.example .env

Edit .env with your real Telegram token, chat ID, and OpenRouter key.

  • Step 2: Start the bot in dev mode
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

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
docker build -t training-assistant .

Expected: build completes without errors.

  • Step 4: Commit
git add Dockerfile .dockerignore
git commit -m "feat: Dockerfile"

Task 22: Docker Compose

Files:

  • Create: docker-compose.yml

  • Step 1: Write docker-compose.yml

services:
  assistant:
    build: .
    restart: unless-stopped
    env_file: .env
    volumes:
      - ./data:/app/data
    environment:
      - TZ=${TZ:-Europe/Amsterdam}
  • Step 2: Start the container
docker compose up -d

Expected: container starts and stays running.

  • Step 3: Verify it's running
docker compose logs -f

Expected: Training assistant bot started. in logs. Bot responds to commands in Telegram.

  • Step 4: Test a reminder manually
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
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

# 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
git add docs/update.md
git commit -m "docs: update procedure"