# ROADMAP — Baustelle PWA Stand: 2026-09-18 · Gegenstück: `data/bericht` (Modul-Version 1.5.1) Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des Bericht-Moduls. --- ## 1. Nachgezogen aus Bericht 1.5.0 ✅ (2026-08-22, Modul 1.5.1) Das Modul hatte mit 1.5.0 Infrastruktur bekommen, von der die PWA nichts hatte. Die Beschreibungen unten schildern jeweils den Zustand davor. ### 1.1 Echte Thumbnails statt Vollbilder ✅ [API] Bis dahin lud die PWA für **jede Kachel das komplette Foto**: - `loadThumbs()` ruft `api.getPhotoBlobUrl(rel, 'small')` auf → `photo.php?size=small` - `photo.php` liefert nur dann ein Thumbnail, wenn Dolibarr eines unter `thumbs/_small.` **vorgefertigt** hat - Die PWA lädt ihre Fotos aber per `orders.php?action=upload_photo` hoch, und dort wird **nie `vignette()` aufgerufen** → es existiert nie ein Thumb → es kommt immer das Original - Bei 30 Baustellenfotos sind das im Mobilfunknetz 30 × 0,5–1 MB (bei über die Dolibarr-Oberfläche hochgeladenen Originalen entsprechend mehr) Das Modul kann das seit 1.5.0 längst: `bericht_attachment_thumb($full, $maxside)` in `lib/bericht.lib.php` (GD, Cache unter `bericht/thumbs/`, EXIF-Rotation, Speicher-Notbremse). `api/photo.php` lädt diese lib bereits. - [x] **[API]** `photo.php` um `size=thumb&w=` erweitern → `bericht_attachment_thumb()`, mit ETag aus mtime und `Cache-Control: private, max-age=…, immutable` - [x] `loadThumbs()` umbauen: `` **direkt** setzen statt fetch → Blob → ObjectURL. Damit greifen HTTP-Cache und `loading="lazy"` wirklich; heute sind beide wirkungslos, weil der Blob schon vorher komplett geladen wird. - [x] Sequenzielle Schleife auflösen — `for (const t of thumbs) { await … }` lädt Foto für Foto nacheinander. Beim ``-Weg erledigt der Browser das parallel von selbst. - [x] Doppel-Ladung entfernen: schlägt `size=small` fehl, wird das Original **noch einmal** geladen. ### 1.2 Aufnahmezeit statt Dateidatum ✅ [API] 1.5.0 hat einen eigenen EXIF-Parser bekommen (`bericht_jpeg_taken_at()` / `bericht_file_taken_at()`), weil im Prod-Container das `exif`-Modul fehlt. Der Editor sortiert Anhänge damit nach Aufnahmezeit. `orders.php?action=photos` lieferte nur `filemtime`. - [x] **[API]** `taken_at` je Datei ausgeben - [x] Fotoliste nach Aufnahmezeit sortieren (Auswahl gemerkt, wie im Editor) ### 1.3 Seiten-Titel kommt nie an ✅ [API] `api/pages.php` **kann** `title` einer Seite setzen, die PWA rendert ihn (`page-title-badge`, `openPageActionsModal(…, title)`) — aber `api/reports.php` gibt `title` im `pages`-Array **nicht aus**. Ein im Editor gesetzter Titel ist in der PWA also unsichtbar, und ein in der PWA gesetzter Titel scheint nach dem Neuladen verschwunden. - [x] **[API]** `'title' => $p->title` in `reports.php` ergänzen (eine Zeile) ### 1.4 Bearbeitete Seiten und Raster-Layouts ✅ [API] Seit 1.5.0 hat eine Seite `composite_path` (das im Editor gebaute Seitenbild) und `layout` (`single`, `grid_2`, `grid_4`, `before_after`, `title_only`, …). Die PWA zeigt stur `source_path`: - Nach Bearbeitung im Desktop-Editor zeigt die PWA weiter das **Rohbild** ohne Anmerkungen - Bei einer Rasterseite ist `source_path` nur **eines von bis zu sechs** Bildern - Bei `title_only` gibt es gar keine Quelle → ❌-Kachel - [x] **[API]** `composite_path` in `reports.php` mit ausgeben - [x] PWA zeigt `composite_path`, wenn vorhanden, sonst `source_path` - [x] Layout-Kennzeichen an der Kachel (z. B. „⊞ 4er-Raster"), damit klar ist, warum dort mehr drauf ist als das eine Foto --- ## 2. Verbesserungen unabhängig vom Modul ### 2.1 Browser-Dialoge raus (10 Stück) ✅ `confirm()` / `alert()` / `prompt()` widersprechen der Hausregel und sehen in der Standalone-PWA auf Android wie ein Systemfehler aus. Ein sauberes Muster liegt schon vor: das „Bericht löschen"-Modal in `router.on('/reports/:id')`. | Stelle | Zeile (vor dem Umbau) | |---|---| | PIN stimmt nicht überein | `alert` 295 | | Bericht finalisieren | `confirm` 1111 | | PIN-Schutz deaktivieren | `confirm` 1264 | | Material-Eintrag löschen | `confirm` 1432 | | Seite aus Bericht entfernen | `confirm` 1809 | | Foto löschen | `confirm` 2701 | | Nicht hochgeladenes Foto löschen | `confirm` 3019 | | Mess-Skala: Länge eingeben | `prompt` 3409 | | Ungültiges Längenformat | `alert` 3419 | | „Zuerst kalibrieren" | `alert` 3440 | - [x] Eine `confirmDialog({title, text, danger})`-Hilfsfunktion (Promise) + `inputDialog()` für die Längeneingabe, alle zehn Stellen darauf umstellen ### 2.2 Fotos offline sichtbar halten ✅ Der Service Worker reichte alles unter `/custom/bericht/api/` ungecacht durch. Auf der Baustelle ohne Netz ist damit **kein einziges** schon angesehenes Foto verfügbar. - [x] Eigener Runtime-Cache `baustelle-media` für `photo.php`-GETs, cache-first mit Größenbegrenzung (LRU, z. B. 200 Einträge). Die Thumb-URLs aus 1.1 sind dafür der richtige Kandidat — klein und unveränderlich. - [x] Vom App-Shell-Cache getrennt halten, damit ein Deploy die Medien nicht wegräumt --- ## 3. Textnotizen ✅ (2026-08-22) Getippte Notiz zum Auftrag — für alles, was man lieber schreibt als diktiert: Zählernummern, Absprachen, Maße. - [x] „📝 Notizen (n)" am Auftrag, direkt unter der Sprachnotiz - [x] Liste mit Betreff, zweizeiliger Vorschau und Zeitstempel; Antippen öffnet zum Lesen und Ändern, 🗑 löscht (mit Rückfrage) - [x] Editor: Betreff-Feld + Textfeld über die volle Höhe, Speichern über ✓ in der Kopfzeile, Rückfrage beim Verlassen mit ungespeicherten Änderungen - [x] Ablage als `notiz__.txt` im Auftragsordner (`api/note.php`) — genau der Weg der Sprachnotiz. Damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors sichtbar. - [x] Notizen sind aus „Weitere Dokumente" ausgefiltert — sie haben ihre eigene Sektion und stünden dort sonst doppelt **Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am Handy ohne Stift ist das unbrauchbar. ## 4. Teilen aus WhatsApp ✅ (2026-08-30) Bilder und Beschreibungstexte, die die Kundin per WhatsApp schickt, landen direkt im Auftrag. Vorher endete jeder Teilen-Vorgang in einer Sackgasse: - `share.html` fragte `api.getToken()` — ein JWT in IndexedDB, das es **seit der SSO-Umstellung gar nicht mehr gibt** (`api.login()` legt nur noch `user` ab, die Sitzung steckt im HttpOnly-Cookie `awl_sso`). Ergebnis: immer „Bitte zuerst anmelden", und der Weg „Zur App" verwarf das Geteilte. - Der Service Worker sicherte **nur Dateien**. Ein Text-Share (oder Bild + Bildunterschrift) schrieb nichts in IndexedDB → „Keine geteilten Fotos gefunden". - [x] Anmeldung über `api/verify.php` (Cookie) statt über das nicht mehr existierende Token - [x] Anmelden **in** der Teilen-Seite (Passwort + Passkey) — das Geteilte bleibt dabei gesichert - [x] Service Worker sichert `title`, `text` und `url` mit; Weiterleitung auch ohne Datei - [x] Auswahl-Seite: Bildvorschau, Betreff + Text zum Nachbessern, Auftragssuche (ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle) - [x] Ablegen: Bilder über die normale Upload-Queue (Persist-First), Text als Notiz über `api/note.php`. Schlägt die Notiz fehl (kein Netz), bleibt der Text gesichert und der Vorgang ist wiederholbar - [x] Banner in der App, wenn noch etwas Geteiltes unabgelegt bereitliegt - [x] **Mehrere Nachrichten hintereinander**: die letzten drei Aufträge stehen unter „Zuletzt benutzt" oben; innerhalb von 30 Minuten ist der letzte vorausgewählt (danach nur noch Schnellzugriff — eine Dauer-Vorauswahl würde irgendwann im falschen Auftrag landen) ## 5. Offline lesbar statt „Failed to fetch" ✅ (2026-08-31) Gemeldet von Eddy am 2026-08-31: Die App zeigte beim normalen Öffnen nur **„⚠️ Failed to fetch"**, während das Teilen aus WhatsApp weiter funktionierte. ### Befund - Prod-Deploy vollständig, alle Dateien liefern 200, die API antwortet korrekt (403 ohne `X-Requested-With`, 401 ohne Anmeldung) — **kein Server- oder Deploy-Fehler.** - Im frischen Desktop-Chromium lief der ganze Weg sauber durch (`auth.php` → `verify.php` → `orders.php?open=1`, alle 200). - „Failed to fetch" ist die *rohe Browsermeldung* eines `fetch()`, das gar nicht bis zum Server kam. Sie stand 1:1 im UI, weil die Routen im `catch` stumpf `e.message` anzeigten. - Warum es beim Teilen nicht auffiel: seit `eaad796` merkt sich `share.html` die letzten Aufträge lokal und kommt ohne Netz aus. Die Hauptapp hatte für die Listen **keinerlei Offline-Fallback** — `lib/offline.js` puffert nur Foto-Uploads. ### Umgesetzt (Muster aus KB #1037 / #1041, dort im Stundenzettel verifiziert) - [x] **Netzfehler kenntlich machen** — `lib/api.js` wandelt den `TypeError` aus `fetch()` in einen eigenen Fehler mit `offline = true` und deutschem Text („Keine Verbindung zum Server" / „Zeitüberschreitung"). Ein 4xx/5xx trägt die Markierung **nicht** und wird weiterhin als Serverfehler gemeldet. - [x] **`isTransient()` in `lib/offline.js` nachgezogen** — die Funktion erkannte Netzfehler an `err instanceof TypeError`. Ohne Anpassung hätte ein Foto aus dem Funkloch als *dauerhaft* fehlgeschlagen gegolten und wäre nach sechs Versuchen in der Quarantäne gelandet, statt hochgeladen zu werden. - [x] **Datenspiegel** (`offline.mirrorSave/mirrorLoad/mirrorClear`) — jede erfolgreiche Antwort wird in IndexedDB gespiegelt: Auftragsliste, Kundenliste, Berichte sowie Auftrags- und Kundendetails (letztere auf 30 Einträge begrenzt, der gerade geschriebene Schlüssel ist beim Aufräumen geschützt — KB #1037). - [x] **Sichtbares Band** „📴 Kein Netz — angezeigt wird der Stand von HH:MM Uhr". Ohne den Hinweis hält man alte Zahlen für aktuell. Es gehört zur Ansicht und wird vom Router bei jedem Routenwechsel zurückgesetzt. - [x] **Suche ohne Netz** filtert im gespiegelten Stand statt ins Leere zu laufen — mit einmaligem Hinweis, dass nur der gespeicherte Stand durchsucht wird. - [x] **Beim Abmelden wird der Spiegel gelöscht** — sonst sähe der nächste Benutzer am selben Gerät offline die Aufträge, Kunden und Preise des vorherigen (KB #1041). - [x] **`sw.js`: echter Fallback.** Der Same-Origin-Zweig endete auf `caches.match(e.request)`; fand der Cache nichts, wurde `respondWith(undefined)` aufgerufen — und die Anfrage scheiterte erneut mit genau der Meldung, die der Fallback verhindern sollte. Jetzt kommt immer eine echte Response: Seite → App-Hülle aus dem Cache, sonst eine erklärende Seite; API → 503 mit JSON. - [x] **Nur vollständige Antworten cachen** (`ok && status === 200 && type !== 'opaque'`) — eine 206 (Teilinhalt beim Audio-Seek) liefert der Worker später als kaputte Datei aus. - [x] **Precache nach Deploy** — der Cache trägt nach jedem Deploy einen neuen Namen und startet leer, `app.js`/`app.css` inklusive. Eine feste Liste hilft nicht, weil die URLs `?v=` tragen. Die Seite meldet dem Worker nach dem Laden, was sie tatsächlich geholt hat (`PRECACHE`-Nachricht), er cacht es einzeln nach (KB #1041). ### Verifiziert (2026-08-31, lokale Testinstanz, Chromium) - Spiegel wird geschrieben (`mirror:orders:open`, `:customers`, `:reports`, `:detail:order:`, `:detail:customer:`) - `fetch` auf Fehler gesetzt: Auftragsliste zeigt 6 Aufträge aus dem Spiegel + Band „Stand von 19:03 Uhr"; Kundenliste ohne Spiegel zeigt die verständliche Meldung statt „Failed to fetch" - Suche „Rolfs" ohne Netz → 2 Treffer aus dem Spiegel + Hinweis-Toast - Band verschwindet auf Routen ohne Spiegel und nach Rückkehr des Netzes - **Echtes Funkloch** (`context.setOffline(true)` + `reload()`): App startet aus dem Cache, zeigt Band und 6 Aufträge; im Cache liegen `app.js`, `app.css` und alle `lib/*.js` mit ihrem `?v=` — vorher wären sie nach einem Deploy nicht dagewesen - Konsole beim Offline-Start: nur Netzfehler, keine JS-Fehler ### Bewusst nicht gebaut Keine Schreib-Warteschlange für Auftragsdaten. Die bräuchte Idempotenz und Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst (Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz. ## 6. Rauswurf aus dem Auftrag + unsichtbare Warteschlange (gemeldet 2026-09-18) Gemeldet von Eddy: am 17.09.2026 gegen 18 Uhr mehrere Fotos in einem Auftrag gemacht, „Fertig" getippt — die App stand danach auf der **Auftragsliste** statt im Auftrag. Wieder in den Auftrag gegangen: **keine Fotos zu sehen**, kein Hinweis, dass sie noch warten. ### Befund (Prod-Zugriffslog 17.09., Auftrag 111 — kein Raten) - 17:30:56 `POST orders.php?action=create` — Auftrag 111 über den ➕-Knopf angelegt. In derselben Sekunde laufen **zwei** Routen gleichzeitig: `orders.php` (Liste) **und** `orders.php?id=111` (Detail). Das ist die Signatur des Fehlers. - 17:40:45 `verify.php` + `orders.php` (Liste) — der Rauswurf. **Kein** `index.php` davor, also kein Reload, kein SW-Update, kein Logout (kein 401), kein Absturz. - 17:40:47 Auftrag 111 erneut geöffnet, `action=photos` liefert eine **leere** Liste; die Uploads kommen erst 17:40:54 und 17:57:25–17:58:22 an. **Ursache Rauswurf — History-Race in `openNewOrderModal`:** ```js closeModal(modal); // -> history.back() (asynchron!) router.go('#/orders/' + res.order.id); // -> location.hash = … (sofort) ``` `history.back()` wird erst nach dem Hash-Wechsel ausgeführt und springt dann **hinter** den neuen Eintrag zurück. Ergebnis: Auf dem Bildschirm steht der Auftrag (seine Route wurde als letzte fertig), in der Adresszeile steht aber `#/orders`. Alles, was danach `router.navigate()` ruft — z. B. „Fertig" in der Kamera — zeichnet die **Auftragsliste**. Nachgestellt in Chromium (18.09.2026): ohne Abwarten endet der Ablauf auf `#/orders`, mit abgewartetem `popstate` auf `#/orders/111`. Dieselbe Falle an zwei weiteren Stellen: „Neuen Bericht anlegen" (`#/reports/`) und Lieferschein-Unterschrift (`#/shipments/`). Betroffen ist nur, wer den Auftrag **in derselben Sitzung frisch angelegt** hat — deshalb fiel es nicht früher auf. **Ursache „keine Fotos, keine Meldung":** Die Auftragsseite zeigt ausschließlich, was der Server schon hat. Fotos in der Warteschlange erscheinen nirgends außer als Zahl im kleinen Badge oben rechts (🔴 3 / 🟡 3), und die Seite zeichnet sich nach einem im Hintergrund fertig gewordenen Upload nicht neu. Verloren ging nichts — alle Fotos kamen an. ### Umsetzung (2026-09-18) - [x] **Race zentral behoben:** `closeModal()` zählt selbst ausgelöste `history.back()` (`_pendingBacks`, Zähler statt Boolean, KB #1209 — der Boolean verschluckt bei zwei schnell nacheinander geschlossenen Dialogen ein `popstate`), `router.go()` wartet über `window.historySettled()`, bis alle durch sind, und wechselt erst dann den Hash (Schutzzeit 1,5 s, falls ein `popstate` ausbleibt). Alle Aufrufstellen bleiben wie sie sind. `closeModal()` nimmt den History-Eintrag jetzt **vor** dem Cleanup zurück, damit ein navigierender Cleanup den Rücksprung sieht; `pushModal()` wartet ebenfalls. - [x] **Sicherheitsnetz in Kamera und Galerie-Auswahl:** nach „Fertig" ausdrücklich `router.go('#/orders/')` statt `router.navigate()` — gezeichnet wird der Auftrag, für den fotografiert wurde, nicht „was gerade in der Adresszeile steht". - [x] **Wartende Fotos am Auftrag:** Block „⏳ Warten auf Upload (n)" über „Hochgeladene Fotos", mit Vorschaubildern direkt aus der Warteschlange (max. 12, danach „+n") und Klartext („📴 Kein Netz — 3 Fotos sind auf dem Gerät gesichert und werden gesendet, sobald wieder Netz da ist."), fehlgeschlagene rot markiert; Tipp öffnet die Warteschlange. - [x] **Auftragsseite zieht nach:** bei `photo-uploaded` für diesen Auftrag wird nach 2,5 s Ruhe neu gezeichnet (Scrollposition bleibt) — nicht, solange ein Modal offen ist, die Mehrfachauswahl läuft oder eine Sprachnotiz abgespielt wird. - [x] **Hinweis beim Schließen der Kamera:** „📴 Kein Netz — n Fotos auf dem Gerät gesichert, Upload folgt automatisch" bzw. „⏳ n Fotos warten noch auf den Upload". ### Verifiziert (2026-09-18, lokale Testinstanz, Chromium, Auftrag 62) - Ablauf „Modal zu + `router.go`": endet auf `#/orders/62` (vorher `#/orders`) - **Echtes Funkloch** (`context.setOffline(true)`): 3 Fotos gesichert, „Fertig" → bleibt im Auftrag, Offline-Band, Block mit 3 Vorschaubildern und Klartext, Badge 🔴 3 - Netz zurück: Upload läuft an, Block verschwindet von selbst, „Hochgeladene Fotos" 2 → 5, Badge 🟢, Warteschlange leer. Testfotos danach wieder entfernt. - **Nicht** getestet: die echte Kamera (`getUserMedia`) am Handy — der Test hat den Kamera-Cleanup nachgestellt, nicht die Kamera selbst bedient. ### Offen - [ ] **Doppel-Uploads** (Log: mehrere Antworten mit `duplicate:true`) — Seite und Service Worker (Background Sync) laden dasselbe Queue-Item parallel hoch. Dank md5-Abgleich seit Bericht 1.6.0 folgenlos, kostet aber im Mobilfunk doppeltes Datenvolumen. Lösung: Sperre je Item in IndexedDB (`uploading_since`), die der jeweils andere achtet. - [ ] **SW-Update lädt mitten in der Arbeit neu** — beim Test beobachtet: `controllerchange` → `location.reload()` (index.php) feuerte, während drei Fotos in der Warteschlange lagen; der Browser zeigte dazu seinen eigenen „Seite verlassen?"-Dialog (`beforeunload`). War am 17.09. **nicht** die Ursache (kein Deploy, kein `index.php` im Log), trifft aber jeden, der direkt nach einem Deploy fotografiert. Lösung: Reload aufschieben, solange ein Modal (Kamera!) offen ist oder die Warteschlange nicht leer ist. ## Erledigt ### 2026-08-22 Abschnitte 1, 2 und 3 vollständig. Dazu nebenbei behoben: - `.modal-header`, `.close-btn` und `.modal-body` hatten **überhaupt kein CSS**, obwohl das Markup sie seit Längerem nutzt (u. a. beim Löschen eines Berichts) — der Schließen-Knopf saß dadurch unter der Überschrift statt daneben. - Das Laden eines Thumbnails überschrieb den kompletten Kachel-Inhalt (`t.innerHTML = …`) und nahm die Overlays mit: Auswahl-Häkchen in der Mehrfachauswahl und Seitennummer der Bericht-Seiten waren weg, sobald das Bild da war. - Beim Abmelden bleiben keine Kundenfotos mehr auf dem Gerät (`CLEAR_MEDIA` an den Service Worker + `clearPhotoCache()`). ### Davor Siehe README (Feature-Liste) und die Commit-Historie: SSO-Migration auf awlauth, WebAuthn-Login, Neuer-Auftrag-Formular, Live-Kamera mit Persist-First-Queue, Lieferschein-Unterschrift.