From a024945d286cde0911f95bae762f170d79c64389 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 20:28:34 +0000 Subject: [PATCH 1/2] Stop the app reloading while a form is being filled in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging an entry in the installed PWA could lose the half-filled form to a page reload. Three things could interrupt it: The service worker. With registerType: 'autoUpdate' vite-plugin-pwa reloads the page itself the moment a new worker activates, unless the app takes over via `onNeedReload` — `onNeedRefresh`, which pwa.ts was passing, is only called in 'prompt' mode, so the reload we thought we controlled was the plugin's unconditional one. After a deploy that fires seconds into the next launch, which is exactly when someone is tapping out a feed. Updates now wait for a moment that costs nothing: the app going to the background, or coming back from a long stint there. The on-focus refetch. A native date picker and the on-screen keyboard both blur and re-focus the window, so it fired repeatedly mid-form, rebuilding every list under the dialog. It's now held while a form is open and runs once the form is closed. The re-auth navigation. Any fetch() rejection was read as an expired Cloudflare Access session and navigated to the login route — but a dropped connection, routine on a phone, throws the same way. A cache-busted /auth/me probe now tells the two apart, so a blip surfaces as an error instead of navigating away. The original request isn't retried: a POST that failed on the way back would double-log. The shared guard is `isUserBusy()` — any open modal or focused field — asked of the DOM rather than registered per dialog, so pages added later are covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TTSRgrWjQN9E99n5wxT6r8 --- client/src/api/client.ts | 32 ++++++- client/src/hooks/useDataRefresh.tsx | 36 +++++++- client/src/pwa.ts | 21 +++-- client/src/utils/deferredReload.ts | 58 ++++++++++++ client/src/utils/interruptions.ts | 29 ++++++ client/test/DataRefresh.test.tsx | 44 +++++++++ client/test/apiClient.test.ts | 38 +++++++- client/test/deferredReload.test.ts | 135 ++++++++++++++++++++++++++++ 8 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 client/src/utils/deferredReload.ts create mode 100644 client/src/utils/interruptions.ts create mode 100644 client/test/deferredReload.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index aba1dec..de04381 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -16,13 +16,39 @@ function triggerReauth(): void { } } +/** + * Can an authenticated request still reach the API? + * + * Used to tell an expired Access session apart from a dropped connection when + * fetch() throws. The cache-busting param matters: the service worker answers + * /api/ GETs network-first and would otherwise hand back a cached 200 from + * before the session expired. + */ +async function sessionIsAlive(): Promise { + if (typeof navigator !== "undefined" && navigator.onLine === false) return true; + try { + const res = await fetch(`${API_BASE}/auth/me?probe=${Date.now()}`, { + credentials: "include", + }); + return res.ok; + } catch { + return false; + } +} + async function doFetch(path: string, options: RequestInit): Promise { try { return await fetch(`${API_BASE}${path}`, options); } catch { - // fetch() throws (surfaced by the browser as a CORS error) when Cloudflare - // Access redirects an unauthenticated request to its login domain instead - // of reaching this API — treat it the same as an expired session. + // fetch() throws for two very different reasons: Cloudflare Access + // redirecting an unauthenticated request to its login domain (surfaced as + // a CORS error), and an ordinary dropped connection — routine on a phone. + // Re-authing navigates away from whatever is on screen, so only do it once + // we know the session is actually gone. Retrying the original request + // isn't an option: a POST that failed on the way back would double-log. + if (await sessionIsAlive()) { + throw new Error("Network error — check your connection and try again."); + } triggerReauth(); throw new Error("Unauthorized"); } diff --git a/client/src/hooks/useDataRefresh.tsx b/client/src/hooks/useDataRefresh.tsx index a5145e9..c967d2e 100644 --- a/client/src/hooks/useDataRefresh.tsx +++ b/client/src/hooks/useDataRefresh.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { isUserBusy } from "../utils/interruptions"; interface DataRefreshContextType { /** Bumped whenever tracked data changes — use as a `useEffect` dependency to refetch. */ @@ -25,19 +26,46 @@ export function DataRefreshProvider({ children }: { children: ReactNode }) { // again. `visibilitychange` and `focus` both fire when returning to a tab, // hence the throttle. const lastFocusRefresh = useRef(0); + const refreshHeld = useRef(false); useEffect(() => { + const runRefresh = () => { + lastFocusRefresh.current = Date.now(); + refreshHeld.current = false; + refreshData(); + }; + const onVisible = () => { if (document.visibilityState !== "visible") return; - const now = Date.now(); - if (now - lastFocusRefresh.current < FOCUS_REFRESH_THROTTLE_MS) return; - lastFocusRefresh.current = now; - refreshData(); + if (Date.now() - lastFocusRefresh.current < FOCUS_REFRESH_THROTTLE_MS) return; + // On a phone the on-screen keyboard and the native date picker both blur + // and re-focus the window, so this fires repeatedly while a form is open. + // Refetching then rebuilds every list under the dialog for no benefit, + // and a request that fails mid-form can bounce the whole app through + // re-auth — taking the half-filled form with it. Hold it until the form + // is closed. + if (isUserBusy()) { + refreshHeld.current = true; + return; + } + runRefresh(); }; + + // Closing the dialog (or just leaving a field) is when a held-back refresh + // becomes safe. `focusout` fires before focus lands, so re-check next tick. + const onFocusOut = () => { + if (!refreshHeld.current) return; + setTimeout(() => { + if (refreshHeld.current && !isUserBusy()) runRefresh(); + }, 0); + }; + document.addEventListener("visibilitychange", onVisible); window.addEventListener("focus", onVisible); + document.addEventListener("focusout", onFocusOut); return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", onVisible); + document.removeEventListener("focusout", onFocusOut); }; }, [refreshData]); diff --git a/client/src/pwa.ts b/client/src/pwa.ts index 383fe26..6983ee8 100644 --- a/client/src/pwa.ts +++ b/client/src/pwa.ts @@ -4,20 +4,29 @@ // Vite config but don't ship a service worker) can safely skip registration // without bundling the module. +import { createDeferredReload } from "./utils/deferredReload"; + export function registerServiceWorker(): void { if (typeof window === "undefined") return; if (!("serviceWorker" in navigator)) return; + const pendingUpdate = createDeferredReload(() => window.location.reload()); + void import("virtual:pwa-register") .then(({ registerSW }) => { - // With registerType: 'autoUpdate', the new SW activates as soon as it's - // ready. We reload once on update so the user picks up fresh assets - // without any prompt — this app is single-user, so a brief refresh is - // friendlier than a banner. registerSW({ immediate: true, - onNeedRefresh() { - window.location.reload(); + // With registerType: 'autoUpdate' the new service worker activates as + // soon as it's ready, and vite-plugin-pwa reloads the page right then + // unless we take over via `onNeedReload`. That reload lands seconds + // after the app is opened following a deploy — often mid-form — and + // wipes out whatever has been typed. Picking up fresh assets still + // needs a reload, so hold it until the app is in the background. + // + // (`onNeedRefresh` is never called in autoUpdate mode; `onNeedReload` + // is the hook that suppresses the plugin's built-in reload.) + onNeedReload() { + pendingUpdate.request(); }, }); }) diff --git a/client/src/utils/deferredReload.ts b/client/src/utils/deferredReload.ts new file mode 100644 index 0000000..4e18eab --- /dev/null +++ b/client/src/utils/deferredReload.ts @@ -0,0 +1,58 @@ +import { isUserBusy } from "./interruptions"; + +/** + * A background stint at least this long means the user walked away, so coming + * back is a natural moment to start on the new build. + */ +export const STALE_BACKGROUND_MS = 30 * 60 * 1000; + +export interface DeferredReload { + /** A new build is ready — reload at the next moment that won't cost the user anything. */ + request: () => void; + /** Detach listeners. */ + dispose: () => void; +} + +/** + * Holds a page reload until it's safe to perform. + * + * "Safe" means nothing is open that a reload would destroy, and the app isn't + * in front of the user: either it's been backgrounded, or it's just come back + * from a long stint in the background where a fresh start is expected anyway. + */ +export function createDeferredReload(reload: () => void): DeferredReload { + let requested = false; + let hiddenAt = 0; + + const attempt = () => { + if (!requested) return; + if (isUserBusy()) return; + + const backgrounded = document.visibilityState === "hidden"; + const returningFromLongAbsence = hiddenAt > 0 && Date.now() - hiddenAt >= STALE_BACKGROUND_MS; + if (!backgrounded && !returningFromLongAbsence) return; + + requested = false; + reload(); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + hiddenAt = Date.now(); + attempt(); + return; + } + attempt(); + hiddenAt = 0; + }; + + document.addEventListener("visibilitychange", onVisibilityChange); + + return { + request: () => { + requested = true; + attempt(); + }, + dispose: () => document.removeEventListener("visibilitychange", onVisibilityChange), + }; +} diff --git a/client/src/utils/interruptions.ts b/client/src/utils/interruptions.ts new file mode 100644 index 0000000..9881991 --- /dev/null +++ b/client/src/utils/interruptions.ts @@ -0,0 +1,29 @@ +/** + * Is the user in the middle of something that must not be interrupted? + * + * Reloading the page — or churning every list underneath an open dialog — + * throws away whatever has been typed. This app gets used one-handed, mid-feed, + * so losing a half-filled form is a real cost. Both the service-worker update + * reload and the on-focus refetch check this before doing anything disruptive. + * + * It's deliberately a DOM-level question rather than a flag each dialog has to + * register: any open modal or focused field counts, so pages added later are + * covered without opting in. + */ +export function isUserBusy(): boolean { + if (typeof document === "undefined") return false; + + // MUI's Dialog puts role="dialog" on its paper; Layout's bottom "Log" sheet + // sets the same role. + if (document.querySelector('[role="dialog"]')) return true; + + const active = document.activeElement as HTMLElement | null; + if (!active) return false; + if (active.isContentEditable) return true; + + // A focused field still counts when the window itself is blurred: tapping a + // datetime input on iOS hands focus to the native picker, which is exactly + // the moment we must not pull the rug out. + const tag = active.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} diff --git a/client/test/DataRefresh.test.tsx b/client/test/DataRefresh.test.tsx index 42fc471..d33b05c 100644 --- a/client/test/DataRefresh.test.tsx +++ b/client/test/DataRefresh.test.tsx @@ -126,6 +126,50 @@ describe("Dashboard refresh after logging from outside the page", () => { await waitFor(() => expect(feedingFetchCount()).toBe(2)); }); + it("does not refetch while a form is open", async () => { + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + + await waitFor(() => expect(feedingFetchCount()).toBe(1)); + + await user.click(screen.getByRole("button", { name: /fab log feeding/i })); + await screen.findByRole("dialog"); + + // A native date picker or the keyboard blurs and re-focuses the window + // while the user is filling the form — that must not rebuild the page. + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new Event("focus")); + }); + + expect(feedingFetchCount()).toBe(1); + }); + + it("runs the held-back refresh once the form is closed", async () => { + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + + await waitFor(() => expect(feedingFetchCount()).toBe(1)); + + await user.click(screen.getByRole("button", { name: /fab log feeding/i })); + await screen.findByRole("dialog"); + + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(feedingFetchCount()).toBe(1); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + await act(async () => { + document.dispatchEvent(new Event("focusout")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => expect(feedingFetchCount()).toBe(2)); + }); + it("throttles duplicate refreshes when visibilitychange and focus both fire", async () => { render(, { wrapper: Wrapper }); diff --git a/client/test/apiClient.test.ts b/client/test/apiClient.test.ts index 0061040..42ef4ae 100644 --- a/client/test/apiClient.test.ts +++ b/client/test/apiClient.test.ts @@ -94,12 +94,48 @@ describe("API Client", () => { }); it("navigates to the login route when fetch throws (e.g. CF Access redirect blocked by CORS)", async () => { - mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + // The session probe is blocked the same way, confirming the session is gone. + mockFetch.mockRejectedValue(new TypeError("Failed to fetch")); await expect(api.get("/children")).rejects.toThrow("Unauthorized"); expect(window.location.href).toBe("/api/auth/login?redirect=%2Ffeedings"); }); + it("reports a network error instead of re-authing when the session is still good", async () => { + mockFetch + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1 }) }); + + await expect(api.get("/children")).rejects.toThrow(/network error/i); + // A dropped connection must not navigate away from a half-filled form. + expect(window.location.href).toBe(""); + }); + + it("does not re-send a failed POST while probing the session", async () => { + mockFetch + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1 }) }); + + await expect(api.post("/feedings", { child_id: 1 })).rejects.toThrow(/network error/i); + + const posts = mockFetch.mock.calls.filter(([, init]) => (init as RequestInit)?.method === "POST"); + expect(posts).toHaveLength(1); + }); + + it("treats a failure while offline as a network error, without probing", async () => { + const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, "onLine"); + Object.defineProperty(navigator, "onLine", { configurable: true, get: () => false }); + mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + + try { + await expect(api.get("/children")).rejects.toThrow(/network error/i); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe(""); + } finally { + if (onLine) Object.defineProperty(navigator, "onLine", onLine); + } + }); + it("does not redirect twice within the loop-guard window", async () => { mockFetch.mockRejectedValue(new TypeError("Failed to fetch")); diff --git a/client/test/deferredReload.test.ts b/client/test/deferredReload.test.ts new file mode 100644 index 0000000..89bff39 --- /dev/null +++ b/client/test/deferredReload.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createDeferredReload, STALE_BACKGROUND_MS, type DeferredReload } from "../src/utils/deferredReload"; + +function setVisibility(state: "visible" | "hidden"): void { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + document.dispatchEvent(new Event("visibilitychange")); +} + +function openDialog(): HTMLElement { + const el = document.createElement("div"); + el.setAttribute("role", "dialog"); + document.body.appendChild(el); + return el; +} + +let pending: DeferredReload | null = null; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-04T09:00:00Z")); +}); + +afterEach(() => { + pending?.dispose(); + pending = null; + document.body.innerHTML = ""; + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" }); + vi.useRealTimers(); +}); + +describe("deferred service-worker update reload", () => { + it("does not reload while the app is on screen", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + + pending.request(); + + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads once the app is backgrounded", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + + pending.request(); + setVisibility("hidden"); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("reloads immediately if the update lands while already backgrounded", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + + setVisibility("hidden"); + pending.request(); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("never reloads out from under an open form", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + const dialog = openDialog(); + + pending.request(); + setVisibility("hidden"); + expect(reload).not.toHaveBeenCalled(); + + // Still held when the user comes back to finish the form. + setVisibility("visible"); + expect(reload).not.toHaveBeenCalled(); + + // …and lands once the form is done with. + dialog.remove(); + setVisibility("hidden"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("holds the reload while a field has focus", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + pending.request(); + setVisibility("hidden"); + + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads on return from a long absence, when a fresh start is expected anyway", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + const dialog = openDialog(); + + pending.request(); + setVisibility("hidden"); + expect(reload).not.toHaveBeenCalled(); + + dialog.remove(); + vi.advanceTimersByTime(STALE_BACKGROUND_MS); + setVisibility("visible"); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does not reload on return from a brief absence", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + const dialog = openDialog(); + + pending.request(); + setVisibility("hidden"); + + dialog.remove(); + vi.advanceTimersByTime(30_000); + setVisibility("visible"); + + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads only once", () => { + const reload = vi.fn(); + pending = createDeferredReload(reload); + + pending.request(); + setVisibility("hidden"); + setVisibility("visible"); + setVisibility("hidden"); + + expect(reload).toHaveBeenCalledTimes(1); + }); +}); From 5989326c85453b7b578e39f1c428439c4642ec93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 20:43:16 +0000 Subject: [PATCH 2/2] Keep the quick-log form when something does tear the page down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most interruptions now wait for the user to finish, but two can't be held off: an expired Cloudflare Access session has to navigate to re-auth, and iOS evicts a backgrounded PWA whenever it likes. Neither gives the form a chance to react. QuickLogDialog now persists what's been entered on every edit and offers it back the next time that category is opened. Drafts are per child, expire after six hours, and are cleared as soon as the entry is saved or the form is dismissed — dismissing is an explicit discard — so the only draft that ever survives is one the user never got to finish. A form that was merely opened stores nothing. localStorage rather than sessionStorage so an evicted app still has the draft on relaunch. Every access is guarded: storage that's full, blocked or holding something corrupt loses the draft, never the form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TTSRgrWjQN9E99n5wxT6r8 --- client/src/components/QuickLogDialog.tsx | 50 ++++++- client/src/utils/formDraft.ts | 72 +++++++++ client/test/FormDraft.test.tsx | 178 +++++++++++++++++++++++ 3 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 client/src/utils/formDraft.ts create mode 100644 client/test/FormDraft.test.tsx diff --git a/client/src/components/QuickLogDialog.tsx b/client/src/components/QuickLogDialog.tsx index 7422281..42eb1f4 100644 --- a/client/src/components/QuickLogDialog.tsx +++ b/client/src/components/QuickLogDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Box, Button, @@ -18,6 +18,7 @@ import { api } from "../api/client"; import { useChildren } from "../hooks/useChildren"; import { useDataRefresh } from "../hooks/useDataRefresh"; import { useNotification } from "../hooks/useNotification"; +import { clearDraft, loadDraft, saveDraft } from "../utils/formDraft"; import NowButton from "./NowButton"; export type QuickLogCategory = "feed" | "diaper" | "sleep" | "pump" | "tummy" | "note"; @@ -87,6 +88,11 @@ interface FormState { content: string; } +/** Has anything been entered since the dialog opened? */ +function isUnchanged(form: FormState, opened: FormState): boolean { + return (Object.keys(opened) as (keyof FormState)[]).every((k) => form[k] === opened[k]); +} + function emptyForm(): FormState { return { time: nowLocal(), @@ -111,13 +117,45 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog const { refreshData } = useDataRefresh(); const [form, setForm] = useState(emptyForm); const [saving, setSaving] = useState(false); + const childId = selectedChild?.id ?? null; + + // What the form looked like when it opened — anything else means the user + // has typed something worth keeping. + const openedWith = useRef(form); + + useEffect(() => { + if (!category) return; + const fresh = emptyForm(); + openedWith.current = fresh; + + const draft = loadDraft(category, childId); + if (draft) { + setForm({ ...fresh, ...draft }); + notify("Picked up where you left off.", "info"); + return; + } + setForm(fresh); + }, [category, childId]); + // Persist on every edit: whatever interrupts the page (an expired Access + // session navigating to re-auth, iOS evicting the PWA) gives no warning. useEffect(() => { - if (category) setForm(emptyForm()); - }, [category]); + if (!category) return; + if (isUnchanged(form, openedWith.current)) { + clearDraft(category, childId); + return; + } + saveDraft(category, childId, form); + }, [category, childId, form]); if (!category) return null; + /** Dismissing the form is an explicit discard — don't offer it back later. */ + const handleClose = () => { + clearDraft(category, childId); + onClose(); + }; + const handleSave = async () => { if (!selectedChild) { notify("Select a child first.", "warning"); @@ -188,7 +226,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog notify("Logged.", "success"); refreshData(); onLogged?.(category); - onClose(); + handleClose(); } catch (err) { notify(err instanceof Error ? err.message : "Failed to save.", "error"); } finally { @@ -239,7 +277,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog ); return ( - + {TITLES[category]} {!selectedChild && ( @@ -395,7 +433,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog )} - + diff --git a/client/src/utils/formDraft.ts b/client/src/utils/formDraft.ts new file mode 100644 index 0000000..ad3848b --- /dev/null +++ b/client/src/utils/formDraft.ts @@ -0,0 +1,72 @@ +/** + * Keeps an in-progress form around when something takes the page out from + * under it. + * + * Most interruptions are now held off until the user is done (see + * `deferredReload`), but a couple can't be: an expired Cloudflare Access + * session has to navigate to re-auth, and iOS will evict a backgrounded PWA + * whenever it likes. A draft costs nothing and makes those non-destructive. + * + * localStorage rather than sessionStorage, so an evicted app still has it on + * relaunch. Drafts are per child, expire on their own, and are cleared the + * moment the form is saved or dismissed — the only way one survives is if the + * user never got to finish with it. + */ + +const KEY_PREFIX = "babytracker.draft."; + +/** Drop a draft older than this rather than restoring something forgotten. */ +export const DRAFT_TTL_MS = 6 * 60 * 60 * 1000; + +interface StoredDraft { + savedAt: number; + form: T; +} + +function keyFor(name: string, childId: number | null): string { + return `${KEY_PREFIX}${name}.${childId ?? "none"}`; +} + +export function saveDraft(name: string, childId: number | null, form: T): void { + try { + const draft: StoredDraft = { savedAt: Date.now(), form }; + localStorage.setItem(keyFor(name, childId), JSON.stringify(draft)); + } catch { + // Storage can be full or blocked (private browsing) — a draft is a bonus, + // never a reason to break the form. + } +} + +export function loadDraft(name: string, childId: number | null): T | null { + const key = keyFor(name, childId); + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + + const draft = JSON.parse(raw) as StoredDraft; + if (!draft?.form || typeof draft.savedAt !== "number") { + localStorage.removeItem(key); + return null; + } + if (Date.now() - draft.savedAt > DRAFT_TTL_MS) { + localStorage.removeItem(key); + return null; + } + return draft.form; + } catch { + try { + localStorage.removeItem(key); + } catch { + // Ignore — nothing to recover. + } + return null; + } +} + +export function clearDraft(name: string, childId: number | null): void { + try { + localStorage.removeItem(keyFor(name, childId)); + } catch { + // Ignore. + } +} diff --git a/client/test/FormDraft.test.tsx b/client/test/FormDraft.test.tsx new file mode 100644 index 0000000..3d8becb --- /dev/null +++ b/client/test/FormDraft.test.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; +import type { Child } from "../src/types/models"; + +vi.mock("../src/api/client", () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + upload: vi.fn(), + }, + API_BASE: "/api", +})); + +vi.mock("../src/hooks/useChildren", () => ({ + useChildren: vi.fn(), +})); + +import QuickLogDialog, { type QuickLogCategory } from "../src/components/QuickLogDialog"; +import { DataRefreshProvider } from "../src/hooks/useDataRefresh"; +import { useChildren } from "../src/hooks/useChildren"; +import { api } from "../src/api/client"; +import { DRAFT_TTL_MS, loadDraft, saveDraft } from "../src/utils/formDraft"; + +const mockUseChildren = vi.mocked(useChildren); +const mockApi = vi.mocked(api); + +const theme = createTheme(); + +const baseChild: Child = { + id: 1, + first_name: "Mikey", + last_name: "Faherty", + birth_date: "2023-08-01", + picture_url: null, + picture_content_type: null, + created_at: "2023-08-01T12:00:00Z", + updated_at: "2023-08-01T12:00:00Z", +}; + +/** Opening and closing the dialog, the way Layout and the Dashboard do. */ +function Harness() { + const [category, setCategory] = useState(null); + return ( + + + setCategory(null)} /> + + ); +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +function noteField(): HTMLElement { + return screen.getByRole("textbox", { name: /^note/i }); +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockUseChildren.mockReturnValue({ + children: [baseChild], + selectedChild: baseChild, + selectChild: vi.fn(), + refreshChildren: vi.fn().mockResolvedValue(undefined), + loading: false, + defaultChildId: null, + setDefaultChild: vi.fn().mockResolvedValue(undefined), + }); + mockApi.post.mockResolvedValue({}); +}); + +describe("in-progress form drafts", () => { + it("keeps what was typed when the page is torn down mid-form", async () => { + const user = userEvent.setup(); + const { unmount } = render(, { wrapper: Wrapper }); + + await user.click(screen.getByRole("button", { name: /open note/i })); + await user.type(noteField(), "Took 4oz, fussy after"); + + // Whatever ends the page — re-auth navigation, iOS evicting the PWA — + // there's no chance to react to it. + unmount(); + + render(, { wrapper: Wrapper }); + await user.click(screen.getByRole("button", { name: /open note/i })); + + await waitFor(() => expect(noteField()).toHaveValue("Took 4oz, fussy after")); + }); + + it("discards the draft once the entry is saved", async () => { + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + + await user.click(screen.getByRole("button", { name: /open note/i })); + await user.type(noteField(), "Slept through"); + await user.click(screen.getByRole("button", { name: /^save$/i })); + + await waitFor(() => expect(mockApi.post).toHaveBeenCalled()); + expect(loadDraft("note", 1)).toBeNull(); + + await user.click(screen.getByRole("button", { name: /open note/i })); + expect(noteField()).toHaveValue(""); + }); + + it("discards the draft when the form is dismissed", async () => { + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + + await user.click(screen.getByRole("button", { name: /open note/i })); + await user.type(noteField(), "never mind"); + await user.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(loadDraft("note", 1)).toBeNull(); + + await user.click(screen.getByRole("button", { name: /open note/i })); + expect(noteField()).toHaveValue(""); + }); + + it("stores nothing for a form that was only opened", async () => { + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + + await user.click(screen.getByRole("button", { name: /open note/i })); + await screen.findByRole("dialog"); + + expect(loadDraft("note", 1)).toBeNull(); + }); + + it("keeps drafts separate per child", async () => { + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + + await user.click(screen.getByRole("button", { name: /open note/i })); + await user.type(noteField(), "Mikey's note"); + await waitFor(() => expect(loadDraft("note", 1)).not.toBeNull()); + + expect(loadDraft("note", 2)).toBeNull(); + }); +}); + +describe("draft storage", () => { + it("drops a draft that has gone stale", () => { + saveDraft("note", 1, { content: "yesterday" }); + const stored = JSON.parse(localStorage.getItem("babytracker.draft.note.1")!); + localStorage.setItem( + "babytracker.draft.note.1", + JSON.stringify({ ...stored, savedAt: Date.now() - DRAFT_TTL_MS - 1 }) + ); + + expect(loadDraft("note", 1)).toBeNull(); + expect(localStorage.getItem("babytracker.draft.note.1")).toBeNull(); + }); + + it("ignores a corrupt draft instead of breaking the form", () => { + localStorage.setItem("babytracker.draft.note.1", "{not json"); + + expect(loadDraft("note", 1)).toBeNull(); + expect(localStorage.getItem("babytracker.draft.note.1")).toBeNull(); + }); + + it("survives storage being unavailable", () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + + expect(() => saveDraft("note", 1, { content: "x" })).not.toThrow(); + + setItem.mockRestore(); + }); +});