feat(api): implement authenticated habit tracking and combined calendars
This commit is contained in:
@@ -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
157
src/habits/api.test.ts
Normal 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
102
src/habits/routes.ts
Normal 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;
|
||||||
|
}
|
||||||
63
src/habits/security.test.ts
Normal file
63
src/habits/security.test.ts
Normal 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
228
src/habits/service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/habits/test-fixture.ts
Normal file
26
src/habits/test-fixture.ts
Normal 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() };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user