From eec0fb56d5c2e0aecf3df9498e1e67cefbed9418 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sun, 17 May 2026 15:24:47 -0500 Subject: [PATCH] =?UTF-8?q?Vocab=20Practice=20=E2=80=94=20add=20"Review=20?= =?UTF-8?q?Learned"=20consolidation=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Conjuga/Services/VocabSessionQueue.swift | 19 ++++ .../Conjuga/Views/Practice/PracticeView.swift | 11 +++ .../Vocab/VocabFlashcardPracticeView.swift | 87 +++++++++++++++---- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/Conjuga/Conjuga/Services/VocabSessionQueue.swift b/Conjuga/Conjuga/Services/VocabSessionQueue.swift index a5ca85d..656503d 100644 --- a/Conjuga/Conjuga/Services/VocabSessionQueue.swift +++ b/Conjuga/Conjuga/Services/VocabSessionQueue.swift @@ -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())) ?? [] + 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 diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 3c567f6..ececa6a 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -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() diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index c153546..4bcded5 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -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 }