feat: Schnellerfassung vom Handy, auch ohne Netz

Ein Feld, ein Knopf, grosse Flaeche. Was ohne Netz erfasst wird, liegt in
IndexedDB, bis es hinausgeht — nicht in localStorage: Ein Eintrag, der
beim Aufraeumen des Speichers verschwindet, ist eine verlorene Aufgabe.

Erst anstellen, dann senden. Andersherum ginge der Eintrag verloren, wenn
der Browser mitten im Senden geschlossen wird. Jede Erfassung traegt eine
vom Geraet vergebene Kennung, damit eine doppelt zugestellte Nachricht
keine zweite Aufgabe ergibt.

Der Dienstarbeiter haelt genau diese eine Seite offline bereit und sonst
nichts. Ein halb veralteter Planer waere schlimmer als gar keiner — ein
Eingabefeld dagegen veraltet nicht, und ohne die Seite naehme die
Warteschlange im Geraet niemandem etwas ab.

Dazu das Manifest, damit sich der Planer als Anwendung ablegen laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-12 11:09:39 +02:00
co-authored by Claude Opus 5
parent b17c42af56
commit 9565b9a920
10 changed files with 562 additions and 5 deletions
+187
View File
@@ -0,0 +1,187 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { CloudOff, Loader2, Plus, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { abarbeiten, alle, anstellen, type Eintrag } from '@/lib/warteschlange'
/**
* Schnellerfassung fürs Handy.
*
* Ein Feld, ein Knopf, große Fläche. Was erfasst wird, geht sofort hinaus —
* und wenn kein Netz da ist, wartet es im Gerät, bis wieder eines da ist.
*
* Der Unterschied zur Erfassung auf der Startseite ist nicht die Optik,
* sondern die Verlässlichkeit: Hier darf nichts verloren gehen, auch nicht
* im Funkloch.
*/
export function Erfassung() {
const [titel, setTitel] = useState('')
const [laeuft, setLaeuft] = useState(false)
const [wartend, setWartend] = useState<Eintrag[]>([])
// Vor der Hydratation weiss der Server nichts vom Netz des Geraets;
// "null" heisst schlicht: noch nicht nachgesehen.
const [online, setOnline] = useState<boolean | null>(null)
const feld = useRef<HTMLTextAreaElement>(null)
const nachsehen = useCallback(async () => {
try {
setWartend(await alle())
} catch {
// Ohne IndexedDB gibt es keine Warteschlange; erfassen geht trotzdem.
}
}, [])
const leeren = useCallback(async () => {
const bericht = await abarbeiten()
if (bericht.gesendet > 0) {
toast.success(
bericht.gesendet === 1
? 'Eine gemerkte Aufgabe ist raus.'
: `${bericht.gesendet} gemerkte Aufgaben sind raus.`,
)
}
await nachsehen()
}, [nachsehen])
useEffect(() => {
function wiederDa() {
setOnline(true)
void leeren()
}
function weg() {
setOnline(false)
}
window.addEventListener('online', wiederDa)
window.addEventListener('offline', weg)
// Den Anfangszustand nicht im Rumpf setzen, sondern als Antwort auf das
// Aussenwelt-Ereignis "die Seite ist jetzt da".
const ersteRunde = window.setTimeout(() => {
setOnline(navigator.onLine)
void nachsehen()
void leeren()
}, 0)
return () => {
window.clearTimeout(ersteRunde)
window.removeEventListener('online', wiederDa)
window.removeEventListener('offline', weg)
}
}, [leeren, nachsehen])
// Der Dienstarbeiter hält die Seite selbst offline bereit. Ohne ihn wäre
// die Warteschlange sinnlos: Man käme gar nicht erst auf die Seite.
useEffect(() => {
if ('serviceWorker' in navigator) {
void navigator.serviceWorker.register('/sw.js').catch(() => undefined)
}
}, [])
async function erfassen(ereignis: React.FormEvent) {
ereignis.preventDefault()
const text = titel.trim()
if (text.length === 0) return
setLaeuft(true)
try {
// Erst anstellen, dann senden. Andersherum ginge der Eintrag verloren,
// wenn der Browser mitten im Senden geschlossen wird.
const eintrag = await anstellen(text)
setTitel('')
feld.current?.focus()
const bericht = await abarbeiten()
await nachsehen()
toast.success(
bericht.gesendet > 0
? 'Erfasst. Liegt in der Inbox.'
: 'Gemerkt. Geht raus, sobald wieder Netz da ist.',
)
// Der Eintrag selbst interessiert nur, wenn er liegen bleibt.
void eintrag
} catch {
toast.error('Das hat nicht geklappt.')
} finally {
setLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
<form onSubmit={(e) => void erfassen(e)} className="flex flex-col gap-3">
<label htmlFor="titel" className="sr-only">
Was ist zu tun?
</label>
<textarea
id="titel"
ref={feld}
value={titel}
onChange={(e) => setTitel(e.target.value)}
onKeyDown={(e) => {
// Auf dem Handy ist Enter ein Zeilenumbruch; am Rechner will man
// damit abschicken.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
void erfassen(e)
}
}}
rows={3}
maxLength={200}
autoFocus
placeholder="Was ist zu tun?"
className="border-rand bg-karte focus-visible:ring-betont/40 w-full resize-none rounded-lg border p-4 text-base outline-none focus-visible:ring-2"
/>
<Button
type="submit"
disabled={laeuft || titel.trim().length === 0}
className="h-12 text-base"
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<Plus aria-hidden />
)}
Erfassen
</Button>
</form>
{online === false && (
<p className="text-gedaempft-vordergrund flex items-center gap-2 text-sm">
<CloudOff className="size-4 shrink-0" aria-hidden />
Kein Netz. Erfasstes wird gemerkt und später verschickt.
</p>
)}
{wartend.length > 0 && (
<div className="border-rand flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{wartend.length === 1 ? 'Eine wartet' : `${wartend.length} warten`}
</p>
<Button
art="leise"
groesse="klein"
disabled={laeuft}
onClick={() => void leeren()}
>
<RefreshCw aria-hidden />
Jetzt versuchen
</Button>
</div>
<ul className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
{wartend.map((e) => (
<li key={e.ref} className="truncate">
{e.titel}
</li>
))}
</ul>
</div>
)}
</div>
)
}