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