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>
244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
# 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`
|