Frontend: gemeinsamer Hook useLiveData, Komponenten nach Bereichen, große Seiten aufgeteilt

- useLiveData: Laden, Nachladen (sichtbarer Tab) und Speichern mit
  Fehlerbehandlung an einer Stelle; genutzt von Vorrat, Reisen,
  Kanban-Übersicht und Kanban-Board (Monitoring/Benutzer arbeiten anders)
- components/<bereich>/: haushalt, reisen, kanban, monitoring, projekte,
  start, design (per git mv, Historie bleibt)
- Reisen.jsx 783 → 227 Zeilen (CitySearch, VisitedList, InfoCard,
  ExportDialog, laender.js), Haushalt.jsx 634 → 193 (ListPanel,
  ListSwitcher, ListDialog, ProductsDialog), KanbanBoard.jsx 452 → 277
  (board.js, TaskCard, TaskDialog)
- storage.js für Einstellungen pro Browser; Städteliste in Reisen memoisiert
- Verhalten unverändert (Browser-Durchlauf aller Seiten und Abläufe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:14:32 +02:00
1 parent 4aba48ce51
commit fa58708e2d
41 files changed
+1264 -1241

No files matched your search

+16 -457
View File
@@ -1,23 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api, personName } from '../api.js';
import { api } from '../api.js';
import ListDialog from '../components/haushalt/ListDialog.jsx';
import ListPanel, { LISTS } from '../components/haushalt/ListPanel.jsx';
import ListSwitcher from '../components/haushalt/ListSwitcher.jsx';
import ProductsDialog from '../components/haushalt/ProductsDialog.jsx';
import RecipeCheck from '../components/haushalt/RecipeCheck.jsx';
import { isCalm } from '../motion.js';
import { useAuth } from '../auth.jsx';
import ProductInput from '../components/ProductInput.jsx';
import RecipeCheck from '../components/RecipeCheck.jsx';
import UnitSelect from '../components/UnitSelect.jsx';
import { fmtItems, parseQty, round2, STEP } from '../units.js';
import useLiveData from '../useLiveData.js';
// Geteilte Liste: regelmäßig nachladen, damit Änderungen der anderen ankommen
// Geteilte Listen: regelmäßig nachladen, damit Änderungen der anderen ankommen
const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LIST_KEY = 'p42.haushaltListe';
const LISTS = {
vorrat: { title: 'Vorrat', lead: 'Was da ist', empty: 'Noch nichts im Vorrat eingetragen.' },
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
@@ -27,9 +23,7 @@ function loadListId() {
}
export default function Haushalt() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const { data, setData, error, busy, run: send } = useLiveData('/api/haushalt', { refresh: REFRESH_SECONDS });
const [tab, setTab] = useState(loadTab);
const [listId, setListId] = useState(loadListId);
// Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten
@@ -37,49 +31,18 @@ export default function Haushalt() {
const dialog = useRef(null);
const listDialogRef = useRef(null);
const load = useCallback(async () => {
try {
setData(await api('GET', '/api/haushalt'));
} catch (e) {
setError(e.message);
}
}, []);
useEffect(() => {
load();
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
const onVisible = () => !document.hidden && load();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [load]);
// Jede Änderung liefert den aktuellen Stand aller Listen zurück.
// animate: Einträge gleiten an ihren neuen Platz (z. B. abgehakt → nach unten), wo der Browser es kann
async function run(method, url, body, { animate = false } = {}) {
setBusy(true);
setError(null);
try {
const next = await api(method, url, body);
if (animate && document.startViewTransition && !isCalm()) {
function run(method, url, body, { animate = false } = {}) {
if (!animate || !document.startViewTransition || isCalm()) return send(method, url, body);
return send(method, url, body, {
apply: (next) => {
const root = document.documentElement;
root.classList.add('vt-items');
const vt = document.startViewTransition(() => flushSync(() => setData(next)));
vt.finished.finally(() => root.classList.remove('vt-items'));
} else {
setData(next);
}
return next;
} catch (e) {
setError(e.message);
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
load();
return false;
} finally {
setBusy(false);
}
},
});
}
function chooseTab(next) {
@@ -228,407 +191,3 @@ export default function Haushalt() {
</>
);
}
function ListPanel({ list, items, products, find, actions, busy, hidden }) {
const { title, lead, empty } = LISTS[list];
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
// Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat
const open = items.filter((i) => !i.checked);
const inCart = items.filter((i) => i.checked);
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
function hint(name) {
const here = find(list, name);
if (here.length) return `schon da: ${fmtItems(here)}`;
const there = find(other, name);
if (there.length) return `${list === 'vorrat' ? 'auf Einkaufsliste' : 'im Vorrat'}: ${fmtItems(there)}`;
return null;
}
return (
<section
className={`card household-panel ${list}`}
id={`panel-${list}`}
role="tabpanel"
aria-labelledby={`title-${list}`}
data-hidden={hidden || undefined}
>
<header className="household-head">
<h2 id={`title-${list}`}>{title}</h2>
<span className="hint">{lead}</span>
</header>
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
{items.length === 0 ? (
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{open.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{inCart.length > 0 && (
<li className="household-cart-head" aria-hidden="true">Im Wagen</li>
)}
{inCart.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
</ul>
)}
{inCart.length > 0 && (
<div className="household-finish">
<span className="hint">
{inCart.length} {inCart.length === 1 ? 'Produkt' : 'Produkte'} im Wagen
</span>
<button className="btn" type="button" disabled={busy} onClick={actions.finish}>
Einkauf beenden ({inCart.length})
</button>
</div>
)}
</section>
);
}
function AddForm({ list, products, hint, onAdd, busy }) {
const [name, setName] = useState('');
const [quantity, setQuantity] = useState('1');
const [unit, setUnit] = useState('stk');
const [invalid, setInvalid] = useState(false);
const nameInput = useRef(null);
async function submit(e) {
e.preventDefault();
const n = name.trim();
const q = parseQty(quantity, unit);
setInvalid(q == null);
if (!n || q == null) return;
if (await onAdd(list, n, q, unit)) {
setName('');
setQuantity('1');
nameInput.current?.focus();
}
}
return (
<form className="household-add" onSubmit={submit} noValidate>
<label className="sr-only" htmlFor={`add-${list}`}>Produkt</label>
<ProductInput
id={`add-${list}`}
inputRef={nameInput}
value={name}
onChange={setName}
products={products}
hint={hint}
placeholder={list === 'vorrat' ? 'Was ist da? z. B. Milch' : 'Was fehlt? z. B. Brot'}
maxLength={60}
required
/>
<div className="qty-unit">
<label className="sr-only" htmlFor={`qty-${list}`}>Menge</label>
<input
id={`qty-${list}`}
className="household-qty-input"
type="text"
inputMode="decimal"
value={quantity}
onChange={(e) => { setQuantity(e.target.value); setInvalid(false); }}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `qty-error-${list}` : undefined}
/>
<UnitSelect id={`unit-${list}`} value={unit} onChange={setUnit} />
</div>
<button className="btn" type="submit" disabled={busy}>Hinzufügen</button>
{invalid && (
<p className="error household-add-error" id={`qty-error-${list}`}>
{unit === 'stk' ? 'Anzahl als ganze Zahl von 1 bis 999 eingeben.' : 'Menge von 0,01 bis 999 eingeben, z. B. 1,5.'}
</p>
)}
</form>
);
}
// Menge direkt eintippen; gespeichert wird beim Verlassen des Felds oder mit Enter
function QuantityInput({ item, label, onCommit, disabled }) {
const shown = String(item.quantity).replace('.', ',');
const [draft, setDraft] = useState(null);
function commit() {
if (draft == null) return;
const q = parseQty(draft, item.unit);
setDraft(null);
if (q != null && q !== item.quantity) onCommit(q);
}
return (
<input
className="stepper-value"
type="text"
inputMode={item.unit === 'stk' ? 'numeric' : 'decimal'}
value={draft ?? shown}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setDraft(null);
}}
aria-label={`Menge ${label}`}
/>
);
}
function ItemRow({ item, list, other, actions, busy }) {
const isShopping = list === 'einkauf';
const step = STEP[item.unit];
const last = item.quantity - step <= 0;
// Bei gleichem Produkt in mehreren Einheiten die Einheit mit ansagen
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
return (
<li className={`household-item${item.checked ? ' checked' : ''}`} style={{ '--vt': `item-${item.id}` }}>
{isShopping && (
<input
type="checkbox"
className="household-check"
checked={item.checked}
disabled={busy}
onChange={() => actions.check(item, !item.checked)}
aria-label={item.checked ? `${label} im Wagen – Haken entfernen` : `${label} abhaken (im Wagen)`}
title={item.checked ? 'Im Wagen – zum Zurücknehmen klicken' : 'Abhaken: kommt mit „Einkauf beenden“ in den Vorrat'}
/>
)}
<div className="household-name">
<strong>{item.name}</strong>
<small>
{other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `}
✎ {personName(item.updatedBy)}
</small>
</div>
<div className="stepper" role="group" aria-label={`Menge ${label}`}>
<button
type="button"
disabled={busy || (isShopping && last)}
onClick={() => (last ? actions.move(item) : actions.update(item, { quantity: round2(item.quantity - step) }))}
aria-label={last && !isShopping ? `${label} aufgebraucht – auf die Einkaufsliste` : `${label} weniger`}
title={last && !isShopping ? 'Aufgebraucht: auf die Einkaufsliste' : 'Weniger'}
>
−
</button>
<QuantityInput key={`${item.quantity}-${item.unit}`} item={item} label={label} disabled={busy} onCommit={(quantity) => actions.update(item, { quantity })} />
<button
type="button"
disabled={busy || item.quantity + step > 999}
onClick={() => actions.update(item, { quantity: round2(item.quantity + step) })}
aria-label={`${label} mehr`}
title="Mehr"
>
+
</button>
</div>
<UnitSelect
value={item.unit}
onChange={(unit) => actions.update(item, { unit })}
disabled={busy}
compact
label={`Einheit ${item.name}`}
/>
<div className="household-actions">
{!isShopping && (
<button
className="link-btn"
type="button"
disabled={busy || other.length > 0}
onClick={() => actions.add('einkauf', item.name, 1, item.unit)}
aria-label={other.length ? `${label} steht schon auf der Einkaufsliste` : `${label} nachkaufen`}
title={other.length ? 'Steht schon auf der Einkaufsliste' : 'Auf die Einkaufsliste setzen, Vorrat bleibt'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2" /><circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" />
</svg>
<span className="label">Nachkaufen</span>
</button>
)}
<button
className="link-btn danger"
type="button"
disabled={busy}
onClick={() => actions.remove(item)}
aria-label={`${label} entfernen`}
title="Entfernen"
>
✕
</button>
</div>
</li>
);
}
// ---------- Listen (je Vorrat + Einkauf): wählen, anlegen, freigeben ----------
function visibleFor(list, users) {
if (list.everyone) return 'alle Konten';
const ids = new Set([list.createdBy?.id, ...list.members]);
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
return names.length ? names.join(', ') : 'niemanden';
}
function ListSwitcher({ lists, current, users, onChoose, onNew, onEdit, busy }) {
return (
<div className="shopping-lists">
<ul className="chip-list" aria-label="Liste wählen">
<li className="shopping-lists-label">Liste:</li>
{lists.map((l) => (
<li key={l.id}>
<button
type="button"
className={`chip${l.id === current?.id ? ' active' : ''}`}
aria-pressed={l.id === current?.id}
onClick={() => onChoose(l.id)}
title={`Sichtbar für ${visibleFor(l, users)}`}
>
{!l.everyone && <span aria-label="nicht für alle sichtbar">🔒 </span>}
{l.name}
</button>
</li>
))}
<li>
<button type="button" className="link-btn" onClick={onNew} disabled={busy}>+ Neue Liste</button>
</li>
</ul>
{current && (
<p className="hint shopping-visibility">
Sichtbar für: {visibleFor(current, users)}
{current.createdBy && ` · angelegt von ${current.createdBy.name}`}
{current.canManage && (
<>
{' · '}
<button type="button" className="link-btn" onClick={() => onEdit(current)} disabled={busy}>✎ Liste bearbeiten</button>
</>
)}
</p>
)}
</div>
);
}
function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) {
const { user } = useAuth();
const ownerId = list ? list.createdBy?.id : user?.id;
const [form, setForm] = useState(() => ({
name: list?.name || '',
everyone: list ? list.everyone : true,
members: list?.members || [],
}));
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl
const choosable = users.filter((u) => u.id !== ownerId);
const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members });
setBusy(false);
if (err) setError(err);
else ref.current.close();
}
async function remove() {
setBusy(true);
const done = await onDelete(list);
setBusy(false);
if (done) ref.current.close();
}
return (
<dialog className="modal task-dialog" ref={ref} onClose={onClosed}>
<form className="form" onSubmit={submit}>
<h2>{list ? 'Liste bearbeiten' : 'Neue Liste'}</h2>
{!list && <p className="hint">Die Liste bekommt einen eigenen Vorrat und eine eigene Einkaufsliste – z. B. für die Ferienwohnung im Urlaub.</p>}
<label>
Name
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" />
</label>
<fieldset className="link-picker">
<legend>Wer kann die Liste sehen?</legend>
<ul>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} />
Alle Konten
</label>
</li>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} />
Nur ausgewählte Konten
</label>
</li>
</ul>
{!form.everyone && (
<>
<p className="hint">
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich:
</p>
{choosable.length === 0 ? (
<p className="hint">Es gibt keine weiteren Konten.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</>
)}
</fieldset>
{error && <p className="error">{error}</p>}
<div className="form-actions">
{list && (
<button className="btn ghost danger-btn list-delete" type="button" disabled={busy} onClick={remove}>Liste löschen</button>
)}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>{list ? 'Speichern' : 'Anlegen'}</button>
</div>
</form>
</dialog>
);
}
function ProductsDialog({ ref, products, onDelete }) {
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Vorschläge verwalten</h2>
<p className="hint">
Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten.
</p>
<ul className="product-list">
{sorted.map((name) => (
<li key={name}>
<span>{name}</span>
<button className="link-btn danger" type="button" onClick={() => onDelete(name)} aria-label={`Vorschlag ${name} löschen`}>
Löschen
</button>
</li>
))}
</ul>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}