Files
Spanish/Conjuga/Conjuga/Models/VocabStudyGroup.swift
T
Trey T dd08a09860 Vocab Practice — apply code-review fixes for the SRS session work
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>
2026-05-17 15:59:33 -05:00

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()
}
}