Cache versioned habit history and load visible calendar ranges

This commit is contained in:
syntaxbullet
2026-09-08 16:15:29 +02:00
parent 31e4f3b2ef
commit b4619dec33
8 changed files with 127 additions and 27 deletions

View File

@@ -326,6 +326,34 @@ describe("homepage", () => {
expect((await f.json("/today")).completed).toBe(2);
});
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
const f = connectAccount();
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
await render("/");
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(calendarRequests()).toHaveLength(2);
expect(calendarRequests()[0]).toContain("from=2026-07-01");
expect(calendarRequests()[0]).toContain("compact=true");
fetchMock.mockClear();
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(0);
await f.json(`/habits/${reading.id}/days/2026-09-04/progress`, "PUT", { done: true });
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain(reading.id);
const history = container.querySelector(`#history-${reading.id}`)!;
fetchMock.mockClear();
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="12 months"]')!.click());
expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain("from=2025-10-01");
expect(history.querySelectorAll(".ds-day")).toHaveLength(365);
fetchMock.mockClear();
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
expect(calendarRequests()).toHaveLength(0);
expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
});
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);

View File

@@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
</div>
</header>
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={habit.calendarRevision ?? revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
</article>)}
</div>
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>

View File

@@ -0,0 +1,27 @@
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
import { CalendarCache } from "./CalendarCache";
const fetchSpy = spyOn(globalThis, "fetch");
afterEach(() => fetchSpy.mockReset());
afterAll(() => fetchSpy.mockRestore());
const range = { from: "2026-07-01", to: "2026-09-30" };
test("calendar cache shares in-flight reads, retains wider history and invalidates changed versions", async () => {
fetchSpy.mockImplementation(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }));
const cache = new CalendarCache();
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0]![0])).toContain("compact=true");
const subset = await cache.load("habit", "v1", { from: "2026-09-01", to: range.to });
expect(subset.days).toHaveLength(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
await cache.load("habit", "v2", range);
expect(fetchSpy).toHaveBeenCalledTimes(2);
await new CalendarCache().load("habit", "v2", range);
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
test("a failed history request can be retried without retaining a rejected cache entry", async () => {
fetchSpy.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(Response.json({ ...range, days: [] }));
const cache = new CalendarCache();
await expect(cache.load("habit", "v1", range)).rejects.toThrow("offline");
await expect(cache.load("habit", "v1", range)).resolves.toMatchObject({ days: [] });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

View File

@@ -0,0 +1,34 @@
import { createContext, useContext, useRef, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest } from "../lib/dashboard";
type Range = { from: string; to: string };
type Entry = Range & { version: string; value: Promise<CalendarResponse> };
/** Account-scoped, bounded cache; never survives signing out or changing accounts. */
export class CalendarCache {
private entries = new Map<string, Entry[]>();
load(habitId: string, version: string, range: Range) {
const entries = (this.entries.get(habitId) ?? []).filter(entry => entry.version === version);
const cached = entries.find(entry => entry.from <= range.from && entry.to >= range.to);
if (cached) return cached.value.then(value => ({ ...value, ...range, days: value.days.filter(day => day.date >= range.from && day.date <= range.to) }));
const entry: Entry = { ...range, version, value: habitRequest<CalendarResponse>(`/habits/${habitId}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`) };
entries.push(entry);
this.entries.delete(habitId);
this.entries.set(habitId, entries.slice(-8));
if (this.entries.size > 100) this.entries.delete(this.entries.keys().next().value!);
entry.value.catch(() => { const current = this.entries.get(habitId); if (current) this.entries.set(habitId, current.filter(item => item !== entry)); });
return entry.value;
}
}
const Context = createContext<CalendarCache | null>(null);
export function CalendarCacheProvider({ children }: { children: ReactNode }) {
const cache = useRef<CalendarCache | null>(null);
cache.current ??= new CalendarCache();
return <Context.Provider value={cache.current}>{children}</Context.Provider>;
}
export function useCalendarCache() {
const shared = useContext(Context);
const local = useRef<CalendarCache | null>(null);
local.current ??= new CalendarCache();
return shared ?? local.current;
}

View File

@@ -1,3 +1,4 @@
import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
@@ -40,6 +41,10 @@ export function HabitHistory({
onHistorySaved?: () => void;
calendarOnly?: boolean;
}) {
const cache = useCalendarCache();
const [range, setRange] = useState(() => monthWindow(date, 3));
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => { setRange(monthWindow(date, 3)); }, [date]);
const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
@@ -49,24 +54,18 @@ export function HabitHistory({
const expired = useRef(onExpired);
expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
let active = true;
setError("");
const range = monthWindow(date, 12);
habitRequest<CalendarResponse>(
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
{ signal: controller.signal },
)
.then((result) => {
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (controller.signal.aborted) return;
if (error.message.includes("session has expired") && expired.current)
expired.current();
setHistoryLoading(true);
cache.load(habit.habitId, `${date}:${revision}:${attempt}`, range)
.then(result => { if (active) setCalendar(result); })
.catch(error => {
if (!active) return;
if (error.message.includes("session has expired") && expired.current) expired.current();
else setError(error.message);
});
return () => controller.abort();
}, [habit.habitId, date, revision, attempt]);
}).finally(() => { if (active) setHistoryLoading(false); });
return () => { active = false; };
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days =
calendar?.days.map((day) => ({
@@ -123,6 +122,9 @@ export function HabitHistory({
selectedDate={selectedDate}
compact
initialMonths={3}
availableRange={monthWindow(date, 12)}
onRangeChange={setRange}
loading={historyLoading}
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}

View File

@@ -10,6 +10,7 @@ import {
calendarTimeline,
MONTH_VIEWS,
visibleCalendarDays,
monthWindow,
type CalendarDay,
type MonthView,
} from "./calendar-model";
@@ -26,7 +27,13 @@ export function CalendarHeatmap({
historyLabel,
legend,
selectedDayAction,
availableRange,
onRangeChange,
loading = false,
}: {
availableRange?: { from: string; to: string };
onRangeChange?: (range: { from: string; to: string }) => void;
loading?: boolean;
days: CalendarDay[];
unit: string;
label: string;
@@ -100,6 +107,7 @@ export function CalendarHeatmap({
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
aria-busy={loading}
style={{ "--calendar-weeks": timeline.weeks, "--calendar-color": color } as CSSProperties}
>
<div className="ds-calendar-range-toolbar">
@@ -130,7 +138,7 @@ export function CalendarHeatmap({
type="button"
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
aria-pressed={months === value}
onClick={() => setMonths(value)}
onClick={() => { setMonths(value); if (anchor) onRangeChange?.(monthWindow(anchor, value)); }}
>
{value}m
</button>
@@ -160,7 +168,7 @@ export function CalendarHeatmap({
const end = form.elements.namedItem("to") as HTMLInputElement;
const isAvailable = (input: HTMLInputElement) =>
input.validity.valid && input.value !== "" &&
input.value >= allDays[0]!.date && input.value <= allDays.at(-1)!.date;
input.value >= (availableRange?.from ?? allDays[0]!.date) && input.value <= (availableRange?.to ?? allDays.at(-1)!.date);
if (!isAvailable(start) || !isAvailable(end)) {
setRangeError("Choose both dates within the available history.");
(!isAvailable(start) ? start : end).focus();
@@ -171,11 +179,12 @@ export function CalendarHeatmap({
end.focus();
return;
}
if (!allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
if (!availableRange && !allDays.some((day) => day.date >= start.value && day.date <= end.value)) {
setRangeError("There are no calendar dates in this range.");
return;
}
setCustomRange({ from: start.value, to: end.value });
onRangeChange?.({ from: start.value, to: end.value });
setRangeError("");
}}
>
@@ -187,8 +196,8 @@ export function CalendarHeatmap({
name={name}
type="date"
required
min={allDays[0]!.date}
max={allDays.at(-1)!.date}
min={availableRange?.from ?? allDays[0]!.date}
max={availableRange?.to ?? allDays.at(-1)!.date}
defaultValue={customRange[name]}
aria-invalid={rangeError ? true : undefined}
aria-describedby={rangeError ? rangeErrorId : undefined}
@@ -201,6 +210,7 @@ export function CalendarHeatmap({
{rangeError && <p id={rangeErrorId} className="ds-calendar-range-error" role="alert">{rangeError}</p>}
</form>
)}
{loading && <p role="status" className="ds-supporting-copy">Loading history…</p>}
<div
className="ds-calendar-viewport"
role="group"

View File

@@ -1,7 +1,7 @@
import type { HabitService } from "../habits/service";
import type { Schedule } from "../habits/contracts";
export type TodayHabit = ReturnType<HabitService["day"]>;
export type TodayHabit = ReturnType<HabitService["day"]> & { calendarRevision?: number };
export type TodayResponse = {
date: string;
timezone: string;

View File

@@ -1,3 +1,4 @@
import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
@@ -63,7 +64,7 @@ export function Home() {
</Card>
</section>
) : user ? (
<Dashboard key={user.id} user={user} onExpired={retry} />
<CalendarCacheProvider key={`${user.id}:${user.timezone}`}><Dashboard user={user} onExpired={retry} /></CalendarCacheProvider>
) : (
<Welcome onSignIn={signIn} />
)}
@@ -163,7 +164,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [today, setToday] = useState<TodayResponse | null>(null);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const [calendarRevision, setCalendarRevision] = useState(0);
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
const progressSaves = useRef(new Set<string>());
@@ -206,7 +206,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
setToday(data);
setNeedsRefresh(false);
setRevision((value) => value + 1);
setCalendarRevision((value) => value + 1);
}
})
.catch((error) => {
@@ -457,7 +456,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
key={habit.habitId}
habit={habit}
date={today.date}
revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)}
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)}
onUpdate={update}