feat: enhance habit tracking with pending state management and history refresh

This commit is contained in:
syntaxbullet
2026-09-08 09:25:40 +02:00
parent f79d593a04
commit 838567e9b8
3 changed files with 51 additions and 25 deletions

View File

@@ -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)); 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((await f.json("/habits")).habits[0].name).toBe("Read a little");
expect(container.querySelector("form")).toBeNull(); 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(); expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click()); await act(async () => buttonNamed("Sign out").click());
expect(container.querySelector("#welcome-title")).not.toBeNull(); expect(container.querySelector("#welcome-title")).not.toBeNull();
@@ -326,6 +326,39 @@ describe("homepage", () => {
expect((await f.json("/today")).completed).toBe(2); 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<void>(resolve => { finish = resolve; });
fetchMock.mockImplementation(Object.assign(async (input: Parameters<typeof fetch>[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<HTMLInputElement>('[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<HTMLInputElement>('[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 () => { test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
const f = connectAccount(); const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z"); f.setTime("2026-09-03T12:00:00Z");

View File

@@ -162,8 +162,11 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) { function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => void }) {
const [today, setToday] = useState<TodayResponse | null>(null); const [today, setToday] = useState<TodayResponse | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const [calendarRevision, setCalendarRevision] = useState(0);
const [habitRevisions, setHabitRevisions] = useState<Record<string, number>>({});
const [pendingHabits, setPendingHabits] = useState<Set<string>>(new Set());
const progressSaves = useRef(new Set<string>());
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [habitView, setHabitView] = useState<"today" | "all">("today"); const [habitView, setHabitView] = useState<"today" | "all">("today");
@@ -203,6 +206,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
setToday(data); setToday(data);
setNeedsRefresh(false); setNeedsRefresh(false);
setRevision((value) => value + 1); setRevision((value) => value + 1);
setCalendarRevision((value) => value + 1);
} }
}) })
.catch((error) => { .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. // Refresh after returning to the page and across the account's local midnight.
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
if (!saving.current && document.visibilityState === "visible") if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
setAttempt((value) => value + 1); setAttempt((value) => value + 1);
}; };
const timer = window.setInterval(refresh, 60_000); const timer = window.setInterval(refresh, 60_000);
@@ -239,11 +243,10 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
body: { count: number } | { done: boolean }, body: { count: number } | { done: boolean },
taskId?: string taskId?: string
) { ) {
if (saving.current || loading || needsRefresh || !today) return; if (saving.current || progressSaves.current.has(habit.habitId) || loading || needsRefresh || !today) return;
saving.current = true; progressSaves.current.add(habit.habitId);
setBusy(true); setPendingHabits(new Set(progressSaves.current));
setError(""); setError("");
setNotice("");
try { try {
const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`; const path = `/habits/${habit.habitId}/days/${today.date}/${taskId ? `tasks/${taskId}` : "progress"}`;
const updated = await habitRequest<TodayHabit>(path, { const updated = await habitRequest<TodayHabit>(path, {
@@ -265,12 +268,12 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}; };
}); });
setRevision((value) => value + 1); setRevision((value) => value + 1);
setNotice(`${habit.name} saved.`); setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 }));
} catch (error) { } catch (error) {
if (mounted.current) reportError(error); if (mounted.current) reportError(error);
} finally { } finally {
saving.current = false; progressSaves.current.delete(habit.habitId);
if (mounted.current) setBusy(false); if (mounted.current) setPendingHabits(new Set(progressSaves.current));
} }
} }
@@ -280,12 +283,11 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
taskId?: string, taskId?: string,
createTask = false 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."); throw new Error("Please wait for the dashboard to refresh.");
saving.current = true; saving.current = true;
setBusy(true); setBusy(true);
setError(""); setError("");
setNotice("");
try { try {
await habitRequest( await habitRequest(
`/habits/${habit.habitId}${createTask ? "/tasks" : taskId ? `/tasks/${taskId}` : ""}`, `/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"); if (refreshed.habits.some(item => item.habitId === habit.habitId && !item.due)) setHabitView("all");
setNeedsRefresh(false); setNeedsRefresh(false);
setRevision((value) => value + 1); setRevision((value) => value + 1);
setHabitRevisions(current => ({ ...current, [habit.habitId]: (current[habit.habitId] ?? 0) + 1 }));
} }
} catch (refreshError) { } catch (refreshError) {
if (refreshError instanceof Error && refreshError.message.includes("session has expired")) 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) { if (mounted.current) {
setNotice(
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.`
);
if (!patch) if (!patch)
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
( (
@@ -422,18 +422,14 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
date={today.date} date={today.date}
onCancel={() => setAdding(false)} onCancel={() => setAdding(false)}
onExpired={() => expired.current()} onExpired={() => expired.current()}
onCreated={(name) => { onCreated={() => {
focusAddAfterRefresh.current = true; focusAddAfterRefresh.current = true;
setAdding(false); setAdding(false);
setNotice(`${name} created.`);
setHabitView("all"); setHabitView("all");
setAttempt((value) => value + 1); setAttempt((value) => value + 1);
}} }}
/> />
)} )}
<p className="ds-form-feedback ds-save-notice" role="status">
{notice}
</p>
{today.habits.length > 0 && ( {today.habits.length > 0 && (
<section <section
className="ds-section ds-habits-section" className="ds-section ds-habits-section"
@@ -461,8 +457,8 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
key={habit.habitId} key={habit.habitId}
habit={habit} habit={habit}
date={today.date} date={today.date}
revision={revision} revision={calendarRevision + (habitRevisions[habit.habitId] ?? 0)}
disabled={busy || loading || needsRefresh} disabled={busy || pendingHabits.has(habit.habitId) || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)} onHistorySaved={() => setAttempt(value => value + 1)}
onUpdate={update} onUpdate={update}
onManage={manage} onManage={manage}

View File

@@ -1356,7 +1356,6 @@
.ds-form-actions { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; } .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 { 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-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 { 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 > legend { padding: 0 12px 0 0; @apply type-ui-heading; }
.ds-edit-task .ds-spec-heading { margin-bottom: 16px; } .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 { 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-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.ds-habit-history-panel { margin-top: 16px; } .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 { 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 { 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); } .ds-counter-input:hover:not(:disabled) { border-color: var(--ds-control-border); }