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]!;