diff --git a/src/habits/regressions.test.ts b/src/habits/regressions.test.ts index c36f20f..f3f974c 100644 --- a/src/habits/regressions.test.ts +++ b/src/habits/regressions.test.ts @@ -49,3 +49,54 @@ test('expired occurrences preserve original expiry through repeated historical c await tick(h, true); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired); await tick(h, false); expect((await detail(h.id)).tasks[0].expiredAt).toBe(expired); }); +test('overlapping patches preserve unrelated changes', async () => { + const h = await f.json('/habits', 'POST', { name: 'Original', method: 'count', target: 8 }, 201); + await Promise.all([ + f.json(`/habits/${h.id}`, 'PATCH', { name: 'Renamed' }), + f.json(`/habits/${h.id}`, 'PATCH', { target: 10 }), + ]); + expect(await f.json(`/habits/${h.id}`)).toMatchObject({ name: 'Renamed', target: 10 }); +}); +test('count correction audit records inherited progress rather than the raw zero default', async () => { + const h = await f.json('/habits', 'POST', { name: 'Carry', method: 'count', target: 8, carryPartialProgress: true }, 201); + await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); + f.setTime('2026-09-05T12:00Z'); + await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 8 }); + const audit = await f.json(`/habits/${h.id}/days/2026-09-05/audit`); + expect(audit.records[0].events[0].before.count).toBe(7); +}); +function delayedPatch(path: string, input: object) { + let controller!: ReadableStreamDefaultController; + let began!: () => void; + const started = new Promise(resolve => { began = resolve; }); + const encoded = new TextEncoder().encode(JSON.stringify(input)); + const stream = new ReadableStream({ start(value) { controller = value; }, pull() { began(); } }); + const response = f.app.request(`${f.origin}/api${path}`, { method: 'PATCH', body: stream, + headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json', 'Content-Length': String(encoded.length) } }); + return { started, response, finish() { controller.enqueue(encoded); controller.close(); } }; +} +test('a request streaming across midnight applies configuration on its actual write date', async () => { + const h = await taskHabit(); f.setTime('2026-09-04T21:59:59Z'); + const pending = delayedPatch(`/habits/${h.id}`, { name: 'Tomorrow' }); await pending.started; + f.setTime('2026-09-04T22:01Z'); pending.finish(); + expect((await pending.response).status).toBe(200); + expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05'); + expect((await detail(h.id)).name).toBe('Routine'); +}); +test('a streaming request observes timezone changes completed before its write', async () => { + const h = await taskHabit(); + const pending = delayedPatch(`/habits/${h.id}`, { name: 'After travel' }); await pending.started; + await f.json('/me', 'PATCH', { timezone: 'Pacific/Kiritimati' }); pending.finish(); + expect((await pending.response).status).toBe(200); + expect((await f.json(`/habits/${h.id}`)).effectiveDate).toBe('2026-09-05'); + expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).timezone).toBe('Pacific/Kiritimati'); + expect((await detail(h.id)).name).toBe('Routine'); +}); +test('task occurrences retain definition order across dates and revisions', async () => { + const h = await f.json('/habits', 'POST', { name: 'Order', method: 'tasks', tasks: [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }] }, 201); + for (let i = 0; i < 3; i++) f.sqlite.query('UPDATE task_occurrences SET id = ? WHERE task_id = ?').run(String(3 - i), h.tasks[i].id); + expect((await detail(h.id)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']); + await f.json(`/habits/${h.id}`, 'PATCH', { name: 'Rename' }); + f.setTime('2026-09-05T12:00Z'); + expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']); +}); diff --git a/src/habits/routes.ts b/src/habits/routes.ts index d6aefdd..b1d2ee8 100644 --- a/src/habits/routes.ts +++ b/src/habits/routes.ts @@ -5,11 +5,11 @@ 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(c: Context, schema: T): Promise> { - 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'); } +type Env = AuthEnv & { Variables: { service: HabitService; payload: { value: unknown; malformed: boolean } } }; +function body(c: Context, schema: T): z.output { + if (c.req.header('Content-Type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') throw new ApiError(400, 'Expected application/json'); + const { value, malformed } = c.get('payload'); + if (malformed) 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; @@ -34,6 +34,13 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType { const proceed = async () => { const response = await limit(c, async () => { + // Finish all asynchronous body reads before taking a dated database snapshot. + // The handlers below then read, validate and write synchronously in one turn. + const payload: Env['Variables']['payload'] = { value: undefined, malformed: false }; + if (['POST', 'PATCH', 'PUT'].includes(c.req.method)) { + try { payload.value = await c.req.json(); } catch { payload.malformed = true; } + } + c.set('payload', payload); const service = new HabitService(db, c.get('user'), now()); service.sync(); c.set('service', service); await next(); }); if (response) c.res = response; }; @@ -49,28 +56,28 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType { - const habit = service(c).create(await body(c, habitInput)); + app.post('/habits', c => { + const habit = service(c).create(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.patch('/habits/:id', c => c.json(service(c).edit(id(c), 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.post('/habits/:id/tasks', c => c.json(service(c).changeTask(id(c), undefined, 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.patch('/habits/:id/tasks/:taskId', c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), 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.put('/habits/:id/days/:date/progress', c => c.json(service(c).progress(id(c), date(c), body(c, progressInput)))); + app.put('/habits/:id/days/:date/tasks/:taskId', c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (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.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), 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)); }); @@ -82,12 +89,12 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType 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.post('/charts', c => { + const chart = service(c).saveChart(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)); + app.patch('/charts/:id', c => { + const patch = 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); }); @@ -97,6 +104,6 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType { 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))); + app.patch('/me', c => c.json(service(c).setTimezone((body(c, timezoneInput)).timezone))); return app; } diff --git a/src/habits/service.ts b/src/habits/service.ts index 004ccc1..bbfad0d 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -22,9 +22,12 @@ export class HabitService { private occurrencesCache = new Map>(); private countCache = new Map>(); constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) { + // A different request may update the timezone while this request's body streams in. + const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone; + this.user = { ...user, timezone }; // 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)!; + this.today = [localDate(now, this.user.timezone), latest ?? ''].sort().at(-1)!; } owned(id: string): Habit { const cached = this.ownedCache.get(id); if (cached) return cached; @@ -193,7 +196,7 @@ export class HabitService { const config = revision?.config; const future = date > this.today; const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date) - ? record ? this.occurrences(id).get(record.id) ?? [] + ? record ? config.tasks.flatMap(task => (this.occurrences(id).get(record.id) ?? []).filter(occurrence => occurrence.taskId === task.id)) : config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, closedAt: null, updatedAt: null })) : []; const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0); const counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined; @@ -219,7 +222,7 @@ export class HabitService { if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date'); this.countCache.delete(id); 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.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run(); this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run(); }); this.invalidate(id);