feat: enhance habit tracking with pending state management and history refresh
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user