cee962c0e0
VocabVerbPool.sessionCardLimit was hardcoded at 20. Settings now has a "Vocab Flashcards → Cards per session" picker (10 / 15 / 20 / 25 / 30 / 50 / All) backed by the vocabSessionCardLimit @AppStorage key. VocabVerbPool.sessionCardLimit became a computed property reading that key (0/unset → default 20; 999 → "All"). Applies to both Quiz and Learn modes since they share the same session pool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.0 KiB
Swift
180 lines
6.0 KiB
Swift
import Foundation
|
||
import SharedModels
|
||
import SwiftData
|
||
|
||
/// In-session "learning steps" queue for vocab practice — the short-term
|
||
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
|
||
///
|
||
/// A card is requeued a relative number of positions ahead based on the
|
||
/// rating, mirroring Anki's learning steps but position-based instead of
|
||
/// time-based:
|
||
/// Again → reappears 5–8 cards later
|
||
/// Hard → reappears 7–10 cards later
|
||
/// Good → first time: advances to `review`, reappears ~20 cards later
|
||
/// already in review: graduates (leaves the session)
|
||
/// Easy → graduates immediately
|
||
///
|
||
/// `answer` returns a `ReviewQuality` only when the card graduates — that's
|
||
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
|
||
/// Again/Hard presses don't touch the cross-session schedule.
|
||
struct VocabSessionQueue {
|
||
|
||
enum CardState {
|
||
case new // never answered this session
|
||
case learning // answered Again/Hard at least once
|
||
case review // answered Good once, one confirmation pass to go
|
||
}
|
||
|
||
enum Rating {
|
||
case again, hard, good, easy
|
||
}
|
||
|
||
struct Entry: Identifiable {
|
||
let id = UUID()
|
||
let verb: Verb
|
||
var state: CardState
|
||
}
|
||
|
||
private(set) var queue: [Entry]
|
||
private(set) var learnedCount: Int = 0
|
||
private let originalVerbs: [Verb]
|
||
|
||
init(verbs: [Verb]) {
|
||
originalVerbs = verbs
|
||
queue = verbs.map { Entry(verb: $0, state: .new) }
|
||
}
|
||
|
||
// MARK: - State
|
||
|
||
var current: Entry? { queue.first }
|
||
var isComplete: Bool { queue.isEmpty }
|
||
var remainingCount: Int { queue.count }
|
||
|
||
/// 0–1, climbs as cards graduate. Requeuing a card lowers it slightly but
|
||
/// it always trends to 1 as the session drains.
|
||
var progress: Double {
|
||
let total = learnedCount + queue.count
|
||
return total == 0 ? 1 : Double(learnedCount) / Double(total)
|
||
}
|
||
|
||
// MARK: - Answering
|
||
|
||
/// Apply a rating to the current card. Returns the `ReviewQuality` to
|
||
/// record in the long-term SRS *iff* the card graduated; nil if it was
|
||
/// requeued for more in-session practice.
|
||
@discardableResult
|
||
mutating func answer(_ rating: Rating) -> ReviewQuality? {
|
||
guard !queue.isEmpty else { return nil }
|
||
var entry = queue.removeFirst()
|
||
|
||
switch rating {
|
||
case .again:
|
||
entry.state = .learning
|
||
insert(entry, offset: Int.random(in: 5...8))
|
||
return nil
|
||
|
||
case .hard:
|
||
entry.state = .learning
|
||
insert(entry, offset: Int.random(in: 7...10))
|
||
return nil
|
||
|
||
case .good:
|
||
if entry.state == .review {
|
||
learnedCount += 1
|
||
return .good
|
||
}
|
||
entry.state = .review
|
||
insert(entry, offset: Int.random(in: 16...24))
|
||
return nil
|
||
|
||
case .easy:
|
||
learnedCount += 1
|
||
return .easy
|
||
}
|
||
}
|
||
|
||
/// Rebuild the session from the same verb set (re-shuffled) — "Study Again".
|
||
mutating func restart() {
|
||
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
|
||
learnedCount = 0
|
||
}
|
||
|
||
// MARK: - Private
|
||
|
||
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
|
||
/// cards will be shown before it reappears. Clamps to the queue's end.
|
||
private mutating func insert(_ entry: Entry, offset: Int) {
|
||
let idx = min(queue.count, offset)
|
||
queue.insert(entry, at: idx)
|
||
}
|
||
}
|
||
|
||
// MARK: - Session verb pool
|
||
|
||
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
|
||
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
|
||
/// sitting is bounded — proper SRS behaviour rather than a 100+ card slog.
|
||
enum VocabVerbPool {
|
||
|
||
/// Maximum verbs in one session, from the "Cards per session" setting
|
||
/// (`vocabSessionCardLimit`). Defaults to 20 when unset; 999 means "All".
|
||
/// Overdue cards are pulled first, then new (never-reviewed) verbs.
|
||
static var sessionCardLimit: Int {
|
||
let stored = UserDefaults.standard.integer(forKey: "vocabSessionCardLimit")
|
||
return stored == 0 ? 20 : stored
|
||
}
|
||
|
||
static func sessionVerbs(
|
||
localContext: ModelContext,
|
||
cloudContext: ModelContext
|
||
) -> [Verb] {
|
||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
|
||
|
||
let store = ReferenceStore(context: localContext)
|
||
let pool = levels.isEmpty
|
||
? store.fetchVerbs()
|
||
: store.fetchVerbs(selectedLevels: levels)
|
||
|
||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||
let cardByVerbId = Dictionary(
|
||
reviewCards.map { ($0.verbId, $0) },
|
||
uniquingKeysWith: { existing, _ in existing }
|
||
)
|
||
|
||
let now = Date()
|
||
var due: [(verb: Verb, dueDate: Date)] = []
|
||
var fresh: [Verb] = []
|
||
for verb in pool {
|
||
if let card = cardByVerbId[verb.id] {
|
||
if card.dueDate <= now {
|
||
due.append((verb, card.dueDate))
|
||
}
|
||
// Not yet due → intentionally skipped; that's the SRS schedule.
|
||
} else {
|
||
fresh.append(verb)
|
||
}
|
||
}
|
||
|
||
// Most-overdue first, then new verbs (lower rank = more common first).
|
||
due.sort { $0.dueDate < $1.dueDate }
|
||
fresh.sort { $0.rank < $1.rank }
|
||
|
||
let ordered = due.map(\.verb) + fresh
|
||
return Array(ordered.prefix(sessionCardLimit))
|
||
}
|
||
}
|
||
|
||
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
|
||
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
|
||
enum VocabExampleTenseIds {
|
||
static let canonical: [String] = [
|
||
TenseID.ind_presente.rawValue,
|
||
TenseID.ind_preterito.rawValue,
|
||
TenseID.ind_imperfecto.rawValue,
|
||
TenseID.ind_futuro.rawValue,
|
||
TenseID.subj_presente.rawValue,
|
||
TenseID.imp_afirmativo.rawValue,
|
||
]
|
||
}
|