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>
This commit is contained in:
Till JS 2026-05-18 22:06:41 +02:00
parent 19fee75c47
commit 9527240bcc
36 changed files with 728 additions and 1565 deletions

244
docs/OFFLINE_SYNC.md Normal file
View file

@ -0,0 +1,244 @@
# 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)
```swift
@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
```swift
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.ts``GET /cards?deck_id`
- `../wordeck/apps/api/src/routes/reviews.ts``GET /reviews/due`
- `../wordeck/apps/api/src/routes/decks.ts``/distractors`