feat(calendar): add Nivo data adapter and verify durable API behavior

This commit is contained in:
syntaxbullet
2026-09-04 09:16:09 +02:00
parent 9a7180ce78
commit ff7ec68262
8 changed files with 232 additions and 11 deletions

View File

@@ -2,8 +2,11 @@ import type { Schedule, CalendarSettings } from './contracts';
const DAY = 86400000;
export const dayNumber = (date: string) => Date.parse(`${date}T00:00:00Z`) / DAY;
export const addDays = (date: string, n: number) => new Date((dayNumber(date) + n) * DAY).toISOString().slice(0, 10);
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
export function localDate(timestamp: number, timezone: string): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(timestamp);
let formatter = dateFormatters.get(timezone);
if (!formatter) { formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }); dateFormatters.set(timezone, formatter); }
return formatter.format(timestamp);
}
// Search an instant boundary rather than adding 24h: DST days can be 23 or 25h.
export function endOfDay(date: string, timezone: string): number {

View File

@@ -0,0 +1,37 @@
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('failed revision writes roll back the habit atomically and do not expose database errors', async () => {
f.sqlite.exec("CREATE TRIGGER reject_revision BEFORE INSERT ON habit_revisions BEGIN SELECT RAISE(ABORT, 'private database detail'); END");
const response = await f.request('/habits', 'POST', { name: 'Rollback', method: 'manual' });
expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Internal server error' });
expect((await f.json('/habits')).habits).toHaveLength(0);
});
test('same-day method switches reset inherited progress even when returning to count', 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');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(7);
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'count', target: 8, carryPartialProgress: true });
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
});
test('timezone travel across the date line never changes yesterday requirements', async () => {
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
f.setTime('2026-09-05T00:30Z'); await f.json('/today');
await f.json('/me', 'PATCH', { timezone: 'Pacific/Honolulu' });
expect((await f.json('/today')).date).toBe('2026-09-05');
await f.json(`/habits/${h.id}`, 'PATCH', { target: 10 });
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).target).toBe(8);
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).target).toBe(10);
});
test('a year of multiple habit history materializes and returns every calendar square', async () => {
const ids: string[] = [];
for (let i = 0; i < 12; i++) ids.push((await f.json('/habits', 'POST', { name: `Habit ${i}`, method: i % 2 ? 'manual' : 'tasks', ...(i % 2 ? {} : { tasks: [{ name: 'Daily' }] }) }, 201)).id);
const c = await f.json('/charts', 'POST', { name: 'Year', habitIds: ids }, 201);
f.setTime('2027-09-04T12:00Z');
const calendar = await f.json(`/charts/${c.id}/calendar?from=2026-09-04&to=2027-09-04`);
expect(calendar.days).toHaveLength(366); expect(calendar.days.every((d: any) => d.due === 12 && d.completed === 0)).toBe(true);
const first = calendar.days[0].habits.find((h: any) => h.method === 'tasks');
expect(first.tasks[0].expiredAt).toBe(Date.parse('2026-09-04T22:00Z'));
});

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, isNull, lte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, 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';
@@ -15,6 +15,11 @@ export type HabitDay = typeof habitDays.$inferSelect;
export class HabitService {
readonly today: string;
private ownedCache = new Map<string, Habit>();
private revisionCache = new Map<string, Revision[]>();
private deadlineCache = new Map<string, number>();
private recordsCache = new Map<string, Map<string, HabitDay>>();
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// Travel west must never reopen or rewrite earlier requirements.
@@ -22,12 +27,15 @@ export class HabitService {
this.today = [localDate(now, user.timezone), latest ?? ''].sort().at(-1)!;
}
owned(id: string): Habit {
const cached = this.ownedCache.get(id); if (cached) return cached;
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;
this.ownedCache.set(id, habit); 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();
let revisions = this.revisionCache.get(id);
if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); }
return revisions.find(r => r.effectiveDate <= date);
}
current(id: string) {
const habit = this.owned(id); const revision = this.revision(id)!;
@@ -67,7 +75,7 @@ export class HabitService {
return this.revise(id, config);
}
revise(id: string, config: HabitConfig) {
this.countCache.delete(id);
this.invalidate(id);
this.owned(id);
this.db.transaction(() => {
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
@@ -103,6 +111,33 @@ export class HabitService {
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
});
}
private invalidate(id: string) {
this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
}
private deadline(date: string) {
let value = this.deadlineCache.get(date);
if (value === undefined) { value = endOfDay(date, this.user.timezone); this.deadlineCache.set(date, value); }
return value;
}
private records(id: string) {
let records = this.recordsCache.get(id);
if (!records) {
records = new Map();
for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day);
this.recordsCache.set(id, records);
}
return records;
}
private occurrences(id: string) {
let occurrences = this.occurrencesCache.get(id);
if (!occurrences) {
occurrences = new Map();
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all();
for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); }
this.occurrencesCache.set(id, occurrences);
}
return occurrences;
}
private materialize(habit: Habit) {
let date = habit.materializedThrough ?? habit.createdDate;
while (date <= this.today) {
@@ -112,8 +147,8 @@ export class HabitService {
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),
countSet: sameMethod ? existing!.countSet : false, count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? this.deadline(date),
countSet: existing && !sameMethod && revision.config.method === 'count' ? true : sameMethod ? existing!.countSet : false, 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))) {
@@ -147,11 +182,11 @@ export class HabitService {
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 record = this.records(id).get(date);
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()
? record ? this.occurrences(id).get(record.id) ?? []
: 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 counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined;
@@ -180,6 +215,7 @@ export class HabitService {
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('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
});
this.invalidate(id);
return this.day(id, date);
}
completeTask(id: string, date: string, taskId: string, done: boolean) {
@@ -190,6 +226,7 @@ export class HabitService {
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();
});
this.invalidate(id);
return this.day(id, date);
}
history(id: string) {
@@ -241,7 +278,7 @@ export class HabitService {
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details });
}
return { from, to, today: this.today, timezone: this.user.timezone, settings, days,
return { kind: combined ? 'combined' as const : 'individual' as const, 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! })) };
}