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:
co-authored by
Claude Opus 5
parent
b17c42af56
commit
9565b9a920
@@ -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": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
+59
-5
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
|
||||
<!-- Maskierbare Fassung: Das Motiv sitzt in der sicheren Zone, damit ein
|
||||
Betriebssystem daraus einen Kreis oder ein Quadrat schneiden kann. -->
|
||||
<rect width="64" height="64" fill="#2563eb" />
|
||||
<path d="M20 34l8 8 16-18" fill="none" stroke="#ffffff" stroke-width="6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
|
||||
<!-- Ein Blatt mit Haken: Liste plus Erledigtes. Bewusst ohne Farbverlauf,
|
||||
damit es auch als kleines Symbol lesbar bleibt. -->
|
||||
<rect x="10" y="6" width="44" height="52" rx="8" fill="#2563eb" />
|
||||
<rect x="20" y="2" width="8" height="10" rx="4" fill="#1e40af" />
|
||||
<rect x="36" y="2" width="8" height="10" rx="4" fill="#1e40af" />
|
||||
<path d="M21 33l8 8 15-16" fill="none" stroke="#ffffff" stroke-width="6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 573 B |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { Erfassung } from '@/app/(app)/erfassen/erfassung'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const metadata: Metadata = { title: 'Erfassen' }
|
||||
|
||||
export default async function ErfassenSeite() {
|
||||
await verlangeAnmeldung('/erfassen')
|
||||
|
||||
const inbox = await db.aufgabe.count({
|
||||
where: { bereichId: null, status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Erfassen</h1>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
Alles landet in der Inbox und wird später einsortiert. Ohne Netz wird es
|
||||
gemerkt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Erfassung />
|
||||
|
||||
<Link
|
||||
href="/inbox"
|
||||
className="text-gedaempft-vordergrund hover:text-vordergrund flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Inbox className="size-4" aria-hidden />
|
||||
{inbox === 0
|
||||
? 'Inbox ist leer'
|
||||
: inbox === 1
|
||||
? 'Eine Aufgabe in der Inbox'
|
||||
: `${inbox} Aufgaben in der Inbox`}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Die Annahmestelle der Schnellerfassung vom Handy.
|
||||
*
|
||||
* Getrennt von der öffentlichen Schnittstelle: Hier zählt die Sitzung, kein
|
||||
* Token. Das ist derselbe Benutzer, der auch die Oberfläche bedient — nur
|
||||
* eben über eine Seite, die auch ohne Netz funktioniert.
|
||||
*
|
||||
* Jede Erfassung trägt eine vom Gerät vergebene Kennung. Wird eine Nachricht
|
||||
* doppelt zugestellt — weil das Netz beim ersten Versuch wegbrach, die
|
||||
* Anfrage aber trotzdem ankam —, entsteht keine zweite Aufgabe.
|
||||
*/
|
||||
|
||||
const eingabe = z.object({
|
||||
titel: z.string().trim().min(1).max(200),
|
||||
/** Vom Gerät vergeben; macht den Aufruf wiederholbar. */
|
||||
ref: z.string().trim().min(8).max(64),
|
||||
})
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const benutzer = await verlangeAnmeldungFuerApi()
|
||||
if (!benutzer) {
|
||||
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||
}
|
||||
|
||||
let inhalt: unknown
|
||||
try {
|
||||
inhalt = await anfrage.json()
|
||||
} catch {
|
||||
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const gelesen = eingabe.safeParse(inhalt)
|
||||
if (!gelesen.success) {
|
||||
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { titel, ref } = gelesen.data
|
||||
|
||||
const vorhanden = await db.aufgabe.findUnique({
|
||||
where: { quelle_quelleRef: { quelle: 'MANUELL', quelleRef: ref } },
|
||||
select: { id: true, titel: true },
|
||||
})
|
||||
|
||||
// Schon da: Die Antwort ist dieselbe wie beim ersten Mal. Das Gerät soll
|
||||
// seinen Eintrag aus der Warteschlange nehmen dürfen, ohne zu grübeln.
|
||||
if (vorhanden) {
|
||||
return NextResponse.json({ ok: true, id: vorhanden.id, doppelt: true })
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: { titel, quelle: 'MANUELL', quelleRef: ref },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: benutzer.id, email: benutzer.email },
|
||||
aktion: 'aufgabe.erfasst',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: aufgabe.id,
|
||||
details: { titel, ueber: 'schnellerfassung' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, id: aufgabe.id, doppelt: false })
|
||||
}
|
||||
@@ -11,6 +11,13 @@ export const metadata: Metadata = {
|
||||
description: 'Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.',
|
||||
applicationName: 'Planer',
|
||||
robots: { index: false, follow: false },
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: {
|
||||
icon: '/symbol.svg',
|
||||
apple: '/symbol.svg',
|
||||
},
|
||||
// Auf dem iPhone laeuft eine hinzugefuegte Seite sonst mit Browserleiste.
|
||||
appleWebApp: { capable: true, title: 'Planer', statusBarStyle: 'default' },
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
@@ -28,6 +28,7 @@ export function Kopfleiste({
|
||||
}) {
|
||||
const EINTRAEGE: Eintrag[] = [
|
||||
{ pfad: '/', name: 'Start' },
|
||||
{ pfad: '/erfassen', name: 'Erfassen' },
|
||||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||||
{ pfad: '/woche', name: 'Woche' },
|
||||
@@ -37,6 +38,7 @@ export function Kopfleiste({
|
||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||
{ pfad: '/integrationen', name: 'Integrationen' },
|
||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||
{ pfad: '/verwaltung/api', name: 'API', nurAdmin: true },
|
||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Warteschlange der Schnellerfassung.
|
||||
*
|
||||
* Was ohne Netz erfasst wird, liegt im Browser, bis es hinausgeht. IndexedDB
|
||||
* statt localStorage, weil das hier echte Daten des Benutzers sind: Ein
|
||||
* Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
|
||||
* Aufgabe — und damit genau das, was der Planer verhindern soll.
|
||||
*
|
||||
* Läuft nur im Browser.
|
||||
*/
|
||||
|
||||
const DATENBANK = 'planer-erfassung'
|
||||
const LADEN = 'warteschlange'
|
||||
const FASSUNG = 1
|
||||
|
||||
export type Eintrag = {
|
||||
/** Vom Gerät vergeben; macht das Senden wiederholbar. */
|
||||
ref: string
|
||||
titel: string
|
||||
erfasstAm: number
|
||||
/** Wie oft das Senden schon scheiterte. */
|
||||
versuche: number
|
||||
}
|
||||
|
||||
function oeffnen(): Promise<IDBDatabase> {
|
||||
return new Promise((fertig, fehler) => {
|
||||
const anfrage = indexedDB.open(DATENBANK, FASSUNG)
|
||||
anfrage.onupgradeneeded = () => {
|
||||
const db = anfrage.result
|
||||
if (!db.objectStoreNames.contains(LADEN)) {
|
||||
db.createObjectStore(LADEN, { keyPath: 'ref' })
|
||||
}
|
||||
}
|
||||
anfrage.onsuccess = () => fertig(anfrage.result)
|
||||
anfrage.onerror = () =>
|
||||
fehler(anfrage.error ?? new Error('IndexedDB ging nicht auf.'))
|
||||
})
|
||||
}
|
||||
|
||||
async function mitLaden<T>(
|
||||
art: IDBTransactionMode,
|
||||
tun: (laden: IDBObjectStore) => IDBRequest,
|
||||
): Promise<T> {
|
||||
const db = await oeffnen()
|
||||
try {
|
||||
return await new Promise<T>((fertig, fehler) => {
|
||||
const vorgang = db.transaction(LADEN, art)
|
||||
const anfrage = tun(vorgang.objectStore(LADEN))
|
||||
anfrage.onsuccess = () => fertig(anfrage.result as T)
|
||||
anfrage.onerror = () => fehler(anfrage.error ?? new Error('IndexedDB streikt.'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function anstellen(titel: string): Promise<Eintrag> {
|
||||
const eintrag: Eintrag = {
|
||||
ref: kennungBauen(),
|
||||
titel,
|
||||
erfasstAm: Date.now(),
|
||||
versuche: 0,
|
||||
}
|
||||
await mitLaden('readwrite', (laden) => laden.put(eintrag))
|
||||
return eintrag
|
||||
}
|
||||
|
||||
export async function alle(): Promise<Eintrag[]> {
|
||||
const eintraege = await mitLaden<Eintrag[]>('readonly', (laden) => laden.getAll())
|
||||
return eintraege.sort((a, b) => a.erfasstAm - b.erfasstAm)
|
||||
}
|
||||
|
||||
export async function abhaken(ref: string): Promise<void> {
|
||||
await mitLaden('readwrite', (laden) => laden.delete(ref))
|
||||
}
|
||||
|
||||
export async function versuchZaehlen(eintrag: Eintrag): Promise<void> {
|
||||
await mitLaden('readwrite', (laden) =>
|
||||
laden.put({ ...eintrag, versuche: eintrag.versuche + 1 }),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Kennung, die auch ohne `crypto.randomUUID` zustande kommt.
|
||||
*
|
||||
* Ältere Browser kennen die Funktion nur über HTTPS; die Warteschlange soll
|
||||
* aber auch dann noch funktionieren, wenn nichts anderes mehr geht.
|
||||
*/
|
||||
function kennungBauen(): string {
|
||||
const quelle: Crypto = crypto
|
||||
if (typeof quelle.randomUUID === 'function') return quelle.randomUUID()
|
||||
|
||||
const zufall = new Uint8Array(16)
|
||||
quelle.getRandomValues(zufall)
|
||||
return [...zufall].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt alles hinaus, was noch aussteht.
|
||||
*
|
||||
* Gibt zurück, wie viele Einträge angekommen sind. Was scheitert, bleibt
|
||||
* liegen — beim nächsten Versuch kommt es erneut dran.
|
||||
*/
|
||||
export async function abarbeiten(): Promise<{ gesendet: number; offen: number }> {
|
||||
const warteschlange = await alle()
|
||||
let gesendet = 0
|
||||
|
||||
for (const eintrag of warteschlange) {
|
||||
try {
|
||||
const antwort = await fetch('/api/erfassen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ titel: eintrag.titel, ref: eintrag.ref }),
|
||||
})
|
||||
|
||||
if (antwort.ok) {
|
||||
await abhaken(eintrag.ref)
|
||||
gesendet++
|
||||
continue
|
||||
}
|
||||
|
||||
// 400 heißt: Das wird auch beim zehnten Mal nichts. Liegen lassen wäre
|
||||
// eine Warteschlange, die nie leer wird.
|
||||
if (antwort.status === 400) {
|
||||
await abhaken(eintrag.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
// 401 und alles Übrige: später noch einmal.
|
||||
await versuchZaehlen(eintrag)
|
||||
} catch {
|
||||
// Kein Netz. Genau dafür gibt es die Warteschlange.
|
||||
await versuchZaehlen(eintrag)
|
||||
}
|
||||
}
|
||||
|
||||
return { gesendet, offen: (await alle()).length }
|
||||
}
|
||||
Reference in New Issue
Block a user