Vocab Practice — add "Review Learned" consolidation mode

After a few Vocab Flashcards sessions, the verbs you've learned have
future SM-2 due dates, so they're excluded from new sessions (which
only pull due + new). There was no way to deliberately re-review a set
you'd already memorised.

Review Learned (new row in Practice → Vocabulary) fixes that — a cram
pass over every verb that has a VerbReviewCard, most-recently-studied
first, uncapped.

  VocabVerbPool.reviewLearnedVerbs — studied verbs sorted by
  lastReviewDate desc. Ignores due dates and the Level filter; it's a
  deliberate "review everything I've learned" pass.

  VocabFlashcardPracticeView gains a `kind` parameter
  (.standard / .reviewLearned). reviewLearned uses the new pool and —
  crucially — does NOT call VerbReviewStore.rate on graduation. Ratings
  drive the in-session learning queue for the session feel, but the
  long-term schedule is left untouched (Anki's reschedule-off cram
  behaviour), so a consolidation pass can't shove your real due dates
  weeks out.

  Header shows "Practice pass — your review schedule won't change" in
  this mode. Quiz/Learn toggle and Study Again work the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-17 15:24:47 -05:00
parent 209602eaad
commit eec0fb56d5
3 changed files with 99 additions and 18 deletions
@@ -163,6 +163,25 @@ enum VocabVerbPool {
let ordered = due.map(\.verb) + fresh let ordered = due.map(\.verb) + fresh
return Array(ordered.prefix(sessionCardLimit)) return Array(ordered.prefix(sessionCardLimit))
} }
/// Verbs the user has already studied at least once (have a
/// `VerbReviewCard`), most-recently-studied first. Used by the
/// "Review Learned" consolidation pass ignores due dates and the
/// Level filter, and is uncapped: it's a deliberate cram over
/// everything you've learned.
static func reviewLearnedVerbs(
localContext: ModelContext,
cloudContext: ModelContext
) -> [Verb] {
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let sorted = reviewCards.sorted {
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
}
let allVerbs = ReferenceStore(context: localContext).fetchVerbs()
let byId = Dictionary(allVerbs.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
return sorted.compactMap { byId[$0.verbId] }
}
} }
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema /// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
@@ -405,6 +405,17 @@ struct PracticeView: View {
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Review Learned consolidation cram over already-studied verbs
NavigationLink {
VocabFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned",
subtitle: "Re-review 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()
@@ -10,9 +10,22 @@ import SwiftData
/// - **Learn** no-pressure browsing. Both sides shown at once (English + /// - **Learn** no-pressure browsing. Both sides shown at once (English +
/// Spanish + example), Next/Previous step through the same session pool /// Spanish + example), Next/Previous step through the same session pool
/// on a loop. No rating, no SRS side effects. /// on a loop. No rating, no SRS side effects.
/// Which pool a flashcard session draws from.
enum VocabSessionKind {
/// Due-first + new verbs, capped the standard SRS session. Ratings
/// update the long-term schedule.
case standard
/// Verbs already studied at least once, most-recent first, uncapped a
/// consolidation cram. Ratings drive the in-session queue only and do NOT
/// reschedule (the long-term SM-2 due dates are left untouched).
case reviewLearned
}
struct VocabFlashcardPracticeView: View { struct VocabFlashcardPracticeView: View {
enum Mode: String { case quiz, learn } enum Mode: String { case quiz, learn }
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
@@ -61,7 +74,7 @@ struct VocabFlashcardPracticeView: View {
.padding() .padding()
.adaptiveContainer(maxWidth: 720) .adaptiveContainer(maxWidth: 720)
} }
.navigationTitle("Vocab Flashcards") .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -94,28 +107,43 @@ struct VocabFlashcardPracticeView: View {
@ViewBuilder @ViewBuilder
private var headerBar: some View { private var headerBar: some View {
switch mode { VStack(spacing: 6) {
case .quiz: switch mode {
VStack(spacing: 6) { case .quiz:
ProgressView(value: session?.progress ?? 0) ProgressView(value: session?.progress ?? 0)
.tint(.purple) .tint(.purple)
Text(quizProgressLabel) Text(quizProgressLabel)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
case .learn:
if sessionVerbs.isEmpty {
Text(emptyPoolMessage)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
} }
case .learn:
if sessionVerbs.isEmpty { if kind == .reviewLearned {
Text("No verbs match the levels enabled in Settings") Text("Practice pass — your review schedule won't change.")
.font(.caption) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.tertiary)
} else {
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
} }
} }
} }
private var emptyPoolMessage: String {
switch kind {
case .standard:
return "No verbs match the levels enabled in Settings"
case .reviewLearned:
return "Nothing studied yet — finish a Vocab Flashcards session first"
}
}
private var quizProgressLabel: String { private var quizProgressLabel: String {
guard let session else { return "Loading…" } guard let session else { return "Loading…" }
if session.isComplete { return "Done" } if session.isComplete { return "Done" }
@@ -200,7 +228,7 @@ struct VocabFlashcardPracticeView: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56)) .font(.system(size: 56))
.foregroundStyle(.green) .foregroundStyle(.green)
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due") Text(completionTitle)
.font(.title2.bold()) .font(.title2.bold())
Text(completionDetail) Text(completionDetail)
.font(.subheadline) .font(.subheadline)
@@ -231,12 +259,28 @@ struct VocabFlashcardPracticeView: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
private var completionTitle: String {
let learned = session?.learnedCount ?? 0
switch kind {
case .standard:
return learned > 0 ? "Session Complete" : "Nothing Due"
case .reviewLearned:
return learned > 0 ? "Review Complete" : "Nothing to Review"
}
}
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 noun = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) verb\(learned == 1 ? "" : "s") \(noun)"
}
switch kind {
case .standard:
return "No verbs are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a Vocab Flashcards session first, then come back to consolidate."
} }
return "No verbs are due right now. Study Again to review anyway."
} }
// MARK: - Learn mode // MARK: - Learn mode
@@ -340,7 +384,12 @@ struct VocabFlashcardPracticeView: View {
private func loadIfNeeded() { private func loadIfNeeded() {
guard sessionVerbs.isEmpty else { return } guard sessionVerbs.isEmpty else { return }
sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) switch kind {
case .standard:
sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
case .reviewLearned:
sessionVerbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
}
session = VocabSessionQueue(verbs: sessionVerbs) session = VocabSessionQueue(verbs: sessionVerbs)
primeExampleForCurrent() primeExampleForCurrent()
} }
@@ -354,7 +403,9 @@ struct VocabFlashcardPracticeView: 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 SM-2 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)
} }
withAnimation(.smooth) { revealed = false } withAnimation(.smooth) { revealed = false }