Files
Spanish/Conjuga/Conjuga/Services/VocabSessionQueue.swift
T
Trey T cee962c0e0 Vocab Flashcards — make session size configurable in Settings
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>
2026-05-17 13:06:07 -05:00

180 lines
6.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 58 cards later
/// Hard reappears 710 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 }
/// 01, 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,
]
}