From ff7ec6826244a8929498821417b42d6950ea3d27 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 4 Sep 2026 09:16:09 +0200 Subject: [PATCH] feat(calendar): add Nivo data adapter and verify durable API behavior --- package.json | 4 ++- scripts/api-smoke.ts | 57 +++++++++++++++++++++++++++++++++++ src/db/migrations.test.ts | 29 ++++++++++++++++++ src/habits/calendar.ts | 5 ++- src/habits/resilience.test.ts | 37 +++++++++++++++++++++++ src/habits/service.ts | 55 +++++++++++++++++++++++++++------ src/shared/calendar.test.ts | 23 ++++++++++++++ src/shared/calendar.ts | 33 ++++++++++++++++++++ 8 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 scripts/api-smoke.ts create mode 100644 src/db/migrations.test.ts create mode 100644 src/habits/resilience.test.ts create mode 100644 src/shared/calendar.test.ts create mode 100644 src/shared/calendar.ts diff --git a/package.json b/package.json index 729ebdf..a3a8beb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/api-smoke.ts b/scripts/api-smoke.ts new file mode 100644 index 0000000..b91739d --- /dev/null +++ b/scripts/api-smoke.ts @@ -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 | 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 }); } diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts new file mode 100644 index 0000000..4aa0612 --- /dev/null +++ b/src/db/migrations.test.ts @@ -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 }); } +}); diff --git a/src/habits/calendar.ts b/src/habits/calendar.ts index e3928eb..51fff7d 100644 --- a/src/habits/calendar.ts +++ b/src/habits/calendar.ts @@ -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(); 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 { diff --git a/src/habits/resilience.test.ts b/src/habits/resilience.test.ts new file mode 100644 index 0000000..67bf27b --- /dev/null +++ b/src/habits/resilience.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { fixture } from './test-fixture'; +let f: ReturnType; +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')); +}); diff --git a/src/habits/service.ts b/src/habits/service.ts index 4ff8cf0..4f1a3e2 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -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(); + private revisionCache = new Map(); + private deadlineCache = new Map(); + private recordsCache = new Map>(); + private occurrencesCache = new Map>(); private countCache = new Map>(); 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! })) }; } diff --git a/src/shared/calendar.test.ts b/src/shared/calendar.test.ts new file mode 100644 index 0000000..ca3366d --- /dev/null +++ b/src/shared/calendar.test.ts @@ -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(); } +}); diff --git a/src/shared/calendar.ts b/src/shared/calendar.ts new file mode 100644 index 0000000..2e350e5 --- /dev/null +++ b/src/shared/calendar.ts @@ -0,0 +1,33 @@ +import { ResponsiveCalendar } from '@nivo/calendar'; +import type { ComponentProps } from 'react'; +import type { HabitService } from '../habits/service'; + +export type CalendarResponse = ReturnType; +export type ResponsiveCalendarProps = ComponentProps; + +/** 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; +}