cards-native/Sources/Core/API/CardsAPI.swift
Till JS 3b861af3fb v0.3.0 — Phase β-2 Study-Loop
Voller Lern-Flow mit Web-Parität: fällige Karten via /reviews/due
laden, flip + rate (4 Buttons + Haptic), Grades via Offline-Queue
ans Server-FSRS schicken.

- Card/Review/DueReview DTOs mit snake_case + camelCase-deckId-
  Sonderfall im embedded card-Subobjekt
- CardType-Enum (alle 7 Typen), Rating-Enum mit deutschen Labels
- Cloze-Helper 1:1-Port aus cards-domain (extractClusterIds,
  subIndexCount, clusterId, renderPrompt/Answer, hint)
- CardsAPI.dueReviews(deckId:) + gradeReview(cardId,subIndex,rating,reviewedAt)
- PendingGrade SwiftData-Model + GradeQueue (FIFO-Drain, originaler
  Timestamp bleibt, bei Netzfehler in Queue, Retry beim nächsten Drain)
- StudySession @Observable State-Machine
- CardRenderer für basic, basic-reverse, cloze; Placeholder für
  image-occlusion/audio-front/typing/multiple-choice (β-3/β-4)
- RatingBar mit UIImpactFeedbackGenerator (medium/heavy)
- StudySessionView per NavigationLink aus DeckListView
- 9 neue Tests (Cloze: 8, Review-Decoding: 3), insgesamt 17 grün

Server-authoritative FSRS bleibt — kein ts-fsrs-Port.
Endurance-Test auf realem Gerät steht aus (siehe PLAN.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:16:11 +02:00

128 lines
4.9 KiB
Swift

import Foundation
import ManaCore
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt.
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 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: - Study
/// `GET /api/v1/reviews/due?deck_id=...&limit=500` fällige Reviews
/// inklusive zugehöriger Card-Daten. Hot-Path für die Study-View.
func dueReviews(deckId: String, limit: Int = 500) async throws -> [DueReview] {
let (data, http) = try await transport.request(
path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=\(limit)"
)
try ensureOK(http, data: data)
return try decoder.decode(DueReviewsListResponse.self, from: data).reviews
}
/// `POST /api/v1/reviews/:cardId/:subIndex/grade` gibt eine
/// Bewertung ab. Server rechnet FSRS, antwortet mit aktualisiertem
/// Review.
@discardableResult
func gradeReview(
cardId: String,
subIndex: Int,
rating: Rating,
reviewedAt: Date = .now
) async throws -> Review {
let body = try makeJSON(GradeReviewBody(rating: rating, reviewedAt: reviewedAt))
let (data, http) = try await transport.request(
path: "/api/v1/reviews/\(cardId)/\(subIndex)/grade",
method: "POST",
body: body
)
try ensureOK(http, data: data)
return try decoder.decode(Review.self, from: data)
}
// MARK: - JSON-Encoding
private func makeJSON<T: Encodable>(_ value: T) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(value)
}
// 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)"
)
}
}