Files
MABEA/frontend/src/offline/queue.ts
T
patrickandClaude Sonnet 5 564e98e5c6
CI / backend-tests (push) Successful in 54s
Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
React/Vite-PWA (frontend/):
- Login, Objektliste (Prompt 11 Screen 1), Kontroll-Screen (Screens 2-6 kompakt:
  Start/Objekt-Sperre-Übernahme, Positionserfassung, Abschluss/Abbruch)
- Offline-Härtung (Prompt 17): IndexedDB-Queue (src/offline/queue.ts) puffert
  Positions-PUTs lokal, automatische Übertragung bei Reconnect + periodischem
  Sync-Versuch, Statusanzeige je Position (nicht gespeichert/wird übertragen/
  gespeichert/Fehler), Abschluss-Button bleibt gesperrt bis alles gespeichert
- Nutzt aus, dass PUT /kontrollen/{id}/positionen/{material_id} backend-seitig
  idempotent ist (Upsert) - Queue kann beliebig oft retryen ohne Duplikate
- Service Worker via vite-plugin-pwa für App-Shell-Caching (Start im Feld ohne
  Cold-Load); PWA-Icons als TODO vermerkt (noch keine echten Bilddateien)
- Client-Fehler (4xx) werden nicht automatisch wiederholt, nur Netzwerkfehler

Flutter-Android-Grundgerüst (flutter_app/), auf Nutzerwunsch parallel begonnen:
- Gleiche API als zweiter Client (Prompt 19 API-first), Login/Objektliste/
  Kontroll-Screen als Dart-Äquivalent zur PWA
- Bewusst OHNE Offline-Queue in dieser ersten Fassung (siehe flutter_app/README.md)
- Plattform-Ordner (android/, ios/) nicht von Hand erzeugt - müssen auf dem
  Zielsystem per `flutter create .` nachgezogen werden, sonst zu fehleranfällig
  ohne Testlauf

Offen: Playwright-E2E (Testphase 4), Praktiker-Session, PWA-Icons, Flutter-
Offline-Queue - brauchen laufendes Deployment bzw. sind kein reiner Code-Schritt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
2026-09-04 00:10:26 +02:00

115 lines
4.4 KiB
TypeScript

import { createStore, del, entries, set } from "idb-keyval";
import { apiRequest, ApiError } from "../api/client";
import type { Kontrollposition } from "../api/types";
// Prompt 17: Eingaben werden lokal zwischengespeichert, sobald eingegeben - nicht
// erst bei Übertragung an den Server verworfen, wenn die Verbindung kurz fehlt.
// IndexedDB statt reinem Service-Worker-Cache, weil hier tatsächliche Nutzdaten
// (Ist-Mengen) über einen Neustart/Tab-Wechsel hinweg erhalten bleiben müssen.
const store = createStore("mabea-offline-queue", "kontrollpositionen");
export type QueueStatus = "wartet" | "wird_uebertragen" | "fehler";
export interface QueueItem {
key: string; // `${kontrolleId}:${materialId}`
kontrolleId: string;
materialId: number;
istmenge: string;
status: QueueStatus;
fehlerText: string | null;
fehlbestandId: string | null; // wird nach erfolgreicher Übertragung befüllt
}
function keyFor(kontrolleId: string, materialId: number): string {
return `${kontrolleId}:${materialId}`;
}
export async function enqueue(kontrolleId: string, materialId: number, istmenge: string): Promise<void> {
const key = keyFor(kontrolleId, materialId);
const item: QueueItem = {
key,
kontrolleId,
materialId,
istmenge,
status: "wartet",
fehlerText: null,
fehlbestandId: null,
};
await set(key, item, store);
}
export async function alleEintraege(): Promise<QueueItem[]> {
const all = await entries<string, QueueItem>(store);
return all.map(([, value]) => value);
}
export async function eintraegeFuerKontrolle(kontrolleId: string): Promise<QueueItem[]> {
const all = await alleEintraege();
return all.filter((item) => item.kontrolleId === kontrolleId);
}
/** Prompt 16: Abschluss darf erst möglich sein, wenn nichts mehr unbestätigt in
* der Warteschlange liegt (weder "wartet" noch "wird_uebertragen"). Ein
* "fehler"-Eintrag blockiert ebenfalls - der Mitarbeiter muss ihn sehen und
* bewusst erneut versuchen, kein stiller Datenverlust. */
export async function hatUnbestaetigtePositionen(kontrolleId: string): Promise<boolean> {
const eintraege = await eintraegeFuerKontrolle(kontrolleId);
return eintraege.length > 0;
}
async function sendeEintrag(item: QueueItem): Promise<void> {
await set(item.key, { ...item, status: "wird_uebertragen" as QueueStatus }, store);
try {
const ergebnis = await apiRequest<Kontrollposition>(
`/kontrollen/${item.kontrolleId}/positionen/${item.materialId}`,
{ method: "PUT", body: { istmenge: item.istmenge } }
);
// Erfolgreich übertragen (idempotent - ein erneutes Senden desselben Werts
// durch die Queue vor einem Absturz führt zu keinem doppelten Fehlbestand,
// siehe Backend app/services/kontrolle.py erfasse_position) -> aus Queue entfernen.
void ergebnis;
await del(item.key, store);
} catch (error) {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
// Client-Fehler (z. B. 403 Objekt-Sperre an anderen Benutzer übergegangen,
// 404 Kontrolle nicht mehr aktiv) - erneutes Senden würde nichts ändern,
// aber der Eintrag bleibt sichtbar in der Queue, bis der Mitarbeiter reagiert.
await set(
item.key,
{
...item,
status: "fehler" as QueueStatus,
fehlerText: `Server hat abgelehnt (${error.status}) - bitte Kontrolle neu prüfen.`,
},
store
);
} else {
// Netzwerkfehler - bleibt "wartet", nächster Sync-Versuch übernimmt wieder.
await set(item.key, { ...item, status: "wartet" as QueueStatus }, store);
}
throw error;
}
}
/** Wird bei Reconnect (online-Event) und periodisch aufgerufen. Sendet alle
* wartenden Einträge, bricht bei Netzwerkfehlern early ab (weitere Versuche
* würden ohnehin scheitern, solange keine Verbindung besteht). */
export async function synchronisiereQueue(): Promise<void> {
const eintraege = await alleEintraege();
for (const item of eintraege) {
if (item.status === "fehler") continue; // braucht bewusste Nutzer-Aktion, kein Auto-Retry
try {
await sendeEintrag(item);
} catch (error) {
if (!(error instanceof ApiError)) {
return; // vermutlich offline - restliche Queue in Ruhe lassen, nicht durchhämmern
}
}
}
}
export async function entferneFehlerEintrag(kontrolleId: string, materialId: number): Promise<void> {
await del(keyFor(kontrolleId, materialId), store);
}