Preserve unchanged habit renders and memoize calendar geometry
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user