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:
parent
19fee75c47
commit
9527240bcc
36 changed files with 728 additions and 1565 deletions
244
docs/OFFLINE_SYNC.md
Normal file
244
docs/OFFLINE_SYNC.md
Normal 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`
|
||||
Loading…
Add table
Add a link
Reference in a new issue