v0.2.0 — Phase β-1 Decks lesen

Deck-Liste mit Web-Parität: alle eigenen Decks aus cardecky-api,
Card-/Due-Counts pro Deck (Web-Pattern: separate Calls), Pull-to-
Refresh, Offline-Read via SwiftData, Inbox-Banner für Marketplace-
Forks.

- Deck-Codable-DTO mit snake_case-CodingKeys (DeckCategory,
  DeckVisibility, FsrsSettings)
- ISO8601-Date-Decoder mit Fractional-Seconds-Toleranz
- CardsAPI.listDecks() + cardCount() + dueCount()
- CachedDeck SwiftData-Model mit lastFetchedAt
- DeckListStore (API + Cache, paralleles Counts-Fetching via TaskGroup)
- DeckListView mit forest-Theme, deck.color-Streifen, Inbox-Banner
- AccountView mit Sign-out
- DashboardView durch DeckListView ersetzt
- 6 Unit-Tests + 1 UI-Test grün

Phasen-Plan: mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 00:06:28 +02:00
parent 28b20cd934
commit f664a00b64
12 changed files with 809 additions and 85 deletions

View file

@ -3,20 +3,86 @@ import ManaCore
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt.
///
/// In Phase β-0 ist die API leer Endpoints kommen ab β-1 (Decks),
/// β-2 (Reviews), β-3 (Editor), β-4 (Media), β-5 (Marketplace).
actor CardsAPI {
private let transport: AuthenticatedTransport
private let decoder: JSONDecoder
init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractional
}
/// Health-Probe für β-0 verifiziert dass cardecky-api erreichbar
/// ist und der eigene JWT akzeptiert wird.
/// Health-Probe verifiziert dass cardecky-api erreichbar ist
/// und der eigene JWT akzeptiert wird.
func healthCheck() async throws -> Bool {
let (_, http) = try await transport.request(path: "/healthz")
return http.statusCode == 200
}
// MARK: - Decks
/// `GET /api/v1/decks?archived=false` alle aktiven Decks des Users.
/// Optional: `forkedFromMarketplaceOnly` filtert auf Inbox-Decks
/// (für den Inbox-Banner).
func listDecks(forkedFromMarketplaceOnly: Bool = false) async throws -> [Deck] {
var path = "/api/v1/decks"
if forkedFromMarketplaceOnly {
path += "?forked_from_marketplace=true"
}
let (data, http) = try await transport.request(path: path)
try ensureOK(http, data: data)
let body = try decoder.decode(DeckListResponse.self, from: data)
return body.decks
}
/// `GET /api/v1/cards?deck_id=...` Anzahl Karten in einem Deck.
/// Web macht das pro Deck einzeln; identisches Pattern hier.
func cardCount(deckId: String) async throws -> Int {
let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)")
try ensureOK(http, data: data)
return try decoder.decode(CardListResponse.self, from: data).total
}
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` Anzahl fälliger
/// Reviews in einem Deck.
func dueCount(deckId: String) async throws -> Int {
let (data, http) = try await transport.request(
path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=500"
)
try ensureOK(http, data: data)
return try decoder.decode(DueReviewsResponse.self, from: data).total
}
// MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard (200 ..< 300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
throw AuthError.serverError(status: http.statusCode, message: message)
}
}
}
private struct CardsServerError: Decodable {
let error: String?
}
extension JSONDecoder.DateDecodingStrategy {
/// Cards-API liefert ISO8601 mit Fractional-Seconds aus
/// `.toISOString()`. Standard-Strategy `.iso8601` akzeptiert die
/// fractional seconds nicht wir nutzen einen eigenen Formatter.
static let iso8601withFractional: JSONDecoder.DateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = f.date(from: raw) { return date }
f.formatOptions = [.withInternetDateTime]
if let date = f.date(from: raw) { return date }
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode ISO8601 date: \(raw)"
)
}
}

View file

@ -0,0 +1,130 @@
import Foundation
/// Deck-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toDeckDto`.
/// snake_case-Felder via `CodingKeys`, Optionals explizit nullable.
struct Deck: Codable, Identifiable, Hashable, Sendable {
let id: String
let userId: String
let name: String
let description: String?
let color: String?
let category: DeckCategory?
let visibility: DeckVisibility
let fsrsSettings: FsrsSettings
let contentHash: String?
let forkedFromMarketplaceDeckId: String?
let forkedFromMarketplaceVersionId: String?
let archivedAt: Date?
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case name
case description
case color
case category
case visibility
case fsrsSettings = "fsrs_settings"
case contentHash = "content_hash"
case forkedFromMarketplaceDeckId = "forked_from_marketplace_deck_id"
case forkedFromMarketplaceVersionId = "forked_from_marketplace_version_id"
case archivedAt = "archived_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
/// Geforkt aus dem Cardecky-Marketplace?
var isFromMarketplace: Bool {
forkedFromMarketplaceDeckId != nil
}
}
enum DeckVisibility: String, Codable, Sendable {
case `private`
case space
case `public`
}
/// Aus `cards/packages/cards-domain/src/schemas/deck.ts:DECK_CATEGORY_IDS`.
enum DeckCategory: String, Codable, Sendable, CaseIterable {
case language
case medicine
case science
case math
case history
case law
case technology
case arts
case music
case sport
case other
/// Deutsche Labels aus `DECK_CATEGORY_LABELS`.
var label: String {
switch self {
case .language: "Sprache"
case .medicine: "Medizin"
case .science: "Wissenschaft"
case .math: "Mathematik"
case .history: "Geschichte"
case .law: "Recht"
case .technology: "Technik"
case .arts: "Kunst"
case .music: "Musik"
case .sport: "Sport"
case .other: "Sonstiges"
}
}
}
/// FSRS-Settings Native bleibt schematisch agnostisch, FSRS rechnet
/// nur der Server. Wir behalten die Felder als roh-JSON, damit eine
/// neue Setting auf dem Server uns nicht bricht.
struct FsrsSettings: Codable, Sendable, Hashable {
let requestRetention: Double?
let maximumInterval: Int?
let enableFuzz: Bool?
enum CodingKeys: String, CodingKey {
case requestRetention = "request_retention"
case maximumInterval = "maximum_interval"
case enableFuzz = "enable_fuzz"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
requestRetention = try container.decodeIfPresent(Double.self, forKey: .requestRetention)
maximumInterval = try container.decodeIfPresent(Int.self, forKey: .maximumInterval)
enableFuzz = try container.decodeIfPresent(Bool.self, forKey: .enableFuzz)
}
static let empty = FsrsSettings()
private init(
requestRetention: Double? = nil,
maximumInterval: Int? = nil,
enableFuzz: Bool? = nil
) {
self.requestRetention = requestRetention
self.maximumInterval = maximumInterval
self.enableFuzz = enableFuzz
}
}
/// Server-Response von `GET /api/v1/decks`.
struct DeckListResponse: Decodable, Sendable {
let decks: [Deck]
let total: Int
}
/// Server-Response von `GET /api/v1/cards?deck_id=...`.
struct CardListResponse: Decodable, Sendable {
let total: Int
}
/// Server-Response von `GET /api/v1/reviews/due?deck_id=...`.
struct DueReviewsResponse: Decodable, Sendable {
let total: Int
}

View file

@ -0,0 +1,79 @@
import Foundation
import SwiftData
/// Lokales Cache-Model für Decks. Spiegelt das Server-DTO + zwei
/// computed Werte (cardCount, dueCount), die Web pro Deck als zusätzliche
/// API-Calls holt.
///
/// Offline-Read: Liste sichtbar ohne Netz. Server bleibt Wahrheit
/// alle Edits laufen über die API, der Cache wird nur beim Re-Fetch
/// aktualisiert.
@Model
final class CachedDeck {
@Attribute(.unique) var id: String
var userId: String
var name: String
var deckDescription: String?
var color: String?
var categoryRaw: String?
var visibilityRaw: String
var contentHash: String?
var forkedFromMarketplaceDeckId: String?
var forkedFromMarketplaceVersionId: String?
var archivedAt: Date?
var createdAt: Date
var updatedAt: Date
/// Anzahl Karten im Deck (über `/api/v1/cards?deck_id=...`).
var cardCount: Int = 0
/// Anzahl fälliger Reviews (über `/api/v1/reviews/due?deck_id=...`).
var dueCount: Int = 0
/// Zeitpunkt des letzten erfolgreichen Server-Pulls für dieses Deck.
var lastFetchedAt: Date
init(deck: Deck, cardCount: Int = 0, dueCount: Int = 0) {
id = deck.id
userId = deck.userId
name = deck.name
deckDescription = deck.description
color = deck.color
categoryRaw = deck.category?.rawValue
visibilityRaw = deck.visibility.rawValue
contentHash = deck.contentHash
forkedFromMarketplaceDeckId = deck.forkedFromMarketplaceDeckId
forkedFromMarketplaceVersionId = deck.forkedFromMarketplaceVersionId
archivedAt = deck.archivedAt
createdAt = deck.createdAt
updatedAt = deck.updatedAt
self.cardCount = cardCount
self.dueCount = dueCount
lastFetchedAt = .now
}
/// Übernimmt aktualisierte Felder vom Server-DTO.
func update(from deck: Deck, cardCount: Int, dueCount: Int) {
name = deck.name
deckDescription = deck.description
color = deck.color
categoryRaw = deck.category?.rawValue
visibilityRaw = deck.visibility.rawValue
contentHash = deck.contentHash
forkedFromMarketplaceDeckId = deck.forkedFromMarketplaceDeckId
forkedFromMarketplaceVersionId = deck.forkedFromMarketplaceVersionId
archivedAt = deck.archivedAt
updatedAt = deck.updatedAt
self.cardCount = cardCount
self.dueCount = dueCount
lastFetchedAt = .now
}
var category: DeckCategory? {
categoryRaw.flatMap(DeckCategory.init(rawValue:))
}
var isFromMarketplace: Bool {
forkedFromMarketplaceDeckId != nil
}
}

View file

@ -0,0 +1,99 @@
import Foundation
import ManaCore
import Observation
import SwiftData
/// Orchestriert API + SwiftData-Cache für die Deck-Liste.
/// View bindet sich an `state` und `errorMessage`.
@MainActor
@Observable
final class DeckListStore {
enum State: Sendable {
case idle
case loading
case loaded
case failed
}
private(set) var state: State = .idle
private(set) var errorMessage: String?
private let api: CardsAPI
private let context: ModelContext
init(auth: AuthClient, context: ModelContext) {
api = CardsAPI(auth: auth)
self.context = context
}
/// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt
/// der Cache (offline-readable).
func refresh() async {
state = .loading
errorMessage = nil
do {
let decks = try await api.listDecks()
try await applyToCache(decks: decks)
state = .loaded
Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server")
} catch let error as AuthError {
errorMessage = error.errorDescription
state = .failed
Log.sync.error("Deck refresh failed: \(error.localizedDescription, privacy: .public)")
} catch {
errorMessage = String(describing: error)
state = .failed
Log.sync.error("Deck refresh failed: \(String(describing: error), privacy: .public)")
}
}
private func applyToCache(decks remoteDecks: [Deck]) async throws {
let remoteIDs = Set(remoteDecks.map(\.id))
// 1. Bestehende Cache-Entries finden
let descriptor = FetchDescriptor<CachedDeck>()
let cached = (try? context.fetch(descriptor)) ?? []
let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) })
// 2. Gelöschte Decks aus Cache entfernen
for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) {
context.delete(cachedDeck)
}
// 3. Counts parallel holen
let counts = await withTaskGroup(of: (String, Int, Int).self) { group in
for deck in remoteDecks {
group.addTask { [api] in
async let cards = api.cardCount(deckId: deck.id)
async let due = api.dueCount(deckId: deck.id)
let cardCount = (try? await cards) ?? 0
let dueCount = (try? await due) ?? 0
return (deck.id, cardCount, dueCount)
}
}
var result: [String: (cardCount: Int, dueCount: Int)] = [:]
for await (id, c, d) in group {
result[id] = (c, d)
}
return result
}
// 4. Neue/aktualisierte Decks einarbeiten
for deck in remoteDecks {
let counts = counts[deck.id] ?? (0, 0)
if let existing = cachedByID[deck.id] {
existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount)
} else {
let cachedDeck = CachedDeck(
deck: deck,
cardCount: counts.cardCount,
dueCount: counts.dueCount
)
context.insert(cachedDeck)
}
}
try context.save()
}
}