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>
11 KiB
Offline-Sync — wordeck-native
Status: Konzept-Draft (2026-05-18). Implementierung als Phase ζ-1 / ζ-2 in
PLAN.mdgeplant, 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,CachedDeckbleibt (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=Xder 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.
- (a) Pagination einbauen (
- 🛑 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 derModelContainermitMigrationPlanversorgt 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 aufcard.updatedeinen Targeted-Refresh triggern (nicht ζ-Scope). - 🛑 Logout = Cache-Wipe. Bei Sign-out alle
CachedCard+CachedDueReviewlöschen. Heute machtauth.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