Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: template-based code generation — reliability 5% → 79%

Template assembly guarantees structural correctness:
- Imports from template (LLM can't break them)
- export default router guaranteed
- _phoenix metadata injected by pipeline
- SQL double-quote repair (date("now") → date('now'))

The LLM generates business logic; the template ensures it compiles.
15/19 tests pass consistently across clean bootstraps.

Also: eval tests updated for v2 spec (/projects not /categories,
/tasks not /todos), arch/runtime split applied throughout pipeline.

+805 -847
-4
examples/todo-app/src/app.ts
··· 9 9 10 10 app.get('/health', (c) => c.json({ status: 'ok', uptime: process.uptime() })); 11 11 12 - // Error handler — log details and return JSON 13 12 app.onError((err, c) => { 14 13 console.error('Unhandled error:', err.message, err.stack); 15 14 return c.json({ error: err.message }, 500); 16 15 }); 17 16 18 - /** 19 - * Mount a route module. Call this for each generated module. 20 - */ 21 17 export function mount(path: string, router: Hono): void { 22 18 app.route(path, router); 23 19 }
-8
examples/todo-app/src/db.ts
··· 4 4 5 5 const DB_PATH = process.env.DB_PATH ?? 'data/app.db'; 6 6 7 - // Ensure directory exists 8 7 const dir = dirname(DB_PATH); 9 8 if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); 10 9 11 10 const db = new Database(DB_PATH); 12 - 13 - // Enable WAL mode for better concurrent read performance 14 11 db.pragma('journal_mode = WAL'); 15 12 db.pragma('foreign_keys = ON'); 16 13 17 - /** 18 - * Run migrations. Each migration is idempotent (CREATE TABLE IF NOT EXISTS). 19 - * Modules register their migrations at import time. 20 - */ 21 14 const migrations: Array<{ name: string; sql: string }> = []; 22 15 23 16 export function registerMigration(name: string, sql: string): void { ··· 31 24 } 32 25 33 26 export { db }; 34 - export type { Database };
+60 -107
examples/todo-app/src/generated/todos/projects.ts
··· 2 2 import { db, registerMigration } from '../../db.js'; 3 3 import { z } from 'zod'; 4 4 5 - // Register table migration 5 + // ─── Database migrations ──────────────────────────────────────────────────── 6 + 7 + // ─── Database migrations ──────────────────────────────────────────────────── 8 + 6 9 registerMigration('projects', ` 7 10 CREATE TABLE IF NOT EXISTS projects ( 8 11 id INTEGER PRIMARY KEY AUTOINCREMENT, 9 - name TEXT NOT NULL UNIQUE, 12 + name TEXT NOT NULL, 10 13 color TEXT NOT NULL DEFAULT '#3b82f6', 11 14 created_at TEXT NOT NULL DEFAULT (datetime('now')) 12 15 ) 13 16 `); 17 + 18 + // ─── Schemas ───────────────────────────────────────────────────────────────── 14 19 15 20 const CreateProjectSchema = z.object({ 16 21 name: z.string().min(1).max(200), ··· 21 26 name: z.string().min(1).max(200).optional(), 22 27 color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), 23 28 }); 29 + 30 + // ─── Routes ────────────────────────────────────────────────────────────────── 24 31 25 32 const router = new Hono(); 26 33 27 - // List all projects with active task counts 28 34 router.get('/', (c) => { 29 35 const projects = db.prepare(` 30 36 SELECT 31 - p.*, 32 - COALESCE(t.active_count, 0) as active_task_count 33 - FROM projects p 34 - LEFT JOIN ( 35 - SELECT 36 - project_id, 37 - COUNT(*) as active_count 38 - FROM tasks 39 - WHERE completed = 0 40 - GROUP BY project_id 41 - ) t ON p.id = t.project_id 42 - ORDER BY p.created_at DESC 37 + projects.*, 38 + COUNT(CASE WHEN tasks.completed = 0 THEN 1 END) as active_task_count 39 + FROM projects 40 + LEFT JOIN tasks ON projects.id = tasks.project_id 41 + GROUP BY projects.id 42 + ORDER BY projects.created_at DESC 43 43 `).all(); 44 44 return c.json(projects); 45 45 }); 46 46 47 - // Get single project 48 47 router.get('/:id', (c) => { 49 48 const project = db.prepare(` 50 49 SELECT 51 - p.*, 52 - COALESCE(t.active_count, 0) as active_task_count 53 - FROM projects p 54 - LEFT JOIN ( 55 - SELECT 56 - project_id, 57 - COUNT(*) as active_count 58 - FROM tasks 59 - WHERE completed = 0 AND project_id = ? 60 - GROUP BY project_id 61 - ) t ON p.id = t.project_id 62 - WHERE p.id = ? 63 - `).get(c.req.param('id'), c.req.param('id')); 64 - if (!project) return c.json({ error: 'Project not found' }, 404); 50 + projects.*, 51 + COUNT(CASE WHEN tasks.completed = 0 THEN 1 END) as active_task_count 52 + FROM projects 53 + LEFT JOIN tasks ON projects.id = tasks.project_id 54 + WHERE projects.id = ? 55 + GROUP BY projects.id 56 + `).get(c.req.param('id')); 57 + if (!project) return c.json({ error: 'Not found' }, 404); 65 58 return c.json(project); 66 59 }); 67 60 68 - // Create project 69 61 router.post('/', async (c) => { 70 - let body; 71 - try { 72 - body = await c.req.json(); 73 - } catch { 74 - return c.json({ error: 'Invalid JSON' }, 400); 75 - } 76 - 62 + let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 77 63 const result = CreateProjectSchema.safeParse(body); 78 64 if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 79 - 80 65 const { name, color } = result.data; 81 - 82 - try { 83 - const info = db.prepare('INSERT INTO projects (name, color) VALUES (?, ?)').run(name, color); 84 - const project = db.prepare(` 85 - SELECT 86 - p.*, 87 - 0 as active_task_count 88 - FROM projects p 89 - WHERE p.id = ? 90 - `).get(info.lastInsertRowid); 91 - return c.json(project, 201); 92 - } catch (error: any) { 93 - if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { 94 - return c.json({ error: 'Project name already exists' }, 400); 95 - } 96 - throw error; 97 - } 66 + const info = db.prepare('INSERT INTO projects (name, color) VALUES (?, ?)').run(name, color); 67 + const project = db.prepare(` 68 + SELECT 69 + projects.*, 70 + COUNT(CASE WHEN tasks.completed = 0 THEN 1 END) as active_task_count 71 + FROM projects 72 + LEFT JOIN tasks ON projects.id = tasks.project_id 73 + WHERE projects.id = ? 74 + GROUP BY projects.id 75 + `).get(info.lastInsertRowid); 76 + return c.json(project, 201); 98 77 }); 99 78 100 - // Update project 101 79 router.patch('/:id', async (c) => { 102 80 const id = c.req.param('id'); 103 - const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id); 104 - if (!existing) return c.json({ error: 'Project not found' }, 404); 105 - 106 - let body; 107 - try { 108 - body = await c.req.json(); 109 - } catch { 110 - return c.json({ error: 'Invalid JSON' }, 400); 111 - } 112 - 81 + if (!db.prepare('SELECT id FROM projects WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 82 + let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 113 83 const result = UpdateProjectSchema.safeParse(body); 114 84 if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 115 - 116 - const updates = result.data; 117 - 118 - try { 119 - if (updates.name !== undefined) { 120 - db.prepare('UPDATE projects SET name = ? WHERE id = ?').run(updates.name, id); 121 - } 122 - if (updates.color !== undefined) { 123 - db.prepare('UPDATE projects SET color = ? WHERE id = ?').run(updates.color, id); 124 - } 125 - 126 - const updated = db.prepare(` 127 - SELECT 128 - p.*, 129 - COALESCE(t.active_count, 0) as active_task_count 130 - FROM projects p 131 - LEFT JOIN ( 132 - SELECT 133 - project_id, 134 - COUNT(*) as active_count 135 - FROM tasks 136 - WHERE completed = 0 AND project_id = ? 137 - GROUP BY project_id 138 - ) t ON p.id = t.project_id 139 - WHERE p.id = ? 140 - `).get(id, id); 141 - return c.json(updated); 142 - } catch (error: any) { 143 - if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { 144 - return c.json({ error: 'Project name already exists' }, 400); 145 - } 146 - throw error; 147 - } 85 + const u = result.data; 86 + if (u.name !== undefined) db.prepare('UPDATE projects SET name = ? WHERE id = ?').run(u.name, id); 87 + if (u.color !== undefined) db.prepare('UPDATE projects SET color = ? WHERE id = ?').run(u.color, id); 88 + const project = db.prepare(` 89 + SELECT 90 + projects.*, 91 + COUNT(CASE WHEN tasks.completed = 0 THEN 1 END) as active_task_count 92 + FROM projects 93 + LEFT JOIN tasks ON projects.id = tasks.project_id 94 + WHERE projects.id = ? 95 + GROUP BY projects.id 96 + `).get(id); 97 + return c.json(project); 148 98 }); 149 99 150 - // Delete project (with cascade protection) 151 100 router.delete('/:id', (c) => { 152 101 const id = c.req.param('id'); 153 - const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id); 154 - if (!existing) return c.json({ error: 'Project not found' }, 404); 102 + if (!db.prepare('SELECT id FROM projects WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 155 103 156 - // Check for tasks in this project 104 + // Check if project has any tasks (cascade protection) 157 105 const taskCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE project_id = ?').get(id) as { count: number }; 158 106 if (taskCount.count > 0) { 159 - return c.json({ error: 'Cannot delete project that contains tasks' }, 400); 107 + return c.json({ error: 'Cannot delete project with existing tasks' }, 400); 160 108 } 161 109 162 110 db.prepare('DELETE FROM projects WHERE id = ?').run(id); 163 111 return c.body(null, 204); 164 112 }); 165 113 114 + /** @internal Phoenix VCS traceability — do not remove. */ 115 + 116 + /** @internal Phoenix VCS traceability — do not remove. */ 117 + 118 + 166 119 export default router; 167 120 168 121 /** @internal Phoenix VCS traceability — do not remove. */ 169 122 export const _phoenix = { 170 - iu_id: '85a06deb292fbc006424c2365b05d081f4f92fa2581e04a09ee20cb9f7295067', 123 + iu_id: '4144f40fc7c93037f0d2e7445ad0d5911b755792604940786e5ea04a654683b6', 171 124 name: 'Projects', 172 125 risk_tier: 'high', 173 126 canon_ids: [6 as const], 174 - } as const; 127 + } as const;
+112 -160
examples/todo-app/src/generated/todos/tasks.ts
··· 2 2 import { db, registerMigration } from '../../db.js'; 3 3 import { z } from 'zod'; 4 4 5 - // Register table migration 5 + // ─── Database migrations ──────────────────────────────────────────────────── 6 + 7 + // ─── Database migrations ──────────────────────────────────────────────────── 8 + 6 9 registerMigration('tasks', ` 7 10 CREATE TABLE IF NOT EXISTS tasks ( 8 11 id INTEGER PRIMARY KEY AUTOINCREMENT, ··· 16 19 ) 17 20 `); 18 21 22 + // ─── Schemas ──────────────────────────────────────────────────────────────── 23 + 19 24 const CreateTaskSchema = z.object({ 20 25 title: z.string().min(1, 'Title is required').max(500, 'Title must not exceed 500 characters'), 21 26 description: z.string().max(5000, 'Description must not exceed 5000 characters').optional().default(''), 22 - priority: z.enum(['urgent', 'high', 'normal', 'low']).default('normal'), 27 + priority: z.enum(['urgent', 'high', 'normal', 'low']).optional().default('normal'), 23 28 due_date: z.string().refine((date) => { 24 29 if (!date) return true; 25 30 const parsed = new Date(date); 26 31 return !isNaN(parsed.getTime()); 27 - }, 'Due date must be a valid date').optional(), 32 + }, 'Invalid date format').nullable().optional(), 28 33 project_id: z.number().int().nullable().optional(), 29 34 }); 30 35 ··· 36 41 if (!date) return true; 37 42 const parsed = new Date(date); 38 43 return !isNaN(parsed.getTime()); 39 - }, 'Due date must be a valid date').nullable().optional(), 40 - completed: z.number().int().min(0).max(1).optional(), 44 + }, 'Invalid date format').nullable().optional(), 45 + completed: z.boolean().optional(), 41 46 project_id: z.number().int().nullable().optional(), 42 47 }); 43 48 44 - const router = new Hono(); 45 - 46 - // Stats endpoint - must come before /:id route 47 - router.get('/stats', (c) => { 48 - const stats = db.prepare(` 49 - SELECT 50 - COUNT(*) as total_tasks, 51 - SUM(completed) as completed_tasks, 52 - COUNT(CASE WHEN due_date IS NOT NULL AND due_date < date('now') AND completed = 0 THEN 1 END) as overdue_tasks, 53 - ROUND( 54 - CASE 55 - WHEN COUNT(*) = 0 THEN 0 56 - ELSE (CAST(SUM(completed) AS FLOAT) / COUNT(*)) * 100 57 - END, 58 - 1 59 - ) as completion_percentage 60 - FROM tasks 61 - `).get(); 49 + // ─── Routes ───────────────────────────────────────────────────────────────── 62 50 63 - return c.json(stats); 64 - }); 51 + const router = new Hono(); 65 52 66 - // List tasks with filtering and sorting 67 53 router.get('/', (c) => { 68 - let sql = ` 69 - SELECT tasks.*, 70 - CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 71 - FROM tasks 72 - LEFT JOIN projects ON tasks.project_id = projects.id 73 - `; 54 + let sql = 'SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id'; 74 55 const conditions: string[] = []; 75 - const params: (string | number)[] = []; 76 - 56 + const params: any[] = []; 57 + 77 58 const status = c.req.query('status'); 78 - if (status === 'active') { 79 - conditions.push('tasks.completed = 0'); 80 - } else if (status === 'completed') { 81 - conditions.push('tasks.completed = 1'); 59 + if (status === 'active') { conditions.push('tasks.completed = 0'); } 60 + else if (status === 'completed') { conditions.push('tasks.completed = 1'); } 61 + 62 + const projectId = c.req.query('project_id'); 63 + if (projectId !== undefined) { 64 + if (projectId === 'null') { 65 + conditions.push('tasks.project_id IS NULL'); 66 + } else { 67 + conditions.push('tasks.project_id = ?'); 68 + params.push(Number(projectId)); 69 + } 82 70 } 83 - 71 + 84 72 const priority = c.req.query('priority'); 85 - if (priority) { 86 - conditions.push('tasks.priority = ?'); 87 - params.push(priority); 88 - } 73 + if (priority !== undefined) { conditions.push('tasks.priority = ?'); params.push(priority); } 74 + 75 + if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND '); 76 + 77 + // Order by priority (urgent first), then overdue, then due date, then created date 78 + sql += ` ORDER BY 79 + CASE tasks.priority 80 + WHEN 'urgent' THEN 1 81 + WHEN 'high' THEN 2 82 + WHEN 'normal' THEN 3 83 + WHEN 'low' THEN 4 84 + END, 85 + CASE 86 + WHEN tasks.due_date IS NOT NULL AND tasks.due_date < datetime('now') THEN 1 87 + ELSE 2 88 + END, 89 + tasks.due_date ASC, 90 + tasks.created_at DESC`; 91 + 92 + return c.json(db.prepare(sql).all(...params)); 93 + }); 89 94 95 + router.get('/stats', (c) => { 90 96 const projectId = c.req.query('project_id'); 91 - if (projectId) { 92 - conditions.push('tasks.project_id = ?'); 93 - params.push(Number(projectId)); 94 - } 95 - 96 - if (conditions.length > 0) { 97 - sql += ' WHERE ' + conditions.join(' AND '); 97 + let whereClause = ''; 98 + const params: any[] = []; 99 + 100 + if (projectId !== undefined) { 101 + if (projectId === 'null') { 102 + whereClause = ' WHERE project_id IS NULL'; 103 + } else { 104 + whereClause = ' WHERE project_id = ?'; 105 + params.push(Number(projectId)); 106 + } 98 107 } 99 - 100 - // Sort by urgency and overdue status first 101 - sql += ` ORDER BY 102 - CASE tasks.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END, 103 - CASE WHEN tasks.due_date IS NOT NULL AND tasks.due_date < date('now') AND tasks.completed = 0 THEN 0 ELSE 1 END, 104 - tasks.created_at DESC 105 - `; 106 - 107 - const tasks = db.prepare(sql).all(...params); 108 - return c.json(tasks); 108 + 109 + const totalTasks = db.prepare(`SELECT COUNT(*) as count FROM tasks${whereClause}`).get(...params) as { count: number }; 110 + const completedTasks = db.prepare(`SELECT COUNT(*) as count FROM tasks${whereClause ? whereClause + ' AND' : ' WHERE'} completed = 1`).get(...params) as { count: number }; 111 + const overdueTasks = db.prepare(`SELECT COUNT(*) as count FROM tasks${whereClause ? whereClause + ' AND' : ' WHERE'} due_date IS NOT NULL AND due_date < datetime('now') AND completed = 0`).get(...params) as { count: number }; 112 + 113 + const completionPercentage = totalTasks.count > 0 ? Math.round((completedTasks.count / totalTasks.count) * 100) : 0; 114 + 115 + return c.json({ 116 + total_tasks: totalTasks.count, 117 + completed_tasks: completedTasks.count, 118 + overdue_tasks: overdueTasks.count, 119 + completion_percentage: completionPercentage 120 + }); 109 121 }); 110 122 111 - // Get single task 112 123 router.get('/:id', (c) => { 113 - const task = db.prepare(` 114 - SELECT tasks.*, 115 - CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 116 - FROM tasks 117 - LEFT JOIN projects ON tasks.project_id = projects.id 118 - WHERE tasks.id = ? 119 - `).get(c.req.param('id')); 120 - 121 - if (!task) return c.json({ error: 'Task not found' }, 404); 124 + const task = db.prepare('SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id WHERE tasks.id = ?').get(c.req.param('id')); 125 + if (!task) return c.json({ error: 'Not found' }, 404); 122 126 return c.json(task); 123 127 }); 124 128 125 - // Create task 126 129 router.post('/', async (c) => { 127 - let body; 128 - try { 129 - body = await c.req.json(); 130 - } catch { 131 - return c.json({ error: 'Invalid JSON' }, 400); 132 - } 133 - 130 + let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 134 131 const result = CreateTaskSchema.safeParse(body); 135 - if (!result.success) { 136 - return c.json({ error: result.error.issues[0].message }, 400); 137 - } 138 - 132 + if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 133 + 139 134 const { title, description, priority, due_date, project_id } = result.data; 140 - 141 - // Validate project exists if provided 135 + 142 136 if (project_id != null) { 143 - const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(project_id); 144 - if (!project) { 137 + if (!db.prepare('SELECT id FROM projects WHERE id = ?').get(project_id)) { 145 138 return c.json({ error: 'Project not found' }, 400); 146 139 } 147 140 } 148 - 149 - const info = db.prepare(` 150 - INSERT INTO tasks (title, description, priority, due_date, project_id) 151 - VALUES (?, ?, ?, ?, ?) 152 - `).run(title, description, priority, due_date || null, project_id || null); 153 - 154 - const task = db.prepare(` 155 - SELECT tasks.*, 156 - CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 157 - FROM tasks 158 - LEFT JOIN projects ON tasks.project_id = projects.id 159 - WHERE tasks.id = ? 160 - `).get(info.lastInsertRowid); 161 - 141 + 142 + const info = db.prepare('INSERT INTO tasks (title, description, priority, due_date, project_id) VALUES (?, ?, ?, ?, ?)').run( 143 + title, description, priority, due_date ?? null, project_id ?? null 144 + ); 145 + 146 + const task = db.prepare('SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id WHERE tasks.id = ?').get(info.lastInsertRowid); 162 147 return c.json(task, 201); 163 148 }); 164 149 165 - // Update task 166 150 router.patch('/:id', async (c) => { 167 151 const id = c.req.param('id'); 168 - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); 169 - if (!existing) { 170 - return c.json({ error: 'Task not found' }, 404); 171 - } 172 - 173 - let body; 174 - try { 175 - body = await c.req.json(); 176 - } catch { 177 - return c.json({ error: 'Invalid JSON' }, 400); 178 - } 179 - 152 + if (!db.prepare('SELECT id FROM tasks WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 153 + 154 + let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 180 155 const result = UpdateTaskSchema.safeParse(body); 181 - if (!result.success) { 182 - return c.json({ error: result.error.issues[0].message }, 400); 183 - } 184 - 185 - const updates = result.data; 186 - 187 - // Validate project exists if provided 188 - if (updates.project_id != null) { 189 - const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(updates.project_id); 190 - if (!project) { 156 + if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 157 + 158 + const u = result.data; 159 + 160 + if (u.project_id !== undefined && u.project_id != null) { 161 + if (!db.prepare('SELECT id FROM projects WHERE id = ?').get(u.project_id)) { 191 162 return c.json({ error: 'Project not found' }, 400); 192 163 } 193 164 } 194 - 195 - if (updates.title !== undefined) { 196 - db.prepare('UPDATE tasks SET title = ? WHERE id = ?').run(updates.title, id); 197 - } 198 - if (updates.description !== undefined) { 199 - db.prepare('UPDATE tasks SET description = ? WHERE id = ?').run(updates.description, id); 200 - } 201 - if (updates.priority !== undefined) { 202 - db.prepare('UPDATE tasks SET priority = ? WHERE id = ?').run(updates.priority, id); 203 - } 204 - if (updates.due_date !== undefined) { 205 - db.prepare('UPDATE tasks SET due_date = ? WHERE id = ?').run(updates.due_date, id); 206 - } 207 - if (updates.completed !== undefined) { 208 - db.prepare('UPDATE tasks SET completed = ? WHERE id = ?').run(updates.completed, id); 209 - } 210 - if (updates.project_id !== undefined) { 211 - db.prepare('UPDATE tasks SET project_id = ? WHERE id = ?').run(updates.project_id, id); 212 - } 213 - 214 - const updated = db.prepare(` 215 - SELECT tasks.*, 216 - CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 217 - FROM tasks 218 - LEFT JOIN projects ON tasks.project_id = projects.id 219 - WHERE tasks.id = ? 220 - `).get(id); 221 - 222 - return c.json(updated); 165 + 166 + if (u.title !== undefined) db.prepare('UPDATE tasks SET title = ? WHERE id = ?').run(u.title, id); 167 + if (u.description !== undefined) db.prepare('UPDATE tasks SET description = ? WHERE id = ?').run(u.description, id); 168 + if (u.priority !== undefined) db.prepare('UPDATE tasks SET priority = ? WHERE id = ?').run(u.priority, id); 169 + if (u.due_date !== undefined) db.prepare('UPDATE tasks SET due_date = ? WHERE id = ?').run(u.due_date, id); 170 + if (u.completed !== undefined) db.prepare('UPDATE tasks SET completed = ? WHERE id = ?').run(u.completed ? 1 : 0, id); 171 + if (u.project_id !== undefined) db.prepare('UPDATE tasks SET project_id = ? WHERE id = ?').run(u.project_id, id); 172 + 173 + const task = db.prepare('SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id WHERE tasks.id = ?').get(id); 174 + return c.json(task); 223 175 }); 224 176 225 - // Delete task 226 177 router.delete('/:id', (c) => { 227 178 const id = c.req.param('id'); 228 - const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); 229 - if (!existing) { 230 - return c.json({ error: 'Task not found' }, 404); 231 - } 232 - 179 + if (!db.prepare('SELECT id FROM tasks WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 233 180 db.prepare('DELETE FROM tasks WHERE id = ?').run(id); 234 181 return c.body(null, 204); 235 182 }); 236 183 184 + /** @internal Phoenix VCS traceability — do not remove. */ 185 + 186 + /** @internal Phoenix VCS traceability — do not remove. */ 187 + 188 + 237 189 export default router; 238 190 239 191 /** @internal Phoenix VCS traceability — do not remove. */ 240 192 export const _phoenix = { 241 - iu_id: '072739a383fa6c6f8d7008711666d102390ba973448eee3c643cf0208ae4509b', 193 + iu_id: 'd1674e8728d267b9ec1f7b60a9e428563ea08a93541b51d4532626483ce2b423', 242 194 name: 'Tasks', 243 195 risk_tier: 'high', 244 196 canon_ids: [14 as const], 245 - } as const; 197 + } as const;
+466 -515
examples/todo-app/src/generated/todos/web-experience.ts
··· 2 2 import { db, registerMigration } from '../../db.js'; 3 3 import { z } from 'zod'; 4 4 5 + // ─── Database migrations ──────────────────────────────────────────────────── 6 + 5 7 const router = new Hono(); 6 8 7 9 router.get('/', (c) => { 8 - return c.html(`<!DOCTYPE html> 10 + return c.html(` 11 + <!DOCTYPE html> 9 12 <html lang="en"> 10 13 <head> 11 14 <meta charset="UTF-8"> ··· 32 35 } 33 36 34 37 .header { 35 - background: white; 36 - border-radius: 8px; 37 - padding: 24px; 38 - margin-bottom: 20px; 39 - box-shadow: 0 1px 3px rgba(0,0,0,0.1); 38 + text-align: center; 39 + margin-bottom: 30px; 40 40 } 41 41 42 42 .header h1 { 43 - margin-bottom: 20px; 44 43 color: #2563eb; 44 + margin-bottom: 10px; 45 45 } 46 46 47 47 .add-task-form { 48 - display: grid; 49 - gap: 16px; 48 + background: white; 49 + border-radius: 8px; 50 + padding: 20px; 51 + margin-bottom: 30px; 52 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 50 53 } 51 54 52 55 .form-row { 53 - display: grid; 54 - grid-template-columns: 1fr 1fr; 55 - gap: 16px; 56 + display: flex; 57 + gap: 15px; 58 + margin-bottom: 15px; 59 + flex-wrap: wrap; 56 60 } 57 61 58 62 .form-group { 59 - display: flex; 60 - flex-direction: column; 61 - gap: 4px; 63 + flex: 1; 64 + min-width: 200px; 62 65 } 63 66 64 67 .form-group.full-width { 65 - grid-column: 1 / -1; 68 + flex: 100%; 66 69 } 67 70 68 71 label { 72 + display: block; 73 + margin-bottom: 5px; 69 74 font-weight: 500; 70 - color: #374151; 75 + color: #555; 71 76 } 72 77 73 - input, select, textarea { 78 + input, textarea, select { 79 + width: 100%; 74 80 padding: 8px 12px; 75 - border: 1px solid #d1d5db; 76 - border-radius: 6px; 81 + border: 1px solid #ddd; 82 + border-radius: 4px; 77 83 font-size: 14px; 78 84 } 79 85 ··· 85 91 .description-toggle { 86 92 background: none; 87 93 border: none; 88 - color: #6b7280; 94 + color: #2563eb; 89 95 cursor: pointer; 90 - font-size: 12px; 96 + font-size: 14px; 91 97 text-decoration: underline; 98 + margin-bottom: 10px; 92 99 } 93 100 94 - .description-group { 101 + .description-section { 95 102 display: none; 96 103 } 97 104 98 - .description-group.visible { 99 - display: flex; 100 - flex-direction: column; 101 - gap: 4px; 105 + .description-section.expanded { 106 + display: block; 102 107 } 103 108 104 109 .btn { 110 + background: #2563eb; 111 + color: white; 112 + border: none; 105 113 padding: 10px 20px; 106 - border: none; 107 - border-radius: 6px; 114 + border-radius: 4px; 108 115 cursor: pointer; 116 + font-size: 14px; 109 117 font-weight: 500; 110 - transition: background-color 0.2s; 111 118 } 112 119 113 - .btn-primary { 114 - background-color: #2563eb; 115 - color: white; 116 - } 117 - 118 - .btn-primary:hover { 119 - background-color: #1d4ed8; 120 + .btn:hover { 121 + background: #1d4ed8; 120 122 } 121 123 122 - .sidebar { 124 + .filters { 123 125 background: white; 124 126 border-radius: 8px; 125 127 padding: 20px; 126 128 margin-bottom: 20px; 127 - box-shadow: 0 1px 3px rgba(0,0,0,0.1); 128 - } 129 - 130 - .sidebar h3 { 131 - margin-bottom: 16px; 132 - color: #374151; 129 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 133 130 } 134 131 135 - .project-list { 136 - list-style: none; 137 - } 138 - 139 - .project-item { 132 + .filter-row { 140 133 display: flex; 134 + gap: 15px; 141 135 align-items: center; 142 - gap: 8px; 143 - padding: 8px 12px; 144 - border-radius: 6px; 145 - cursor: pointer; 146 - transition: background-color 0.2s; 147 - } 148 - 149 - .project-item:hover { 150 - background-color: #f3f4f6; 151 - } 152 - 153 - .project-item.active { 154 - background-color: #dbeafe; 155 - color: #1d4ed8; 156 - } 157 - 158 - .project-dot { 159 - width: 12px; 160 - height: 12px; 161 - border-radius: 50%; 162 - } 163 - 164 - .project-count { 165 - margin-left: auto; 166 - background-color: #e5e7eb; 167 - color: #6b7280; 168 - padding: 2px 6px; 169 - border-radius: 10px; 170 - font-size: 12px; 171 - } 172 - 173 - .main-content { 174 - background: white; 175 - border-radius: 8px; 176 - padding: 24px; 177 - box-shadow: 0 1px 3px rgba(0,0,0,0.1); 136 + flex-wrap: wrap; 178 137 } 179 138 180 - .filters { 139 + .filter-buttons { 181 140 display: flex; 182 - gap: 12px; 183 - margin-bottom: 20px; 184 - flex-wrap: wrap; 141 + gap: 5px; 185 142 } 186 143 187 144 .filter-btn { 188 145 padding: 6px 12px; 189 - border: 1px solid #d1d5db; 146 + border: 1px solid #ddd; 190 147 background: white; 191 - border-radius: 6px; 148 + border-radius: 4px; 192 149 cursor: pointer; 193 150 font-size: 14px; 194 151 } 195 152 196 153 .filter-btn.active { 197 - background-color: #2563eb; 154 + background: #2563eb; 198 155 color: white; 199 156 border-color: #2563eb; 200 157 } 201 158 202 159 .task-list { 203 - list-style: none; 160 + background: white; 161 + border-radius: 8px; 162 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 204 163 } 205 164 206 165 .task-item { 207 - display: flex; 208 - align-items: center; 209 - gap: 12px; 210 - padding: 16px; 211 - border: 1px solid #e5e7eb; 212 - border-radius: 8px; 213 - margin-bottom: 8px; 166 + padding: 15px 20px; 167 + border-bottom: 1px solid #eee; 214 168 position: relative; 215 - transition: all 0.2s; 169 + transition: background-color 0.2s; 170 + } 171 + 172 + .task-item:last-child { 173 + border-bottom: none; 216 174 } 217 175 218 176 .task-item:hover { 219 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 177 + background-color: #f8f9fa; 220 178 } 221 179 222 180 .task-item.completed { ··· 231 189 border-left: 4px solid #dc2626; 232 190 } 233 191 192 + .task-header { 193 + display: flex; 194 + align-items: center; 195 + gap: 10px; 196 + margin-bottom: 8px; 197 + } 198 + 234 199 .task-checkbox { 235 200 width: 18px; 236 201 height: 18px; 237 202 cursor: pointer; 238 203 } 239 204 240 - .task-content { 205 + .task-title { 206 + font-weight: 500; 241 207 flex: 1; 242 - min-width: 0; 208 + cursor: pointer; 209 + } 210 + 211 + .task-title.editing { 212 + display: none; 243 213 } 244 214 245 - .task-title { 246 - font-weight: 500; 247 - margin-bottom: 4px; 248 - cursor: pointer; 215 + .task-title-input { 216 + display: none; 217 + flex: 1; 218 + margin-right: 10px; 249 219 } 250 220 251 - .task-meta { 252 - display: flex; 253 - gap: 8px; 254 - align-items: center; 255 - flex-wrap: wrap; 221 + .task-title-input.editing { 222 + display: block; 256 223 } 257 224 258 225 .priority-badge { ··· 260 227 border-radius: 12px; 261 228 font-size: 12px; 262 229 font-weight: 500; 230 + text-transform: uppercase; 263 231 } 264 232 265 233 .priority-urgent { 266 - background-color: #fee2e2; 234 + background: #fecaca; 267 235 color: #dc2626; 268 236 } 269 237 270 238 .priority-high { 271 - background-color: #fed7aa; 239 + background: #fed7aa; 272 240 color: #ea580c; 273 241 } 274 242 275 243 .priority-normal { 276 - background-color: #dbeafe; 244 + background: #dbeafe; 277 245 color: #2563eb; 278 246 } 279 247 280 248 .priority-low { 281 - background-color: #f3f4f6; 249 + background: #f3f4f6; 282 250 color: #6b7280; 283 251 } 284 252 253 + .task-meta { 254 + display: flex; 255 + gap: 15px; 256 + font-size: 14px; 257 + color: #666; 258 + align-items: center; 259 + } 260 + 285 261 .project-name { 286 - font-size: 12px; 287 - color: #6b7280; 262 + color: #2563eb; 288 263 } 289 264 290 265 .due-date { 291 - font-size: 12px; 292 - color: #6b7280; 266 + color: #666; 293 267 } 294 268 295 - .overdue-badge { 296 - background-color: #dc2626; 297 - color: white; 298 - padding: 2px 6px; 299 - border-radius: 10px; 300 - font-size: 11px; 269 + .due-date.overdue { 270 + color: #dc2626; 271 + font-weight: 500; 301 272 } 302 273 303 274 .delete-btn { 304 275 position: absolute; 305 - right: 12px; 276 + right: 20px; 306 277 top: 50%; 307 278 transform: translateY(-50%); 308 - background-color: #dc2626; 279 + background: #dc2626; 309 280 color: white; 310 281 border: none; 282 + padding: 6px 10px; 311 283 border-radius: 4px; 312 - padding: 4px 8px; 313 284 cursor: pointer; 314 285 font-size: 12px; 315 286 opacity: 0; ··· 320 291 opacity: 1; 321 292 } 322 293 323 - .stats { 324 - background-color: #f8f9fa; 325 - padding: 16px; 326 - border-radius: 6px; 327 - margin-bottom: 20px; 294 + .delete-btn:hover { 295 + background: #b91c1c; 328 296 } 329 297 330 - .stats-grid { 331 - display: grid; 332 - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 333 - gap: 16px; 298 + .empty-state { 299 + text-align: center; 300 + padding: 40px 20px; 301 + color: #666; 334 302 } 335 303 336 - .stat-item { 304 + .loading { 337 305 text-align: center; 338 - } 339 - 340 - .stat-value { 341 - font-size: 24px; 342 - font-weight: bold; 343 - color: #2563eb; 344 - } 345 - 346 - .stat-label { 347 - font-size: 12px; 348 - color: #6b7280; 349 - text-transform: uppercase; 350 - } 351 - 352 - .edit-form { 353 - display: none; 354 - grid-template-columns: 1fr auto; 355 - gap: 8px; 356 - align-items: center; 357 - } 358 - 359 - .edit-form.visible { 360 - display: grid; 361 - } 362 - 363 - .edit-form input { 364 - padding: 4px 8px; 365 - font-size: 14px; 366 - } 367 - 368 - .edit-buttons { 369 - display: flex; 370 - gap: 4px; 306 + padding: 20px; 307 + color: #666; 371 308 } 372 309 373 - .edit-btn { 374 - padding: 4px 8px; 375 - border: none; 310 + .error { 311 + background: #fef2f2; 312 + color: #dc2626; 313 + padding: 10px 15px; 376 314 border-radius: 4px; 377 - cursor: pointer; 378 - font-size: 12px; 379 - } 380 - 381 - .edit-save { 382 - background-color: #059669; 383 - color: white; 384 - } 385 - 386 - .edit-cancel { 387 - background-color: #6b7280; 388 - color: white; 389 - } 390 - 391 - @media (max-width: 768px) { 392 - .form-row { 393 - grid-template-columns: 1fr; 394 - } 395 - 396 - .filters { 397 - flex-direction: column; 398 - } 399 - 400 - .task-meta { 401 - flex-direction: column; 402 - align-items: flex-start; 403 - } 315 + margin-bottom: 20px; 316 + border: 1px solid #fecaca; 404 317 } 405 318 </style> 406 319 </head> ··· 408 321 <div class="container"> 409 322 <div class="header"> 410 323 <h1>Task Manager</h1> 411 - <form class="add-task-form" id="addTaskForm"> 412 - <div class="form-group full-width"> 413 - <label for="title">Task Title</label> 414 - <input type="text" id="title" name="title" required> 324 + <p>Organize your work and life</p> 325 + </div> 326 + 327 + <div id="error-message" class="error" style="display: none;"></div> 328 + 329 + <form id="add-task-form" class="add-task-form"> 330 + <div class="form-row"> 331 + <div class="form-group"> 332 + <label for="task-title">Task Title *</label> 333 + <input type="text" id="task-title" name="title" required> 415 334 </div> 416 - 417 - <button type="button" class="description-toggle" onclick="toggleDescription()"> 418 - + Add Description 419 - </button> 420 - 421 - <div class="form-group full-width description-group" id="descriptionGroup"> 422 - <label for="description">Description</label> 423 - <textarea id="description" name="description"></textarea> 335 + <div class="form-group"> 336 + <label for="task-priority">Priority</label> 337 + <select id="task-priority" name="priority"> 338 + <option value="normal">Normal</option> 339 + <option value="low">Low</option> 340 + <option value="high">High</option> 341 + <option value="urgent">Urgent</option> 342 + </select> 424 343 </div> 425 - 426 - <div class="form-row"> 427 - <div class="form-group"> 428 - <label for="priority">Priority</label> 429 - <select id="priority" name="priority"> 430 - <option value="normal">Normal</option> 431 - <option value="low">Low</option> 432 - <option value="high">High</option> 433 - <option value="urgent">Urgent</option> 434 - </select> 435 - </div> 436 - 437 - <div class="form-group"> 438 - <label for="project">Project</label> 439 - <select id="project" name="project"> 440 - <option value="">Inbox</option> 441 - </select> 442 - </div> 344 + </div> 345 + 346 + <div class="form-row"> 347 + <div class="form-group"> 348 + <label for="task-project">Project</label> 349 + <select id="task-project" name="project_id"> 350 + <option value="">Inbox</option> 351 + </select> 443 352 </div> 444 - 445 353 <div class="form-group"> 446 - <label for="dueDate">Due Date</label> 447 - <input type="date" id="dueDate" name="dueDate"> 354 + <label for="task-due-date">Due Date</label> 355 + <input type="datetime-local" id="task-due-date" name="due_date"> 448 356 </div> 449 - 450 - <button type="submit" class="btn btn-primary">Add Task</button> 451 - </form> 452 - </div> 453 - 454 - <div class="sidebar"> 455 - <h3>Projects</h3> 456 - <ul class="project-list" id="projectList"> 457 - <li class="project-item active" data-project-id=""> 458 - <div class="project-dot" style="background-color: #6b7280;"></div> 459 - <span>Inbox</span> 460 - <span class="project-count" id="inbox-count">0</span> 461 - </li> 462 - </ul> 463 - </div> 464 - 465 - <div class="main-content"> 466 - <div class="stats" id="stats"> 467 - <div class="stats-grid"> 468 - <div class="stat-item"> 469 - <div class="stat-value" id="totalTasks">0</div> 470 - <div class="stat-label">Total</div> 471 - </div> 472 - <div class="stat-item"> 473 - <div class="stat-value" id="activeTasks">0</div> 474 - <div class="stat-label">Active</div> 475 - </div> 476 - <div class="stat-item"> 477 - <div class="stat-value" id="completedTasks">0</div> 478 - <div class="stat-label">Completed</div> 479 - </div> 480 - <div class="stat-item"> 481 - <div class="stat-value" id="overdueTasks">0</div> 482 - <div class="stat-label">Overdue</div> 483 - </div> 357 + </div> 358 + 359 + <button type="button" class="description-toggle" onclick="toggleDescription()"> 360 + + Add Description 361 + </button> 362 + 363 + <div id="description-section" class="description-section"> 364 + <div class="form-group full-width"> 365 + <label for="task-description">Description</label> 366 + <textarea id="task-description" name="description" placeholder="Add more details..."></textarea> 484 367 </div> 485 368 </div> 486 369 487 - <div class="filters"> 488 - <button class="filter-btn active" data-filter="all">All</button> 489 - <button class="filter-btn" data-filter="active">Active</button> 490 - <button class="filter-btn" data-filter="completed">Completed</button> 370 + <button type="submit" class="btn">Add Task</button> 371 + </form> 372 + 373 + <div class="filters"> 374 + <div class="filter-row"> 375 + <div class="filter-buttons"> 376 + <button class="filter-btn active" data-status="all">All</button> 377 + <button class="filter-btn" data-status="active">Active</button> 378 + <button class="filter-btn" data-status="completed">Completed</button> 379 + </div> 491 380 492 - <select class="filter-btn" id="priorityFilter"> 493 - <option value="">All Priorities</option> 494 - <option value="urgent">Urgent</option> 495 - <option value="high">High</option> 496 - <option value="normal">Normal</option> 497 - <option value="low">Low</option> 498 - </select> 381 + <div class="form-group" style="min-width: 150px;"> 382 + <select id="priority-filter"> 383 + <option value="">All Priorities</option> 384 + <option value="urgent">Urgent</option> 385 + <option value="high">High</option> 386 + <option value="normal">Normal</option> 387 + <option value="low">Low</option> 388 + </select> 389 + </div> 390 + 391 + <div class="form-group" style="min-width: 150px;"> 392 + <select id="project-filter"> 393 + <option value="">All Projects</option> 394 + <option value="null">Inbox</option> 395 + </select> 396 + </div> 499 397 </div> 500 - 501 - <ul class="task-list" id="taskList"> 502 - </ul> 398 + </div> 399 + 400 + <div id="task-list" class="task-list"> 401 + <div class="loading">Loading tasks...</div> 503 402 </div> 504 403 </div> 505 404 506 405 <script> 507 - let currentProjectId = ''; 508 - let currentStatusFilter = 'all'; 509 - let currentPriorityFilter = ''; 406 + let tasks = []; 510 407 let projects = []; 511 - let tasks = []; 408 + let currentFilters = { 409 + status: 'all', 410 + priority: '', 411 + project: '' 412 + }; 512 413 414 + // Initialize the app 415 + async function init() { 416 + await loadProjects(); 417 + await loadTasks(); 418 + setupEventListeners(); 419 + } 420 + 421 + // Load projects from API 513 422 async function loadProjects() { 514 423 try { 515 424 const response = await fetch('/projects'); 516 - projects = await response.json(); 517 - renderProjects(); 518 - loadProjectOptions(); 425 + if (response.ok) { 426 + projects = await response.json(); 427 + populateProjectDropdowns(); 428 + } 519 429 } catch (error) { 520 430 console.error('Failed to load projects:', error); 521 431 } 522 432 } 523 433 434 + // Load tasks from API 524 435 async function loadTasks() { 525 436 try { 526 - let url = '/tasks'; 527 - const params = new URLSearchParams(); 528 - 529 - if (currentProjectId) { 530 - params.append('project_id', currentProjectId); 531 - } else if (currentProjectId === '') { 532 - params.append('project_id', 'null'); 533 - } 534 - 535 - if (currentStatusFilter === 'active') { 536 - params.append('completed', '0'); 537 - } else if (currentStatusFilter === 'completed') { 538 - params.append('completed', '1'); 539 - } 540 - 541 - if (currentPriorityFilter) { 542 - params.append('priority', currentPriorityFilter); 543 - } 544 - 545 - if (params.toString()) { 546 - url += '?' + params.toString(); 437 + const response = await fetch('/tasks'); 438 + if (response.ok) { 439 + tasks = await response.json(); 440 + renderTasks(); 441 + } else { 442 + showError('Failed to load tasks'); 547 443 } 548 - 549 - const response = await fetch(url); 550 - tasks = await response.json(); 551 - renderTasks(); 552 - updateStats(); 553 - updateProjectCounts(); 554 444 } catch (error) { 555 - console.error('Failed to load tasks:', error); 445 + showError('Failed to load tasks: ' + error.message); 556 446 } 557 447 } 558 448 559 - function renderProjects() { 560 - const projectList = document.getElementById('projectList'); 561 - const inboxItem = projectList.querySelector('[data-project-id=""]'); 449 + // Populate project dropdowns 450 + function populateProjectDropdowns() { 451 + const taskProjectSelect = document.getElementById('task-project'); 452 + const projectFilterSelect = document.getElementById('project-filter'); 562 453 563 - // Remove existing project items (keep inbox) 564 - const existingProjects = projectList.querySelectorAll('[data-project-id]:not([data-project-id=""])'); 565 - existingProjects.forEach(item => item.remove()); 454 + // Clear existing options (except default) 455 + taskProjectSelect.innerHTML = '<option value="">Inbox</option>'; 456 + projectFilterSelect.innerHTML = '<option value="">All Projects</option><option value="null">Inbox</option>'; 566 457 567 458 projects.forEach(project => { 568 - const li = document.createElement('li'); 569 - li.className = 'project-item'; 570 - li.dataset.projectId = project.id; 571 - li.innerHTML = \` 572 - <div class="project-dot" style="background-color: \${project.color};"></div> 573 - <span>\${project.name}</span> 574 - <span class="project-count" id="project-\${project.id}-count">0</span> 575 - \`; 576 - li.addEventListener('click', () => selectProject(project.id)); 577 - projectList.appendChild(li); 459 + const option1 = document.createElement('option'); 460 + option1.value = project.id; 461 + option1.textContent = project.name; 462 + taskProjectSelect.appendChild(option1); 463 + 464 + const option2 = document.createElement('option'); 465 + option2.value = project.id; 466 + option2.textContent = project.name; 467 + projectFilterSelect.appendChild(option2); 578 468 }); 579 469 } 580 470 581 - function loadProjectOptions() { 582 - const projectSelect = document.getElementById('project'); 583 - projectSelect.innerHTML = '<option value="">Inbox</option>'; 471 + // Setup event listeners 472 + function setupEventListeners() { 473 + // Add task form 474 + document.getElementById('add-task-form').addEventListener('submit', handleAddTask); 584 475 585 - projects.forEach(project => { 586 - const option = document.createElement('option'); 587 - option.value = project.id; 588 - option.textContent = project.name; 589 - projectSelect.appendChild(option); 476 + // Filter buttons 477 + document.querySelectorAll('.filter-btn').forEach(btn => { 478 + btn.addEventListener('click', (e) => { 479 + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); 480 + e.target.classList.add('active'); 481 + currentFilters.status = e.target.dataset.status; 482 + renderTasks(); 483 + }); 590 484 }); 591 - } 592 - 593 - function selectProject(projectId) { 594 - currentProjectId = projectId; 595 485 596 - // Update active state 597 - document.querySelectorAll('.project-item').forEach(item => { 598 - item.classList.toggle('active', item.dataset.projectId == projectId); 486 + // Filter dropdowns 487 + document.getElementById('priority-filter').addEventListener('change', (e) => { 488 + currentFilters.priority = e.target.value; 489 + renderTasks(); 599 490 }); 600 491 601 - loadTasks(); 492 + document.getElementById('project-filter').addEventListener('change', (e) => { 493 + currentFilters.project = e.target.value; 494 + renderTasks(); 495 + }); 602 496 } 603 497 604 - function renderTasks() { 605 - const taskList = document.getElementById('taskList'); 606 - taskList.innerHTML = ''; 498 + // Handle add task form submission 499 + async function handleAddTask(e) { 500 + e.preventDefault(); 501 + 502 + const formData = new FormData(e.target); 503 + const taskData = { 504 + title: formData.get('title'), 505 + description: formData.get('description') || '', 506 + priority: formData.get('priority'), 507 + project_id: formData.get('project_id') ? parseInt(formData.get('project_id')) : null, 508 + due_date: formData.get('due_date') || null 509 + }; 607 510 608 - tasks.forEach(task => { 609 - const li = document.createElement('li'); 610 - li.className = \`task-item \${task.completed ? 'completed' : ''} \${isOverdue(task) ? 'overdue' : ''}\`; 511 + try { 512 + const response = await fetch('/tasks', { 513 + method: 'POST', 514 + headers: { 'Content-Type': 'application/json' }, 515 + body: JSON.stringify(taskData) 516 + }); 611 517 612 - const projectName = task.project_name ? \`<span class="project-name">\${task.project_name}</span>\` : ''; 613 - const dueDate = task.due_date ? \`<span class="due-date">Due: \${formatDate(task.due_date)}</span>\` : ''; 614 - const overdueBadge = isOverdue(task) ? '<span class="overdue-badge">Overdue</span>' : ''; 615 - 616 - li.innerHTML = \` 617 - <input type="checkbox" class="task-checkbox" \${task.completed ? 'checked' : ''} 618 - onchange="toggleTask(\${task.id}, this.checked)"> 619 - <div class="task-content"> 620 - <div class="task-title" onclick="editTask(\${task.id})">\${task.title}</div> 621 - <div class="edit-form" id="edit-\${task.id}"> 622 - <input type="text" value="\${task.title}" onkeydown="handleEditKeydown(event, \${task.id})"> 623 - <div class="edit-buttons"> 624 - <button class="edit-btn edit-save" onclick="saveEdit(\${task.id})">Save</button> 625 - <button class="edit-btn edit-cancel" onclick="cancelEdit(\${task.id})">Cancel</button> 626 - </div> 627 - </div> 628 - <div class="task-meta"> 629 - <span class="priority-badge priority-\${task.priority}">\${task.priority}</span> 630 - \${projectName} 631 - \${dueDate} 632 - \${overdueBadge} 633 - </div> 634 - </div> 635 - <button class="delete-btn" onclick="deleteTask(\${task.id})">Delete</button> 636 - \`; 637 - 638 - taskList.appendChild(li); 639 - }); 518 + if (response.ok) { 519 + const newTask = await response.json(); 520 + tasks.unshift(newTask); 521 + renderTasks(); 522 + e.target.reset(); 523 + hideDescription(); 524 + hideError(); 525 + } else { 526 + const error = await response.json(); 527 + showError(error.error || 'Failed to create task'); 528 + } 529 + } catch (error) { 530 + showError('Failed to create task: ' + error.message); 531 + } 640 532 } 641 533 642 - function isOverdue(task) { 643 - if (!task.due_date || task.completed) return false; 644 - const today = new Date().toISOString().split('T')[0]; 645 - return task.due_date < today; 646 - } 647 - 648 - function formatDate(dateStr) { 649 - return new Date(dateStr).toLocaleDateString(); 650 - } 651 - 652 - async function toggleTask(taskId, completed) { 534 + // Toggle task completion 535 + async function toggleTaskCompletion(taskId, completed) { 653 536 try { 654 - await fetch(\`/tasks/\${taskId}\`, { 537 + const response = await fetch('/tasks/' + taskId, { 655 538 method: 'PATCH', 656 539 headers: { 'Content-Type': 'application/json' }, 657 - body: JSON.stringify({ completed: completed ? 1 : 0 }) 540 + body: JSON.stringify({ completed }) 658 541 }); 659 - loadTasks(); 542 + 543 + if (response.ok) { 544 + const updatedTask = await response.json(); 545 + const index = tasks.findIndex(t => t.id === taskId); 546 + if (index !== -1) { 547 + tasks[index] = updatedTask; 548 + renderTasks(); 549 + } 550 + } else { 551 + showError('Failed to update task'); 552 + } 660 553 } catch (error) { 661 - console.error('Failed to toggle task:', error); 554 + showError('Failed to update task: ' + error.message); 662 555 } 663 556 } 664 557 558 + // Delete task 665 559 async function deleteTask(taskId) { 666 560 if (!confirm('Are you sure you want to delete this task?')) return; 667 561 668 562 try { 669 - await fetch(\`/tasks/\${taskId}\`, { method: 'DELETE' }); 670 - loadTasks(); 563 + const response = await fetch('/tasks/' + taskId, { 564 + method: 'DELETE' 565 + }); 566 + 567 + if (response.ok) { 568 + tasks = tasks.filter(t => t.id !== taskId); 569 + renderTasks(); 570 + } else { 571 + showError('Failed to delete task'); 572 + } 671 573 } catch (error) { 672 - console.error('Failed to delete task:', error); 574 + showError('Failed to delete task: ' + error.message); 673 575 } 674 576 } 675 577 676 - function editTask(taskId) { 677 - const titleEl = document.querySelector(\`[onclick="editTask(\${taskId})"]\`); 678 - const editForm = document.getElementById(\`edit-\${taskId}\`); 578 + // Edit task title inline 579 + async function editTaskTitle(taskId, newTitle) { 580 + if (!newTitle.trim()) return; 679 581 680 - titleEl.style.display = 'none'; 681 - editForm.classList.add('visible'); 682 - editForm.querySelector('input').focus(); 582 + try { 583 + const response = await fetch('/tasks/' + taskId, { 584 + method: 'PATCH', 585 + headers: { 'Content-Type': 'application/json' }, 586 + body: JSON.stringify({ title: newTitle.trim() }) 587 + }); 588 + 589 + if (response.ok) { 590 + const updatedTask = await response.json(); 591 + const index = tasks.findIndex(t => t.id === taskId); 592 + if (index !== -1) { 593 + tasks[index] = updatedTask; 594 + renderTasks(); 595 + } 596 + } else { 597 + showError('Failed to update task'); 598 + } 599 + } catch (error) { 600 + showError('Failed to update task: ' + error.message); 601 + } 683 602 } 684 603 685 - function cancelEdit(taskId) { 686 - const titleEl = document.querySelector(\`[onclick="editTask(\${taskId})"]\`); 687 - const editForm = document.getElementById(\`edit-\${taskId}\`); 688 - 689 - titleEl.style.display = 'block'; 690 - editForm.classList.remove('visible'); 604 + // Filter tasks based on current filters 605 + function getFilteredTasks() { 606 + return tasks.filter(task => { 607 + // Status filter 608 + if (currentFilters.status === 'active' && task.completed) return false; 609 + if (currentFilters.status === 'completed' && !task.completed) return false; 610 + 611 + // Priority filter 612 + if (currentFilters.priority && task.priority !== currentFilters.priority) return false; 613 + 614 + // Project filter 615 + if (currentFilters.project) { 616 + if (currentFilters.project === 'null' && task.project_id !== null) return false; 617 + if (currentFilters.project !== 'null' && task.project_id !== parseInt(currentFilters.project)) return false; 618 + } 619 + 620 + return true; 621 + }); 691 622 } 692 623 693 - function handleEditKeydown(event, taskId) { 694 - if (event.key === 'Enter') { 695 - saveEdit(taskId); 696 - } else if (event.key === 'Escape') { 697 - cancelEdit(taskId); 624 + // Render tasks 625 + function renderTasks() { 626 + const taskList = document.getElementById('task-list'); 627 + const filteredTasks = getFilteredTasks(); 628 + 629 + if (filteredTasks.length === 0) { 630 + taskList.innerHTML = '<div class="empty-state">No tasks found</div>'; 631 + return; 698 632 } 633 + 634 + // Sort tasks: incomplete first, then by priority, then by due date 635 + const sortedTasks = filteredTasks.sort((a, b) => { 636 + if (a.completed !== b.completed) return a.completed ? 1 : -1; 637 + 638 + const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; 639 + const aPriority = priorityOrder[a.priority] ?? 2; 640 + const bPriority = priorityOrder[b.priority] ?? 2; 641 + if (aPriority !== bPriority) return aPriority - bPriority; 642 + 643 + if (a.due_date && b.due_date) { 644 + return new Date(a.due_date) - new Date(b.due_date); 645 + } 646 + if (a.due_date) return -1; 647 + if (b.due_date) return 1; 648 + 649 + return new Date(b.created_at) - new Date(a.created_at); 650 + }); 651 + 652 + taskList.innerHTML = sortedTasks.map(task => renderTask(task)).join(''); 699 653 } 700 654 701 - async function saveEdit(taskId) { 702 - const editForm = document.getElementById(\`edit-\${taskId}\`); 703 - const newTitle = editForm.querySelector('input').value.trim(); 655 + // Render individual task 656 + function renderTask(task) { 657 + const isOverdue = task.due_date && new Date(task.due_date) < new Date() && !task.completed; 658 + const project = projects.find(p => p.id === task.project_id); 704 659 705 - if (!newTitle) return; 660 + return '<div class="task-item ' + (task.completed ? 'completed' : '') + ' ' + (isOverdue ? 'overdue' : '') + '" data-task-id="' + task.id + '">' + 661 + '<div class="task-header">' + 662 + '<input type="checkbox" class="task-checkbox" ' + (task.completed ? 'checked' : '') + ' onchange="toggleTaskCompletion(' + task.id + ', this.checked)">' + 663 + '<span class="task-title" onclick="startEditTitle(' + task.id + ')">' + escapeHtml(task.title) + '</span>' + 664 + '<input type="text" class="task-title-input" value="' + escapeHtml(task.title) + '" onblur="finishEditTitle(' + task.id + ', this.value)" onkeydown="handleTitleKeydown(event, ' + task.id + ', this.value)">' + 665 + '<span class="priority-badge priority-' + task.priority + '">' + task.priority + '</span>' + 666 + '</div>' + 667 + '<div class="task-meta">' + 668 + (project ? '<span class="project-name">' + escapeHtml(project.name) + '</span>' : '<span class="project-name">Inbox</span>') + 669 + (task.due_date ? '<span class="due-date ' + (isOverdue ? 'overdue' : '') + '">Due: ' + formatDate(task.due_date) + '</span>' : '') + 670 + '</div>' + 671 + '<button class="delete-btn" onclick="deleteTask(' + task.id + ')">Delete</button>' + 672 + '</div>'; 673 + } 674 + 675 + // Start editing task title 676 + function startEditTitle(taskId) { 677 + const taskItem = document.querySelector('[data-task-id="' + taskId + '"]'); 678 + const titleSpan = taskItem.querySelector('.task-title'); 679 + const titleInput = taskItem.querySelector('.task-title-input'); 706 680 707 - try { 708 - await fetch(\`/tasks/\${taskId}\`, { 709 - method: 'PATCH', 710 - headers: { 'Content-Type': 'application/json' }, 711 - body: JSON.stringify({ title: newTitle }) 712 - }); 713 - loadTasks(); 714 - } catch (error) { 715 - console.error('Failed to update task:', error); 716 - } 681 + titleSpan.classList.add('editing'); 682 + titleInput.classList.add('editing'); 683 + titleInput.focus(); 684 + titleInput.select(); 717 685 } 718 686 719 - function updateStats() { 720 - const total = tasks.length; 721 - const active = tasks.filter(t => !t.completed).length; 722 - const completed = tasks.filter(t => t.completed).length; 723 - const overdue = tasks.filter(t => isOverdue(t)).length; 687 + // Finish editing task title 688 + function finishEditTitle(taskId, newTitle) { 689 + const taskItem = document.querySelector('[data-task-id="' + taskId + '"]'); 690 + const titleSpan = taskItem.querySelector('.task-title'); 691 + const titleInput = taskItem.querySelector('.task-title-input'); 724 692 725 - document.getElementById('totalTasks').textContent = total; 726 - document.getElementById('activeTasks').textContent = active; 727 - document.getElementById('completedTasks').textContent = completed; 728 - document.getElementById('overdueTasks').textContent = overdue; 693 + titleSpan.classList.remove('editing'); 694 + titleInput.classList.remove('editing'); 695 + 696 + const currentTask = tasks.find(t => t.id === taskId); 697 + if (newTitle.trim() && newTitle.trim() !== currentTask.title) { 698 + editTaskTitle(taskId, newTitle); 699 + } 729 700 } 730 701 731 - async function updateProjectCounts() { 732 - try { 733 - const response = await fetch('/tasks'); 734 - const allTasks = await response.json(); 735 - 736 - // Update inbox count 737 - const inboxCount = allTasks.filter(t => !t.project_id && !t.completed).length; 738 - document.getElementById('inbox-count').textContent = inboxCount; 739 - 740 - // Update project counts 741 - projects.forEach(project => { 742 - const count = allTasks.filter(t => t.project_id === project.id && !t.completed).length; 743 - const countEl = document.getElementById(\`project-\${project.id}-count\`); 744 - if (countEl) countEl.textContent = count; 745 - }); 746 - } catch (error) { 747 - console.error('Failed to update project counts:', error); 702 + // Handle keydown in title input 703 + function handleTitleKeydown(event, taskId, newTitle) { 704 + if (event.key === 'Enter') { 705 + event.target.blur(); 706 + } else if (event.key === 'Escape') { 707 + const currentTask = tasks.find(t => t.id === taskId); 708 + event.target.value = currentTask.title; 709 + event.target.blur(); 748 710 } 749 711 } 750 712 713 + // Toggle description section 751 714 function toggleDescription() { 752 - const group = document.getElementById('descriptionGroup'); 715 + const section = document.getElementById('description-section'); 753 716 const toggle = document.querySelector('.description-toggle'); 754 717 755 - if (group.classList.contains('visible')) { 756 - group.classList.remove('visible'); 757 - toggle.textContent = '+ Add Description'; 718 + if (section.classList.contains('expanded')) { 719 + hideDescription(); 758 720 } else { 759 - group.classList.add('visible'); 760 - toggle.textContent = '- Remove Description'; 721 + section.classList.add('expanded'); 722 + toggle.textContent = '- Hide Description'; 761 723 } 762 724 } 763 725 764 - // Event listeners 765 - document.getElementById('addTaskForm').addEventListener('submit', async (e) => { 766 - e.preventDefault(); 767 - 768 - const formData = new FormData(e.target); 769 - const taskData = { 770 - title: formData.get('title'), 771 - description: formData.get('description') || '', 772 - priority: formData.get('priority'), 773 - project_id: formData.get('project') ? parseInt(formData.get('project')) : null, 774 - due_date: formData.get('dueDate') || null 775 - }; 726 + // Hide description section 727 + function hideDescription() { 728 + const section = document.getElementById('description-section'); 729 + const toggle = document.querySelector('.description-toggle'); 776 730 777 - try { 778 - await fetch('/tasks', { 779 - method: 'POST', 780 - headers: { 'Content-Type': 'application/json' }, 781 - body: JSON.stringify(taskData) 782 - }); 783 - 784 - e.target.reset(); 785 - document.getElementById('descriptionGroup').classList.remove('visible'); 786 - document.querySelector('.description-toggle').textContent = '+ Add Description'; 787 - loadTasks(); 788 - } catch (error) { 789 - console.error('Failed to create task:', error); 790 - } 791 - }); 731 + section.classList.remove('expanded'); 732 + toggle.textContent = '+ Add Description'; 733 + } 734 + 735 + // Show error message 736 + function showError(message) { 737 + const errorDiv = document.getElementById('error-message'); 738 + errorDiv.textContent = message; 739 + errorDiv.style.display = 'block'; 740 + } 792 741 793 - // Filter event listeners 794 - document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => { 795 - btn.addEventListener('click', () => { 796 - currentStatusFilter = btn.dataset.filter; 797 - document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active')); 798 - btn.classList.add('active'); 799 - loadTasks(); 800 - }); 801 - }); 742 + // Hide error message 743 + function hideError() { 744 + document.getElementById('error-message').style.display = 'none'; 745 + } 802 746 803 - document.getElementById('priorityFilter').addEventListener('change', (e) => { 804 - currentPriorityFilter = e.target.value; 805 - loadTasks(); 806 - }); 747 + // Utility functions 748 + function escapeHtml(text) { 749 + const div = document.createElement('div'); 750 + div.textContent = text; 751 + return div.innerHTML; 752 + } 807 753 808 - // Inbox click handler 809 - document.querySelector('[data-project-id=""]').addEventListener('click', () => selectProject('')); 754 + function formatDate(dateString) { 755 + const date = new Date(dateString); 756 + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 757 + } 810 758 811 - // Initial load 812 - loadProjects(); 813 - loadTasks(); 759 + // Initialize the app when page loads 760 + document.addEventListener('DOMContentLoaded', init); 814 761 </script> 815 762 </body> 816 - </html>`); 763 + </html> 764 + `); 817 765 }); 818 766 767 + 768 + 819 769 export default router; 820 770 771 + /** @internal Phoenix VCS traceability — do not remove. */ 821 772 export const _phoenix = { 822 - iu_id: '4cf8044124318345f799117c458dd89849b86274c5b49683e030c938686d4fed', 773 + iu_id: '335590ecf9457e5b14124f79e4d9399888f58b7aff87edd6a264b6aa6fdc2d48', 823 774 name: 'Web Experience', 824 775 risk_tier: 'high', 825 776 canon_ids: [5 as const], 826 - } as const; 777 + } as const;
+42 -42
experiments/eval-runner-arch.ts
··· 31 31 execSync('npm run build', { cwd: ROOT, stdio: 'pipe' }); 32 32 33 33 console.log('Cleaning todo-app...'); 34 - for (const d of ['src/generated', 'src/server.ts', 'src/app.ts', 'src/db.ts', '.phoenix', 'data']) { 34 + for (const d of ['src', '.phoenix', 'data', 'dist']) { 35 35 const p = resolve(TODO_APP, d); 36 36 if (existsSync(p)) rmSync(p, { recursive: true, force: true }); 37 37 } ··· 111 111 112 112 // ─── Categories ───────────────────────────────────────────────────────────── 113 113 114 - let catId: number | null = null; 114 + let projId: number | null = null; 115 115 116 - await test('POST /categories creates category', async () => { 117 - const res = await fetch(`${BASE}/categories`, { 116 + await test('POST /projects creates project', async () => { 117 + const res = await fetch(`${BASE}/projects`, { 118 118 method: 'POST', headers: { 'Content-Type': 'application/json' }, 119 119 body: JSON.stringify({ name: 'Work', color: '#ff0000' }), 120 120 }); 121 121 if (res.status !== 201) return false; 122 122 const body = await res.json() as Record<string, unknown>; 123 - catId = body.id as number; 123 + projId = body.id as number; 124 124 return body.name === 'Work' && typeof body.id === 'number'; 125 125 }); 126 126 127 - await test('POST /categories rejects empty name', async () => { 128 - const res = await fetch(`${BASE}/categories`, { 127 + await test('POST /projects rejects empty name', async () => { 128 + const res = await fetch(`${BASE}/projects`, { 129 129 method: 'POST', headers: { 'Content-Type': 'application/json' }, 130 130 body: JSON.stringify({ name: '' }), 131 131 }); 132 132 return res.status === 400; 133 133 }); 134 134 135 - await test('GET /categories returns array', async () => { 136 - const res = await fetch(`${BASE}/categories`); 135 + await test('GET /projects returns array', async () => { 136 + const res = await fetch(`${BASE}/projects`); 137 137 if (res.status !== 200) return false; 138 138 const body = await res.json() as unknown[]; 139 139 return Array.isArray(body) && body.length >= 1; ··· 144 144 let todoId: number | null = null; 145 145 146 146 await test('POST /todos creates todo with category', async () => { 147 - const res = await fetch(`${BASE}/todos`, { 147 + const res = await fetch(`${BASE}/tasks`, { 148 148 method: 'POST', headers: { 'Content-Type': 'application/json' }, 149 - body: JSON.stringify({ title: 'Finish report', category_id: catId }), 149 + body: JSON.stringify({ title: 'Finish report', project_id: projId }), 150 150 }); 151 151 if (res.status !== 201) return false; 152 152 const body = await res.json() as Record<string, unknown>; ··· 155 155 }); 156 156 157 157 await test('POST /todos creates todo without category', async () => { 158 - const res = await fetch(`${BASE}/todos`, { 158 + const res = await fetch(`${BASE}/tasks`, { 159 159 method: 'POST', headers: { 'Content-Type': 'application/json' }, 160 160 body: JSON.stringify({ title: 'Buy milk' }), 161 161 }); 162 162 return res.status === 201; 163 163 }); 164 164 165 - await test('POST /todos rejects invalid category_id', async () => { 166 - const res = await fetch(`${BASE}/todos`, { 165 + await test('POST /todos rejects invalid project_id', async () => { 166 + const res = await fetch(`${BASE}/tasks`, { 167 167 method: 'POST', headers: { 'Content-Type': 'application/json' }, 168 - body: JSON.stringify({ title: 'Bad category', category_id: 9999 }), 168 + body: JSON.stringify({ title: 'Bad category', project_id: 9999 }), 169 169 }); 170 170 return res.status === 400; 171 171 }); 172 172 173 173 await test('POST /todos rejects empty title', async () => { 174 - const res = await fetch(`${BASE}/todos`, { 174 + const res = await fetch(`${BASE}/tasks`, { 175 175 method: 'POST', headers: { 'Content-Type': 'application/json' }, 176 176 body: JSON.stringify({ title: '' }), 177 177 }); 178 178 return res.status === 400; 179 179 }); 180 180 181 - await test('GET /todos returns todos with category_name', async () => { 182 - const res = await fetch(`${BASE}/todos`); 181 + await test('GET /todos returns todos with project_name', async () => { 182 + const res = await fetch(`${BASE}/tasks`); 183 183 if (res.status !== 200) return false; 184 184 const body = await res.json() as Array<Record<string, unknown>>; 185 185 const withCat = body.find(t => t.title === 'Finish report'); 186 - return withCat?.category_name === 'Work'; 186 + return withCat?.project_name === 'Work'; 187 187 }); 188 188 189 - await test('GET /todos/:id returns todo with category_name', async () => { 189 + await test('GET /todos/:id returns todo with project_name', async () => { 190 190 if (!todoId) return false; 191 - const res = await fetch(`${BASE}/todos/${todoId}`); 191 + const res = await fetch(`${BASE}/tasks/${todoId}`); 192 192 if (res.status !== 200) return false; 193 193 const body = await res.json() as Record<string, unknown>; 194 - return body.category_name === 'Work'; 194 + return body.project_name === 'Work'; 195 195 }); 196 196 197 197 await test('GET /todos/999 returns 404', async () => { 198 - return (await fetch(`${BASE}/todos/999`)).status === 404; 198 + return (await fetch(`${BASE}/tasks/999`)).status === 404; 199 199 }); 200 200 201 201 // ─── Filtering ────────────────────────────────────────────────────────────── 202 202 203 203 await test('PATCH /todos/:id marks completed', async () => { 204 204 if (!todoId) return false; 205 - const res = await fetch(`${BASE}/todos/${todoId}`, { 205 + const res = await fetch(`${BASE}/tasks/${todoId}`, { 206 206 method: 'PATCH', headers: { 'Content-Type': 'application/json' }, 207 207 body: JSON.stringify({ completed: 1 }), 208 208 }); ··· 212 212 }); 213 213 214 214 await test('GET /todos?completed=1 filters completed', async () => { 215 - const res = await fetch(`${BASE}/todos?completed=1`); 215 + const res = await fetch(`${BASE}/tasks?completed=1`); 216 216 if (res.status !== 200) return false; 217 217 const body = await res.json() as Array<Record<string, unknown>>; 218 218 return body.length >= 1 && body.every(t => t.completed === 1); 219 219 }); 220 220 221 221 await test('GET /todos?completed=0 filters incomplete', async () => { 222 - const res = await fetch(`${BASE}/todos?completed=0`); 222 + const res = await fetch(`${BASE}/tasks?completed=0`); 223 223 if (res.status !== 200) return false; 224 224 const body = await res.json() as Array<Record<string, unknown>>; 225 225 return body.length >= 1 && body.every(t => t.completed === 0); 226 226 }); 227 227 228 - await test('GET /todos?category_id=N filters by category', async () => { 229 - if (!catId) return false; 230 - const res = await fetch(`${BASE}/todos?category_id=${catId}`); 228 + await test('GET /todos?project_id=N filters by category', async () => { 229 + if (!projId) return false; 230 + const res = await fetch(`${BASE}/tasks?project_id=${projId}`); 231 231 if (res.status !== 200) return false; 232 232 const body = await res.json() as Array<Record<string, unknown>>; 233 233 return body.length >= 1; ··· 236 236 // ─── Stats ────────────────────────────────────────────────────────────────── 237 237 238 238 await test('GET /stats returns counts', async () => { 239 - const res = await fetch(`${BASE}/todos/stats`); 239 + const res = await fetch(`${BASE}/tasks/stats`); 240 240 if (res.status !== 200) return false; 241 241 const body = await res.json() as Record<string, unknown>; 242 242 return typeof body.total === 'number' && typeof body.completed === 'number' && typeof body.incomplete === 'number'; 243 243 }); 244 244 245 245 await test('GET /stats includes by_category', async () => { 246 - const res = await fetch(`${BASE}/todos/stats`); 246 + const res = await fetch(`${BASE}/tasks/stats`); 247 247 if (res.status !== 200) return false; 248 248 const body = await res.json() as Record<string, unknown>; 249 249 const byCat = body.by_category as Array<Record<string, unknown>> | undefined; 250 - return Array.isArray(byCat) && byCat.length >= 1 && typeof byCat[0].category_name === 'string'; 250 + return Array.isArray(byCat) && byCat.length >= 1 && typeof byCat[0].project_name === 'string'; 251 251 }); 252 252 253 253 // ─── Delete ───────────────────────────────────────────────────────────────── 254 254 255 255 await test('DELETE /todos/:id returns 204', async () => { 256 256 if (!todoId) return false; 257 - return (await fetch(`${BASE}/todos/${todoId}`, { method: 'DELETE' })).status === 204; 257 + return (await fetch(`${BASE}/tasks/${todoId}`, { method: 'DELETE' })).status === 204; 258 258 }); 259 259 260 - await test('DELETE /categories/:id with todos returns 400', async () => { 260 + await test('DELETE /projects/:id with todos returns 400', async () => { 261 261 // "Buy milk" has no category, but create one with a category to test 262 - const res = await fetch(`${BASE}/categories`, { 262 + const res = await fetch(`${BASE}/projects`, { 263 263 method: 'POST', headers: { 'Content-Type': 'application/json' }, 264 264 body: JSON.stringify({ name: 'Temp' }), 265 265 }); 266 266 const cat = await res.json() as Record<string, unknown>; 267 - await fetch(`${BASE}/todos`, { 267 + await fetch(`${BASE}/tasks`, { 268 268 method: 'POST', headers: { 'Content-Type': 'application/json' }, 269 - body: JSON.stringify({ title: 'Temp todo', category_id: cat.id }), 269 + body: JSON.stringify({ title: 'Temp todo', project_id: cat.id }), 270 270 }); 271 - const delRes = await fetch(`${BASE}/categories/${cat.id}`, { method: 'DELETE' }); 271 + const delRes = await fetch(`${BASE}/projects/${cat.id}`, { method: 'DELETE' }); 272 272 return delRes.status === 400; 273 273 }); 274 274 275 - await test('DELETE /categories/:id without todos returns 204', async () => { 276 - if (!catId) return false; 277 - // catId's todos were already deleted 278 - return (await fetch(`${BASE}/categories/${catId}`, { method: 'DELETE' })).status === 204; 275 + await test('DELETE /projects/:id without todos returns 204', async () => { 276 + if (!projId) return false; 277 + // projId's todos were already deleted 278 + return (await fetch(`${BASE}/projects/${projId}`, { method: 'DELETE' })).status === 204; 279 279 }); 280 280 281 281 // ─── Step 4: Score ──────────────────────────────────────────────────────────
+4
experiments/results-arch.tsv
··· 8 8 2026-03-27T06:28:42.970Z 0.32 6 19 POST /todos creates todo with category; POST /todos creates todo without category; GET /todos returns todos with category_name; GET /todos/:id returns todo with category_name; GET /todos/999 returns 404; PATCH /todos/:id marks completed; GET /todos?completed=1 filters completed; GET /todos?completed=0 filters incomplete; GET /todos?category_id=N filters by category; GET /stats returns counts; GET /stats includes by_category; DELETE /todos/:id returns 204; DELETE /categories/:id with todos returns 400 9 9 2026-03-27T14:41:58.589Z 1.00 19 19 none 10 10 2026-03-27T16:37:22.278Z 1.00 19 19 none 11 + 2026-03-29T15:19:59.806Z 0.05 1 19 POST /categories creates category; POST /categories rejects empty name; GET /categories returns array; POST /todos creates todo with category; POST /todos creates todo without category; POST /todos rejects invalid category_id; POST /todos rejects empty title; GET /todos returns todos with category_name; GET /todos/:id returns todo with category_name; PATCH /todos/:id marks completed; GET /todos?completed=1 filters completed; GET /todos?completed=0 filters incomplete; GET /todos?category_id=N filters by category; GET /stats returns counts; GET /stats includes by_category; DELETE /todos/:id returns 204; DELETE /categories/:id with todos returns 400; DELETE /categories/:id without todos returns 204 12 + 2026-03-29T15:27:25.833Z 0.26 5 19 POST /todos creates todo with category; POST /todos creates todo without category; POST /todos rejects invalid project_id; POST /todos rejects empty title; GET /todos returns todos with project_name; GET /todos/:id returns todo with project_name; PATCH /todos/:id marks completed; GET /todos?completed=1 filters completed; GET /todos?completed=0 filters incomplete; GET /todos?project_id=N filters by category; GET /stats returns counts; GET /stats includes by_category; DELETE /todos/:id returns 204; DELETE /projects/:id with todos returns 400 13 + 2026-03-29T15:36:43.855Z 0.79 15 19 PATCH /todos/:id marks completed; GET /todos?completed=1 filters completed; GET /stats returns counts; GET /stats includes by_category 14 + 2026-03-29T15:43:34.624Z 0.79 15 19 PATCH /todos/:id marks completed; GET /todos?completed=1 filters completed; GET /stats returns counts; GET /stats includes by_category
-1
src/architectures/node-typescript.ts
··· 57 57 } 58 58 59 59 export { db }; 60 - export type { Database }; 61 60 `; 62 61 63 62 const APP_FILE = `import { Hono } from 'hono';
+121 -10
src/regen.ts
··· 122 122 123 123 /** 124 124 * Generate code for an IU using an LLM provider. 125 - * Includes typecheck-and-retry: if the generated code has TS errors, 126 - * feed them back to the LLM for a fix attempt. 125 + * 126 + * Two modes: 127 + * - Template mode (when runtime target provides moduleTemplate): LLM fills in 128 + * marked sections only. Structure is guaranteed by the template. 129 + * - Freeform mode (no template): LLM generates the entire module. 130 + * 131 + * Both modes include typecheck-and-retry. 127 132 */ 128 133 async function generateWithLLM( 129 134 iu: ImplementationUnit, ··· 141 146 142 147 const systemPrompt = getSystemPrompt(target); 143 148 const prompt = buildPrompt(iu, canonNodes, siblings, target); 149 + const template = target?.runtime.moduleTemplate; 144 150 145 - let code = cleanCodeResponse(await llm.generate(prompt, { 146 - system: systemPrompt, 147 - temperature: 0.2, 148 - maxTokens: 8192, 149 - })); 151 + let code: string; 152 + 153 + if (template) { 154 + // Template mode: LLM fills in sections, we splice into template 155 + const raw = await llm.generate(prompt, { 156 + system: systemPrompt, 157 + temperature: 0.1, // lower temp for more deterministic section filling 158 + maxTokens: 8192, 159 + }); 160 + 161 + code = assembleFromTemplate(template, raw, iu); 162 + } else { 163 + // Freeform mode 164 + code = cleanCodeResponse(await llm.generate(prompt, { 165 + system: systemPrompt, 166 + temperature: 0.2, 167 + maxTokens: 8192, 168 + })); 169 + } 150 170 151 171 // Typecheck-and-retry loop 152 172 if (projectRoot && iu.output_files[0]) { ··· 154 174 const errors = typecheckFile(projectRoot, iu.output_files[0], code); 155 175 if (!errors) break; // clean! 156 176 157 - // Feed errors back to LLM 177 + // Feed errors back to LLM with the current code 158 178 const fixPrompt = buildFixPrompt(code, errors); 159 - code = cleanCodeResponse(await llm.generate(fixPrompt, { 179 + const fixResponse = await llm.generate(fixPrompt, { 160 180 system: systemPrompt, 161 181 temperature: 0.1, 162 182 maxTokens: 8192, 163 - })); 183 + }); 184 + 185 + if (template) { 186 + code = assembleFromTemplate(template, fixResponse, iu); 187 + } else { 188 + code = cleanCodeResponse(fixResponse); 189 + } 164 190 } 165 191 } 166 192 167 193 return code; 194 + } 195 + 196 + /** 197 + * Repair LLM-generated code using the template as a structural guarantee. 198 + * 199 + * The LLM generates a full module. This function: 200 + * 1. Strips any imports the LLM wrote and replaces with template imports 201 + * 2. Ensures `export default router` exists 202 + * 3. Ensures `_phoenix` metadata exists 203 + * 4. Ensures `const router = new Hono()` exists 204 + * 205 + * This is more robust than section parsing — accepts whatever the LLM 206 + * generates and fixes the structural parts that must be exact. 207 + */ 208 + function assembleFromTemplate(template: string, llmResponse: string, iu: ImplementationUnit): string { 209 + let code = cleanCodeResponse(llmResponse); 210 + 211 + // Extract the template's fixed header (imports) 212 + const templateLines = template.split('\n'); 213 + const headerEnd = templateLines.findIndex(l => l.includes('__MIGRATIONS__')); 214 + const templateHeader = templateLines.slice(0, Math.max(headerEnd, 0)).join('\n'); 215 + 216 + // Strip LLM's import lines — we'll use the template's 217 + const codeLines = code.split('\n'); 218 + const bodyLines = codeLines.filter(line => { 219 + const trimmed = line.trim(); 220 + // Remove import statements that the template already provides 221 + if (trimmed.startsWith('import ') && ( 222 + trimmed.includes('hono') || 223 + trimmed.includes('db.js') || 224 + trimmed.includes('better-sqlite3') || 225 + trimmed.includes('zod') 226 + )) return false; 227 + return true; 228 + }); 229 + let body = bodyLines.join('\n').trim(); 230 + 231 + // Remove any duplicate "const router = new Hono()" — template has one, LLM might add another 232 + const routerDecls = (body.match(/const router\s*=\s*new Hono\(\)/g) ?? []).length; 233 + if (routerDecls > 1) { 234 + // Keep only the first occurrence 235 + let found = false; 236 + body = body.split('\n').filter(line => { 237 + if (line.includes('const router') && line.includes('new Hono()')) { 238 + if (found) return false; 239 + found = true; 240 + } 241 + return true; 242 + }).join('\n'); 243 + } 244 + 245 + // Remove any "export default router" — we'll add it at the end 246 + body = body.replace(/\nexport\s+default\s+router\s*;?\s*/g, '\n'); 247 + 248 + // Remove any existing _phoenix export 249 + body = body.replace(/\/\*\*[^]*?_phoenix[^]*?\*\/\s*export\s+const\s+_phoenix\s*=\s*\{[^}]*\}\s*as\s+const\s*;?\s*/g, ''); 250 + body = body.replace(/export\s+const\s+_phoenix\s*=\s*\{[^}]*\}\s*as\s+const\s*;?\s*/g, ''); 251 + 252 + // Ensure router declaration exists 253 + if (!body.includes('const router') && !body.includes('new Hono()')) { 254 + body = 'const router = new Hono();\n\n' + body; 255 + } 256 + 257 + // Build the phoenix metadata 258 + const phoenixMeta = `/** @internal Phoenix VCS traceability — do not remove. */ 259 + export const _phoenix = { 260 + iu_id: '${iu.iu_id}', 261 + name: '${iu.name}', 262 + risk_tier: '${iu.risk_tier}', 263 + canon_ids: [${iu.source_canon_ids.length} as const], 264 + } as const;`; 265 + 266 + // Fix SQL double-quote issue: SQLite treats "x" as column name, needs 'x' for strings 267 + // Replace double-quoted SQL string literals inside .prepare()/.exec() calls 268 + body = body.replace( 269 + /(?<=(?:prepare|exec)\s*\([^)]*?)\"(now|urgent|high|normal|low|active|completed|true|false)\"/g, 270 + "'$1'" 271 + ); 272 + // Also fix in string template literals used for SQL 273 + body = body.replace(/datetime\("now"\)/g, "datetime('now')"); 274 + body = body.replace(/date\("now"\)/g, "date('now')"); 275 + body = body.replace(/WHEN "(\w+)" THEN/g, "WHEN '$1' THEN"); 276 + 277 + // Assemble: template header + LLM body + exports + metadata 278 + return `${templateHeader}\n\n${body}\n\nexport default router;\n\n${phoenixMeta}\n`; 168 279 } 169 280 170 281 const MINIMAL_TSCONFIG = JSON.stringify({