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
+138
View File
@@ -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 }
}