From 0ad448a600f0660cd959e08d644ab217f37f0478 Mon Sep 17 00:00:00 2001 From: Trey T Date: Tue, 9 Jun 2026 10:43:42 -0500 Subject: [PATCH] =?UTF-8?q?Vocab=20sessions=20=E2=80=94=20new-word=20throt?= =?UTF-8?q?tle=20+=20per-type=20status=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 4 + .../Conjuga/Services/LexemeSessionQueue.swift | 26 ++++- .../Conjuga/Services/VocabSessionQueue.swift | 19 +++- .../Conjuga/Services/WordStatusMetrics.swift | 107 ++++++++++++++++++ .../Conjuga/Views/Settings/SettingsView.swift | 88 ++++++++++++++ 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 Conjuga/Conjuga/Services/WordStatusMetrics.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index dfcd8e8..dd926f4 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = ""; }; CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = ""; }; + CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordStatusMetrics.swift; sourceTree = ""; }; CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = ""; }; 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 = ""; }; @@ -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; diff --git a/Conjuga/Conjuga/Services/LexemeSessionQueue.swift b/Conjuga/Conjuga/Services/LexemeSessionQueue.swift index 08624b2..dcc4e8b 100644 --- a/Conjuga/Conjuga/Services/LexemeSessionQueue.swift +++ b/Conjuga/Conjuga/Services/LexemeSessionQueue.swift @@ -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)`, diff --git a/Conjuga/Conjuga/Services/VocabSessionQueue.swift b/Conjuga/Conjuga/Services/VocabSessionQueue.swift index 776ec50..fa42ec8 100644 --- a/Conjuga/Conjuga/Services/VocabSessionQueue.swift +++ b/Conjuga/Conjuga/Services/VocabSessionQueue.swift @@ -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 diff --git a/Conjuga/Conjuga/Services/WordStatusMetrics.swift b/Conjuga/Conjuga/Services/WordStatusMetrics.swift new file mode 100644 index 0000000..4d95c6f --- /dev/null +++ b/Conjuga/Conjuga/Services/WordStatusMetrics.swift @@ -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, + 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())) ?? [] + 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, + localContext: ModelContext, + cloudContext: ModelContext + ) -> StatusCounts { + let lexDescriptor = FetchDescriptor( + predicate: #Predicate { $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( + predicate: #Predicate { + $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 + } + } +} diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index 731012b..47acd15 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -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) -> 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() {