2 Commits

Author SHA1 Message Date
Trey T 4b8c966685 Merge book-reader read-aloud features into vocab-session-tuning 2026-06-09 10:44:22 -05:00
Trey T 0ad448a600 Vocab sessions — new-word throttle + per-type status metrics
- New "New Words Per Session" setting (verbs/nouns/adjectives, 0–20 or
  All, default 10). Session builders now fill with due reviews first, then
  add fresh words only up to that throttle in the leftover room — so reviews
  take priority and new vocab is introduced steadily. Fixes both flashcards
  and multiple choice; Review Learned untouched.
- New per-type word-status metrics in Settings (New / Overdue / Due today /
  Upcoming / Learned + Total), scoped to the enabled levels, shown under the
  Verb Levels and Vocabulary Levels sections. Backed by WordStatusMetrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:43:42 -05:00
5 changed files with 240 additions and 4 deletions
@@ -93,6 +93,7 @@
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */; };
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
@@ -286,6 +287,7 @@
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordStatusMetrics.swift; sourceTree = "<group>"; };
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
@@ -416,6 +418,7 @@
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
);
path = Services;
@@ -924,6 +927,7 @@
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */,
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -130,6 +130,21 @@ enum LexemePool {
return stored == 0 ? 20 : stored
}
/// Max brand-new words to introduce per session for a POS, from its
/// "New words per session" setting. 0 is a valid value (review-only), so
/// "unset" is distinguished from 0 and defaults to 10. 999 means "no
/// throttle" (fill whatever room reviews leave).
static func newWordsPerSession(for partOfSpeech: String) -> Int {
let key: String
switch partOfSpeech {
case "noun": key = "nounNewWordsPerSession"
case "adjective": key = "adjectiveNewWordsPerSession"
default: key = "lexemeNewWordsPerSession"
}
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
return UserDefaults.standard.integer(forKey: key)
}
static func sessionLexemes(
partOfSpeech: String,
drillMode: String,
@@ -179,8 +194,15 @@ enum LexemePool {
return lhs.baseForm < rhs.baseForm
}
let ordered = due.map(\.lexeme) + fresh
return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech)))
// Reviews take priority: due cards fill the session first, then up to
// `newMax` fresh words take whatever room is left (Anki-style new-card
// throttle). 999 = no throttle (old behavior: fill the cap with fresh).
let cap = sessionCardLimit(for: partOfSpeech)
let newMax = newWordsPerSession(for: partOfSpeech)
let dueTaken = Array(due.map(\.lexeme).prefix(cap))
let remaining = cap - dueTaken.count
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
return dueTaken + newTaken
}
/// Lexemes the user has already studied at least once for `(POS, drill)`,
@@ -142,6 +142,15 @@ enum VocabVerbPool {
return stored == 0 ? 20 : stored
}
/// Max brand-new verbs to introduce per session, from the "New verbs per
/// session" setting. 0 is valid (review-only), so "unset" defaults to 10;
/// 999 means "no throttle". Mirrors `LexemePool.newWordsPerSession`.
static var newWordsPerSession: Int {
let key = "vocabNewWordsPerSession"
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
return UserDefaults.standard.integer(forKey: key)
}
static func sessionVerbs(
localContext: ModelContext,
cloudContext: ModelContext
@@ -178,8 +187,14 @@ enum VocabVerbPool {
due.sort { $0.dueDate < $1.dueDate }
fresh.sort { $0.rank < $1.rank }
let ordered = due.map(\.verb) + fresh
return Array(ordered.prefix(sessionCardLimit))
// Reviews take priority: due verbs fill the session first, then up to
// `newMax` fresh verbs take whatever room is left. 999 = no throttle.
let cap = sessionCardLimit
let newMax = newWordsPerSession
let dueTaken = Array(due.map(\.verb).prefix(cap))
let remaining = cap - dueTaken.count
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
return dueTaken + newTaken
}
/// Verbs the user has already studied at least once (have a
@@ -0,0 +1,107 @@
import Foundation
import SharedModels
import SwiftData
/// Status breakdown for a word type, scoped to the levels the user currently
/// has enabled. Buckets are mutually exclusive and sum to `total` (the count
/// of enabled-level words of that type).
struct StatusCounts: Equatable {
var new = 0 // never studied (no review card)
var overdue = 0 // card due before today
var dueToday = 0 // card due today
var upcoming = 0 // scheduled for the future, still maturing (interval < mature)
var learned = 0 // scheduled for the future and mature (interval >= mature)
var total: Int { new + overdue + dueToday + upcoming + learned }
}
/// Counts words by SRS status for the Settings progress readout. Pure counting
/// over the level-scoped reference pool + review cards no mutation.
enum WordStatusMetrics {
/// Anki convention: a card with an interval of three weeks or more is
/// considered "mature" rather than still being learned.
private static let matureIntervalDays = 21
static func verbCounts(
selectedLevels: Set<VerbLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> StatusCounts {
let store = ReferenceStore(context: localContext)
let levelStrings = Set(selectedLevels.map(\.rawValue))
// Mirror `VocabVerbPool.sessionVerbs`: an empty selection means "all".
let verbs = levelStrings.isEmpty ? store.fetchVerbs() : store.fetchVerbs(selectedLevels: levelStrings)
let cards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let byId = Dictionary(cards.map { ($0.verbId, $0) }, uniquingKeysWith: { existing, _ in existing })
var counts = StatusCounts()
let calendar = Calendar.current
let now = Date()
for verb in verbs {
if let card = byId[verb.id] {
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
} else {
counts.new += 1
}
}
return counts
}
static func lexemeCounts(
partOfSpeech: String,
drillMode: String = "recall",
selectedLevels: Set<LexemeLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> StatusCounts {
let lexDescriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
)
let all = (try? localContext.fetch(lexDescriptor)) ?? []
// Scope to the enabled CEFR levels same gate the fresh pool uses.
let pool = all.filter { selectedLevels.contains(LexemeLevel.level(forRank: $0.frequencyRank)) }
let cardDescriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let cards = (try? cloudContext.fetch(cardDescriptor)) ?? []
let byId = Dictionary(cards.map { ($0.lexemeId, $0) }, uniquingKeysWith: { existing, _ in existing })
var counts = StatusCounts()
let calendar = Calendar.current
let now = Date()
for lexeme in pool {
if let card = byId[lexeme.id] {
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
} else {
counts.new += 1
}
}
return counts
}
/// Classify one studied card into a bucket. Due-state takes precedence over
/// maturity: a mature card that's due still counts as overdue/due-today.
private static func bump(
_ counts: inout StatusCounts,
interval: Int,
dueDate: Date,
now: Date,
calendar: Calendar
) {
let today = calendar.startOfDay(for: now)
let dueDay = calendar.startOfDay(for: dueDate)
if dueDay < today {
counts.overdue += 1
} else if dueDay == today {
counts.dueToday += 1
} else if interval >= matureIntervalDays {
counts.learned += 1
} else {
counts.upcoming += 1
}
}
}
@@ -4,6 +4,8 @@ import SwiftData
struct SettingsView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
/// Local reference store (verbs, lexemes) needed for the status metrics.
@Environment(\.modelContext) private var localContext
@State private var progress: UserProgress?
@State private var dailyGoal: Double = 50
@@ -16,6 +18,19 @@ struct SettingsView: View {
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
/// New (never-studied) words introduced per session, per type. 0 =
/// review-only; 999 = "All" (no throttle).
@AppStorage("vocabNewWordsPerSession") private var vocabNewWordsPerSession: Int = 10
@AppStorage("nounNewWordsPerSession") private var nounNewWordsPerSession: Int = 10
@AppStorage("adjectiveNewWordsPerSession") private var adjectiveNewWordsPerSession: Int = 10
private let newWordsSizes: [Int] = [0, 5, 10, 15, 20, 999]
/// SRS status breakdowns, scoped to the enabled levels. Recomputed on
/// appear and whenever the level toggles change.
@State private var verbStatus = StatusCounts()
@State private var nounStatus = StatusCounts()
@State private var adjectiveStatus = StatusCounts()
private let levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular
@@ -58,6 +73,16 @@ struct SettingsView: View {
Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
}
Section {
newWordsPicker("New verbs per session", selection: $vocabNewWordsPerSession)
newWordsPicker("New nouns per session", selection: $nounNewWordsPerSession)
newWordsPicker("New adjectives per session", selection: $adjectiveNewWordsPerSession)
} header: {
Text("New Words Per Session")
} footer: {
Text("How many brand-new words a session introduces. Overdue reviews are shown first; new words fill whatever room is left, up to this number. 0 = reviews only.")
}
Section {
ForEach(levels, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
@@ -68,6 +93,7 @@ struct SettingsView: View {
guard let progress else { return }
progress.setLevelEnabled(level, enabled: enabled)
saveProgress()
refreshMetrics()
}
))
}
@@ -77,6 +103,14 @@ struct SettingsView: View {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
}
Section {
statusRows(verbStatus)
} header: {
Text("Verb Status")
} footer: {
Text("Counts reflect only the verb levels enabled above.")
}
Section {
ForEach(LexemeLevel.allCases, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
@@ -87,6 +121,7 @@ struct SettingsView: View {
guard let progress else { return }
progress.setLexemeLevelEnabled(level, enabled: enabled)
saveProgress()
refreshMetrics()
}
))
}
@@ -96,6 +131,18 @@ struct SettingsView: View {
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
}
Section {
statusRows(nounStatus)
} header: {
Text("Noun Status")
} footer: {
Text("Counts reflect only the vocabulary levels enabled above.")
}
Section("Adjective Status") {
statusRows(adjectiveStatus)
}
Section {
ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding(
@@ -180,12 +227,53 @@ struct SettingsView: View {
}
}
private func newWordsPicker(_ title: String, selection: Binding<Int>) -> some View {
Picker(title, selection: selection) {
ForEach(newWordsSizes, id: \.self) { size in
Text(size == 999 ? "All" : "\(size)").tag(size)
}
}
}
@ViewBuilder
private func statusRows(_ counts: StatusCounts) -> some View {
LabeledContent("New", value: "\(counts.new)")
LabeledContent("Overdue", value: "\(counts.overdue)")
LabeledContent("Due today", value: "\(counts.dueToday)")
LabeledContent("Upcoming", value: "\(counts.upcoming)")
LabeledContent("Learned", value: "\(counts.learned)")
LabeledContent("Total", value: "\(counts.total)")
.foregroundStyle(.secondary)
}
private func loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress = resolved
dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem
refreshMetrics()
}
private func refreshMetrics() {
guard let progress else { return }
verbStatus = WordStatusMetrics.verbCounts(
selectedLevels: progress.selectedVerbLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
nounStatus = WordStatusMetrics.lexemeCounts(
partOfSpeech: "noun",
selectedLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
adjectiveStatus = WordStatusMetrics.lexemeCounts(
partOfSpeech: "adjective",
selectedLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
}
private func saveProgress() {