diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx
index d3b0138..823b205 100644
--- a/src/components/HabitHistory.tsx
+++ b/src/components/HabitHistory.tsx
@@ -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 ??
diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx
index 998897e..edb9935 100644
--- a/src/components/design-system/CalendarHeatmap.tsx
+++ b/src/components/design-system/CalendarHeatmap.tsx
@@ -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.
diff --git a/src/components/design-system/accessibility.test.tsx b/src/components/design-system/accessibility.test.tsx
index f59ebea..a30ba24 100644
--- a/src/components/design-system/accessibility.test.tsx
+++ b/src/components/design-system/accessibility.test.tsx
@@ -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());
+ const days = [...container.querySelectorAll(".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");
+});
diff --git a/src/components/design-system/calendar-model.ts b/src/components/design-system/calendar-model.ts
index b846255..28001d7 100644
--- a/src/components/design-system/calendar-model.ts
+++ b/src/components/design-system/calendar-model.ts
@@ -17,6 +17,7 @@ export type CalendarDay = {
date: string;
value: number;
target: number;
+ future?: boolean;
state: "due" | "not-due" | "future";
};
diff --git a/src/habits/service.ts b/src/habits/service.ts
index deec55f..c35718d 100644
--- a/src/habits/service.ts
+++ b/src/habits/service.ts
@@ -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();
diff --git a/src/shared/calendar.test.ts b/src/shared/calendar.test.ts
index ca3366d..4aa150e 100644
--- a/src/shared/calendar.test.ts
+++ b/src/shared/calendar.test.ts
@@ -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(); }
+});
diff --git a/src/shared/calendar.ts b/src/shared/calendar.ts
index 2e350e5..b3f9780 100644
--- a/src/shared/calendar.ts
+++ b/src/shared/calendar.ts
@@ -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;
export type ResponsiveCalendarProps = ComponentProps;
+/** 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]!;