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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:32:20 +02:00
1 parent 8b6f48b996
commit 32dfd8d8a7
11 files changed
+1006 -311

No files matched your search

@@ -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;
}
@@ -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<T> {
savedAt: number;
values: T;
}
export interface FormDraft<T> {
/** 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<T>(key: string): StoredDraft<T> | null {
try {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredDraft<T>;
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<T>(key: string, values: T, dirty: boolean): FormDraft<T> {
const [pending, setPending] = useState<FormDraft<T>["pending"]>(null);
const offered = useRef(false);
useEffect(() => {
if (offered.current) return;
offered.current = true;
const stored = read<T>(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<T> = { 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 };
}