From 9565b9a920fb83a4e3556ed145daee347cf73a81 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 12 Sep 2026 11:09:39 +0200 Subject: [PATCH] feat: Schnellerfassung vom Handy, auch ohne Netz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/manifest.webmanifest | 39 ++++++ public/sw.js | 64 ++++++++- public/symbol-flaeche.svg | 7 + public/symbol.svg | 9 ++ src/app/(app)/erfassen/erfassung.tsx | 187 +++++++++++++++++++++++++++ src/app/(app)/erfassen/page.tsx | 42 ++++++ src/app/api/erfassen/route.ts | 72 +++++++++++ src/app/layout.tsx | 7 + src/components/kopfleiste.tsx | 2 + src/lib/warteschlange.ts | 138 ++++++++++++++++++++ 10 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 public/manifest.webmanifest create mode 100644 public/symbol-flaeche.svg create mode 100644 public/symbol.svg create mode 100644 src/app/(app)/erfassen/erfassung.tsx create mode 100644 src/app/(app)/erfassen/page.tsx create mode 100644 src/app/api/erfassen/route.ts create mode 100644 src/lib/warteschlange.ts diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..53b356e --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,39 @@ +{ + "name": "Planer", + "short_name": "Planer", + "description": "Aufgaben, Ideen, Projekte und Zeitblöcke.", + "lang": "de", + "dir": "ltr", + "start_url": "/erfassen", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#f7f8fa", + "theme_color": "#f7f8fa", + "icons": [ + { + "src": "/symbol.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/symbol-flaeche.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Erfassen", + "short_name": "Erfassen", + "url": "/erfassen" + }, + { + "name": "Heute", + "short_name": "Heute", + "url": "/" + } + ] +} diff --git a/public/sw.js b/public/sw.js index bbe231e..18c2d14 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,18 +1,72 @@ /* * Service Worker des Planers. * - * Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die - * richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb - * veralteter Planer wäre schlimmer als gar keiner. + * Zwei Aufgaben: Push-Nachrichten anzeigen, und die Seite "Erfassen" auch + * ohne Netz bereithalten. + * + * Zwischengespeichert wird **genau diese eine Seite** und sonst nichts. Ein + * halb veralteter Planer wäre schlimmer als gar keiner — ein Eingabefeld + * dagegen veraltet nicht, und ohne die Seite nützte die Warteschlange im + * Gerät niemandem: Man käme gar nicht erst hin. */ -self.addEventListener('install', () => { +const LAGER = 'planer-erfassen-v1' +const OFFLINE_SEITE = '/erfassen' + +self.addEventListener('install', (ereignis) => { + ereignis.waitUntil( + caches + .open(LAGER) + .then((lager) => lager.add(new Request(OFFLINE_SEITE, { cache: 'reload' }))) + .catch(() => undefined), + ) // Sofort übernehmen, nicht auf das Schließen aller Tabs warten. self.skipWaiting() }) self.addEventListener('activate', (ereignis) => { - ereignis.waitUntil(self.clients.claim()) + ereignis.waitUntil( + caches + .keys() + .then((namen) => + Promise.all(namen.filter((n) => n !== LAGER).map((n) => caches.delete(n))), + ) + .then(() => self.clients.claim()), + ) +}) + +self.addEventListener('fetch', (ereignis) => { + const anfrage = ereignis.request + if (anfrage.method !== 'GET') return + + const adresse = new URL(anfrage.url) + if (adresse.origin !== self.location.origin) return + if (adresse.pathname !== OFFLINE_SEITE) return + + // Zuerst das Netz: Wer online ist, bekommt die frische Seite. Erst wenn das + // scheitert, kommt die aufgehobene Fassung. + ereignis.respondWith( + fetch(anfrage) + .then((antwort) => { + if (antwort.ok) { + const kopie = antwort.clone() + void caches.open(LAGER).then((lager) => lager.put(OFFLINE_SEITE, kopie)) + } + return antwort + }) + .catch(() => + caches + .match(OFFLINE_SEITE) + .then( + (aufgehoben) => + aufgehoben ?? + new Response('Offline und nichts aufgehoben.', { + status: 503, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }), + ), + ), + ) }) self.addEventListener('push', (ereignis) => { diff --git a/public/symbol-flaeche.svg b/public/symbol-flaeche.svg new file mode 100644 index 0000000..a492338 --- /dev/null +++ b/public/symbol-flaeche.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/public/symbol.svg b/public/symbol.svg new file mode 100644 index 0000000..6e03d02 --- /dev/null +++ b/public/symbol.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/app/(app)/erfassen/erfassung.tsx b/src/app/(app)/erfassen/erfassung.tsx new file mode 100644 index 0000000..634bf71 --- /dev/null +++ b/src/app/(app)/erfassen/erfassung.tsx @@ -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([]) + // Vor der Hydratation weiss der Server nichts vom Netz des Geraets; + // "null" heisst schlicht: noch nicht nachgesehen. + const [online, setOnline] = useState(null) + const feld = useRef(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 ( +
+
void erfassen(e)} className="flex flex-col gap-3"> + +