Vocab study — per-type session sizes + Review Learned multiple choice

- Settings: split the single session-size picker into separate Verbs /
  Nouns / Adjectives pickers. Nouns and adjectives previously shared one
  hidden limit; they now use nounSessionCardLimit / adjectiveSessionCardLimit.
- LexemePool.sessionCardLimit is now per part-of-speech.
- Multiple-choice views (verb/noun/adjective) gained a kind param so
  Review Learned can run as multiple choice, not just flashcards. The
  cram pass drives the in-session queue only and leaves the long-term
  SRS schedule untouched.
- PracticeView: each section now offers Review Learned — Flashcards and
  Review Learned — Multiple Choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-06-01 23:54:10 -05:00
parent 179400b90d
commit aab64116b3
6 changed files with 154 additions and 44 deletions
@@ -114,9 +114,19 @@ struct LexemeSessionQueue {
/// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped. /// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped.
enum LexemePool { enum LexemePool {
/// Per-session cap. 0/unset 20. Mirrors `VocabVerbPool.sessionCardLimit`. /// Per-session cap for a part of speech, from its "Cards per session"
static var sessionCardLimit: Int { /// setting. Nouns read `nounSessionCardLimit`, adjectives
let stored = UserDefaults.standard.integer(forKey: "lexemeSessionCardLimit") /// `adjectiveSessionCardLimit`; anything else falls back to the legacy
/// shared `lexemeSessionCardLimit`. 0/unset 20. Mirrors
/// `VocabVerbPool.sessionCardLimit`.
static func sessionCardLimit(for partOfSpeech: String) -> Int {
let key: String
switch partOfSpeech {
case "noun": key = "nounSessionCardLimit"
case "adjective": key = "adjectiveSessionCardLimit"
default: key = "lexemeSessionCardLimit"
}
let stored = UserDefaults.standard.integer(forKey: key)
return stored == 0 ? 20 : stored return stored == 0 ? 20 : stored
} }
@@ -170,7 +180,7 @@ enum LexemePool {
} }
let ordered = due.map(\.lexeme) + fresh let ordered = due.map(\.lexeme) + fresh
return Array(ordered.prefix(sessionCardLimit)) return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech)))
} }
/// Lexemes the user has already studied at least once for `(POS, drill)`, /// Lexemes the user has already studied at least once for `(POS, drill)`,
@@ -439,12 +439,22 @@ struct PracticeView: View {
VocabFlashcardPracticeView(kind: .reviewLearned) VocabFlashcardPracticeView(kind: .reviewLearned)
} label: { } label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple, practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned", title: "Review Learned — Flashcards",
subtitle: "Re-review verbs you've studied — schedule unchanged") subtitle: "Re-review verbs you've studied — schedule unchanged")
} }
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
VocabMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over verbs you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Existing: Vocab Review (due cards) // Existing: Vocab Review (due cards)
NavigationLink { NavigationLink {
VocabReviewView() VocabReviewView()
@@ -516,12 +526,22 @@ struct PracticeView: View {
NounFlashcardPracticeView(kind: .reviewLearned) NounFlashcardPracticeView(kind: .reviewLearned)
} label: { } label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal, practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
title: "Review Learned", title: "Review Learned — Flashcards",
subtitle: "Re-review nouns you've studied — schedule unchanged") subtitle: "Re-review nouns you've studied — schedule unchanged")
} }
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
NounMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over nouns you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink { NavigationLink {
NounReviewView() NounReviewView()
} label: { } label: {
@@ -591,12 +611,22 @@ struct PracticeView: View {
AdjectiveFlashcardPracticeView(kind: .reviewLearned) AdjectiveFlashcardPracticeView(kind: .reviewLearned)
} label: { } label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink, practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
title: "Review Learned", title: "Review Learned — Flashcards",
subtitle: "Re-review adjectives you've studied — schedule unchanged") subtitle: "Re-review adjectives you've studied — schedule unchanged")
} }
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink {
AdjectiveMultipleChoicePracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
title: "Review Learned — Multiple Choice",
subtitle: "Multiple choice over adjectives you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
NavigationLink { NavigationLink {
AdjectiveReviewView() AdjectiveReviewView()
} label: { } label: {
@@ -7,6 +7,8 @@ import SwiftData
/// adjective pool; 4 options (1 correct + 3 random distractors from the /// adjective pool; 4 options (1 correct + 3 random distractors from the
/// session). Options are bare base forms agreement isn't drilled here. /// session). Options are bare base forms agreement isn't drilled here.
struct AdjectiveMultipleChoicePracticeView: View { struct AdjectiveMultipleChoicePracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -33,7 +35,7 @@ struct AdjectiveMultipleChoicePracticeView: View {
.padding() .padding()
.adaptiveContainer(maxWidth: 720) .adaptiveContainer(maxWidth: 720)
} }
.navigationTitle("Adjective Multiple Choice") .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjective Multiple Choice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded) .onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id) .animation(.smooth, value: selectedOption?.id)
@@ -185,20 +187,39 @@ struct AdjectiveMultipleChoicePracticeView: View {
private var completionDetail: String { private var completionDetail: String {
let learned = session?.learnedCount ?? 0 let learned = session?.learnedCount ?? 0
if learned > 0 { return "\(learned) adjective\(learned == 1 ? "" : "s") learned" } if learned > 0 {
return "No adjectives are due right now. Study Again to review anyway." let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) adjective\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No adjectives are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish an adjective session first, then come back to consolidate."
}
} }
private func loadIfNeeded() { private func loadIfNeeded() {
guard session == nil else { return } guard session == nil else { return }
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) let lexemes: [Lexeme]
let lexemes = LexemePool.sessionLexemes( switch kind {
partOfSpeech: "adjective", case .standard:
drillMode: Self.drillMode, let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
enabledLevels: progress.selectedLexemeLevels, lexemes = LexemePool.sessionLexemes(
localContext: localContext, partOfSpeech: "adjective",
cloudContext: cloudContext drillMode: Self.drillMode,
) enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
case .reviewLearned:
lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
}
distractorPool = lexemes distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode) session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions() prepareOptions()
@@ -220,7 +241,9 @@ struct AdjectiveMultipleChoicePracticeView: View {
private func answer(_ rating: LexemeSessionQueue.Rating) { private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return } guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating) let graduation = session?.answer(rating)
if let graduation { // Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate( LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id, lexemeId: lexeme.id,
partOfSpeech: "adjective", partOfSpeech: "adjective",
@@ -9,6 +9,8 @@ import SwiftData
/// el problema), example sentence when present, and Again/Hard/Good/Easy /// el problema), example sentence when present, and Again/Hard/Good/Easy
/// rating which drives the `LexemeReviewStore` schedule. /// rating which drives the `LexemeReviewStore` schedule.
struct NounMultipleChoicePracticeView: View { struct NounMultipleChoicePracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -35,7 +37,7 @@ struct NounMultipleChoicePracticeView: View {
.padding() .padding()
.adaptiveContainer(maxWidth: 720) .adaptiveContainer(maxWidth: 720)
} }
.navigationTitle("Noun Multiple Choice") .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Noun Multiple Choice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded) .onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id) .animation(.smooth, value: selectedOption?.id)
@@ -193,22 +195,41 @@ struct NounMultipleChoicePracticeView: View {
private var completionDetail: String { private var completionDetail: String {
let learned = session?.learnedCount ?? 0 let learned = session?.learnedCount ?? 0
if learned > 0 { return "\(learned) noun\(learned == 1 ? "" : "s") learned" } if learned > 0 {
return "No nouns are due right now. Study Again to review anyway." let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) noun\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No nouns are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a noun session first, then come back to consolidate."
}
} }
// MARK: - Logic // MARK: - Logic
private func loadIfNeeded() { private func loadIfNeeded() {
guard session == nil else { return } guard session == nil else { return }
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) let lexemes: [Lexeme]
let lexemes = LexemePool.sessionLexemes( switch kind {
partOfSpeech: "noun", case .standard:
drillMode: Self.drillMode, let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
enabledLevels: progress.selectedLexemeLevels, lexemes = LexemePool.sessionLexemes(
localContext: localContext, partOfSpeech: "noun",
cloudContext: cloudContext drillMode: Self.drillMode,
) enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
case .reviewLearned:
lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
}
distractorPool = lexemes distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode) session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions() prepareOptions()
@@ -230,7 +251,9 @@ struct NounMultipleChoicePracticeView: View {
private func answer(_ rating: LexemeSessionQueue.Rating) { private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return } guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating) let graduation = session?.answer(rating)
if let graduation { // Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate( LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id, lexemeId: lexeme.id,
partOfSpeech: "noun", partOfSpeech: "noun",
@@ -7,6 +7,8 @@ import SwiftData
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS /// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates. /// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
struct VocabMultipleChoicePracticeView: View { struct VocabMultipleChoicePracticeView: View {
var kind: VocabSessionKind = .standard
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache @Environment(VerbExampleCache.self) private var exampleCache
@@ -37,7 +39,7 @@ struct VocabMultipleChoicePracticeView: View {
.padding() .padding()
.adaptiveContainer(maxWidth: 720) .adaptiveContainer(maxWidth: 720)
} }
.navigationTitle("Vocab Multiple Choice") .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Multiple Choice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded) .onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id) .animation(.smooth, value: selectedOption?.id)
@@ -221,16 +223,28 @@ struct VocabMultipleChoicePracticeView: View {
private var completionDetail: String { private var completionDetail: String {
let learned = session?.learnedCount ?? 0 let learned = session?.learnedCount ?? 0
if learned > 0 { if learned > 0 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned" let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) verb\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No verbs are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a Vocab session first, then come back to consolidate."
} }
return "No verbs are due right now. Study Again to review anyway."
} }
// MARK: - Logic // MARK: - Logic
private func loadIfNeeded() { private func loadIfNeeded() {
guard session == nil else { return } guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) let verbs: [Verb]
switch kind {
case .standard:
verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
case .reviewLearned:
verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
}
distractorPool = verbs distractorPool = verbs
session = VocabSessionQueue(verbs: verbs) session = VocabSessionQueue(verbs: verbs)
prepareOptions() prepareOptions()
@@ -254,7 +268,9 @@ struct VocabMultipleChoicePracticeView: View {
private func answer(_ rating: VocabSessionQueue.Rating) { private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return } guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil let graduation = session?.answer(rating) ?? nil
if let graduation { // Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
} }
selectedOption = nil selectedOption = nil
@@ -10,8 +10,10 @@ struct SettingsView: View {
@State private var showVosotros: Bool = true @State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false @State private var autoFillStem: Bool = false
/// Cards per vocab-practice session. 999 = "All" (no cap). /// Cards per study session, per word type. 999 = "All" (no cap).
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20 @AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
@AppStorage("nounSessionCardLimit") private var nounSessionCardLimit: Int = 20
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999] private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
@@ -47,15 +49,13 @@ struct SettingsView: View {
} }
Section { Section {
Picker("Cards per session", selection: $vocabSessionCardLimit) { sessionSizePicker("Verbs per session", selection: $vocabSessionCardLimit)
ForEach(vocabSessionSizes, id: \.self) { size in sessionSizePicker("Nouns per session", selection: $nounSessionCardLimit)
Text(size == 999 ? "All" : "\(size)").tag(size) sessionSizePicker("Adjectives per session", selection: $adjectiveSessionCardLimit)
}
}
} header: { } header: {
Text("Vocab Flashcards") Text("Cards Per Session")
} footer: { } footer: {
Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.") Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
} }
Section { Section {
@@ -172,6 +172,14 @@ struct SettingsView: View {
} }
} }
private func sessionSizePicker(_ title: String, selection: Binding<Int>) -> some View {
Picker(title, selection: selection) {
ForEach(vocabSessionSizes, id: \.self) { size in
Text(size == 999 ? "All" : "\(size)").tag(size)
}
}
}
private func loadProgress() { private func loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress = resolved progress = resolved