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:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user