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

@@ -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
View 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;
}