wordeck-native/docs/OFFLINE_SYNC.md
Till JS 9527240bcc feat(offline): text-only Cleanup + ζ-1 Offline-Sync
Drei zusammenhängende Blöcke in einem Commit (Files überlappen sich
zwischen den Themen — sauberer Split nicht ohne Friktion möglich):

1. Wordeck-Text-Only-Cleanup
   Image-Occlusion + Audio-Front-Code raus. Server ist seit Migration
   0004_wordeck_text_only.sql text-only (in Prod waren 0 Karten der
   Typen, 0 Media-Files). Native-Code war Build-11-Altlast.
   - Gelöscht: MediaCache, MediaEnvironment, RemoteImage,
     AudioPlayerButton, MaskEditorView, CardEditorMediaFields,
     CardEditorPayload, Media.swift
   - CardType-Enum auf 5 Werte: basic / basic-reverse / cloze /
     typing / multiple-choice
   - media_refs aus Card, CardCreateBody, CardUpdateBody, call-sites
   - WordeckAPI.uploadMedia / .fetchMedia / .deleteMedia + Single-File-
     makeMultipartBody gestrichen
   - MarketplaceCardConverter ohne Media-Cases
   - CardRenderer ohne imageOcclusionView / audioFrontView

2. AI-Media-Mode raus
   /decks/from-image-Endpoint existiert serverseitig nicht (server
   registriert nur /decks/generate für Text-Prompts). Native-Aufrufe
   wären 404 — toter Code.
   - aiMedia-Case aus DeckEditorView.CreateMode, ModePicker auf
     3 Optionen (Leer / KI / CSV)
   - AIMediaFormSections, MediaFileRow, mediaPickers, thumbnail,
     ingestPhotoItems, handlePDFImport raus
   - generateDeckFromMedia + makeFromImageMultipartBody raus
   - GenerationMediaFile-Struct + PhotosUI-Import + PlatformImage-
     typealias raus
   - NSPhotoLibraryUsageDescription aus project.yml entfernt (es gibt
     keinen Photo-Library-Zugriff mehr)
   - maxMediaFiles/maxImageBytes/maxPDFBytes + inferImageMimeType +
     imageExtension aus DeckEditorHelpers raus

3. ζ-1 Offline-Sync
   Konzept in docs/OFFLINE_SYNC.md. Server-authoritative-FSRS bleibt —
   kein lokales FSRS, nur Snapshot-Modell.
   - Neue SwiftData-Models: CachedCard + CachedDueReview, beide mit
     userId/deckId-Indizes
   - ModelContainer um die zwei Models erweitert (additive Migration,
     sollte automatisch laufen — vor TestFlight verifizieren)
   - DueReview bekommt programmatischen init(review:card:) für die
     Cache-Rekonstruktion
   - DeckListStore.refresh() zieht Cards + Due-Reviews pro Deck
     parallel in einer TaskGroup; applyToCache in drei Helpers
     gesplittet (applyDecks / applyCards / applyDueReviews)
   - Karten: Upsert mit Orphan-Cleanup
   - Due-Reviews: voll ersetzt pro Refresh (Server-`due`-Zeiten
     ändern sich, Merge wäre falsch)
   - StudySession.start() fällt bei Netz-Fehler auf
     CachedDueReview-Snapshot zurück, setzt isOfflineSession-Flag
   - StudySessionView zeigt offline-Banner und am Ende der Session
     einen Hinweis „Weitere Karten erst nach Verbindung verfügbar"
   - AccountView.wipeLocalCache(): DSGVO-Wipe vor signOut() und nach
     deleteAccount → CachedDeck + CachedCard + CachedDueReview +
     PendingGrade werden gelöscht

Plus: Keychain-Test in WordeckNativeTests.swift fix — erwartete
"ev.mana.wordeck", muss seit Cross-App-SSO-Commit 19fee75
ManaSharedKeychainGroup nutzen. Auf Konstant-Reference umgestellt,
damit's nicht wieder driftet.

Verifikation:
- xcodebuild iOS-Simulator: BUILD SUCCEEDED
- swiftlint --strict: 0 violations in 68 files
- swiftformat: clean
- 37/37 Tests grün (inkl. fix-Keychain-Test)
- macOS-Build scheitert an pre-existing .topBarTrailing in
  StudySessionView (iOS-only API seit 2026-05-13, nicht durch
  diesen Commit verursacht)

Pflicht-Verifikation vor TestFlight (in PLAN.md verewigt):
- SwiftData-Migration auf Bestandsbuilder
- Offline-Endurance (50+ Karten Flugmodus)
- Logout-Wipe mit Account-Switch
- Cross-Check Web ↔ Native nach Offline-Grade

Diff: 35 files, +869 / -1622, netto ~−750 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:06:41 +02:00

11 KiB

Offline-Sync — wordeck-native

Status: Konzept-Draft (2026-05-18). Implementierung als Phase ζ-1 / ζ-2 in PLAN.md geplant, noch nicht begonnen.

Ziel

Alle Decks des Users — eigene + abonnierte Marketplace-Forks — sollen automatisch beim Login / App-Foreground gecacht werden, so dass der komplette „Heute fällige Karten lernen"-Pfad ohne Netz funktioniert. Grades laufen wie heute über die GradeQueue und drainen beim Reconnect.

Warum jetzt einfach

Mit dem Wordeck-Text-Only-Rebrand (2026-05-17) sind Bilder und Audio aus dem Schema raus. Eine Karte ist jetzt nur noch {type, fields: [String:String], deck_id, …} — pure Text. Damit ist die komplette Offline-Payload JSON-only:

Bestandteil Größe pro Eintrag Bei 10 000 Karten
Card-Record (text-only) ≈ 300 B JSON ≈ 3 MB
Review-Snapshot (FSRS-State) ≈ 150 B ≈ 1,5 MB
Distractor-Pool (nur MC, ≈ 10/Karte) ≈ 500 B ≈ 0,5 MB (pro MC-Karte)

Selbst Power-User mit 50 Decks und 5 000 Karten landen unter 5 MB Total-Footprint. SwiftData verkraftet das mit Links.

Server-Invariante bleibt

FSRS rechnet weiterhin nur am Server. Lokales FSRS bleibt verboten (CLAUDE.md §1). Der Offline-Modus ist ein Snapshot- Modell: der Client lernt das, was der Server beim letzten Sync als „due" markiert hat, schickt Grades hinterher, holt nach Sync einen frischen Snapshot. Mehr ist nicht erlaubt.

Architektur

┌────────────────────────────────────────────────────────────┐
│                    DeckListStore.refresh()                  │
│                                                             │
│  GET /decks ──┐                                             │
│               ├── TaskGroup ── per Deck ──┬── listCards()  │
│               │                            ├── dueReviews()│
│               │                            └── distractors │
│               │                                            │
│               ▼                                            │
│        SwiftData-Persistenz                                │
│        ┌─────────────┐ ┌────────────┐ ┌──────────────┐    │
│        │ CachedDeck  │ │ CachedCard │ │ CachedDue    │    │
│        │             │ │            │ │   Review     │    │
│        │ (heute)     │ │ (neu)      │ │ (neu)        │    │
│        └─────────────┘ └────────────┘ └──────────────┘    │
│                                                             │
└────────────────────────────────────────────────────────────┘
                              │
                              ▼
       ┌────────────────────────────────────────────┐
       │           StudySession.start()              │
       │                                             │
       │   try:  api.dueReviews(deckId)              │
       │   fall: CachedDueReview (deckId)            │
       │                                             │
       │   grade → GradeQueue (PendingGrade)         │
       │                                             │
       └────────────────────────────────────────────┘

Daten-Modelle (neu)

@Model
final class CachedCard {
    @Attribute(.unique) var id: String        // card_id
    var deckId: String
    var userId: String
    var typeRaw: String                       // CardType.rawValue
    var fields: [String: String]              // pures JSON-Field-Bag
    var contentHash: String?
    var createdAt: Date
    var updatedAt: Date
    var lastFetchedAt: Date

    // Multiple-Choice-Pool für Offline-Rendering.
    // Leer für non-MC-Karten.
    var distractorPool: [String] = []
}

@Model
final class CachedDueReview {
    @Attribute(.unique) var compoundId: String  // "\(cardId)-\(subIndex)"
    var cardId: String
    var subIndex: Int
    var deckId: String                          // Index für StudySession-Lookup
    var due: Date                               // Server-berechnet
    var stability: Double
    var difficulty: Double
    var stateRaw: String                        // ReviewState
    var lastReview: Date?
    var snapshottedAt: Date                     // wann gepullt
}

API-Endpoints (vorhanden, kein Server-Change nötig)

Endpoint Verwendung Limit
GET /api/v1/cards?deck_id=X komplette Card-Liste pro Deck kein Limit
GET /api/v1/reviews/due?deck_id=X&limit=500 due-Snapshot 500
GET /api/v1/decks/:deckId/distractors?card_id=Y&field=back&count=10 MC-Pool 10

Sync-Algorithmus (DeckListStore.refresh() erweitert)

1. GET /decks → remoteDecks
2. Diff Cache ↔ remoteDecks, gelöschte Decks aus Cache entfernen
3. Für jedes Deck parallel (TaskGroup):
   a. listCards(deckId) → in CachedCard upserten
   b. dueReviews(deckId, limit: 500) → CachedDueReview ersetzen
      (nicht mergen — Snapshot überschreibt komplett, weil due-Zeiten
      sich serverseitig ändern können)
   c. Für jede MC-Karte: distractors(deckId, cardId, count: 10) →
      CachedCard.distractorPool
4. WidgetSnapshot updaten (heute schon, bleibt)

StudySession-Anpassung

func start() async {
    phase = .loading
    do {
        queue = try await api.dueReviews(deckId: deckId, limit: 500)
        // ... wie heute
    } catch {
        // Netz-Fehler → Cache befragen
        queue = loadFromCache(deckId: deckId)
        if queue.isEmpty {
            phase = .failed("Kein Netz und keine gecachten Karten.")
        } else {
            Log.study.notice("Offline-Mode: \(queue.count) cached due reviews")
            phase = .studying
            isOfflineSession = true
        }
    }
}

Beim Grade-Submit ändert sich nichts: GradeQueue.submit() persistiert eh erst lokal und drained später. Das funktioniert heute schon offline.

Trigger

Wann Was
App-Foreground / Login DeckListStore.refresh() (heute) → erweitert auf Card+Review+Distractor-Sync
Pull-to-Refresh in DeckListView dasselbe
Nach subscribe(slug:) im Marketplace direkt refresh() aufrufen, damit das frisch abonnierte Deck sofort komplett gecacht ist
BGAppRefreshTask (alle ~12 h, optional, β-7-Polish) Drain Grade-Queue + Refresh; nur wenn wifi_only=true erlaubt oder User hat Mobile-Sync aktiv

Settings (in SettingsView)

  • Auto-Sync (Default: an) — schaltet Card/Review-Prefetch ein/aus
  • Background-Refresh (Default: aus) — BGAppRefreshTask
  • Cache-Footprint anzeigen — „17 Decks, 1 234 Karten, 4,2 MB"
  • Cache leeren — Wipe aller CachedCard + CachedDueReview, CachedDeck bleibt (sonst Deck-Liste leer)

Phasen

Phase Inhalt Aufwand
ζ-1 CachedCard + Sync in DeckListStore, StudySession-Cache-Fallback 1 Tag
ζ-2 CachedDueReview + Distractor-Pool für MC-Karten 0,5 Tag
ζ-3 SettingsView-Footprint + Cache-Clear 0,5 Tag
ζ-4 (optional) BGAppRefreshTask, Wi-Fi-Only-Toggle 0,5 Tag

Endurance-Pflicht (siehe PLAN.md): 200+ Karten offline lernen, Flugmodus, alle Grades landen nach Reconnect am Server, Cross- Check mit Web-Review-State.

Offene Punkte

  • 🛑 dueReviews(limit: 500) ist hardcoded — Decks > 500 Karten haben einen stillen Cap. Wenn ein Marketplace-Deck mehr als 500 fällige Karten hat (passiert bei frischen Abos), bekommt der Client offline nur die ersten 500. Optionen:
    • (a) Pagination einbauen (offset=…) und mehrere Calls chainen — billig.
    • (b) Server-Endpoint /api/v1/reviews/due-all?deck_id=X der paginiert in einer Response liefert — sauberer, braucht Backend-PR.
    • (c) Aktzeptieren, Banner „Sync unvollständig — weitere Karten erst nach Online-Refresh". Vorschlag: (a) zunächst, Schwelle im Snapshot loggen.
  • 🛑 Distractor-Pool drifted, wenn der User Karten löscht. Ein Pool von 10 Distractors zur Sync-Zeit kann nach Lösch- Aktionen Treffer in der Liste haben, die offline nicht mehr existieren. Akzeptabel, weil MC-Distractors ohnehin „Fülltext" sind — Reveal-Korrekt-Highlight kommt vom answer-Feld der Karte, nicht aus dem Pool.
  • 🛑 „Mehr Karten als der Snapshot enthält" — wenn User offline alle 100 fälligen Karten durchgelernt hat und weiter klickt, gibt es keinen lokalen Weg, „nächste fällige Karte" zu bestimmen. UX-Honest: am Ende der Session Banner zeigen („Weitere Karten erst nach Verbindung verfügbar"), Server- authoritative-FSRS bleibt damit intakt.
  • 🛑 SwiftData-Migration. Schema-Update von Build 11 → ζ-1 legt zwei neue @Model-Klassen an. Bei In-Place-Upgrade von TestFlight-Buildern muss der ModelContainer mit MigrationPlan versorgt werden — sonst Crash beim ersten Start nach Update. Wir haben das vorher noch nicht gebraucht; für ζ-1 Pflicht-Aufgabe vor Submit.
  • 🛑 Cache-Invalidierung bei Cross-Device-Edits. User editiert Karte auf Web → Native zeigt offline noch alte Version, bis der nächste Refresh läuft. Heute akzeptabel — updatedAt-Vergleich beim Sync wirft die alte Version raus. Wenn das in der Praxis weh tut, kann später ein Web-Push-Hook auf card.updated einen Targeted-Refresh triggern (nicht ζ-Scope).
  • 🛑 Logout = Cache-Wipe. Bei Sign-out alle CachedCard + CachedDueReview löschen. Heute macht auth.signOut() das nicht. Muss in ζ-1 mit rein.

Was nicht in ζ kommt

  • Lokales FSRS-Berechnen — verboten per CLAUDE.md §1.
  • Offline-Card-Create — Editor bleibt online-only. Drafting ohne Netz wäre nett, hat aber Konflikt-Auflösung als Folge- Problem. Aufgeschoben bis nach v1.
  • Media-Prefetch — gegenstandslos seit Wordeck-Rebrand (text-only).

Cross-Refs

  • CLAUDE.md — Architektur-Invarianten (§1 FSRS, §2 Offline-Read)
  • PLAN.md — Phasen-Stand
  • ../mana/docs/playbooks/WORDECK_REBRAND.md — Text-Only-Cut
  • ../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md — Greenfield-SOT
  • ../wordeck/apps/api/src/routes/cards.tsGET /cards?deck_id
  • ../wordeck/apps/api/src/routes/reviews.tsGET /reviews/due
  • ../wordeck/apps/api/src/routes/decks.ts/distractors