dd08a09860
Addresses the lead-review findings on the new vocab SRS/persistence code: - Resume a persisted study group only when every stored verb resolves, else rebuild fresh — a partial resume desynced learnedCount from a shrunken queue and then persisted the loss. - studyAgain() clears the finished group before building a new one. - VocabStudyGroupStore.persist() collapses duplicate group records (cross-device sync races) down to the newest. - Learn mode now derives its verb list from the live quiz queue, so it browses exactly what's left to learn instead of a stale frozen pool. - VerbExampleGenerator retries when the first batch throws, not only when it returns bad data. - Per-verb example generation tracks in-flight verbs in a Set, so the loading spinner clears reliably and duplicate generations are skipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
90 lines
3.4 KiB
Swift
90 lines
3.4 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// The user's active vocab-flashcard study set, persisted and CloudKit-synced
|
|
/// so the same group of verbs follows them across launches and across devices.
|
|
/// A new group is created when the previous one is fully learned.
|
|
///
|
|
/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids
|
|
/// `@Attribute(.unique)`). There is one active standard group at a time.
|
|
@Model
|
|
final class VocabStudyGroup {
|
|
var id: String = ""
|
|
/// JSON-encoded `[StoredVocabEntry]` — the in-session queue (un-graduated
|
|
/// verbs only) in order, each with its learning-step state.
|
|
var entriesJSON: Data = Data()
|
|
var learnedCount: Int = 0
|
|
var createdAt: Date = Date()
|
|
|
|
init(entriesJSON: Data, learnedCount: Int) {
|
|
self.id = Self.activeID
|
|
self.entriesJSON = entriesJSON
|
|
self.learnedCount = learnedCount
|
|
self.createdAt = Date()
|
|
}
|
|
|
|
/// Single active standard-session group.
|
|
static let activeID = "active-standard"
|
|
|
|
var entries: [StoredVocabEntry] {
|
|
(try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? []
|
|
}
|
|
}
|
|
|
|
/// One verb's persisted spot in the study group.
|
|
struct StoredVocabEntry: Codable {
|
|
var verbId: Int
|
|
/// Raw value of `VocabSessionQueue.CardState`.
|
|
var state: String
|
|
}
|
|
|
|
/// Fetch / persist / clear the active study group. Operates on the cloud
|
|
/// context so the group syncs across devices.
|
|
struct VocabStudyGroupStore {
|
|
let context: ModelContext
|
|
|
|
/// The current active group, or nil. If duplicate records exist (two
|
|
/// devices both created one before sync settled), the newest wins.
|
|
func activeGroup() -> VocabStudyGroup? {
|
|
let id = VocabStudyGroup.activeID
|
|
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
|
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
|
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
|
)
|
|
return (try? context.fetch(descriptor))?.first
|
|
}
|
|
|
|
/// Write the in-progress group, creating it if needed. If duplicate records
|
|
/// exist (two devices created a group before sync settled), the newest is
|
|
/// updated and the rest deleted so a single record survives.
|
|
func persist(entries: [StoredVocabEntry], learnedCount: Int) {
|
|
let data = (try? JSONEncoder().encode(entries)) ?? Data()
|
|
let id = VocabStudyGroup.activeID
|
|
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
|
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
|
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
|
)
|
|
let existing = (try? context.fetch(descriptor)) ?? []
|
|
if let newest = existing.first {
|
|
newest.entriesJSON = data
|
|
newest.learnedCount = learnedCount
|
|
for duplicate in existing.dropFirst() { context.delete(duplicate) }
|
|
} else {
|
|
context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
|
|
}
|
|
try? context.save()
|
|
}
|
|
|
|
/// Remove the active group (and any duplicates) — the set is finished.
|
|
func clear() {
|
|
let id = VocabStudyGroup.activeID
|
|
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
|
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
|
|
)
|
|
for group in (try? context.fetch(descriptor)) ?? [] {
|
|
context.delete(group)
|
|
}
|
|
try? context.save()
|
|
}
|
|
}
|