From 32dfd8d8a7f7688d705d1fdecc137c48ecdbdc85 Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Thu, 1 Oct 2026 07:32:20 +0200
Subject: [PATCH] feat(ui): calmer inspection form with sections, tap ratings
and drafts
AuDHD-oriented rework of the most-used form, plus shared building blocks
for the remaining forms:
- Field: label, "Pflicht"/"optional", hint and the error right at the
field instead of a vanishing toast.
- FormSection: numbered steps; optional parts start folded and say how
many fields they hold.
- RatingScale (1-5 as five 44 px buttons with both ends named),
ToggleChip (yes/no tiles with tick and colour), StickySubmit (save
stays in thumb reach on phones).
- useFormDraft + DraftBanner: an unfinished entry is kept on the device
and offered back explicitly ("Weitermachen" / "Verwerfen"), never
applied silently; drafts expire after 3 days.
Inspection form: 21 flat fields became 5 steps, only colony and date are
required, observations are single taps, a hint appears when neither queen
nor eggs were seen, server messages show inline.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../inspections/new/inspection-form-values.ts | 99 +++
.../app/(app)/inspections/new/page.spec.tsx | 85 +++
.../src/app/(app)/inspections/new/page.tsx | 694 ++++++++++--------
.../src/components/form/draft-banner.tsx | 44 ++
.../src/components/form/field.tsx | 77 ++
.../src/components/form/form-section.tsx | 83 +++
.../src/components/form/rating-scale.tsx | 68 ++
.../src/components/form/sticky-submit.tsx | 20 +
.../src/components/form/toggle-chip.tsx | 41 ++
.../src/lib/forms/error-message.ts | 12 +
.../src/lib/forms/use-form-draft.ts | 94 +++
11 files changed, 1006 insertions(+), 311 deletions(-)
create mode 100644 ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts
create mode 100644 ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx
create mode 100644 ApiCore-Frontend/src/components/form/draft-banner.tsx
create mode 100644 ApiCore-Frontend/src/components/form/field.tsx
create mode 100644 ApiCore-Frontend/src/components/form/form-section.tsx
create mode 100644 ApiCore-Frontend/src/components/form/rating-scale.tsx
create mode 100644 ApiCore-Frontend/src/components/form/sticky-submit.tsx
create mode 100644 ApiCore-Frontend/src/components/form/toggle-chip.tsx
create mode 100644 ApiCore-Frontend/src/lib/forms/error-message.ts
create mode 100644 ApiCore-Frontend/src/lib/forms/use-form-draft.ts
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts
new file mode 100644
index 0000000..c9744c9
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts
@@ -0,0 +1,99 @@
+import type { createInspection } from "@/lib/inspections/api";
+
+export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
+
+export const FEED_STATUS_OPTIONS: { value: FeedStatus; label: string }[] = [
+ { value: "ausreichend", label: "Ausreichend" },
+ { value: "niedrig", label: "Niedrig" },
+ { value: "kritisch", label: "Kritisch" },
+];
+
+/** Everything the form holds, as plain values that survive a draft save. */
+export interface InspectionFormValues {
+ colonyId: string;
+ date: string;
+ weather: string;
+ temperature: string;
+ colonyStrength: string;
+ broodAmount: string;
+ broodPattern: number | null;
+ openBroodPresent: boolean;
+ cappedBroodPresent: boolean;
+ queenSeen: boolean;
+ eggsPresent: boolean;
+ queenCellsPresent: boolean;
+ swarmMood: number | null;
+ gentleness: number | null;
+ combBehavior: number | null;
+ feedStatus: FeedStatus;
+ honeySuperStatus: string;
+ healthIssues: string;
+ varroaIssues: string;
+ measures: string;
+ notes: string;
+}
+
+function todayIsoDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function initialInspectionValues(
+ colonyId: string,
+): InspectionFormValues {
+ return {
+ colonyId,
+ date: todayIsoDate(),
+ weather: "",
+ temperature: "",
+ colonyStrength: "",
+ broodAmount: "",
+ broodPattern: null,
+ openBroodPresent: false,
+ cappedBroodPresent: false,
+ queenSeen: false,
+ eggsPresent: true,
+ queenCellsPresent: false,
+ swarmMood: null,
+ gentleness: null,
+ combBehavior: null,
+ feedStatus: "ausreichend",
+ honeySuperStatus: "",
+ healthIssues: "",
+ varroaIssues: "",
+ measures: "",
+ notes: "",
+ };
+}
+
+const textOrUndefined = (value: string) =>
+ value.trim() ? value.trim() : undefined;
+const numberOrUndefined = (value: string) =>
+ value.trim() ? Number(value) : undefined;
+
+export function toInspectionInput(
+ values: InspectionFormValues,
+): Parameters[0] {
+ return {
+ colonyId: values.colonyId,
+ date: new Date(values.date).toISOString(),
+ weather: textOrUndefined(values.weather),
+ temperature: numberOrUndefined(values.temperature),
+ colonyStrength: numberOrUndefined(values.colonyStrength),
+ broodAmount: textOrUndefined(values.broodAmount),
+ broodPattern: values.broodPattern ?? undefined,
+ openBroodPresent: values.openBroodPresent,
+ cappedBroodPresent: values.cappedBroodPresent,
+ queenSeen: values.queenSeen,
+ eggsPresent: values.eggsPresent,
+ queenCellsPresent: values.queenCellsPresent,
+ swarmMood: values.swarmMood ?? undefined,
+ gentleness: values.gentleness ?? undefined,
+ combBehavior: values.combBehavior ?? undefined,
+ feedStatus: values.feedStatus,
+ honeySuperStatus: textOrUndefined(values.honeySuperStatus),
+ healthIssues: textOrUndefined(values.healthIssues),
+ varroaIssues: textOrUndefined(values.varroaIssues),
+ measures: textOrUndefined(values.measures),
+ notes: textOrUndefined(values.notes),
+ };
+}
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx
new file mode 100644
index 0000000..602eae4
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import NewInspectionPage from "./page";
+import { createInspection } from "@/lib/inspections/api";
+import { fetchColonies } from "@/lib/colonies/api";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+ useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
+}));
+vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
+vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
+vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() }));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("NewInspectionPage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ push.mockReset();
+ vi.mocked(fetchColonies).mockResolvedValue([
+ { id: "colony-1", code: "V-01", status: "ACTIVE" },
+ ] as never);
+ vi.mocked(createInspection).mockReset();
+ });
+
+ it("marks what is required and keeps the extra fields folded", () => {
+ renderPage();
+ expect(screen.getAllByText("Pflicht")).toHaveLength(2);
+ expect(screen.queryByLabelText(/Wetter/)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Weitere Angaben/ }),
+ ).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("records ratings and observations with single taps", async () => {
+ vi.mocked(createInspection).mockResolvedValue({
+ id: "i-1",
+ colonyId: "colony-1",
+ } as never);
+ renderPage();
+
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Königin gesehen" }),
+ );
+ // Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter.
+ const gentleness = screen.getAllByRole("radiogroup")[1];
+ await userEvent.click(
+ gentleness.querySelector('[aria-label="4 von 5"]') as HTMLElement,
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Durchsicht speichern" }),
+ );
+
+ const [input] = vi.mocked(createInspection).mock.calls[0];
+ expect(input).toMatchObject({
+ colonyId: "colony-1",
+ queenSeen: true,
+ gentleness: 4,
+ });
+ expect(push).toHaveBeenCalledWith("/colonies/colony-1");
+ });
+
+ it("warns when neither queen nor eggs were seen", async () => {
+ renderPage();
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Stifte vorhanden" }),
+ );
+ expect(
+ screen.getByText(/Weder Königin noch Stifte gesehen/),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx
index 5240d34..527c684 100644
--- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx
@@ -4,15 +4,16 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
-import { ClipboardCheck } from "lucide-react";
+import { AlertTriangle, ClipboardCheck } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createInspection } from "@/lib/inspections/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueInspection } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
+import { useFormDraft } from "@/lib/forms/use-form-draft";
+import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -23,52 +24,48 @@ import {
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
-
-const FEED_STATUS_LABELS = {
- ausreichend: "Ausreichend",
- niedrig: "Niedrig",
- kritisch: "Kritisch",
-} as const;
-
-function todayIsoDate(): string {
- return new Date().toISOString().slice(0, 10);
-}
+import { Field } from "@/components/form/field";
+import { FormSection } from "@/components/form/form-section";
+import { RatingScale } from "@/components/form/rating-scale";
+import { ToggleChip } from "@/components/form/toggle-chip";
+import { StickySubmit } from "@/components/form/sticky-submit";
+import { DraftBanner } from "@/components/form/draft-banner";
+import { cn } from "@/lib/utils";
+import {
+ FEED_STATUS_OPTIONS,
+ InspectionFormValues,
+ initialInspectionValues,
+ toInspectionInput,
+} from "./inspection-form-values";
export default function NewInspectionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
- const { data: colonies } = useQuery({
+ const { data: colonies, isLoading: coloniesLoading } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
- const activeColonies = colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
+ const activeColonies =
+ colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
- const [colonyId, setColonyId] = useState(searchParams.get("colonyId") ?? "");
- const [date, setDate] = useState(todayIsoDate());
- const [weather, setWeather] = useState("");
- const [temperature, setTemperature] = useState("");
- const [colonyStrength, setColonyStrength] = useState("");
- const [broodAmount, setBroodAmount] = useState("");
- const [broodPattern, setBroodPattern] = useState("");
- const [openBroodPresent, setOpenBroodPresent] = useState(false);
- const [cappedBroodPresent, setCappedBroodPresent] = useState(false);
- const [queenSeen, setQueenSeen] = useState(false);
- const [eggsPresent, setEggsPresent] = useState(true);
- const [queenCellsPresent, setQueenCellsPresent] = useState(false);
- const [swarmMood, setSwarmMood] = useState("");
- const [gentleness, setGentleness] = useState("");
- const [combBehavior, setCombBehavior] = useState("");
- const [feedStatus, setFeedStatus] = useState<"ausreichend" | "niedrig" | "kritisch">(
- "ausreichend",
+ const [values, setValues] = useState(() =>
+ initialInspectionValues(searchParams.get("colonyId") ?? ""),
);
- const [honeySuperStatus, setHoneySuperStatus] = useState("");
- const [healthIssues, setHealthIssues] = useState("");
- const [varroaIssues, setVarroaIssues] = useState("");
- const [measures, setMeasures] = useState("");
- const [notes, setNotes] = useState("");
- const [error, setError] = useState(null);
+ const [dirty, setDirty] = useState(false);
+ const [colonyError, setColonyError] = useState(null);
+ const [submitError, setSubmitError] = useState(null);
+ const draft = useFormDraft("inspection-new", values, dirty);
+
+ function set(
+ key: K,
+ value: InspectionFormValues[K],
+ ) {
+ setValues((current) => ({ ...current, [key]: value }));
+ setDirty(true);
+ if (key === "colonyId") setColonyError(null);
+ }
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
@@ -77,6 +74,7 @@ export default function NewInspectionPage() {
mutationFn: (input: Parameters[0]) =>
createInspection(input, { idempotencyKey }),
onSuccess: (inspection) => {
+ draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"],
});
@@ -85,11 +83,19 @@ export default function NewInspectionPage() {
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
- setError("Durchsicht konnte nicht gespeichert werden.");
+ setSubmitError(
+ errorMessage(
+ mutationError,
+ "Durchsicht konnte nicht gespeichert werden.",
+ ),
+ );
return;
}
void queueInspection(variables, idempotencyKey).then(() => {
- toast.info("Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.");
+ draft.clear();
+ toast.info(
+ "Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.",
+ );
router.push(`/colonies/${variables.colonyId}`);
});
},
@@ -97,294 +103,360 @@ export default function NewInspectionPage() {
function handleSubmit(event: FormEvent) {
event.preventDefault();
- setError(null);
- if (!colonyId) {
- setError("Bitte ein Volk auswählen.");
+ setSubmitError(null);
+ if (!values.colonyId) {
+ setColonyError("Bitte ein Volk auswählen.");
+ document.getElementById("inspection-colony")?.focus();
return;
}
- mutation.mutate({
- colonyId,
- date: new Date(date).toISOString(),
- weather: weather || undefined,
- temperature: temperature ? Number(temperature) : undefined,
- colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
- broodAmount: broodAmount || undefined,
- broodPattern: broodPattern ? Number(broodPattern) : undefined,
- openBroodPresent,
- cappedBroodPresent,
- queenSeen,
- eggsPresent,
- queenCellsPresent,
- swarmMood: swarmMood ? Number(swarmMood) : undefined,
- gentleness: gentleness ? Number(gentleness) : undefined,
- combBehavior: combBehavior ? Number(combBehavior) : undefined,
- feedStatus,
- honeySuperStatus: honeySuperStatus || undefined,
- healthIssues: healthIssues || undefined,
- varroaIssues: varroaIssues || undefined,
- measures: measures || undefined,
- notes: notes || undefined,
- });
+ mutation.mutate(toInspectionInput(values));
}
+ function restoreDraft() {
+ const restored = draft.restore();
+ if (restored) {
+ setValues(restored);
+ setDirty(true);
+ }
+ }
+
+ const noQueenSigns = !values.queenSeen && !values.eggsPresent;
+
return (
+ {draft.pending ? (
+
+ ) : null}
-
+ ) : null
+ }
+ >
+
+
diff --git a/ApiCore-Frontend/src/components/form/draft-banner.tsx b/ApiCore-Frontend/src/components/form/draft-banner.tsx
new file mode 100644
index 0000000..3861a19
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/draft-banner.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { History } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface DraftBannerProps {
+ savedAt: Date;
+ onRestore: () => void;
+ onDiscard: () => void;
+}
+
+/**
+ * Offers an unfinished entry back - never applies it silently, so the form
+ * is always what the person expects to see.
+ */
+export function DraftBanner({
+ savedAt,
+ onRestore,
+ onDiscard,
+}: DraftBannerProps) {
+ const time = savedAt.toLocaleString("de-DE", {
+ dateStyle: "short",
+ timeStyle: "short",
+ });
+ return (
+
+
+
+ Unfertiger Eintrag vom {time} gefunden.
+
+
+
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/form/field.tsx b/ApiCore-Frontend/src/components/form/field.tsx
new file mode 100644
index 0000000..a2d174e
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/field.tsx
@@ -0,0 +1,77 @@
+import { ReactNode, useId } from "react";
+import { cn } from "@/lib/utils";
+
+interface FieldProps {
+ label: string;
+ /** Marks the field "Pflicht"; everything else is visibly "optional". */
+ required?: boolean;
+ hint?: string;
+ error?: string | null;
+ className?: string;
+ /** Receives the ids the control must carry for label, hint and error. */
+ children: (ids: {
+ id: string;
+ labelId: string;
+ describedBy: string | undefined;
+ invalid: boolean;
+ }) => ReactNode;
+}
+
+/**
+ * One form field with everything in one place: label, whether it is needed,
+ * a short hint and - right next to it, not in a toast that vanishes - what
+ * is wrong. Fewer things to keep in mind while filling a form.
+ */
+export function Field({
+ label,
+ required,
+ hint,
+ error,
+ className,
+ children,
+}: FieldProps) {
+ const id = useId();
+ const hintId = hint ? `${id}-hint` : undefined;
+ const errorId = error ? `${id}-error` : undefined;
+ const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
+
+ return (
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/form/sticky-submit.tsx b/ApiCore-Frontend/src/components/form/sticky-submit.tsx
new file mode 100644
index 0000000..65448b2
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/sticky-submit.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from "react";
+
+/**
+ * Keeps the one primary action within thumb reach on a phone, however long
+ * the form is; on wider screens it simply ends the form.
+ */
+export function StickySubmit({
+ children,
+ note,
+}: {
+ children: ReactNode;
+ note?: ReactNode;
+}) {
+ return (
+
+ {note}
+ {children}
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/form/toggle-chip.tsx b/ApiCore-Frontend/src/components/form/toggle-chip.tsx
new file mode 100644
index 0000000..5096094
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/toggle-chip.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface ToggleChipProps {
+ label: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}
+
+/** A yes/no observation as a large tile; state shown by tick AND colour. */
+export function ToggleChip({ label, checked, onChange }: ToggleChipProps) {
+ return (
+
+ );
+}
diff --git a/ApiCore-Frontend/src/lib/forms/error-message.ts b/ApiCore-Frontend/src/lib/forms/error-message.ts
new file mode 100644
index 0000000..f51a9bf
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/forms/error-message.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "@/lib/auth/api-client";
+
+/**
+ * The server's own explanation when there is one (it names the problem,
+ * e.g. "Code wird bereits verwendet"), otherwise a plain fallback.
+ */
+export function errorMessage(error: unknown, fallback: string): string {
+ if (error instanceof ApiError && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
diff --git a/ApiCore-Frontend/src/lib/forms/use-form-draft.ts b/ApiCore-Frontend/src/lib/forms/use-form-draft.ts
new file mode 100644
index 0000000..148b1b7
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/forms/use-form-draft.ts
@@ -0,0 +1,94 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const PREFIX = "apicore:draft:";
+const SAVE_DELAY_MS = 600;
+// Drafts older than this are stale (the situation at the hive has changed).
+const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
+
+interface StoredDraft {
+ savedAt: number;
+ values: T;
+}
+
+export interface FormDraft {
+ /** A draft from an earlier visit, offered until restored or discarded. */
+ pending: { savedAt: Date; values: T } | null;
+ restore: () => T | null;
+ discard: () => void;
+ /** Call after a successful save. */
+ clear: () => void;
+}
+
+function read(key: string): StoredDraft | null {
+ try {
+ const raw = window.localStorage.getItem(PREFIX + key);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as StoredDraft;
+ if (typeof parsed.savedAt !== "number" || Date.now() - parsed.savedAt > MAX_AGE_MS) {
+ window.localStorage.removeItem(PREFIX + key);
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+function remove(key: string): void {
+ try {
+ window.localStorage.removeItem(PREFIX + key);
+ } catch {
+ // Storage unavailable (private mode) - nothing to remove.
+ }
+}
+
+/**
+ * Keeps an unfinished form on this device: an interruption (a call, a
+ * sting, a dead battery) does not cost the typed-in entry. Saves only once
+ * something was changed (`dirty`), never sends anything anywhere.
+ */
+export function useFormDraft(key: string, values: T, dirty: boolean): FormDraft {
+ const [pending, setPending] = useState["pending"]>(null);
+ const offered = useRef(false);
+
+ useEffect(() => {
+ if (offered.current) return;
+ offered.current = true;
+ const stored = read(key);
+ if (stored) {
+ // Read after mount: localStorage does not exist during server render.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setPending({ savedAt: new Date(stored.savedAt), values: stored.values });
+ }
+ }, [key]);
+
+ useEffect(() => {
+ if (!dirty || pending) return;
+ const timer = window.setTimeout(() => {
+ try {
+ const draft: StoredDraft = { savedAt: Date.now(), values };
+ window.localStorage.setItem(PREFIX + key, JSON.stringify(draft));
+ } catch {
+ // Storage full or blocked - the form still works, just without a draft.
+ }
+ }, SAVE_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }, [key, values, dirty, pending]);
+
+ const restore = useCallback(() => {
+ const values = pending?.values ?? null;
+ setPending(null);
+ return values;
+ }, [pending]);
+
+ const discard = useCallback(() => {
+ remove(key);
+ setPending(null);
+ }, [key]);
+
+ const clear = useCallback(() => remove(key), [key]);
+
+ return { pending, restore, discard, clear };
+}