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>
This commit is contained in:
parent
f664a00b64
commit
3b861af3fb
15 changed files with 1013 additions and 23 deletions
|
|
@ -54,6 +54,46 @@ actor CardsAPI {
|
|||
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 {
|
||||
|
|
|
|||
51
Sources/Core/Domain/Card.swift
Normal file
51
Sources/Core/Domain/Card.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto`
|
||||
/// und `cards/packages/cards-domain/src/schemas/card.ts`.
|
||||
struct Card: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let deckId: String
|
||||
let userId: String
|
||||
let type: CardType
|
||||
let fields: [String: String]
|
||||
let mediaRefs: [String]
|
||||
let contentHash: String?
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case deckId = "deck_id"
|
||||
case userId = "user_id"
|
||||
case type
|
||||
case fields
|
||||
case mediaRefs = "media_refs"
|
||||
case contentHash = "content_hash"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern
|
||||
/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types
|
||||
/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar.
|
||||
enum CardType: String, Codable, Sendable, CaseIterable {
|
||||
case basic
|
||||
case basicReverse = "basic-reverse"
|
||||
case cloze
|
||||
case imageOcclusion = "image-occlusion"
|
||||
case audioFront = "audio-front"
|
||||
case typing
|
||||
case multipleChoice = "multiple-choice"
|
||||
}
|
||||
|
||||
/// Vereinfachtes Card-Sub-Objekt aus `/reviews/due?deck_id=X`-Response.
|
||||
/// Server liefert nur 4 Felder (id, deckId, type, fields) als Drizzle-
|
||||
/// Joined-Subset — Achtung: `deckId` hier in **camelCase**, nicht
|
||||
/// snake_case wie sonst.
|
||||
struct ReviewCard: Codable, Hashable, Sendable {
|
||||
let id: String
|
||||
let deckId: String
|
||||
let type: CardType
|
||||
let fields: [String: String]
|
||||
}
|
||||
78
Sources/Core/Domain/Cloze.swift
Normal file
78
Sources/Core/Domain/Cloze.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import Foundation
|
||||
|
||||
/// Cloze-Helpers. 1:1-Port der Funktionen aus
|
||||
/// `cards/packages/cards-domain/src/cloze.ts`.
|
||||
///
|
||||
/// **Web-Parität-Hinweis:** Web rendert Cloze client-side. Native macht
|
||||
/// dasselbe, weil Server keinen Render-Endpoint dafür hat. Pure-String-
|
||||
/// Manipulation, kein FSRS — Mocking gegen die TS-Implementierung via
|
||||
/// Fixture-Tests.
|
||||
///
|
||||
/// Markup: `{{cN::answer}}` oder `{{cN::answer::hint}}`. N ist
|
||||
/// 1-basierte Cluster-ID. Mehrere Cluster pro Karte → mehrere
|
||||
/// Sub-Index-Reviews.
|
||||
enum Cloze {
|
||||
/// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert,
|
||||
/// weil `Regex` unter Strict-Concurrency nicht Sendable ist.
|
||||
private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> {
|
||||
#/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/#
|
||||
}
|
||||
|
||||
/// Distinct Cluster-IDs, sortiert.
|
||||
static func extractClusterIds(_ text: String) -> [Int] {
|
||||
var ids = Set<Int>()
|
||||
for match in text.matches(of: clusterPattern) {
|
||||
if let n = Int(match.output.1), n >= 1 {
|
||||
ids.insert(n)
|
||||
}
|
||||
}
|
||||
return ids.sorted()
|
||||
}
|
||||
|
||||
/// Hint für einen Cluster (erstes Vorkommen gewinnt).
|
||||
static func hint(for text: String, clusterId: Int) -> String? {
|
||||
for match in text.matches(of: clusterPattern) {
|
||||
if let n = Int(match.output.1), n == clusterId, let hint = match.output.3 {
|
||||
return String(hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Anzahl distinct Cluster — entspricht Sub-Index-Count.
|
||||
static func subIndexCount(_ text: String) -> Int {
|
||||
extractClusterIds(text).count
|
||||
}
|
||||
|
||||
/// Mapping Sub-Index → Cluster-ID.
|
||||
static func clusterId(for text: String, subIndex: Int) -> Int? {
|
||||
let ids = extractClusterIds(text)
|
||||
guard ids.indices.contains(subIndex) else { return nil }
|
||||
return ids[subIndex]
|
||||
}
|
||||
|
||||
/// Prompt-Render: aktiver Cluster wird zu `[…]` (oder `[hint]`),
|
||||
/// alle anderen werden auf ihre Antwort expandiert.
|
||||
static func renderPrompt(_ text: String, activeClusterId: Int) -> String {
|
||||
text.replacing(clusterPattern) { match in
|
||||
guard let n = Int(match.output.1) else { return String(match.output.0) }
|
||||
if n == activeClusterId {
|
||||
if let hint = match.output.3 {
|
||||
return "[\(hint)]"
|
||||
}
|
||||
return "[…]"
|
||||
}
|
||||
return String(match.output.2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Antwort-Render: alle Cluster expandiert. Aktiver Cluster wird
|
||||
/// als Markdown-Bold markiert.
|
||||
static func renderAnswer(_ text: String, activeClusterId: Int) -> String {
|
||||
text.replacing(clusterPattern) { match in
|
||||
guard let n = Int(match.output.1) else { return String(match.output.0) }
|
||||
let answer = String(match.output.2)
|
||||
return n == activeClusterId ? "**\(answer)**" : answer
|
||||
}
|
||||
}
|
||||
}
|
||||
113
Sources/Core/Domain/Review.swift
Normal file
113
Sources/Core/Domain/Review.swift
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import Foundation
|
||||
|
||||
/// Rating-Werte für `POST /reviews/:cardId/:subIndex/grade`.
|
||||
/// Aus `cards/packages/cards-domain/src/schemas/review.ts:RatingSchema`.
|
||||
enum Rating: String, Codable, Sendable, CaseIterable {
|
||||
case again
|
||||
case hard
|
||||
case good
|
||||
case easy
|
||||
|
||||
/// Anzeige-Label auf dem Rating-Button.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .again: "Nochmal"
|
||||
case .hard: "Schwer"
|
||||
case .good: "Gut"
|
||||
case .easy: "Leicht"
|
||||
}
|
||||
}
|
||||
|
||||
/// Kurz-Symbol für minimalistische UI.
|
||||
var shortcut: String {
|
||||
switch self {
|
||||
case .again: "1"
|
||||
case .hard: "2"
|
||||
case .good: "3"
|
||||
case .easy: "4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FSRS-Review-State. Aus `ReviewStateSchema`.
|
||||
enum ReviewState: String, Codable, Sendable {
|
||||
case new
|
||||
case learning
|
||||
case review
|
||||
case relearning
|
||||
}
|
||||
|
||||
/// Review-DTO. Wire-Format aus `cards/apps/api/src/routes/reviews.ts:toReviewDto`.
|
||||
struct Review: Codable, Hashable, Sendable {
|
||||
let cardId: String
|
||||
let subIndex: Int
|
||||
let userId: String
|
||||
let due: Date
|
||||
let stability: Double
|
||||
let difficulty: Double
|
||||
let elapsedDays: Double
|
||||
let scheduledDays: Double
|
||||
let learningSteps: Int
|
||||
let reps: Int
|
||||
let lapses: Int
|
||||
let state: ReviewState
|
||||
let lastReview: Date?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case cardId = "card_id"
|
||||
case subIndex = "sub_index"
|
||||
case userId = "user_id"
|
||||
case due
|
||||
case stability
|
||||
case difficulty
|
||||
case elapsedDays = "elapsed_days"
|
||||
case scheduledDays = "scheduled_days"
|
||||
case learningSteps = "learning_steps"
|
||||
case reps
|
||||
case lapses
|
||||
case state
|
||||
case lastReview = "last_review"
|
||||
}
|
||||
}
|
||||
|
||||
/// Eintrag aus `/reviews/due?deck_id=X` — Review + zugehörige Card.
|
||||
struct DueReview: Codable, Hashable, Sendable, Identifiable {
|
||||
let review: Review
|
||||
let card: ReviewCard
|
||||
|
||||
var id: String { "\(review.cardId)-\(review.subIndex)" }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Flat-Decoding: Review-Felder + card-Objekt im selben JSON-Objekt
|
||||
review = try Review(from: decoder)
|
||||
card = try container.decode(ReviewCard.self, forKey: .card)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
try review.encode(to: encoder)
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(card, forKey: .card)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case card
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper-Response von `GET /api/v1/reviews/due?deck_id=X`.
|
||||
struct DueReviewsListResponse: Decodable, Sendable {
|
||||
let reviews: [DueReview]
|
||||
let total: Int
|
||||
}
|
||||
|
||||
/// Body für `POST /reviews/:cardId/:subIndex/grade`.
|
||||
struct GradeReviewBody: Encodable, Sendable {
|
||||
let rating: Rating
|
||||
let reviewedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case rating
|
||||
case reviewedAt = "reviewed_at"
|
||||
}
|
||||
}
|
||||
31
Sources/Core/Storage/PendingGrade.swift
Normal file
31
Sources/Core/Storage/PendingGrade.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Offline-Grade in der lokalen Queue. Wird beim Reconnect der Reihe nach
|
||||
/// an `POST /reviews/:cardId/:subIndex/grade` gesendet — mit dem
|
||||
/// **originalen** `reviewedAt`-Timestamp, damit der Server-FSRS
|
||||
/// korrekt rechnet.
|
||||
@Model
|
||||
final class PendingGrade {
|
||||
@Attribute(.unique) var id: String
|
||||
var cardId: String
|
||||
var subIndex: Int
|
||||
var ratingRaw: String
|
||||
var reviewedAt: Date
|
||||
var queuedAt: Date
|
||||
var lastTryAt: Date?
|
||||
var lastError: String?
|
||||
|
||||
init(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date) {
|
||||
id = "\(cardId)-\(subIndex)-\(reviewedAt.timeIntervalSince1970)"
|
||||
self.cardId = cardId
|
||||
self.subIndex = subIndex
|
||||
ratingRaw = rating.rawValue
|
||||
self.reviewedAt = reviewedAt
|
||||
queuedAt = .now
|
||||
}
|
||||
|
||||
var rating: Rating? {
|
||||
Rating(rawValue: ratingRaw)
|
||||
}
|
||||
}
|
||||
91
Sources/Core/Sync/GradeQueue.swift
Normal file
91
Sources/Core/Sync/GradeQueue.swift
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
import Observation
|
||||
import SwiftData
|
||||
|
||||
/// Persistente Offline-Queue für Grade-Aktionen. Drain-Loop kann
|
||||
/// vom UI ausgelöst werden (bei Reconnect oder App-Foreground).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GradeQueue {
|
||||
private(set) var isDraining = false
|
||||
private(set) var lastDrainError: String?
|
||||
|
||||
private let api: CardsAPI
|
||||
private let context: ModelContext
|
||||
|
||||
init(api: CardsAPI, context: ModelContext) {
|
||||
self.api = api
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Enqueue + sofort versuchen zu senden. Bei Fehler bleibt der
|
||||
/// Eintrag in der Queue.
|
||||
func submit(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date = .now) async {
|
||||
let grade = PendingGrade(
|
||||
cardId: cardId,
|
||||
subIndex: subIndex,
|
||||
rating: rating,
|
||||
reviewedAt: reviewedAt
|
||||
)
|
||||
context.insert(grade)
|
||||
try? context.save()
|
||||
Log.study.info(
|
||||
"Queued grade for \(cardId, privacy: .public)/\(subIndex, privacy: .public): \(rating.rawValue, privacy: .public)"
|
||||
)
|
||||
await drain()
|
||||
}
|
||||
|
||||
/// Schickt alle pending grades in FIFO-Reihenfolge ab. Bei Server-
|
||||
/// Erfolg: aus Queue löschen. Bei Netzfehler: Loop abbrechen
|
||||
/// (kommender Drain probiert es nochmal).
|
||||
func drain() async {
|
||||
guard !isDraining else { return }
|
||||
isDraining = true
|
||||
defer { isDraining = false }
|
||||
|
||||
let descriptor = FetchDescriptor<PendingGrade>(
|
||||
sortBy: [SortDescriptor(\.queuedAt, order: .forward)]
|
||||
)
|
||||
let pending = (try? context.fetch(descriptor)) ?? []
|
||||
guard !pending.isEmpty else {
|
||||
lastDrainError = nil
|
||||
return
|
||||
}
|
||||
|
||||
for grade in pending {
|
||||
guard let rating = grade.rating else {
|
||||
context.delete(grade)
|
||||
continue
|
||||
}
|
||||
do {
|
||||
_ = try await api.gradeReview(
|
||||
cardId: grade.cardId,
|
||||
subIndex: grade.subIndex,
|
||||
rating: rating,
|
||||
reviewedAt: grade.reviewedAt
|
||||
)
|
||||
context.delete(grade)
|
||||
try? context.save()
|
||||
} catch {
|
||||
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
grade.lastTryAt = .now
|
||||
grade.lastError = msg
|
||||
try? context.save()
|
||||
lastDrainError = msg
|
||||
Log.study.notice(
|
||||
"Drain stopped for \(grade.cardId, privacy: .public)/\(grade.subIndex, privacy: .public): \(msg, privacy: .public)"
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
lastDrainError = nil
|
||||
Log.study.info("Drain complete")
|
||||
}
|
||||
|
||||
/// Wie viele Grades hängen aktuell offline?
|
||||
func pendingCount() -> Int {
|
||||
let descriptor = FetchDescriptor<PendingGrade>()
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue