diff --git a/src/App.test.tsx b/src/App.test.tsx index 3f9cabf..7680f54 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -282,7 +282,7 @@ describe("homepage", () => { await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); expect((await f.json("/habits")).habits[0].name).toBe("Read a little"); expect(container.querySelector("form")).toBeNull(); - expect(container.textContent).toContain("Read a little created."); + expect(container.textContent).not.toContain("Read a little created."); expect(container.querySelector(".ds-habit-chart")).not.toBeNull(); await act(async () => buttonNamed("Sign out").click()); expect(container.querySelector("#welcome-title")).not.toBeNull(); @@ -326,6 +326,39 @@ describe("homepage", () => { expect((await f.json("/today")).completed).toBe(2); }); + 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); + const reading = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); + await render("/"); + const calendar = container.querySelector(`#history-${reading.id} .ds-calendar`); + const originalFetch = fetchMock.getMockImplementation()!; + let finish!: () => void; + const pending = new Promise(resolve => { finish = resolve; }); + fetchMock.mockImplementation(Object.assign(async (input: Parameters[0], init?: RequestInit) => { + if (init?.method === "PUT" && String(input).includes(`/habits/${water.id}/`)) await pending; + return originalFetch(input, init); + }, { preconnect: fetch.preconnect })); + fetchMock.mockClear(); + await act(async () => buttonNamed("Increase Water").click()); + expect(buttonNamed("Increase Water").disabled).toBe(true); + const check = container.querySelector('[aria-label="Reading done"]')!; + expect(check.disabled).toBe(false); + await act(async () => check.click()); + expect(check.checked).toBe(true); + expect(buttonNamed("Increase Water").disabled).toBe(true); + expect((await f.json("/today")).completed).toBe(1); + fetchMock.mockClear(); + await act(async () => { finish(); await pending; }); + expect(buttonNamed("Increase Water").disabled).toBe(false); + expect(container.querySelector('[aria-label="Total Water"]')!.value).toBe("1"); + expect(container.querySelector(`#history-${reading.id} .ds-calendar`)).toBe(calendar); + const historyRequests = fetchMock.mock.calls.map(([url]) => String(url)).filter(url => url.includes("/calendar?")); + expect(historyRequests).toHaveLength(1); + expect(historyRequests[0]).toContain(`/habits/${water.id}/calendar?`); + expect((await f.json("/today")).completed).toBe(1); + }); + test("inline habit edits persist configuration and delete archives without losing earlier history", async () => { const f = connectAccount(); f.setTime("2026-09-03T12:00:00Z"); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 428503d..d1e62e7 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -162,8 +162,11 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) { function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) { const [today, setToday] = useState(null); const [error, setError] = useState(""); - const [notice, setNotice] = 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()); const [attempt, setAttempt] = useState(0); const [loading, setLoading] = useState(true); const [habitView, setHabitView] = useState<"today" | "all">("today"); @@ -203,6 +206,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi setToday(data); setNeedsRefresh(false); setRevision((value) => value + 1); + setCalendarRevision((value) => value + 1); } }) .catch((error) => { @@ -223,7 +227,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi // Refresh after returning to the page and across the account's local midnight. useEffect(() => { const refresh = () => { - if (!saving.current && document.visibilityState === "visible") + if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible") setAttempt((value) => value + 1); }; const timer = window.setInterval(refresh, 60_000); @@ -239,11 +243,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi body: { count: number } | { done: boolean }, taskId?: string ) { - if (saving.current || loading || needsRefresh || !today) return; - saving.current = true; - setBusy(true); + if (saving.current || progressSaves.current.has(habit.habitId) || loading || needsRefresh || !today) return; + progressSaves.current.add(habit.habitId); + setPendingHabits(new Set(progressSaves.current)); setError(""); - setNotice(""); try { const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`; const updated = await habitRequest(path, { @@ -265,12 +268,12 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi }; }); setRevision((value) => value + 1); - setNotice(`${habit.name} saved.`); + setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 })); } catch (error) { if (mounted.current) reportError(error); } finally { - saving.current = false; - if (mounted.current) setBusy(false); + progressSaves.current.delete(habit.habitId); + if (mounted.current) setPendingHabits(new Set(progressSaves.current)); } } @@ -280,12 +283,11 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi taskId?: string, createTask = false ) { - if (saving.current || loading || needsRefresh) + if (saving.current || progressSaves.current.size > 0 || loading || needsRefresh) throw new Error("Please wait for the dashboard to refresh."); saving.current = true; setBusy(true); setError(""); - setNotice(""); try { await habitRequest( `/habits/${habit.habitId}${createTask ? "/tasks" : taskId ? `/tasks/${taskId}` : ""}`, @@ -305,6 +307,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all"); setNeedsRefresh(false); setRevision((value) => value + 1); + setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 })); } } catch (refreshError) { if (refreshError instanceof Error && refreshError.message.includes("session has expired")) @@ -317,9 +320,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi } } if (mounted.current) { - setNotice( - `${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.` - ); if (!patch) window.requestAnimationFrame(() => { ( @@ -422,18 +422,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi date={today.date} onCancel={() => setAdding(false)} onExpired={() => expired.current()} - onCreated={(name) => { + onCreated={() => { focusAddAfterRefresh.current = true; setAdding(false); - setNotice(`${name} created.`); setHabitView("all"); setAttempt((value) => value + 1); }} /> )} -

- {notice} -

{today.habits.length > 0 && (
voi key={habit.habitId} habit={habit} date={today.date} - revision={revision} - disabled={busy || loading || needsRefresh} + revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)} + disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh} onHistorySaved={() => setAttempt(value => value + 1)} onUpdate={update} onManage={manage} diff --git a/styles/design-system.css b/styles/design-system.css index 9b3ada8..4b94908 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -1356,7 +1356,6 @@ .ds-form-actions { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; } .ds-form-feedback { min-height: 24px; margin: 20px 0 12px !important; color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; } .ds-form-feedback[role="alert"] { border-left: 2px solid var(--ds-ink); padding-left: 12px; color: var(--ds-ink); } -.ds-save-notice { margin-bottom: 0 !important; } .ds-edit-task { min-width: 0; margin: 0; border: 0; border-top: 1px solid var(--ds-rule); padding: 16px 0 10px; } .ds-edit-task > legend { padding: 0 12px 0 0; @apply type-ui-heading; } .ds-edit-task .ds-spec-heading { margin-bottom: 16px; } @@ -1615,8 +1614,6 @@ .ds-habit-history-toggle { display: inline-flex; align-items: center; gap: 8px; @apply type-small; color: var(--ds-secondary); } .ds-habit-history-toggle[aria-expanded="true"] svg { transform: rotate(180deg); } .ds-habit-history-panel { margin-top: 16px; } -.ds-save-notice { margin: 0 !important; padding-top: 12px; min-height: 0; } -.ds-save-notice:empty { padding: 0; } .ds-counter { flex-wrap: wrap; max-width: 100%; gap: 4px; padding: 4px; border: 1px solid var(--ds-rule); border-radius: 6px; background: var(--ds-paper); } .ds-counter-input { box-sizing: border-box; width: 7ch; min-width: 56px; min-height: 44px; padding: 8px; border: 1px solid transparent; background: var(--ds-surface); color: var(--ds-ink); text-align: center; font: inherit; font-variant-numeric: tabular-nums; border-radius: 3px; } .ds-counter-input:hover:not(:disabled) { border-color: var(--ds-control-border); }