Preserve unchanged habit renders and memoize calendar geometry

This commit is contained in:
syntaxbullet
2026-09-08 16:17:19 +02:00
parent b4619dec33
commit 9a0fc108a9
6 changed files with 52 additions and 22 deletions

View File

@@ -328,17 +328,23 @@ describe("homepage", () => {
test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => { test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => {
const f = connectAccount(); const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201); await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await render("/"); await render("/");
const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?")); const calendarRequests = () => fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?"));
expect(calendarRequests()).toHaveLength(2); expect(calendarRequests()).toHaveLength(2);
expect(calendarRequests()[0]).toContain("from=2026-07-01"); expect(calendarRequests()[0]).toContain("from=2026-07-01");
expect(calendarRequests()[0]).toContain("compact=true"); expect(calendarRequests()[0]).toContain("compact=true");
const parse = spyOn(Date, "parse");
await act(async () => buttonNamed("All habits · 2").click());
expect(parse).not.toHaveBeenCalled();
parse.mockRestore();
fetchMock.mockClear(); fetchMock.mockClear();
await act(async () => dom.dispatchEvent(new dom.Event("focus"))); await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(0); expect(calendarRequests()).toHaveLength(0);
await f.json(`/habits/${reading.id}/days/2026-09-04/progress`, "PUT", { done: true }); await f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true });
await act(async () => dom.dispatchEvent(new dom.Event("focus"))); await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(1); expect(calendarRequests()).toHaveLength(1);
expect(calendarRequests()[0]).toContain(reading.id); expect(calendarRequests()[0]).toContain(reading.id);
@@ -352,6 +358,11 @@ describe("homepage", () => {
await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click()); await act(async () => history.querySelector<HTMLButtonElement>('[aria-label="3 months"]')!.click());
expect(calendarRequests()).toHaveLength(0); expect(calendarRequests()).toHaveLength(0);
expect(history.querySelectorAll(".ds-day")).toHaveLength(92); expect(history.querySelectorAll(".ds-day")).toHaveLength(92);
expect(history.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
fetchMock.mockClear();
f.setTime("2026-09-05T12:00:00Z");
await act(async () => dom.dispatchEvent(new dom.Event("focus")));
expect(calendarRequests()).toHaveLength(2);
}); });
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 () => {

View File

@@ -5,7 +5,7 @@ afterEach(() => fetchSpy.mockReset());
afterAll(() => fetchSpy.mockRestore()); afterAll(() => fetchSpy.mockRestore());
const range = { from: "2026-07-01", to: "2026-09-30" }; 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 () => { 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" }] })); fetchSpy.mockImplementation(Object.assign(async () => Response.json({ from: range.from, to: range.to, days: [{ date: "2026-07-01" }, { date: "2026-09-01" }] }), { preconnect: fetch.preconnect }));
const cache = new CalendarCache(); const cache = new CalendarCache();
await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]); await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]);
expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(1);

View File

@@ -1,7 +1,7 @@
import { useCalendarCache } from "./CalendarCache"; import { useCalendarCache } from "./CalendarCache";
import { Modal } from "./design-system/Modal"; import { Modal } from "./design-system/Modal";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react"; 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";
@@ -67,7 +67,7 @@ export function HabitHistory({
return () => { active = false; }; return () => { active = false; };
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]); }, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
const days = const days = useMemo(() =>
calendar?.days.map((day) => ({ calendar?.days.map((day) => ({
date: day.date, date: day.date,
color: day.color, color: day.color,
@@ -79,14 +79,14 @@ export function HabitHistory({
: day.due : day.due
? ("due" as const) ? ("due" as const)
: ("not-due" as const), : ("not-due" as const),
})) ?? []; })) ?? [], [calendar]);
const steps = Math.max( const steps = Math.max(
1, 1,
calendar?.days.find((day) => day.date === date)?.shadeCount ?? calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ?? habit.target ??
1, 1,
); );
const shades = calendar const shades = useMemo(() => calendar
? [ ? [
calendar.settings.emptyColor, calendar.settings.emptyColor,
...Array.from( ...Array.from(
@@ -100,7 +100,7 @@ export function HabitHistory({
).color, ).color,
), ),
] ]
: []; : [], [calendar, steps]);
const chart = error ? ( const chart = error ? (
<div className="ds-form-feedback"> <div className="ds-form-feedback">
<p role="alert">{error}</p> <p role="alert">{error}</p>

View File

@@ -1,6 +1,6 @@
import { useTheme } from "../ThemeProvider"; import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette"; import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useId, useMemo, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend"; import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field"; import { Field } from "./Field";
import { Button } from "./primitives"; import { Button } from "./primitives";
@@ -72,15 +72,15 @@ export function CalendarHeatmap({
if (onSelectDate) onSelectDate(date); if (onSelectDate) onSelectDate(date);
else setLocalSelectedDate(date); else setLocalSelectedDate(date);
} }
const days = months === "custom" && customRange const days = useMemo(() => months === "custom" && customRange
? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to) ? allDays.filter((day) => day.date >= customRange.from && day.date <= customRange.to)
: anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : []; : anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]);
const selected = const selected =
days.find((day) => day.date === selectedDate) ?? days.find((day) => day.date === selectedDate) ??
days.find((day) => day.date === anchor) ?? days.find((day) => day.date === anchor) ??
days[0]; days[0];
// Date keys and the shared timeline remain stable when the chart resizes. // Date keys and the shared timeline remain stable when the chart resizes.
const timeline = calendarTimeline(days); const timeline = useMemo(() => calendarTimeline(days), [days]);
const buttons = useRef<(HTMLButtonElement | null)[]>([]); const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const inspectorId = useId(); const inspectorId = useId();
const instructionsId = useId(); const instructionsId = useId();

View File

@@ -1,7 +1,7 @@
import type { HabitService } from "../habits/service"; import type { HabitService } from "../habits/service";
import type { Schedule } from "../habits/contracts"; import type { Schedule } from "../habits/contracts";
export type TodayHabit = ReturnType<HabitService["day"]> & { calendarRevision?: number }; export type TodayHabit = ReturnType<HabitService["day"]>;
export type TodayResponse = { export type TodayResponse = {
date: string; date: string;
timezone: string; timezone: string;
@@ -46,3 +46,15 @@ export function formatTrackingDate(date: string) {
timeZone: "UTC", timeZone: "UTC",
}); });
} }
/** Preserve unchanged row references when a background refresh returns new JSON objects. */
export function reconcileToday(current: TodayResponse | null, next: TodayResponse): TodayResponse {
if (!current || current.date !== next.date || current.timezone !== next.timezone) return next;
const previous = new Map(current.habits.map(habit => [habit.habitId, habit]));
const habits = next.habits.map(habit => {
const old = previous.get(habit.habitId);
return old && JSON.stringify(old) === JSON.stringify(habit) ? old : habit;
});
if (current.due === next.due && current.completed === next.completed && habits.length === current.habits.length && habits.every((habit, index) => habit === current.habits[index])) return current;
return { ...next, habits };
}

View File

@@ -2,7 +2,7 @@ import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react"; import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits"; import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings"; import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider"; import { useAuth } from "../components/AuthProvider";
import { DiscordSignInButton } from "../components/DiscordSignInButton"; import { DiscordSignInButton } from "../components/DiscordSignInButton";
import { PageLayout } from "../components/design-system/PageLayout"; import { PageLayout } from "../components/design-system/PageLayout";
@@ -17,7 +17,7 @@ import { Card, CardGrid } from "../components/design-system/Card";
import { HabitChart } from "../components/design-system/HabitChart"; import { HabitChart } from "../components/design-system/HabitChart";
import { HABIT_COLORS } from "../components/design-system/calendar-model"; import { HABIT_COLORS } from "../components/design-system/calendar-model";
import { HabitForm } from "../components/HabitForm"; import { HabitForm } from "../components/HabitForm";
import { habitRequest, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard"; import { habitRequest, reconcileToday, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard";
import { HabitHistory } from "../components/HabitHistory"; import { HabitHistory } from "../components/HabitHistory";
import { WelcomePanel } from "../components/design-system/WelcomePanel"; import { WelcomePanel } from "../components/design-system/WelcomePanel";
import type { PublicUser } from "../shared/user"; import type { PublicUser } from "../shared/user";
@@ -203,7 +203,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
habitRequest<TodayResponse>("/today", { signal: controller.signal }) habitRequest<TodayResponse>("/today", { signal: controller.signal })
.then((data) => { .then((data) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setToday(data); setToday(current => reconcileToday(current, data));
setNeedsRefresh(false); setNeedsRefresh(false);
setRevision((value) => value + 1); setRevision((value) => value + 1);
} }
@@ -301,7 +301,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
try { try {
const refreshed = await habitRequest<TodayResponse>("/today"); const refreshed = await habitRequest<TodayResponse>("/today");
if (mounted.current) { if (mounted.current) {
setToday(refreshed); setToday(current => reconcileToday(current, refreshed));
// Keep a habit reachable when an edit makes it unscheduled (including an empty routine). // Keep a habit reachable when an edit makes it unscheduled (including an empty routine).
if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all"); if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
setNeedsRefresh(false); setNeedsRefresh(false);
@@ -336,6 +336,13 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
} }
} }
const actions = useRef({ update, manage });
actions.current = { update, manage };
const updateHabit = useCallback((...args: Parameters<typeof update>) => actions.current.update(...args), []);
const manageHabit = useCallback((...args: Parameters<typeof manage>) => actions.current.manage(...args), []);
const refreshHistory = useCallback(() => setAttempt(value => value + 1), []);
const expire = useCallback(() => expired.current(), []);
return ( return (
<> <>
<WelcomePanel <WelcomePanel
@@ -458,10 +465,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
date={today.date} date={today.date}
revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0} revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0}
disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh} disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)} onHistorySaved={refreshHistory}
onUpdate={update} onUpdate={updateHabit}
onManage={manage} onManage={manageHabit}
onExpired={() => expired.current()} onExpired={expire}
/> />
))} ))}
</div> </div>
@@ -473,7 +480,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
); );
} }
function SavedHabit({ const SavedHabit = memo(function SavedHabit({
habit, habit,
date, date,
revision, revision,
@@ -603,7 +610,7 @@ function SavedHabit({
) : undefined} ) : undefined}
</HabitHistory> </HabitHistory>
); );
} });
function SavedTask({ function SavedTask({
task, task,