From 82fe4a1db134822a980fb8fa71e9e151ee67cb96 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:29:20 +0200 Subject: [PATCH] Preserve calendar presets and custom ranges across local midnight --- src/App.test.tsx | 31 +++++++++++++++++++ src/components/HabitHistory.tsx | 24 +++++++++----- .../design-system/CalendarHeatmap.tsx | 7 +++-- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 02809ef..971ac6e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -398,6 +398,37 @@ describe("homepage", () => { expect(calendarRequests()).toHaveLength(2); }); + test("month rollover preserves a twelve-month preset and an explicit custom window", async () => { + const f = connectAccount(); + f.setTime("2026-09-30T12:00:00Z"); + const annual = await f.json("/habits", "POST", { name: "Annual", method: "manual" }, 201); + const custom = await f.json("/habits", "POST", { name: "Custom", method: "manual" }, 201); + await render("/"); + const annualHistory = container.querySelector(`#history-${annual.id}`)!; + const customHistory = container.querySelector(`#history-${custom.id}`)!; + await act(async () => annualHistory.querySelector('[aria-label="12 months"]')!.click()); + await act(async () => [...customHistory.querySelectorAll("button")].find(button => button.textContent === "Custom")!.click()); + await act(async () => { + for (const [name, value] of [["from", "2026-08-05"], ["to", "2026-09-10"]]) { + const input = customHistory.querySelector(`input[name="${name}"]`)!; + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event); + } + customHistory.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event); + }); + fetchMock.mockClear(); + f.setTime("2026-10-01T12:00:00Z"); + await act(async () => dom.dispatchEvent(new dom.Event("focus"))); + const requests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?")); + expect(requests).toHaveLength(2); + expect(requests.find(url => url.includes(annual.id))).toContain("from=2025-11-01&to=2026-10-31"); + expect(requests.find(url => url.includes(custom.id))).toContain("from=2026-08-05&to=2026-09-10"); + expect(annualHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("12"); + expect(annualHistory.querySelectorAll(".ds-day")).toHaveLength(365); + expect(customHistory.querySelector(".ds-calendar")?.getAttribute("data-months")).toBe("custom"); + expect(customHistory.querySelectorAll(".ds-day")).toHaveLength(37); + }); + test("a pending counter save leaves other habits usable and refreshes only its history", async () => { const f = connectAccount(); const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201); diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index fde0552..d3b0138 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { CalendarResponse } from "../shared/calendar"; import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard"; import { CalendarHeatmap } from "./design-system/CalendarHeatmap"; -import { monthWindow } from "./design-system/calendar-model"; +import { monthWindow, type MonthView } from "./design-system/calendar-model"; import { Button } from "./design-system/primitives"; import { HabitChart } from "./design-system/HabitChart"; import { CalendarLegend } from "./design-system/CalendarLegend"; @@ -43,16 +43,26 @@ export function HabitHistory({ calendarOnly?: boolean; }) { const cache = useCalendarCache(); - const [range, setRange] = useState(() => monthWindow(date, 3)); + const [rangeSelection, setRangeSelection] = useState(() => ({ date, months: 3 as MonthView | "custom", range: monthWindow(date, 3) })); + const range = useMemo(() => { + if (rangeSelection.date === date || rangeSelection.months === "custom") return rangeSelection.range; + // A preset follows the current month across midnight; an explicitly inspected + // older window and custom dates retain the user's chosen historical period. + const previousCurrent = monthWindow(rangeSelection.date, rangeSelection.months); + const anchor = rangeSelection.range.to < previousCurrent.to ? rangeSelection.range.to : date; + return monthWindow(anchor, rangeSelection.months); + }, [date, rangeSelection]); + function requestRange(next: { from: string; to: string }, months: MonthView | "custom" = rangeSelection.months) { + setRangeSelection({ date, months, range: next }); + } const [historyLoading, setHistoryLoading] = useState(false); - useEffect(() => { setRange(monthWindow(date, 3)); }, [date]); const [selectedDate, setSelectedDate] = useState(); function selectDate(selected: string) { setSelectedDate(selected); const available = monthWindow(date, 12); - if (selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) { - const requested = monthWindow(selected, 3); - setRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to }); + if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) { + const requested = monthWindow(selected, rangeSelection.months); + requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to }); } } const [editingDay, setEditingDay] = useState(false); @@ -132,7 +142,7 @@ export function HabitHistory({ compact initialMonths={3} availableRange={monthWindow(date, 12)} - onRangeChange={setRange} + onRangeChange={requestRange} loading={historyLoading} historyLabel="Your recorded progress" label={`${habit.name} progress calendar`} diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index d9f648b..998897e 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -32,7 +32,7 @@ export function CalendarHeatmap({ loading = false, }: { availableRange?: { from: string; to: string }; - onRangeChange?: (range: { from: string; to: string }) => void; + onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void; loading?: boolean; days: CalendarDay[]; unit: string; @@ -138,7 +138,7 @@ export function CalendarHeatmap({ type="button" aria-label={`${value} ${value === 1 ? "month" : "months"}`} aria-pressed={months === value} - onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value)); }} + onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value), value); }} > {value}m @@ -150,6 +150,7 @@ export function CalendarHeatmap({ if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date }); setRangeError(""); setMonths("custom"); + onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom"); }} > Custom @@ -184,7 +185,7 @@ export function CalendarHeatmap({ return; } setCustomRange({ from: start.value, to: end.value }); - onRangeChange?.({ from: start.value, to: end.value }); + onRangeChange?.({ from: start.value, to: end.value }, "custom"); setRangeError(""); }} >