feat(calendar): add Nivo data adapter and verify durable API behavior
This commit is contained in:
@@ -11,7 +11,9 @@
|
||||
"db:generate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development x --bun drizzle-kit generate",
|
||||
"db:migrate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts",
|
||||
"test": "bun test",
|
||||
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts"
|
||||
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"test:smoke": "bun run build && bun scripts/api-smoke.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nivo/calendar": "^0.99.0",
|
||||
|
||||
57
scripts/api-smoke.ts
Normal file
57
scripts/api-smoke.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { hashToken } from '../src/auth';
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'minabot-api-smoke-'));
|
||||
const databasePath = join(directory, 'test.sqlite');
|
||||
const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() });
|
||||
const port = probe.port!; probe.stop(true);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
||||
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
||||
const token = 's'.repeat(43);
|
||||
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
||||
async function boot() {
|
||||
processHandle = Bun.spawn([process.execPath, 'scripts/start.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try { if ((await fetch(`${origin}/api/health`)).ok) return; } catch { /* Wait for startup. */ }
|
||||
if (processHandle.exitCode !== null) throw new Error(`Production server exited with ${processHandle.exitCode}`);
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
throw new Error('Production server did not become healthy');
|
||||
}
|
||||
async function stop() { if (processHandle) { processHandle.kill(); await processHandle.exited; processHandle = undefined; } }
|
||||
async function request(path: string, method = 'GET', body?: unknown, status = 200) {
|
||||
const response = await fetch(`${origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${token}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const data = await response.json();
|
||||
if (response.status !== status) throw new Error(`${method} ${path}: ${response.status}: ${JSON.stringify(data)}`);
|
||||
return data as any;
|
||||
}
|
||||
function check(value: unknown, message: string): asserts value { if (!value) throw new Error(message); }
|
||||
try {
|
||||
const migration = Bun.spawn([process.execPath, 'scripts/migrate.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
|
||||
check(await migration.exited === 0, 'Fresh production migrations failed');
|
||||
const sqlite = new Database(databasePath); const now = Date.now();
|
||||
sqlite.query('INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run('smoke', 'smoke', 'Smoke', 'UTC', now, now);
|
||||
sqlite.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(hashToken(token), 'smoke', now, now + 3600000);
|
||||
sqlite.close();
|
||||
await boot();
|
||||
const date = (await request('/today')).date;
|
||||
const h = await request('/habits', 'POST', { name: 'HTTP hydration', method: 'count', target: 8 }, 201);
|
||||
const t = await request('/habits', 'POST', { name: 'HTTP tasks', method: 'tasks', tasks: [{ name: 'Check' }] }, 201);
|
||||
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 7 });
|
||||
await request(`/habits/${t.id}/days/${date}/tasks/${t.tasks[0].id}`, 'PUT', { done: true });
|
||||
const c = await request('/charts', 'POST', { name: 'HTTP combined', habitIds: [h.id, t.id] }, 201);
|
||||
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Partial habit incorrectly contributed to combined score');
|
||||
await stop(); await boot();
|
||||
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
||||
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
||||
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 8 });
|
||||
const result = await request(`/charts/${c.id}/calendar?from=${date}&to=${date}`);
|
||||
check(result.days[0].ratio === 1, 'Correction did not update combined calendar');
|
||||
check((await fetch(`${origin}/api/habits`)).status === 401, 'HTTP authentication was bypassed');
|
||||
check((await fetch(`${origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${token}`, Origin: 'https://evil.example', 'Content-Type': 'application/json' }, body: '{}' })).status === 403, 'HTTP Origin enforcement was bypassed');
|
||||
console.log('Production HTTP smoke passed: migrations, count/task logs, combined scores, correction, authentication, CSRF, and persistence across restart.');
|
||||
} finally { await stop(); rmSync(directory, { recursive: true, force: true }); }
|
||||
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