#97 — Offline-Verfügbarkeit für Einkaufsliste & Vorratsschrank mit Sync bei Wiederverbindung #96
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#96
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Story: Offline-Verfügbarkeit für Einkaufsliste & Vorratsschrank mit Sync bei Wiederverbindung
Status (Phase 1 delivered 2026-08-08): Diese Story ist als "eine der aufwändigeren... realistisch
mehrere Zyklen Arbeit" eingestuft (siehe Aufwandseinschätzung unten) — Phase 1 liefert das komplette
Fundament (Read-Cache, Write-Queue, Sync-on-Reconnect, Status-Banner) plus die Aktionen, die der
Architect-Hinweis unten selbst als konfliktarm einstuft: Shopping-Abhaken/-Mengenänderung
(
ActivateShoppingProductCommand/DeactivateShoppingProductCommand) und Pantry-Check-in/-out/-Anlegen(
CheckInPantryProductCommand/CheckOutPantryProductCommand/CreatePantryProductCommand). Bewusstzurückgestellt auf
#102: Shopping's Smart-Add (ML-Worker-gestützt) und alle absolutenWertüberschreibungen (Zielmenge, Umbenennen, Kategorie-Zuordnung) — letztere brauchen die
Last-Write-Wins-plus-Aktivitäts-Feed-Sichtbarkeit, die der Architect-Hinweis unten fordert, und die
setzt einen Pantry-Aktivitäts-Feed-Eintragstyp voraus, den es noch nicht gibt (siehe
#101). Siehe97_offline_support_shopping_pantry_design.mdfür die vollständige Architect-Entscheidung.As a Haushaltsmitglied,
I want to die Einkaufsliste und den Vorratsschrank auch ohne Internetempfang ansehen und bearbeiten können
(z. B. im Supermarkt oder Keller), mit automatischer Synchronisierung sobald wieder Empfang da ist,
so that ich gemeinsam mit anderen einkaufen gehen kann, auch wenn der Empfang im Laden schlecht ist, ohne
dass Änderungen verloren gehen.
Depends on:
#89(Listentyp-Vereinheitlichung),#90(Einkaufslisten-Upgrades — Offline muss gegen die fertigeListen-Form gebaut werden, nicht gegen die alte Master-List/Shopping-List-Struktur),
#94(Vorratsschrank).Scope-Empfehlung: Bewusst auf die zwei Listentypen beschränkt, bei denen der Offline-Anwendungsfall
tatsächlich vorkommt (Laden/Keller ohne Empfang) — nicht "alle Listen" auf einmal. Siehe Out-of-scope unten für
die Begründung.
Acceptance criteria:
Lesen offline
Caching, baut auf der bestehenden PWA-Infrastruktur aus
#47auf, die bisher nur Installierbarkeit,keine echte Offline-Datenhaltung abdeckt).
Schreiben offline
abgedeckten Aktionen (siehe Status oben); Änderungen werden lokal in einer Warteschlange gespeichert (nicht
verworfen). Smart-Add und absolute Wertüberschreibungen bleiben bis
#102online-only."Syncing…" während die Warteschlange nach Wiederverbindung noch nicht leer ist (englischer Text, passend zur
sonst durchgehend englischen UI-Sprache dieser Codebase).
Sync bei Wiederverbindung
Nutzer manuell etwas anstoßen muss.
von Natur aus konfliktarm, da sie relative Änderungen sind, keine absoluten Werte überschreiben — auch
wenn zwei Personen offline gleichzeitig scannen, addieren/subtrahieren sich beide Aktionen korrekt beim
Nachliefern (setzt dieselbe atomare Erhöhung/Verringerung voraus, die
#94bereits für Concurrencyinnerhalb einer Session fordert). Für Aktionen, die einen absoluten Wert überschreiben (z. B. Soll-Menge
manuell auf einen neuen Wert setzen), reicht für diese erste Runde Last-Write-Wins, aber der
Aktivitäts-Feed muss beide Werte sichtbar machen, damit ein überschriebener Stand nicht spurlos verloren
geht.
Out of scope for this story:
bewusst nicht in dieser Story. Der Offline-Anwendungsfall (im Laden/Keller ohne Empfang aktiv arbeiten) betrifft
in der Praxis vor allem Einkaufsliste und Vorratsschrank; eine Ausweitung auf alle Listentypen ist eine
separate Folge-Story, sobald sich das Muster hier bewährt hat.
Sichtbarkeit im Aktivitäts-Feed reicht fürs Erste.
Aufwandseinschätzung (Antwort auf die Rückfrage des Menschen): Echte Offline-Fähigkeit ist eine der
aufwändigeren Story-Kategorien in diesem Backlog, kein kleiner Zusatz. Die App ist aktuell durchgehend auf
Live-WebSocket-Broadcast aufgebaut (Rx-Streams,
Change<...,...Dto>), nicht auf ein lokal-first-Modell. Nötigsind: ein Service-Worker mit echter Daten-Caching-Strategie (nicht nur App-Shell wie bisher bei
#47), eine lokaleSchreib-Warteschlange (z. B. IndexedDB), Wiederverbindungs-Erkennung + Replay-Logik, und eine — wenn auch
einfache — Konfliktstrategie. Realistisch mehrere Zyklen Arbeit, wenn es sauber gemacht werden soll. Deshalb hier
bewusst auf die zwei Listentypen mit dem konkreten "im Laden ohne Empfang"-Bedarf begrenzt, statt es für alle
Listentypen auf einmal zu bauen.
design (
97_offline_support_shopping_pantry_design.md)Design:
#97— Offline-Verfügbarkeit für Einkaufsliste & VorratsschrankArchitect decision: scope for this cycle
The story's own text calls this "eine der aufwändigeren Story-Kategorien... realistisch mehrere Zyklen
Arbeit". This cycle delivers a first, real (not stubbed) slice rather than the full AC, following the same
scope-down-and-document pattern already established for
#89/#90/#94/#82. Covered this cycle:connection.
conflict-safe (incremental, not absolute-value overwrites): Shopping's check-off/checkbox
(
ActivateShoppingProductCommand/DeactivateShoppingProductCommand) and Pantry's check-in/check-out(
CheckInPantryProductCommand/CheckOutPantryProductCommand), plus Pantry's plain "add product"(
CreatePantryProductCommand).Deliberately deferred to a follow-up story (
#102, drafted alongside this one):SmartAddInput, ML-worker-driven) and its plain fallback(
AddOrActivateShoppingProductCommand) — coupled to a corpus rebuild that itself depends on a freshcategory/product fetch; safe to extend once the simpler create path here is proven.
SetPantryProductTargetQuantityCommand,RenameShoppingProductCommand/RenamePantryProductCommand, category moves, barcode scanning, CSVimport/export, member/invitation management). These need the last-write-wins + activity-feed-visibility
design the story's own Architect note calls for, which itself needs an activity-feed entry shape Pantry
doesn't have yet (
#101is exactly that gap) — a separate, larger chunk of work.#52/#85/#86device-verification items belong to#96, whichneeds a human with a real device and is out of scope here.
Why no service worker changes
The realistic failure mode this story targets is "already-open app, signal drops or is too weak" (in a shop
or a cellar), not "cold-load the app with zero network ever" — the latter is what a Workbox-style
precaching SW would target, and it isn't what the AC asks for. So this cycle adds no
fetchhandling topublic/sw.js(which#47deliberately kept free of caching logic) and instead does the caching at theapp layer: an IndexedDB-backed read cache plus a write queue, both driven from
api.tsx. This also avoidsservice-worker-specific test/debugging overhead this sandbox can't fully exercise (no way to simulate an
installed, activated SW intercepting
fetchin the unit-test environment).How it works
ReactUi/src/offline/:offlineDb.ts— a minimal native-indexedDBwrapper, two object stores:queue(auto-increment,one row per pending action:
{id, requestName, request, timestamp}) andcache(key → last-knownarray of products/categories per list, e.g.
shopping-products:42).offlineConfig.ts— the whitelist of offline-safe commands, each with a small pure function thatcomputes the optimistic result from the cached entity + the request (mirroring the real backend handler
logic exactly, see comments referencing the C# handler each mirrors) and a cache-key resolver.
offlineSync.ts— a hand-rolled pub/sub (subscribe/getSnapshot, no new dependency) tracking{isOnline, pendingCount, isSyncing}; listens forwindow'sonline/offlineevents and drains thequeue in FIFO order on reconnect (and once on module load, in case items were queued in a previous
session). Draining calls the network directly (bypassing the offline-catch path) so a still-offline
failure stops the drain immediately, leaving the remainder queued; a full successful drain fires a
offline:sync-completeCustomEventonwindowso open pages refetch and reconcile (sortOrder, serverside effects like Pantry's low-stock shopping-list write, etc. can't be replicated optimistically).
useOfflineStatus.ts— a thin hook wrapping the pub/sub for React components.api.tsx'scallApi: on a network-level fetch failure (thefetch()promise itself rejecting — this isthe browser's own signal that no response was ever received, not a 4xx/5xx from the server) for a command
in the offline whitelist, instead of the existing red error toast it computes the optimistic result,
writes it into the read cache, enqueues the action, and resolves the caller's promise with that optimistic
DTO — every existing call site (
ShoppingProductItem's checkbox,PantryProductItem's +/- buttons,PantryPage's add-product form) needed zero changes, since they already just consume whatevercallApiresolves with. Deliberately keyed off the fetch rejecting rather than
navigator.onLine, since a"connected but no signal" Wi-Fi (the story's own "schlechter Empfang im Laden" case) often reports
navigator.onLine === trueright up until a request actually times out.Successful query responses (
GetShoppingProductsForListQuery,GetShoppingCategoriesForListQuery,GetPantryProductsQuery,GetPantryCategoriesQuery) refresh the same read cache, so it's kept warm by theapp's already-frequent refetch-after-mutation pattern with no separate cache-invalidation plumbing. If one
of those same queries'
fetch()rejects while offline, it now resolves with the last cached array insteadof rejecting (falls through to the normal error path if there's no cache yet, same as today).
ShoppingListPage.tsx/PantryPage.tsx: add anOfflineBannernear the header, and awindow.addEventListener('offline:sync-complete', refetch)alongside the existing mount-time fetch effect.Known, accepted risk
If a queued command actually reached the server but the response was lost (rare — most
fetch()rejectionshappen before the request leaves the browser), replaying it on reconnect double-applies it. Given the
in-scope actions are all idempotent-ish toggles/±1s a user can trivially correct by tapping again, this is
accepted for this round rather than adding request-id deduplication on the backend — flagged here rather
than silently assumed away.