From b4619dec33a15f2a2ad9819a265f794c87f90cea Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:15:29 +0200 Subject: [PATCH] Cache versioned habit history and load visible calendar ranges --- src/App.test.tsx | 28 +++++++++++++++ src/components/ArchivedHabits.tsx | 2 +- src/components/CalendarCache.test.ts | 27 +++++++++++++++ src/components/CalendarCache.tsx | 34 +++++++++++++++++++ src/components/HabitHistory.tsx | 34 ++++++++++--------- .../design-system/CalendarHeatmap.tsx | 20 ++++++++--- src/lib/dashboard.ts | 2 +- src/pages/Home.tsx | 7 ++-- 8 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 src/components/CalendarCache.test.ts create mode 100644 src/components/CalendarCache.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index 7680f54..0d99b75 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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('[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('[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); diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx index 0952382..4cd0db1 100644 --- a/src/components/ArchivedHabits.tsx +++ b/src/components/ArchivedHabits.tsx @@ -71,7 +71,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose } - {selected === habit.habitId &&
setAttempt(value => value + 1)} />
} + {selected === habit.habitId &&
setAttempt(value => value + 1)} />
} )} {deleting && archiveFocus.current} onClose={() => setDeleting(null)}> diff --git a/src/components/CalendarCache.test.ts b/src/components/CalendarCache.test.ts new file mode 100644 index 0000000..aed09b8 --- /dev/null +++ b/src/components/CalendarCache.test.ts @@ -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); +}); diff --git a/src/components/CalendarCache.tsx b/src/components/CalendarCache.tsx new file mode 100644 index 0000000..2b38572 --- /dev/null +++ b/src/components/CalendarCache.tsx @@ -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 }; +/** Account-scoped, bounded cache; never survives signing out or changing accounts. */ +export class CalendarCache { + private entries = new Map(); + 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(`/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(null); +export function CalendarCacheProvider({ children }: { children: ReactNode }) { + const cache = useRef(null); + cache.current ??= new CalendarCache(); + return {children}; +} +export function useCalendarCache() { + const shared = useContext(Context); + const local = useRef(null); + local.current ??= new CalendarCache(); + return shared ?? local.current; +} diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index 93bd5f2..9bf341a 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -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(); 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( - `/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} diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index 400fd4c..d484bb3 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -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({
@@ -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 @@ -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 && } )} + {loading &&

Loading history…

}
; +export type TodayHabit = ReturnType & { calendarRevision?: number }; export type TodayResponse = { date: string; timezone: string; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index d1e62e7..158f785 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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() { ) : user ? ( - + ) : ( )} @@ -163,7 +164,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi const [today, setToday] = useState(null); const [error, setError] = useState(""); const [revision, setRevision] = useState(0); - const [calendarRevision, setCalendarRevision] = useState(0); const [habitRevisions, setHabitRevisions] = useState>({}); const [pendingHabits, setPendingHabits] = useState>(new Set()); const progressSaves = useRef(new Set()); @@ -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}