feat(api): implement authenticated habit tracking and combined calendars

This commit is contained in:
syntaxbullet
2026-09-04 09:08:50 +02:00
parent 7fefe0c7d8
commit e9cc591576
6 changed files with 580 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
import { createHabitRoutes } from "./habits/routes";
import { ApiError } from "./habits/service";
import { Hono } from "hono"; import { Hono } from "hono";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { createAuth, type AppDatabase, type AuthEnv } from "./auth"; import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
@@ -13,9 +15,11 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
}); });
app.route("/api/auth", auth.routes); app.route("/api/auth", auth.routes);
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user"))); app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
app.notFound(c => c.json({ error: "Not found" }, 404)); app.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => { app.onError((_error, c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
if (_error instanceof ApiError) return c.json({ error: _error.message }, _error.status);
return c.json({ error: "Internal server error" }, 500); return c.json({ error: "Internal server error" }, 500);
}); });
return app; return app;

157
src/habits/api.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); });
afterEach(() => f.close());
const create = (input: object) => f.json('/habits', 'POST', input, 201);
const count = () => create({ name: 'Hydration', method: 'count', target: 8, unit: 'glasses' });
const manual = () => create({ name: 'Read', method: 'manual' });
const tasks = () => create({ name: 'Routine', method: 'tasks', tasks: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] });
const day = (id: string, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}`);
const log = (id: string, input: object, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/progress`, 'PUT', input);
const tick = (id: string, taskId: string, done = true, date = '2026-09-04') => f.json(`/habits/${id}/days/${date}/tasks/${taskId}`, 'PUT', { done });
const combined = (ids: string[]) => f.json('/charts', 'POST', { name: 'Combined', habitIds: ids }, 201);
const square = (id: string, date = '2026-09-04') => f.json(`/charts/${id}/days/${date}`);
test('habit CRUD, defaults, archive, restore, history, and date inspection', async () => {
const h = await count(); expect(h.schedule).toEqual({ type: 'daily' }); expect(h.archived).toBe(false);
expect((await f.json('/habits')).habits).toHaveLength(1);
expect((await f.json(`/habits/${h.id}`)).name).toBe('Hydration');
expect((await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Water' })).name).toBe('Water');
expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2);
expect((await f.json('/days/2026-09-04')).habits).toHaveLength(1);
expect((await f.request(`/habits/${h.id}`, 'DELETE')).status).toBe(204);
expect((await f.json('/habits')).habits).toHaveLength(0);
expect((await f.json('/habits?archived=true')).habits).toHaveLength(1);
expect((await day(h.id)).due).toBe(false);
await f.json(`/habits/${h.id}`, 'PATCH', { archived: false });
expect((await day(h.id)).due).toBe(true);
});
test('count exact underlying values, over-target logging, and correction', async () => {
const h = await count(); const c = await combined([h.id]);
expect((await log(h.id, { count: 7 })).ratio).toBe(7 / 8);
expect((await square(c.id)).completed).toBe(0);
expect((await log(h.id, { count: 8 })).complete).toBe(true);
expect((await square(c.id)).ratio).toBe(1);
expect((await log(h.id, { count: 12 })).value).toBe(12);
await log(h.id, { count: 2 }); expect((await square(c.id)).ratio).toBe(0);
const detail = await day(h.id); expect(detail.target).toBe(8); expect(detail.unit).toBe('glasses');
const audit = await f.json(`/habits/${h.id}/days/2026-09-04/audit`);
expect(audit.records[0].events).toHaveLength(4);
});
test('manual habits expose only binary progress', async () => {
const h = await manual(); expect((await day(h.id)).ratio).toBe(0);
expect((await log(h.id, { done: true })).ratio).toBe(1);
expect((await log(h.id, { done: false })).ratio).toBe(0);
expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 1 })).status).toBe(422);
});
test('task completion is derived; 2/3 is partial and contributes zero to every combined chart', async () => {
const h = await tasks(); const a = await combined([h.id]); const b = await combined([h.id]);
await tick(h.id, h.tasks[0].id); const partial = await tick(h.id, h.tasks[1].id);
expect(partial.ratio).toBe(2 / 3); expect(partial.complete).toBe(false);
expect((await square(a.id)).ratio).toBe(0); expect((await square(b.id)).ratio).toBe(0);
await tick(h.id, h.tasks[2].id); expect((await square(a.id)).ratio).toBe(1);
await tick(h.id, h.tasks[1].id, false); expect((await square(b.id)).ratio).toBe(0);
expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true })).status).toBe(409);
});
test('combined equal weights, 3/4, partial contributes zero, scheduled denominator, full and neutral', async () => {
const a = await manual(); const b = await manual(); const c = await manual(); const d = await count();
const chart = await combined([a.id,b.id,c.id,d.id]);
for (const h of [a,b,c]) await log(h.id, { done: true });
await log(d.id, { count: 7 });
expect(await square(chart.id)).toMatchObject({ due: 4, completed: 3, ratio: 0.75, level: 3 });
await log(d.id, { count: 8 }); const full = await square(chart.id);
expect(full).toMatchObject({ due: 4, completed: 4, ratio: 1 });
const one = await combined([a.id]); expect((await square(one.id)).color).toBe(full.color);
for (const h of [b,c,d]) await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
expect(await square(chart.id)).toMatchObject({ due: 1, completed: 1, ratio: 1 });
await f.json(`/habits/${a.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
expect(await square(chart.id)).toMatchObject({ due: 0, ratio: null, status: 'not_due', level: null });
});
test('task schedule intersects parent and zero due tasks never auto-completes', async () => {
const h = await create({ name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [{ name: 'Friday', schedule: { type: 'weekdays', days: [5] } }] });
const c = await combined([h.id]); expect((await square(c.id)).ratio).toBeNull();
expect((await day(h.id)).tasks).toHaveLength(0);
expect((await day(h.id, '2026-09-07')).due).toBe(false);
const empty = await create({ name: 'Empty', method: 'tasks' }); expect((await day(empty.id)).due).toBe(false);
});
test('incomplete occurrences expire at exact local midnight and correction retains expiry', async () => {
const h = await tasks(); await tick(h.id, h.tasks[0].id);
f.setTime('2026-09-04T21:59:59.999Z'); expect((await day(h.id)).tasks.every((t: any) => t.expiredAt === null)).toBe(true);
f.setTime('2026-09-04T22:00:00Z');
const previous = await day(h.id); const a = previous.tasks.find((t: any) => t.taskId === h.tasks[0].id); const b = previous.tasks.find((t: any) => t.taskId === h.tasks[1].id);
expect(a.expiredAt).toBeNull(); expect(b.expiredAt).toBe(Date.parse('2026-09-04T22:00Z'));
expect((await day(h.id, '2026-09-05')).tasks.every((t: any) => !t.done && t.expiredAt === null)).toBe(true);
const corrected = await tick(h.id, h.tasks[1].id); expect(corrected.tasks.find((t: any) => t.taskId === h.tasks[1].id).expiredAt).toBe(b.expiredAt);
expect((await day(h.id, '2026-09-05')).value).toBe(0);
});
test('downtime materializes missing occurrences with original expiry deadlines', async () => {
const h = await tasks(); f.setTime('2026-09-08T12:00Z');
expect((await day(h.id, '2026-09-06')).tasks.every((t: any) => t.expiredAt === Date.parse('2026-09-06T22:00Z'))).toBe(true);
expect((await day(h.id, '2026-09-08')).tasks.every((t: any) => t.expiredAt === null)).toBe(true);
});
test('target and method edits preserve yesterday, same-day carry and superseded audit records', async () => {
const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z');
await log(h.id, { count: 8 }, '2026-09-05');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 });
expect(await day(h.id)).toMatchObject({ value: 8, target: 8, complete: true });
expect(await day(h.id, '2026-09-05')).toMatchObject({ value: 8, target: 10, complete: false });
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
expect(await day(h.id, '2026-09-05')).toMatchObject({ method: 'manual', value: 0 });
await log(h.id, { count: 7 }); expect((await day(h.id)).ratio).toBe(7 / 8);
const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`);
expect(audit.records).toHaveLength(3); expect(audit.records[0].count).toBe(8);
expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'count' })).status).toBe(422);
});
test('task CRUD and recurrence edits preserve historical tasks, expiry and requirements', async () => {
const h = await tasks(); const t = h.tasks[0];
expect((await f.json(`/habits/${h.id}/tasks`)).tasks).toHaveLength(3);
expect((await f.json(`/habits/${h.id}/tasks/${t.id}`)).name).toBe('A');
await tick(h.id, t.id); f.setTime('2026-09-05T12:00Z');
const newTask = await f.json(`/habits/${h.id}/tasks`, 'POST', { name: 'New' }, 201);
expect((await day(h.id)).target).toBe(3); expect((await day(h.id, '2026-09-05')).target).toBe(4);
await f.json(`/habits/${h.id}/tasks/${newTask.id}`, 'PATCH', { schedule: { type: 'interval', every: 3, anchor: '2026-09-05' }, name: 'Every three days' });
expect((await f.request(`/habits/${h.id}/tasks/${t.id}`, 'DELETE')).status).toBe(204);
expect((await day(h.id)).tasks.find((v: any) => v.taskId === t.id).done).toBe(true);
expect((await f.request(`/habits/${h.id}/tasks/${t.id}`)).status).toBe(404);
await tick(h.id, t.id, false); expect((await day(h.id)).value).toBe(0);
expect((await day(h.id, '2026-09-06')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(false);
expect((await day(h.id, '2026-09-08')).tasks.some((v: any) => v.taskId === newTask.id)).toBe(true);
});
test('schedule edits and archiving apply today while earlier logs remain correctable', async () => {
const h = await count(); await log(h.id, { count: 8 }); f.setTime('2026-09-05T12:00Z');
await f.json(`/habits/${h.id}`, 'PATCH', { schedule: { type: 'weekdays', days: [1] } });
expect((await day(h.id)).complete).toBe(true); expect((await day(h.id, '2026-09-05')).due).toBe(false);
await f.request(`/habits/${h.id}`, 'DELETE'); await log(h.id, { count: 6 }); expect((await day(h.id)).value).toBe(6);
});
test('future and pre-creation dates are inspectable but not writable', async () => {
const h = await count(); expect((await day(h.id, '2026-09-05')).status).toBe('future');
expect((await day(h.id, '2026-09-03')).status).toBe('not_due');
for (const d of ['2026-09-05', '2026-09-03']) expect((await f.request(`/habits/${h.id}/days/${d}/progress`, 'PUT', { count: 8 })).status).toBe(409);
});
test('combined CRUD, membership edits recompute history, calendar data and settings', async () => {
const h = await count(); const m = await manual(); await log(h.id, { count: 8 });
const c = await combined([h.id]); expect((await f.json('/charts')).charts).toHaveLength(1);
expect((await f.json(`/charts/${c.id}`)).habitIds).toEqual([h.id]);
await f.json(`/charts/${c.id}`, 'PATCH', { name: 'Both', habitIds: [h.id, m.id], settings: { mainColor: '#ff0000', shadeCount: 8 } });
expect((await square(c.id)).ratio).toBe(0.5);
const cal = await f.json(`/charts/${c.id}/calendar?from=2026-09-03&to=2026-09-05`);
expect(cal.data).toHaveLength(3); expect(cal.data[0].value).toBe(-1); expect(cal.data[2].value).toBe(-2);
expect(cal.settings.mainColor).toBe('#ff0000');
const settings = await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#123456', shadeCount: 6 });
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toEqual(settings);
const individual = await f.json(`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-04`);
expect(individual.data).toEqual([{ day: '2026-09-04', value: 8 }]); expect(individual.days[0].color).toBe('#123456');
expect((await f.request(`/charts/${c.id}`, 'DELETE')).status).toBe(204);
expect((await f.request(`/charts/${c.id}`)).status).toBe(404);
});
test('timezone changes preserve recorded deadlines and never rewind the tracking date', async () => {
const h = await tasks(); const before = await day(h.id);
expect((await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' })).timezone).toBe('America/Los_Angeles');
expect((await f.json('/me')).timezone).toBe('America/Los_Angeles');
expect((await day(h.id)).endsAt).toBe(before.endsAt);
f.setTime('2026-09-05T12:00Z'); const next = await day(h.id, '2026-09-05');
expect(next.timezone).toBe('America/Los_Angeles'); expect(next.endsAt).toBe(Date.parse('2026-09-06T07:00Z'));
await f.json('/me', 'PATCH', { timezone: 'Pacific/Honolulu' });
expect((await f.json('/today')).date).toBe('2026-09-05');
});

102
src/habits/routes.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import type { AppDatabase, AuthEnv, createAuth } from '../auth';
import { HabitService, ApiError } from './service';
import { calendarSettingsSchema, chartInput, chartPatch, dateSchema, doneInput, habitInput, habitPatch, progressInput, taskInput, taskPatch, timezoneInput } from './contracts';
import { dayNumber } from './calendar';
type Env = AuthEnv & { Variables: { service: HabitService } };
async function body<T extends z.ZodType>(c: Context<Env>, schema: T): Promise<z.output<T>> {
if (!c.req.header('Content-Type')?.toLowerCase().startsWith('application/json')) throw new ApiError(400, 'Expected application/json');
let value: unknown;
try { value = await c.req.json(); } catch { throw new ApiError(400, 'Malformed JSON'); }
const result = schema.safeParse(value);
if (!result.success) throw new ApiError(422, result.error.issues.map(i => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '));
return result.data;
}
function date(c: Context<Env>) {
const result = dateSchema.safeParse(c.req.param('date'));
if (!result.success) throw new ApiError(422, 'Expected a real YYYY-MM-DD date');
return result.data;
}
function range(c: Context<Env>) {
const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query());
if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days');
return result.data;
}
export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
const app = new Hono<Env>();
const protectedPath = (path: string, method: string) => /^\/api\/(habits|charts|today|days)(\/|$)/.test(path) || (path === '/api/me' && method === 'PATCH');
const authenticate = auth.requireAuth as unknown as MiddlewareHandler<Env>;
const origin = auth.requireSameOrigin as unknown as MiddlewareHandler<Env>;
const limit = bodyLimit({ maxSize: 65536, onError: c => c.json({ error: 'Request body exceeds 64 KiB' }, 413) });
app.use('*', async (c, next) => {
if (!protectedPath(c.req.path, c.req.method)) return next();
return authenticate(c, async () => {
const proceed = async () => { const response = await limit(c, async () => {
const service = new HabitService(db, c.get('user'), now());
service.sync(); c.set('service', service); await next();
}); if (response) c.res = response; };
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
const response = await origin(c, proceed); if (response) c.res = response;
} else await proceed();
});
});
const service = (c: Context<Env>) => c.get('service');
const id = (c: Context<Env>) => c.req.param('id')!;
app.get('/habits', c => {
const q = z.object({ archived: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query());
if (!q.success) throw new ApiError(422, 'archived must be true or false');
return c.json({ habits: service(c).list(q.data.archived === 'true') });
});
app.post('/habits', async c => {
const habit = service(c).create(await body(c, habitInput));
c.header('Location', `/api/habits/${habit.id}`); return c.json(habit, 201);
});
app.get('/habits/:id', c => c.json(service(c).current(id(c))));
app.patch('/habits/:id', async c => c.json(service(c).edit(id(c), await body(c, habitPatch))));
app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); });
app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) }));
app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) }));
app.post('/habits/:id/tasks', async c => c.json(service(c).changeTask(id(c), undefined, await body(c, taskInput)), 201));
app.get('/habits/:id/tasks/:taskId', c => {
const task = service(c).tasks(id(c)).find(t => t.id === c.req.param('taskId'));
if (!task) throw new ApiError(404, 'Not found'); return c.json(task);
});
app.patch('/habits/:id/tasks/:taskId', async c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), await body(c, taskPatch))));
app.delete('/habits/:id/tasks/:taskId', c => { service(c).changeTask(id(c), c.req.param('taskId'), null); return c.body(null, 204); });
app.get('/habits/:id/days/:date', c => c.json(service(c).day(id(c), date(c))));
app.get('/habits/:id/days/:date/audit', c => c.json({ records: service(c).audit(id(c), date(c)) }));
app.put('/habits/:id/days/:date/progress', async c => c.json(service(c).progress(id(c), date(c), await body(c, progressInput))));
app.put('/habits/:id/days/:date/tasks/:taskId', async c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (await body(c, doneInput)).done)));
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
app.put('/habits/:id/calendar-settings', async c => c.json(service(c).saveSettings(id(c), await body(c, calendarSettingsSchema))));
app.get('/habits/:id/calendar', c => {
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false));
});
app.get('/today', c => {
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
return c.json({ date: s.today, timezone: s.user.timezone, habits, due: habits.filter(h => h.due).length, completed: habits.filter(h => h.complete).length });
});
app.get('/days/:date', c => {
const s = service(c); const day = date(c); return c.json({ date: day, habits: s.list(true).map(h => s.day(h.id, day)) });
});
app.get('/charts', c => c.json({ charts: service(c).charts() }));
app.post('/charts', async c => {
const chart = service(c).saveChart(await body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201);
});
app.get('/charts/:id', c => c.json(service(c).chart(id(c))));
app.patch('/charts/:id', async c => {
const patch = await body(c, chartPatch); const old = service(c).chart(id(c));
return c.json(service(c).saveChart({ name: patch.name ?? old.name, habitIds: patch.habitIds ?? old.habitIds, settings: patch.settings ?? old.settings }, id(c)));
});
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
app.get('/charts/:id/calendar', c => {
const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true));
});
app.get('/charts/:id/days/:date', c => {
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);
});
app.patch('/me', async c => c.json(service(c).setTimezone((await body(c, timezoneInput)).timezone)));
return app;
}

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
test('every protected resource route enforces authentication, ownership and mutation origin', async () => {
const h = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201);
const t = h.tasks[0].id;
const chart = await f.json('/charts', 'POST', { name: 'Private chart', habitIds: [h.id] }, 201);
const cases: [string, string, any?][] = [
[`/habits/${h.id}`, 'GET'], [`/habits/${h.id}`, 'PATCH', { name: 'Stolen' }], [`/habits/${h.id}`, 'DELETE'],
[`/habits/${h.id}/history`, 'GET'], [`/habits/${h.id}/tasks`, 'GET'], [`/habits/${h.id}/tasks`, 'POST', { name: 'Bad' }],
[`/habits/${h.id}/tasks/${t}`, 'GET'], [`/habits/${h.id}/tasks/${t}`, 'PATCH', { name: 'Bad' }], [`/habits/${h.id}/tasks/${t}`, 'DELETE'],
[`/habits/${h.id}/days/2026-09-04`, 'GET'], [`/habits/${h.id}/days/2026-09-04/audit`, 'GET'],
[`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true }], [`/habits/${h.id}/days/2026-09-04/tasks/${t}`, 'PUT', { done: true }],
[`/habits/${h.id}/calendar-settings`, 'GET'], [`/habits/${h.id}/calendar-settings`, 'PUT', {}], [`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'],
[`/charts/${chart.id}`, 'GET'], [`/charts/${chart.id}`, 'PATCH', { name: 'Bad' }], [`/charts/${chart.id}`, 'DELETE'],
[`/charts/${chart.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'], [`/charts/${chart.id}/days/2026-09-04`, 'GET'],
];
for (const [path, method, body] of cases) {
const unauthorized = await f.request(path, method, body, 'z'); expect(unauthorized.status).toBe(401);
expect(unauthorized.headers.get('Cache-Control')).toBe('no-store');
expect((await f.request(path, method, body, 'b')).status).toBe(404);
if (method !== 'GET') for (const origin of ['', 'https://evil.example']) expect((await f.request(path, method, body, 'a', { Origin: origin })).status).toBe(403);
}
for (const path of ['/habits', '/charts', '/today', '/days/2026-09-04']) expect((await f.request(path, 'GET', undefined, 'z')).status).toBe(401);
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
expect((await f.json('/charts', 'GET', undefined, 200, 'b')).charts).toHaveLength(0);
expect((await f.json('/today', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
expect((await f.json('/days/2026-09-04', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
expect((await f.request('/charts', 'POST', { name: 'Steal', habitIds: [h.id] }, 'b')).status).toBe(404);
for (const path of ['/habits', '/charts', '/me']) {
const method = path === '/me' ? 'PATCH' : 'POST';
expect((await f.request(path, method, {}, 'z')).status).toBe(401);
expect((await f.request(path, method, {}, 'a', { Origin: '' })).status).toBe(403);
}
});
test('strict JSON, dates, bounds, methods, task membership and non-render settings validation', async () => {
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
const invalidHabits = [{}, { name: ' ', method: 'manual' }, { name: 'Mixed', method: 'manual', target: 8 }, { name: 'Water', method: 'count', target: -1 }, { name: 'Huge', method: 'count', target: 10001 }, { name: 'Odd', method: 'manual', schedule: { type: 'interval', every: 0, anchor: '2026-09-04' } }];
for (const body of invalidHabits) expect((await f.request('/habits', 'POST', body)).status).toBe(422);
for (const body of [{ count: -1 }, { count: 1.5 }, { done: true }, { count: 2, done: true }, { count: 1e20 }]) expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', body)).status).toBe(422);
for (const date of ['2026-02-30', 'garbage', '2026-9-4']) expect((await f.request(`/habits/${h.id}/days/${date}`)).status).toBe(422);
for (const query of ['', '?from=2026-09-04', '?from=2026-09-05&to=2026-09-04', '?from=2020-01-01&to=2026-01-01', '?from=2026-09-04&to=2026-09-05&bad=x']) expect((await f.request(`/habits/${h.id}/calendar${query}`)).status).toBe(422);
expect((await f.request('/habits?archived=yes')).status).toBe(422);
expect((await f.request(`/habits/${h.id}`, 'PATCH', {})).status).toBe(422);
expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'manual', target: 4 })).status).toBe(422);
expect((await f.request(`/habits/${h.id}/tasks`)).status).toBe(409);
expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'Wrong method' })).status).toBe(409);
expect((await f.request(`/habits/${h.id}/days/2026-09-04/tasks/missing`, 'PUT', { done: true })).status).toBe(404);
for (const settings of [{ mainColor: 'red' }, { shadeCount: 1 }, { daySpacing: 5 }]) expect((await f.request(`/habits/${h.id}/calendar-settings`, 'PUT', settings)).status).toBe(422);
for (const timezone of ['', 'invalid', '+02:00']) expect((await f.request('/me', 'PATCH', { timezone })).status).toBe(422);
for (const habitIds of [[], [h.id, h.id], ['not-uuid']]) expect((await f.request('/charts', 'POST', { name: 'Bad', habitIds })).status).toBe(422);
const response = await f.app.request(`${f.origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' }, body: '{' }); expect(response.status).toBe(400);
expect((await f.request('/habits', 'POST', {}, 'a', { 'Content-Type': 'text/plain' })).status).toBe(400);
expect((await f.request('/habits', 'POST', { name: 'x'.repeat(70000) })).status).toBe(413);
});
test('missing tasks and archived editing fail without partial writes', async () => {
const h = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks' }, 201);
expect((await f.request(`/habits/${h.id}/tasks/nope`, 'PATCH', { name: 'No' })).status).toBe(404);
await f.request(`/habits/${h.id}`, 'DELETE');
expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'No' })).status).toBe(409);
expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2);
});

228
src/habits/service.ts Normal file
View File

@@ -0,0 +1,228 @@
import { and, asc, desc, eq, inArray, isNull, lte, sql } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import type { PublicUser } from '../shared/user';
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
import { addDays, endOfDay, localDate, scheduled, shade } from './calendar';
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
export class ApiError extends Error {
constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); }
}
const missing = () => new ApiError(404, 'Not found');
export type Habit = typeof habits.$inferSelect;
export type Revision = typeof habitRevisions.$inferSelect;
export type HabitDay = typeof habitDays.$inferSelect;
export class HabitService {
readonly today: string;
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// Travel west must never reopen or rewrite earlier requirements.
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
this.today = [localDate(now, user.timezone), latest ?? ''].sort().at(-1)!;
}
owned(id: string): Habit {
const habit = this.db.select().from(habits).where(and(eq(habits.id, id), eq(habits.userId, this.user.id))).get();
if (!habit) throw missing();
return habit;
}
revision(id: string, date = this.today): Revision | undefined {
return this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, date))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get();
}
current(id: string) {
const habit = this.owned(id); const revision = this.revision(id)!;
return { id: habit.id, ...revision.config, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
}
list(includeArchived = false) {
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
.map(h => this.current(h.id)).filter(h => includeArchived || !h.archived);
}
create(input: ReturnType<typeof habitInput.parse>) {
const id = crypto.randomUUID();
const config: HabitConfig = input.method === 'tasks'
? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
: { ...input, archived: false };
this.db.transaction(() => {
this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run();
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
this.materialize(this.owned(id));
});
return this.current(id);
}
edit(id: string, patch: Record<string, unknown>) {
const old = this.current(id);
const method = patch.method ?? old.method;
const base: Record<string, unknown> = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method };
if (method === 'count') {
base.target = patch.target ?? (old.method === 'count' ? old.target : undefined);
base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps');
} else if ('target' in patch || 'unit' in patch) throw new ApiError(422, 'Target and unit require the count method');
if (method === 'tasks') base.tasks = [];
const parsed = habitInput.safeParse(base);
if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings');
const config: HabitConfig = parsed.data.method === 'tasks'
? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean }
: { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean };
return this.revise(id, config);
}
revise(id: string, config: HabitConfig) {
this.owned(id);
this.db.transaction(() => {
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
this.materialize(this.owned(id));
});
return this.current(id);
}
tasks(id: string) {
const config = this.current(id);
if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks');
return config.tasks;
}
changeTask(id: string, taskId: string | undefined, input: { name?: string; schedule?: HabitConfig['schedule'] } | null) {
const config = this.revision(this.owned(id).id)!.config;
if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks');
if (config.archived) throw new ApiError(409, 'Restore the habit before editing tasks');
if (taskId && !config.tasks.some(t => t.id === taskId)) throw missing();
const nextId = taskId ?? crypto.randomUUID();
const tasks = taskId ? config.tasks.flatMap(t => t.id !== taskId ? [t] : input ? [{ ...t, ...input }] : [])
: [...config.tasks, { id: nextId, name: input!.name!, schedule: input!.schedule! }];
if (tasks.length > 100) throw new ApiError(422, 'A habit supports at most 100 tasks');
this.revise(id, { ...config, tasks });
return input ? tasks.find(t => t.id === nextId)! : undefined;
}
sync() {
this.db.transaction(() => {
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit);
// Record the original deadline even after downtime. Only active day snapshots expire.
this.db.run(sql`UPDATE task_occurrences SET expired_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
WHERE done = 0 AND expired_at IS NULL AND day_id IN (
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
});
}
private materialize(habit: Habit) {
let date = habit.materializedThrough ?? habit.createdDate;
while (date <= this.today) {
const revision = this.revision(habit.id, date)!;
const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
if (!existing || existing.revisionId !== revision.id) {
const prior = existing ? this.db.select().from(habitRevisions).where(eq(habitRevisions.id, existing.revisionId)).get() : undefined;
const sameMethod = prior?.config.method === revision.config.method;
const day = this.db.insert(habitDays).values({ habitId: habit.id, revisionId: revision.id, date,
timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? endOfDay(date, this.user.timezone),
count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) {
const previous = existing ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, existing.id)).all() : [];
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
const old = previous.find(t => t.taskId === task.id);
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
done: old?.done ?? false, expiredAt: old?.expiredAt ?? (day.endsAt <= this.now && !old?.done ? day.endsAt : null), updatedAt: this.now }).run();
}
}
}
date = addDays(date, 1);
}
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
}
day(id: string, date: string) {
this.owned(id);
const revision = this.revision(id, date);
const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
const config = revision?.config;
const future = date > this.today;
const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date)
? record ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, record.id)).orderBy(asc(taskOccurrences.id)).all()
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, updatedAt: null })) : [];
const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0);
const value = !due ? 0 : config!.method === 'count' ? record?.count ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
const ratio = due ? Math.min(1, value / target!) : null;
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
value, target, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
}
writable(id: string, date: string) {
const detail = this.day(id, date);
if (detail.future) throw new ApiError(409, 'Future progress cannot be logged');
if (!detail.due) throw new ApiError(409, 'Nothing is scheduled for this date');
const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get()!;
return { detail, record };
}
progress(id: string, date: string, input: { count: number } | { done: boolean }) {
const { detail, record } = this.writable(id, date);
if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences');
if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date');
this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: record.count, done: record.done }, after: input, createdAt: this.now }).run();
this.db.update(habitDays).set(input).where(eq(habitDays.id, record.id)).run();
});
return this.day(id, date);
}
completeTask(id: string, date: string, taskId: string, done: boolean) {
const { record } = this.writable(id, date);
const occurrence = this.db.select().from(taskOccurrences).where(and(eq(taskOccurrences.dayId, record.id), eq(taskOccurrences.taskId, taskId))).get();
if (!occurrence) throw missing();
this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run();
this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
});
return this.day(id, date);
}
history(id: string) {
this.owned(id);
return this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.id)).all();
}
audit(id: string, date: string) {
this.owned(id);
return this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(asc(habitDays.id)).all().map(day => ({
...day, requirements: this.db.select().from(habitRevisions).where(eq(habitRevisions.id, day.revisionId)).get()!.config,
occurrences: this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, day.id)).all(),
events: this.db.select().from(progressEvents).where(eq(progressEvents.dayId, day.id)).orderBy(asc(progressEvents.id)).all(),
}));
}
settings(id: string): CalendarSettings {
this.owned(id);
return this.db.select().from(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).get()?.settings ?? calendarSettingsSchema.parse({});
}
saveSettings(id: string, settings: CalendarSettings) {
this.owned(id);
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
return settings;
}
chart(id: string) {
const chart = this.db.select().from(combinedCharts).where(and(eq(combinedCharts.id, id), eq(combinedCharts.userId, this.user.id))).get();
if (!chart) throw missing();
return chart;
}
charts() { return this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).orderBy(asc(combinedCharts.createdAt), asc(combinedCharts.id)).all(); }
saveChart(input: { name: string; habitIds: string[]; settings: CalendarSettings }, id?: string) {
if (id) this.chart(id);
input.habitIds.forEach(h => this.owned(h));
if (id) this.db.update(combinedCharts).set({ ...input, updatedAt: this.now }).where(eq(combinedCharts.id, id)).run();
else { id = crypto.randomUUID(); this.db.insert(combinedCharts).values({ ...input, id, userId: this.user.id, createdAt: this.now, updatedAt: this.now }).run(); }
return this.chart(id);
}
deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); }
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) {
ids.forEach(id => this.owned(id));
const days = [];
for (let date = from; date <= to; date = addDays(date, 1)) {
const details = ids.map(id => this.day(id, date));
const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length;
const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio;
const progress = shade(ratio, combined ? settings.shadeCount : details[0]!.target ?? 1, settings, !combined);
const future = date > this.today;
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
...progress, color: future ? settings.futureColor : progress.color, habits: details });
}
return { from, to, today: this.today, timezone: this.user.timezone, settings, days,
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
}
setTimezone(timezone: string) {
this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run();
return { ...this.user, timezone };
}
}

View File

@@ -0,0 +1,26 @@
import { Database } from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
import { createApi } from '../api';
import { hashToken } from '../auth';
import * as schema from '../db/schema';
export function fixture(path = ':memory:') {
const sqlite = new Database(path); sqlite.exec('PRAGMA foreign_keys = ON');
const db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: './drizzle' });
let time = Date.parse('2026-09-04T12:00:00Z');
const origin = 'http://127.0.0.1:3000';
const app = createApi(db, { origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-secret-at-least-32-characters' }, undefined, () => time);
for (const [id, token] of [['alice', 'a'], ['bob', 'b']]) {
db.insert(schema.users).values({ id: id!, discordId: id!, username: id!, timezone: 'Europe/Belgrade', createdAt: time, updatedAt: time }).onConflictDoNothing().run();
db.insert(schema.sessions).values({ tokenHash: hashToken(token!.repeat(43)), userId: id!, createdAt: time, expiresAt: time + 10 * 365 * 86400000 }).onConflictDoNothing().run();
}
async function request(path: string, method = 'GET', body?: unknown, user = 'a', extra?: Record<string, string>) {
return app.request(`${origin}/api${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, 'Content-Type': 'application/json', ...extra }, body: body === undefined ? undefined : JSON.stringify(body) });
}
async function json(path: string, method = 'GET', body?: unknown, status = 200, user = 'a'): Promise<any> {
const response = await request(path, method, body, user); const data = await response.json();
if (response.status !== status) throw new Error(`${method} ${path}: expected ${status}, got ${response.status}: ${JSON.stringify(data)}`);
return data;
}
return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => sqlite.close() };
}