feat(calendar): add Nivo data adapter and verify durable API behavior
This commit is contained in:
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