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
.envfile 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:
/setup— complete onboarding (3 questions), verify schedule is generated/status— verify today's plan appears/log 30m— verify session is logged and schedule updates to completed/history— verify the session appears/streak— verify streak shows 1/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"