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:
@@ -163,6 +163,25 @@ enum VocabVerbPool {
|
||||
let ordered = due.map(\.verb) + fresh
|
||||
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
|
||||
|
||||
@@ -405,6 +405,17 @@ struct PracticeView: View {
|
||||
.tint(.primary)
|
||||
.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)
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
|
||||
@@ -10,9 +10,22 @@ import SwiftData
|
||||
/// - **Learn** — no-pressure browsing. Both sides shown at once (English +
|
||||
/// Spanish + example), Next/Previous step through the same session pool
|
||||
/// 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 {
|
||||
enum Mode: String { case quiz, learn }
|
||||
|
||||
var kind: VocabSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@@ -61,7 +74,7 @@ struct VocabFlashcardPracticeView: View {
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle("Vocab Flashcards")
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -94,28 +107,43 @@ struct VocabFlashcardPracticeView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 6) {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(quizProgressLabel)
|
||||
.font(.caption)
|
||||
.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 {
|
||||
Text("No verbs match the levels enabled in Settings")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if kind == .reviewLearned {
|
||||
Text("Practice pass — your review schedule won't change.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
@@ -200,7 +228,7 @@ struct VocabFlashcardPracticeView: View {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||
Text(completionTitle)
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
@@ -231,12 +259,28 @@ struct VocabFlashcardPracticeView: View {
|
||||
.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 {
|
||||
let learned = session?.learnedCount ?? 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
|
||||
@@ -340,7 +384,12 @@ struct VocabFlashcardPracticeView: View {
|
||||
|
||||
private func loadIfNeeded() {
|
||||
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)
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
@@ -354,7 +403,9 @@ struct VocabFlashcardPracticeView: View {
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
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)
|
||||
}
|
||||
withAnimation(.smooth) { revealed = false }
|
||||
|
||||
Reference in New Issue
Block a user