Preserve calendar presets and custom ranges across local midnight
This commit is contained in:
@@ -398,6 +398,37 @@ describe("homepage", () => {
|
|||||||
expect(calendarRequests()).toHaveLength(2);
|
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<HTMLButtonElement>('[aria-label="12 months"]')!.click());
|
||||||
|
await act(async () => [...customHistory.querySelectorAll<HTMLButtonElement>("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<HTMLInputElement>(`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 () => {
|
test("a pending counter save leaves other habits usable and refreshes only its history", async () => {
|
||||||
const f = connectAccount();
|
const f = connectAccount();
|
||||||
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|||||||
import type { CalendarResponse } from "../shared/calendar";
|
import type { CalendarResponse } from "../shared/calendar";
|
||||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
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 { Button } from "./design-system/primitives";
|
||||||
import { HabitChart } from "./design-system/HabitChart";
|
import { HabitChart } from "./design-system/HabitChart";
|
||||||
import { CalendarLegend } from "./design-system/CalendarLegend";
|
import { CalendarLegend } from "./design-system/CalendarLegend";
|
||||||
@@ -43,16 +43,26 @@ export function HabitHistory({
|
|||||||
calendarOnly?: boolean;
|
calendarOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cache = useCalendarCache();
|
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);
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
useEffect(() => { setRange(monthWindow(date, 3)); }, [date]);
|
|
||||||
const [selectedDate, setSelectedDate] = useState<string>();
|
const [selectedDate, setSelectedDate] = useState<string>();
|
||||||
function selectDate(selected: string) {
|
function selectDate(selected: string) {
|
||||||
setSelectedDate(selected);
|
setSelectedDate(selected);
|
||||||
const available = monthWindow(date, 12);
|
const available = monthWindow(date, 12);
|
||||||
if (selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
|
if (rangeSelection.months !== "custom" && selected >= available.from && selected <= available.to && (selected < range.from || selected > range.to)) {
|
||||||
const requested = monthWindow(selected, 3);
|
const requested = monthWindow(selected, rangeSelection.months);
|
||||||
setRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
|
requestRange({ from: requested.from < available.from ? available.from : requested.from, to: requested.to });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [editingDay, setEditingDay] = useState(false);
|
const [editingDay, setEditingDay] = useState(false);
|
||||||
@@ -132,7 +142,7 @@ export function HabitHistory({
|
|||||||
compact
|
compact
|
||||||
initialMonths={3}
|
initialMonths={3}
|
||||||
availableRange={monthWindow(date, 12)}
|
availableRange={monthWindow(date, 12)}
|
||||||
onRangeChange={setRange}
|
onRangeChange={requestRange}
|
||||||
loading={historyLoading}
|
loading={historyLoading}
|
||||||
historyLabel="Your recorded progress"
|
historyLabel="Your recorded progress"
|
||||||
label={`${habit.name} progress calendar`}
|
label={`${habit.name} progress calendar`}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function CalendarHeatmap({
|
|||||||
loading = false,
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
availableRange?: { from: string; to: string };
|
availableRange?: { from: string; to: string };
|
||||||
onRangeChange?: (range: { from: string; to: string }) => void;
|
onRangeChange?: (range: { from: string; to: string }, months: MonthView | "custom") => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -138,7 +138,7 @@ export function CalendarHeatmap({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||||
aria-pressed={months === value}
|
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
|
{value}m
|
||||||
</button>
|
</button>
|
||||||
@@ -150,6 +150,7 @@ export function CalendarHeatmap({
|
|||||||
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
if (!customRange) setCustomRange({ from: days[0]!.date, to: days.at(-1)!.date });
|
||||||
setRangeError("");
|
setRangeError("");
|
||||||
setMonths("custom");
|
setMonths("custom");
|
||||||
|
onRangeChange?.(customRange ?? { from: days[0]!.date, to: days.at(-1)!.date }, "custom");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Custom
|
Custom
|
||||||
@@ -184,7 +185,7 @@ export function CalendarHeatmap({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCustomRange({ from: start.value, to: end.value });
|
setCustomRange({ from: start.value, to: end.value });
|
||||||
onRangeChange?.({ from: start.value, to: end.value });
|
onRangeChange?.({ from: start.value, to: end.value }, "custom");
|
||||||
setRangeError("");
|
setRangeError("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user