diff --git a/src/App.test.tsx b/src/App.test.tsx index 0d99b75..045fd09 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -328,17 +328,23 @@ describe("homepage", () => { test("focus retains unchanged calendars, detects remote historical edits and loads expanded history on demand", async () => { const f = connectAccount(); + f.setTime("2026-09-03T12:00:00Z"); const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); await f.json("/habits", "POST", { name: "Walking", method: "manual" }, 201); + f.setTime("2026-09-04T12:00:00Z"); 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"); + const parse = spyOn(Date, "parse"); + await act(async () => buttonNamed("All habits · 2").click()); + expect(parse).not.toHaveBeenCalled(); + parse.mockRestore(); 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 f.json(`/habits/${reading.id}/days/2026-09-03/progress`, "PUT", { done: true }); await act(async () => dom.dispatchEvent(new dom.Event("focus"))); expect(calendarRequests()).toHaveLength(1); expect(calendarRequests()[0]).toContain(reading.id); @@ -352,6 +358,11 @@ describe("homepage", () => { await act(async () => history.querySelector('[aria-label="3 months"]')!.click()); expect(calendarRequests()).toHaveLength(0); 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 () => { diff --git a/src/components/CalendarCache.test.ts b/src/components/CalendarCache.test.ts index aed09b8..1065574 100644 --- a/src/components/CalendarCache.test.ts +++ b/src/components/CalendarCache.test.ts @@ -5,7 +5,7 @@ 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" }] })); + 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(); await Promise.all([cache.load("habit", "v1", range), cache.load("habit", "v1", range)]); expect(fetchSpy).toHaveBeenCalledTimes(1); diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index 9bf341a..ce5d688 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -1,7 +1,7 @@ import { useCalendarCache } from "./CalendarCache"; import { Modal } from "./design-system/Modal"; 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 { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard"; import { CalendarHeatmap } from "./design-system/CalendarHeatmap"; @@ -67,7 +67,7 @@ export function HabitHistory({ return () => { active = false; }; }, [cache, habit.habitId, date, revision, attempt, range.from, range.to]); - const days = + const days = useMemo(() => calendar?.days.map((day) => ({ date: day.date, color: day.color, @@ -79,14 +79,14 @@ export function HabitHistory({ : day.due ? ("due" as const) : ("not-due" as const), - })) ?? []; + })) ?? [], [calendar]); const steps = Math.max( 1, calendar?.days.find((day) => day.date === date)?.shadeCount ?? habit.target ?? 1, ); - const shades = calendar + const shades = useMemo(() => calendar ? [ calendar.settings.emptyColor, ...Array.from( @@ -100,7 +100,7 @@ export function HabitHistory({ ).color, ), ] - : []; + : [], [calendar, steps]); const chart = error ? (

{error}

diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index d484bb3..d9f648b 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -1,6 +1,6 @@ import { useTheme } from "../ThemeProvider"; 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 { Field } from "./Field"; import { Button } from "./primitives"; @@ -72,15 +72,15 @@ export function CalendarHeatmap({ if (onSelectDate) onSelectDate(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) - : anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : []; + : anchor ? visibleCalendarDays(allDays, months === "custom" ? 12 : months, anchor) : [], [allDays, months, customRange, anchor]); const selected = days.find((day) => day.date === selectedDate) ?? days.find((day) => day.date === anchor) ?? days[0]; // 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 inspectorId = useId(); const instructionsId = useId(); diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts index b1ae0eb..2090659 100644 --- a/src/lib/dashboard.ts +++ b/src/lib/dashboard.ts @@ -1,7 +1,7 @@ import type { HabitService } from "../habits/service"; import type { Schedule } from "../habits/contracts"; -export type TodayHabit = ReturnType & { calendarRevision?: number }; +export type TodayHabit = ReturnType; export type TodayResponse = { date: string; timezone: string; @@ -46,3 +46,15 @@ export function formatTrackingDate(date: string) { 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 }; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 158f785..cc14f67 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,7 +2,7 @@ import { CalendarCacheProvider } from "../components/CalendarCache"; import { Archive, Plus, Settings2 } from "lucide-react"; import { ArchivedHabits } from "../components/ArchivedHabits"; 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 { DiscordSignInButton } from "../components/DiscordSignInButton"; 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 { HABIT_COLORS } from "../components/design-system/calendar-model"; 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 { WelcomePanel } from "../components/design-system/WelcomePanel"; import type { PublicUser } from "../shared/user"; @@ -203,7 +203,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi habitRequest("/today", { signal: controller.signal }) .then((data) => { if (!controller.signal.aborted) { - setToday(data); + setToday(current => reconcileToday(current, data)); setNeedsRefresh(false); setRevision((value) => value + 1); } @@ -301,7 +301,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi try { const refreshed = await habitRequest("/today"); if (mounted.current) { - setToday(refreshed); + setToday(current => reconcileToday(current, refreshed)); // 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"); 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) => actions.current.update(...args), []); + const manageHabit = useCallback((...args: Parameters) => actions.current.manage(...args), []); + const refreshHistory = useCallback(() => setAttempt(value => value + 1), []); + const expire = useCallback(() => expired.current(), []); + return ( <> voi date={today.date} revision={habit.calendarRevision ?? habitRevisions[habit.habitId] ?? 0} disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh} - onHistorySaved={() => setAttempt(value => value + 1)} - onUpdate={update} - onManage={manage} - onExpired={() => expired.current()} + onHistorySaved={refreshHistory} + onUpdate={updateHabit} + onManage={manageHabit} + onExpired={expire} /> ))}
@@ -473,7 +480,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi ); } -function SavedHabit({ +const SavedHabit = memo(function SavedHabit({ habit, date, revision, @@ -603,7 +610,7 @@ function SavedHabit({ ) : undefined} ); -} +}); function SavedTask({ task,