fix(calendar): show unscheduled markers on future days off

This commit is contained in:
syntaxbullet
2026-10-24 20:23:54 +02:00
parent ccd998ab04
commit 7a0cae8f08
7 changed files with 77 additions and 22 deletions

View File

@@ -2,7 +2,7 @@ import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow, type MonthView } from "./design-system/calendar-model";
@@ -86,19 +86,7 @@ export function HabitHistory({
return () => { active = false; };
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days = useMemo(() =>
calendar?.days.map((day) => ({
date: day.date,
color: day.color,
unit: day.habits[0]?.unit,
value: day.habits[0]?.value ?? 0,
target: day.habits[0]?.target ?? 0,
state: day.future
? ("future" as const)
: day.due
? ("due" as const)
: ("not-due" as const),
})) ?? [], [calendar]);
const days = useMemo(() => calendar ? toHeatmapDays(calendar) : [], [calendar]);
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??

View File

@@ -52,7 +52,7 @@ export function CalendarHeatmap({
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.findLast((day) => !(day.future ?? day.state === "future"))?.date ??
allDays.at(-1)?.date;
// Inspecting a square changes selection, not the window. Only an external
// date outside the visible preset needs a new anchor to bring it into view.

View File

@@ -262,3 +262,20 @@ test("custom timeframe rejects missing, reversed and unavailable dates without c
expect(description(container.querySelector("input")!)).toContain(error!);
}
});
test("upcoming days off render the not-scheduled marker without moving selection past today", async () => {
await act(async () => root.render(<CalendarHeatmap unit="completion" label="Weekly habit" days={[
{ date: "2026-09-04", value: 0, target: 1, state: "due", future: false },
{ date: "2026-09-05", value: 0, target: 1, state: "not-due", future: true },
{ date: "2026-09-06", value: 0, target: 1, state: "future", future: true },
{ date: "2026-09-07", value: 0, target: 1, state: "not-due", future: true },
]} />));
const days = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
expect(days.map(day => day.className)).toEqual([
"ds-day ds-day--due", "ds-day ds-day--not-due", "ds-day ds-day--future", "ds-day ds-day--not-due",
]);
expect(days[0]!.getAttribute("aria-pressed")).toBe("true");
expect(days[1]!.getAttribute("aria-label")).toContain("Nothing scheduled");
expect(days[2]!.getAttribute("aria-label")).toContain("Upcoming");
expect(days[1]!.style.backgroundColor).toBe("transparent");
});

View File

@@ -17,6 +17,7 @@ export type CalendarDay = {
date: string;
value: number;
target: number;
future?: boolean;
state: "due" | "not-due" | "future";
};

View File

@@ -326,7 +326,7 @@ export class HabitService {
const ratio = due ? Math.min(1, value / target!) : null;
return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
status: !due ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
}
@@ -409,15 +409,15 @@ export class HabitService {
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
const future = date > this.today;
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.map(detail => {
days.push({ date, due: due.length, completed, ratio, future, status: ratio === null ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
shadeCount: steps, ...progress, color: future && ratio !== null ? settings.futureColor : progress.color, habits: details.map(detail => {
const { tasks, requirements, ...summary } = detail;
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
}) });
}
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! })) };
data: days.map(d => ({ day: d.date, value: d.ratio === null ? -1 : d.future ? -2 : d.level! })) };
}
setTimezone(timezone: string) {
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();

View File

@@ -1,6 +1,6 @@
import { expect, test } from 'bun:test';
import { fixture } from '../habits/test-fixture';
import { toResponsiveCalendarProps } from './calendar';
import { toHeatmapDays, toResponsiveCalendarProps } from './calendar';
test('Nivo adapter preserves per-date colors, neutral/future states and readable exact counts', async () => {
const f = fixture();
try {
@@ -21,3 +21,38 @@ test('Nivo adapter preserves per-date colors, neutral/future states and readable
expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete');
} finally { f.close(); }
});
test('future calendars preserve scheduled days and days off for individual and combined charts', async () => {
const f = fixture();
try {
for (const method of ['manual', 'count', 'tasks']) {
const schedule = { type: 'weekdays', days: [1, 3, 5] };
const h = await f.json('/habits', 'POST', {
name: method, method, ...(method === 'count' ? { target: 8 } : {}),
...(method === 'tasks' ? { tasks: [{ name: 'Exercise', schedule }] } : { schedule }),
}, 201);
const c = await f.json('/charts', 'POST', { name: method, habitIds: [h.id] }, 201);
for (const path of [`/habits/${h.id}`, `/charts/${c.id}`]) {
const payload = await f.json(`${path}/calendar?from=2026-09-04&to=2026-09-11`);
const props = toResponsiveCalendarProps(payload);
const heatmap = toHeatmapDays(payload);
expect(heatmap.map(day => day.state)).toEqual([
'due', 'not-due', 'not-due', 'future', 'not-due', 'future', 'not-due', 'future',
]);
for (let index = 1; index < payload.days.length; index++) {
const day = payload.days[index];
expect(day.future).toBe(true);
expect(heatmap[index]!.future).toBe(true);
expect(day.status).toBe(day.due ? 'future' : 'not_due');
expect(props.colorScale(index)).toBe(day.due ? payload.settings.futureColor : payload.settings.notDueColor);
expect(props.valueFormat(index)).toBe(day.due ? 'Upcoming' : 'Nothing scheduled');
expect(payload.data[index].value).toBe(day.due ? -2 : -1);
}
}
const off = await f.json(`/habits/${h.id}/days/2026-09-05`);
expect(off.future).toBe(true);
expect(off.status).toBe('not_due');
expect((await f.request(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', method === 'count' ? { count: 1 } : { done: true })).status).toBe(409);
}
} finally { f.close(); }
});

View File

@@ -1,10 +1,24 @@
import { ResponsiveCalendar } from '@nivo/calendar';
import type { ResponsiveCalendar } from '@nivo/calendar';
import type { ComponentProps } from 'react';
import type { CalendarDay } from '../components/design-system/calendar-model';
import type { HabitService } from '../habits/service';
export type CalendarResponse = ReturnType<HabitService['calendar']>;
export type ResponsiveCalendarProps = ComponentProps<typeof ResponsiveCalendar>;
/** Preserve timing independently so upcoming days off do not move the selected date. */
export function toHeatmapDays(calendar: CalendarResponse): CalendarDay[] {
return calendar.days.map(day => ({
date: day.date,
color: day.color,
unit: day.habits[0]?.unit,
value: day.habits[0]?.value ?? 0,
target: day.habits[0]?.target ?? 0,
future: day.future,
state: !day.due ? 'not-due' : day.future ? 'future' : 'due',
}));
}
/** 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
@@ -21,8 +35,8 @@ export function toResponsiveCalendarProps(calendar: CalendarResponse) {
valueFormat: (index: number) => {
const day = calendar.days[index];
if (!day) return '';
if (day.future) return 'Upcoming';
if (day.ratio === null) return 'Nothing scheduled';
if (day.future) return 'Upcoming';
// 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]!;