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"> + +