feat(calendar): add Nivo data adapter and verify durable API behavior
This commit is contained in:
29
src/db/migrations.test.ts
Normal file
29
src/db/migrations.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
test('carryover migration preserves pre-existing counts and explicit zero corrections', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'minabot-upgrade-'));
|
||||
const sqlite = new Database(':memory:'); sqlite.exec('PRAGMA foreign_keys = ON'); const db = drizzle(sqlite);
|
||||
try {
|
||||
mkdirSync(join(directory, 'meta'));
|
||||
const journal = JSON.parse(readFileSync('drizzle/meta/_journal.json', 'utf8'));
|
||||
journal.entries = journal.entries.slice(0, 2);
|
||||
writeFileSync(join(directory, 'meta/_journal.json'), JSON.stringify(journal));
|
||||
for (const entry of journal.entries) copyFileSync(`drizzle/${entry.tag}.sql`, join(directory, `${entry.tag}.sql`));
|
||||
migrate(db, { migrationsFolder: directory });
|
||||
sqlite.exec("INSERT INTO users (id, discord_id, username, created_at, updated_at) VALUES ('u', 'u', 'u', 0, 0)");
|
||||
sqlite.exec("INSERT INTO habits (id, user_id, created_date, created_at) VALUES ('h', 'u', '2026-09-04', 0)");
|
||||
sqlite.query('INSERT INTO habit_revisions (habit_id, effective_date, config, created_at) VALUES (?, ?, ?, ?)').run('h', '2026-09-04', JSON.stringify({ name: 'Old count', method: 'count', target: 8, unit: 'steps', schedule: { type: 'daily' }, archived: false }), 0);
|
||||
for (const [date, count] of [['2026-09-04', 7], ['2026-09-05', 0], ['2026-09-06', 0]] as const) sqlite.query('INSERT INTO habit_days (habit_id, revision_id, date, timezone, ends_at, count) VALUES (?, ?, ?, ?, ?, ?)').run('h', 1, date, 'UTC', 0, count);
|
||||
sqlite.query('INSERT INTO progress_events (day_id, before, after, created_at) VALUES (?, ?, ?, ?)').run(2, '{"count":2}', '{"count":0}', 0);
|
||||
migrate(db, { migrationsFolder: './drizzle' }); migrate(db, { migrationsFolder: './drizzle' });
|
||||
expect(sqlite.query('SELECT count, count_set FROM habit_days ORDER BY id').all()).toEqual([{ count: 7, count_set: 1 }, { count: 0, count_set: 1 }, { count: 0, count_set: 0 }]);
|
||||
const config = (sqlite.query('SELECT config FROM habit_revisions').get() as { config: string }).config;
|
||||
expect(JSON.parse(config).carryPartialProgress).toBe(false);
|
||||
} finally { sqlite.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
37
src/habits/resilience.test.ts
Normal file
37
src/habits/resilience.test.ts
Normal 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'));
|
||||
});
|
||||
@@ -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! })) };
|
||||
}
|
||||
|
||||
23
src/shared/calendar.test.ts
Normal file
23
src/shared/calendar.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { fixture } from '../habits/test-fixture';
|
||||
import { toResponsiveCalendarProps } from './calendar';
|
||||
test('Nivo adapter preserves per-date colors, neutral/future states and readable exact counts', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 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}`, 'PATCH', { target: 10 });
|
||||
await f.json(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', { count: 7 });
|
||||
const payload = await f.json(`/habits/${h.id}/calendar?from=2026-09-03&to=2026-09-06`);
|
||||
const props = toResponsiveCalendarProps(payload);
|
||||
expect(props.data).toHaveLength(4);
|
||||
expect(props.colorScale(1)).not.toBe(props.colorScale(2));
|
||||
expect(props.colorScale(0)).toBe(payload.settings.notDueColor); expect(props.colorScale(3)).toBe(payload.settings.futureColor);
|
||||
expect(props.valueFormat(0)).toBe('Nothing scheduled'); expect(props.valueFormat(1)).toBe('7 of 8 steps');
|
||||
expect(props.valueFormat(2)).toBe('7 of 10 steps'); expect(props.valueFormat(3)).toBe('Upcoming');
|
||||
expect(props.valueFormat(99)).toBe(''); expect(props.colorScale(99)).toBe(payload.settings.notDueColor); expect(props.colorScale.ticks()).toEqual([]);
|
||||
const c = await f.json('/charts', 'POST', { name: 'One', habitIds: [h.id] }, 201);
|
||||
const combined = await f.json(`/charts/${c.id}/calendar?from=2026-09-04&to=2026-09-04`);
|
||||
expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete');
|
||||
} finally { f.close(); }
|
||||
});
|
||||
33
src/shared/calendar.ts
Normal file
33
src/shared/calendar.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ResponsiveCalendar } from '@nivo/calendar';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { HabitService } from '../habits/service';
|
||||
|
||||
export type CalendarResponse = ReturnType<HabitService['calendar']>;
|
||||
export type ResponsiveCalendarProps = ComponentProps<typeof ResponsiveCalendar>;
|
||||
|
||||
/** Data adapter only: no frontend component or page is rendered here. */
|
||||
export function toResponsiveCalendarProps(calendar: CalendarResponse) {
|
||||
// Nivo calls colorScale with a value, not a date. Give each date a stable
|
||||
// index so identical shade levels under different historical targets cannot
|
||||
// collapse into one color. Actual progress remains in calendar.days.
|
||||
const data = calendar.days.map((day, index) => ({ day: day.date, value: index }));
|
||||
const colorScale = Object.assign(
|
||||
(value: number | { valueOf(): number }) => calendar.days[Number(value)]?.color ?? calendar.settings.notDueColor,
|
||||
{ ticks: () => [] as number[] },
|
||||
);
|
||||
const props = {
|
||||
from: calendar.from, to: calendar.to, data, colorScale,
|
||||
emptyColor: calendar.settings.notDueColor,
|
||||
valueFormat: (index: number) => {
|
||||
const day = calendar.days[index];
|
||||
if (!day) return '';
|
||||
if (day.future) return 'Upcoming';
|
||||
if (day.ratio === null) return 'Nothing scheduled';
|
||||
// Counts for combined charts are always equal-weight completed habits.
|
||||
if (calendar.kind === 'combined') return `${day.completed} of ${day.due} habits complete`;
|
||||
const habit = day.habits[0]!;
|
||||
return `${habit.value} of ${habit.target} ${habit.unit}`;
|
||||
},
|
||||
} satisfies ResponsiveCalendarProps;
|
||||
return props;
|
||||
}
|
||||
Reference in New Issue
Block a user