From dce2cc1f51102e7f2186da67633637283f8b0986 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 26 Apr 2026 01:22:58 -0500 Subject: [PATCH] Make Full Table level-agnostic, fix the streak system end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Table (issue from chat): drop the level filter — Full Table tests regular conjugation patterns, not vocabulary recognition, so restricting to Basic-level verbs collapsed the eligible pool to two combos (vivir present, ir future). Pool now draws from all 1,750 verbs. Random sampling first; if 40 attempts fail we fall through to a deterministic shuffled scan that guarantees finding any eligible (verb, tense) combo when one exists. Returning nil now happens only when the user's filters genuinely produce zero eligible prompts. The view replaces its silent blank screen with a ContentUnavailableView pointing at the settings that need adjusting. FeatureReferenceView documents the level exception. Streak (issue #31 follow-up): activity recording was scoped to flashcard and Full Table reviews only, so spending an hour on textbook work, guides, videos, or AI chat could break a "streak" that the dashboard kept displaying as if it were intact. Three fixes: 1. Extract ReviewStore.recordActivity(context:) — a streak-only entry point that any user-initiated learning action can call. 2. Add UserProgress.validateStreakIfStale(today:context:) — resets a broken currentStreak to 0 immediately, called from app launch and dashboard appear so the displayed number is never a lie. 3. DailyLog formatter pins POSIX locale + current timezone so the yyyy-MM-dd strings can't drift across locales. Wired recordActivity into every previously-silent learning action: chat send, story-quiz completion, textbook exercise submit, grammar exercise completion, course-deck study finish, week test / checkpoint save, listening + pronunciation check, cloze quiz completion, lyrics word lookup, video stream / play / download success, sentence-builder check, and course-vocab SRS rate (which was bypassing ReviewStore entirely). Co-Authored-By: Claude Opus 4.7 (1M context) --- Conjuga/Conjuga/ConjugaApp.swift | 5 ++ Conjuga/Conjuga/Models/DailyLog.swift | 19 +++-- Conjuga/Conjuga/Models/UserProgress.swift | 18 +++++ .../Services/PracticeSessionService.swift | 54 ++++++++++--- Conjuga/Conjuga/Services/ReviewStore.swift | 30 +++++-- .../Conjuga/Views/Course/CourseQuizView.swift | 1 + .../Conjuga/Views/Course/DeckStudyView.swift | 7 +- .../Views/Course/TextbookExerciseView.swift | 1 + .../Views/Dashboard/DashboardView.swift | 5 +- .../Views/Guide/GrammarExerciseView.swift | 4 + .../Views/Guide/VideoActionsView.swift | 5 ++ .../Views/Practice/Chat/ChatView.swift | 1 + .../Conjuga/Views/Practice/ClozeView.swift | 1 + .../Views/Practice/FullTableView.swift | 78 ++++++++++++------- .../Views/Practice/ListeningView.swift | 4 + .../Practice/Lyrics/LyricsReaderView.swift | 4 + .../Views/Practice/SentenceBuilderView.swift | 3 + .../Practice/Stories/StoryQuizView.swift | 5 ++ .../Views/Practice/VocabReviewView.swift | 1 + .../Views/Settings/FeatureReferenceView.swift | 4 +- 20 files changed, 196 insertions(+), 54 deletions(-) diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 37f80f3..3df81f8 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -141,6 +141,11 @@ struct ConjugaApp: App { localContainer: localContainer, cloudContainer: cloudContainer ) + // Reset a broken streak immediately on launch so the + // dashboard never shows a stale number even if the user + // hasn't navigated to it yet. + let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext) + progress.validateStreakIfStale(context: cloudContainer.mainContext) WidgetDataService.update( localContainer: localContainer, cloudContainer: cloudContainer diff --git a/Conjuga/Conjuga/Models/DailyLog.swift b/Conjuga/Conjuga/Models/DailyLog.swift index c5e2d02..73f022f 100644 --- a/Conjuga/Conjuga/Models/DailyLog.swift +++ b/Conjuga/Conjuga/Models/DailyLog.swift @@ -32,10 +32,21 @@ final class DailyLog { } } - static func dateString(from date: Date) -> String { + /// Defensive formatter: explicit POSIX locale + current timezone so date + /// strings can never drift due to locale formatting (e.g. Arabic numerals) + /// or implicit-zone shifts. The string format is timezone-naive + /// `yyyy-MM-dd`, which works because we only ever compare to other + /// strings produced by this same formatter. + private static func makeFormatter() -> DateFormatter { let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + return formatter + } + + static func dateString(from date: Date) -> String { + makeFormatter().string(from: date) } static func todayString() -> String { @@ -43,8 +54,6 @@ final class DailyLog { } static func date(from string: String) -> Date? { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.date(from: string) + makeFormatter().date(from: string) } } diff --git a/Conjuga/Conjuga/Models/UserProgress.swift b/Conjuga/Conjuga/Models/UserProgress.swift index b4d4733..7d98019 100644 --- a/Conjuga/Conjuga/Models/UserProgress.swift +++ b/Conjuga/Conjuga/Models/UserProgress.swift @@ -123,6 +123,24 @@ final class UserProgress { unlockedBadgeIDs = values.sorted() } + /// Resets `currentStreak` to zero if more than one day has passed since + /// the last recorded activity. Without this check the dashboard keeps + /// displaying a stale streak number for days after the user actually + /// stops practicing — the underlying counter only updates on the *next* + /// practice action. Call from app launch and the dashboard's `.task`. + @MainActor + func validateStreakIfStale(today: Date = Date(), context: ModelContext) { + guard !todayDate.isEmpty else { return } + let todayString = DailyLog.dateString(from: today) + if todayDate == todayString { return } + guard let prevDate = DailyLog.date(from: todayDate) else { return } + let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today) + if (diff.day ?? Int.max) > 1 && currentStreak != 0 { + currentStreak = 0 + try? context.save() + } + } + func migrateLegacyStorageIfNeeded() { if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty { enabledTenseIDs = enabledTenses diff --git a/Conjuga/Conjuga/Services/PracticeSessionService.swift b/Conjuga/Conjuga/Services/PracticeSessionService.swift index 7e7bf4d..a97153a 100644 --- a/Conjuga/Conjuga/Services/PracticeSessionService.swift +++ b/Conjuga/Conjuga/Services/PracticeSessionService.swift @@ -97,32 +97,62 @@ struct PracticeSessionService { func randomFullTablePrompt() -> FullTablePrompt? { let settings = settings() - // Full Table practice is regular-only, so the irregular-category setting is - // deliberately ignored here (applying it would empty the pool). + // Full Table is testing the user's grasp of regular conjugation patterns, + // not vocabulary recognition. Level filter is intentionally bypassed so + // we draw from the entire verb pool — being able to conjugate `hablar` + // regularly transfers to any other regular verb regardless of "level". + // Irregular-category and tense filters still apply via downstream checks. let verbs = applyReflexiveFilter( - to: referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels), + to: referenceStore.fetchVerbs(), settings: settings ) guard !verbs.isEmpty else { return nil } + let candidateTenseIds = settings.selectionTenseIDs + guard !candidateTenseIds.isEmpty else { return nil } + + // Cheap path: random sampling. With ~1750 verbs and several hundred + // fully-regular combos this almost always succeeds within a handful + // of attempts. for _ in 0..<40 { guard let verb = verbs.randomElement(), - let tenseId = settings.selectionTenseIDs.randomElement(), + let tenseId = candidateTenseIds.randomElement(), let tenseInfo = TenseInfo.find(tenseId) else { continue } + if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) { + return prompt + } + } - let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId) - if forms.isEmpty { continue } - - // Full Table practice is for regular patterns only — skip combos - // where any form in this (verb, tense) is irregular. - if forms.contains(where: { $0.regularity != "regular" }) { continue } - - return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms) + // Guarantee: if any eligible (verb, tense) combo exists in the data we + // return one. Only return nil when the user's settings genuinely produce + // an empty pool (so the UI can show an error state instead of a blank). + let shuffledVerbs = verbs.shuffled() + let shuffledTenseIds = candidateTenseIds.shuffled() + for verb in shuffledVerbs { + for tenseId in shuffledTenseIds { + guard let tenseInfo = TenseInfo.find(tenseId) else { continue } + if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) { + return prompt + } + } } return nil } + /// Returns a `FullTablePrompt` if this verb's forms in the given tense are + /// all marked `regular` and complete. Nil otherwise. + private func makePromptIfFullyRegular( + verb: Verb, + tenseId: String, + tenseInfo: TenseInfo + ) -> FullTablePrompt? { + let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId) + guard !forms.isEmpty else { return nil } + if forms.contains(where: { $0.regularity != "regular" }) { return nil } + return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms) + } + func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] { ReviewStore.recordReview( verbId: verbId, diff --git a/Conjuga/Conjuga/Services/ReviewStore.swift b/Conjuga/Conjuga/Services/ReviewStore.swift index c424d26..6b23f51 100644 --- a/Conjuga/Conjuga/Services/ReviewStore.swift +++ b/Conjuga/Conjuga/Services/ReviewStore.swift @@ -72,13 +72,13 @@ struct ReviewStore { return newCard } + /// Bumps the streak / "showed up today" bookkeeping without touching + /// review-specific counters. Call from any user-initiated learning action + /// — sending a chat message, doing an exercise, watching a curated video, + /// looking up a word in lyrics, etc. Safe to call multiple times per day; + /// only the first call on a fresh date moves the streak. @discardableResult - static func updateProgress( - reviewIncrement: Int, - correctIncrement: Int, - context: ModelContext, - date: Date = Date() - ) -> UserProgress { + static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress { let progress = fetchOrCreateUserProgress(context: context) let todayString = DailyLog.dateString(from: date) @@ -97,9 +97,25 @@ struct ReviewStore { progress.todayCount = 0 } + progress.longestStreak = max(progress.longestStreak, progress.currentStreak) + try? context.save() + return progress + } + + @discardableResult + static func updateProgress( + reviewIncrement: Int, + correctIncrement: Int, + context: ModelContext, + date: Date = Date() + ) -> UserProgress { + // Bump streak / today-date first so review-specific counters land on + // the correct day if this is the user's first action after midnight. + let progress = recordActivity(context: context, date: date) + let todayString = DailyLog.dateString(from: date) + progress.todayCount += reviewIncrement progress.totalReviewed += reviewIncrement - progress.longestStreak = max(progress.longestStreak, progress.currentStreak) let log = fetchOrCreateDailyLog(dateString: todayString, context: context) log.reviewCount += reviewIncrement diff --git a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift index e8a2baa..91fcecf 100644 --- a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift @@ -585,6 +585,7 @@ struct CourseQuizView: View { ) cloudModelContext.insert(result) try? cloudModelContext.save() + ReviewStore.recordActivity(context: cloudModelContext) } } diff --git a/Conjuga/Conjuga/Views/Course/DeckStudyView.swift b/Conjuga/Conjuga/Views/Course/DeckStudyView.swift index 24e3399..4f688fe 100644 --- a/Conjuga/Conjuga/Views/Course/DeckStudyView.swift +++ b/Conjuga/Conjuga/Views/Course/DeckStudyView.swift @@ -5,6 +5,8 @@ import SwiftData struct DeckStudyView: View { let deck: CourseDeck @Environment(\.modelContext) private var modelContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } @State private var isStudying = false @State private var speechService = SpeechService() @State private var deckCards: [VocabCard] = [] @@ -24,7 +26,10 @@ struct DeckStudyView: View { VocabFlashcardView( cards: deckCards.shuffled(), speechService: speechService, - onDone: { isStudying = false }, + onDone: { + ReviewStore.recordActivity(context: cloudModelContext) + isStudying = false + }, deckTitle: deck.title ) .toolbar { diff --git a/Conjuga/Conjuga/Views/Course/TextbookExerciseView.swift b/Conjuga/Conjuga/Views/Course/TextbookExerciseView.swift index ee1f39e..c283cbb 100644 --- a/Conjuga/Conjuga/Views/Course/TextbookExerciseView.swift +++ b/Conjuga/Conjuga/Views/Course/TextbookExerciseView.swift @@ -249,6 +249,7 @@ struct TextbookExerciseView: View { } grades = newGrades isChecked = true + ReviewStore.recordActivity(context: cloudModelContext) saveAttempt(states: states, exerciseId: b.exerciseId ?? "") } diff --git a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift index 3ab258b..86c81b2 100644 --- a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift +++ b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift @@ -288,7 +288,10 @@ struct DashboardView: View { } private func loadData() { - userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) + let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) + // Reset a stale streak before rendering so the dashboard never lies. + progress.validateStreakIfStale(context: cloudModelContext) + userProgress = progress let dailyDescriptor = FetchDescriptor( sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)] ) diff --git a/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift b/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift index b112b19..9506809 100644 --- a/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift +++ b/Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift @@ -1,9 +1,12 @@ import SwiftUI +import SwiftData struct GrammarExerciseView: View { let noteId: String let noteTitle: String @Environment(\.dismiss) private var dismiss + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } @State private var exercises: [GrammarExercise] = [] @State private var currentIndex = 0 @@ -96,6 +99,7 @@ struct GrammarExerciseView: View { currentIndex += 1 selectedOption = nil } else { + ReviewStore.recordActivity(context: cloudModelContext) withAnimation { isFinished = true } } } label: { diff --git a/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift b/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift index 209a3ee..c3b2297 100644 --- a/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift +++ b/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift @@ -15,6 +15,8 @@ struct VideoActionsButtonRow: View { @Environment(\.openURL) private var openURL @Environment(\.modelContext) private var modelContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } @State private var downloadService = VideoDownloadService.shared @State private var isDownloaded: Bool @@ -89,6 +91,7 @@ struct VideoActionsButtonRow: View { private var streamButton: some View { Button { if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") { + ReviewStore.recordActivity(context: cloudModelContext) openURL(url) } } label: { @@ -151,6 +154,7 @@ struct VideoActionsButtonRow: View { private var playButton: some View { Button { + ReviewStore.recordActivity(context: cloudModelContext) playerVideoId = video.videoId } label: { Label("Play", systemImage: "play.fill") @@ -173,6 +177,7 @@ struct VideoActionsButtonRow: View { into: modelContext ) isDownloaded = true + ReviewStore.recordActivity(context: cloudModelContext) } catch { downloadError = error.localizedDescription } diff --git a/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift b/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift index 5f2106a..e67bc42 100644 --- a/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift +++ b/Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift @@ -119,6 +119,7 @@ struct ChatView: View { messages = conversation.decodedMessages inputText = "" try? cloudContext.save() + ReviewStore.recordActivity(context: cloudContext) Task { do { diff --git a/Conjuga/Conjuga/Views/Practice/ClozeView.swift b/Conjuga/Conjuga/Views/Practice/ClozeView.swift index ffe1017..4d733ca 100644 --- a/Conjuga/Conjuga/Views/Practice/ClozeView.swift +++ b/Conjuga/Conjuga/Views/Practice/ClozeView.swift @@ -98,6 +98,7 @@ struct ClozeView: View { currentIndex += 1 selectedOption = nil } else { + ReviewStore.recordActivity(context: cloudContext) withAnimation { isFinished = true } } } label: { diff --git a/Conjuga/Conjuga/Views/Practice/FullTableView.swift b/Conjuga/Conjuga/Views/Practice/FullTableView.swift index c30c455..8cce9a1 100644 --- a/Conjuga/Conjuga/Views/Practice/FullTableView.swift +++ b/Conjuga/Conjuga/Views/Practice/FullTableView.swift @@ -20,6 +20,7 @@ struct FullTableView: View { @State private var useHandwriting = false @State private var sessionCount = 0 @State private var sessionCorrect = 0 + @State private var noEligibleVerbs = false // Handwriting state per field @State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6) @@ -53,35 +54,39 @@ struct FullTableView: View { var body: some View { ScrollView { - VStack(spacing: 32) { - // Header - if let verb = currentVerb, let tense = currentTense { - headerSection(verb: verb, tense: tense) - } - - // Input mode toggle - HStack { - Picker("Input", selection: $useHandwriting) { - Label("Keyboard", systemImage: "keyboard").tag(false) - Label("Pencil", systemImage: "pencil.and.outline").tag(true) + if noEligibleVerbs { + emptyPoolError + } else { + VStack(spacing: 32) { + // Header + if let verb = currentVerb, let tense = currentTense { + headerSection(verb: verb, tense: tense) + } + + // Input mode toggle + HStack { + Picker("Input", selection: $useHandwriting) { + Label("Keyboard", systemImage: "keyboard").tag(false) + Label("Pencil", systemImage: "pencil.and.outline").tag(true) + } + .pickerStyle(.segmented) + } + .padding(.horizontal) + + // Input fields + inputSection + + // Check / Next button + actionButton + + // Score + if sessionCount > 0 { + scoreSection } - .pickerStyle(.segmented) - } - .padding(.horizontal) - - // Input fields - inputSection - - // Check / Next button - actionButton - - // Score - if sessionCount > 0 { - scoreSection } + .padding() + .adaptiveContainer() } - .padding() - .adaptiveContainer() } .navigationTitle("Full Table") .navigationBarTitleDisplayMode(.inline) @@ -91,6 +96,22 @@ struct FullTableView: View { } } + // MARK: - Empty pool error + + private var emptyPoolError: some View { + VStack(spacing: 16) { + ContentUnavailableView( + "No regular verbs available", + systemImage: "exclamationmark.triangle", + description: Text( + "None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings." + ) + ) + } + .padding() + .adaptiveContainer() + } + // MARK: - Header private func headerSection(verb: Verb, tense: TenseInfo) -> some View { @@ -249,13 +270,18 @@ struct FullTableView: View { reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives ) guard let prompt = service.randomFullTablePrompt() else { + // Genuinely no eligible (verb, tense) combo. Surface a clear error + // instead of a blank screen — the previous behaviour silently + // rendered an empty header and inputs. currentVerb = nil currentTense = nil userAnswers = Array(repeating: "", count: 6) focusedField = nil + noEligibleVerbs = true return } + noEligibleVerbs = false currentVerb = prompt.verb currentTense = prompt.tenseInfo correctForms = prompt.forms diff --git a/Conjuga/Conjuga/Views/Practice/ListeningView.swift b/Conjuga/Conjuga/Views/Practice/ListeningView.swift index 0ec44db..7b20f0d 100644 --- a/Conjuga/Conjuga/Views/Practice/ListeningView.swift +++ b/Conjuga/Conjuga/Views/Practice/ListeningView.swift @@ -4,6 +4,8 @@ import SwiftData struct ListeningView: View { @Environment(\.modelContext) private var localContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } @State private var pronunciation = PronunciationService() @State private var speechService = SpeechService() @@ -122,6 +124,7 @@ struct ListeningView: View { Button { let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput) if result.score >= 0.7 { correctCount += 1 } + ReviewStore.recordActivity(context: cloudModelContext) withAnimation { isRevealed = true } } label: { Text("Check") @@ -164,6 +167,7 @@ struct ListeningView: View { score = result.score wordMatches = result.matches if result.score >= 0.7 { correctCount += 1 } + ReviewStore.recordActivity(context: cloudModelContext) withAnimation { isRevealed = true } } else { pronunciation.startRecording() diff --git a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift index 5c3608d..a9c5d24 100644 --- a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift +++ b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift @@ -1,10 +1,13 @@ import SwiftUI +import SwiftData import SharedModels struct LyricsReaderView: View { let song: SavedSong @Environment(DictionaryService.self) private var dictionary + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } @State private var selectedWord: LyricsWordLookup? @State private var lookupCache: [String: LyricsWordLookup] = [:] @@ -98,6 +101,7 @@ struct LyricsReaderView: View { return LyricsFlowLayout(spacing: 0) { ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in + ReviewStore.recordActivity(context: cloudModelContext) selectedWord = word } } diff --git a/Conjuga/Conjuga/Views/Practice/SentenceBuilderView.swift b/Conjuga/Conjuga/Views/Practice/SentenceBuilderView.swift index 2350195..9f2e7e6 100644 --- a/Conjuga/Conjuga/Views/Practice/SentenceBuilderView.swift +++ b/Conjuga/Conjuga/Views/Practice/SentenceBuilderView.swift @@ -4,6 +4,8 @@ import SwiftData struct SentenceBuilderView: View { @Environment(\.modelContext) private var modelContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } @State private var currentCard: VocabCard? @State private var exampleIndex: Int = 0 @@ -316,6 +318,7 @@ struct SentenceBuilderView: View { if isCorrect { sessionCorrect += 1 } + ReviewStore.recordActivity(context: cloudModelContext) } private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? { diff --git a/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift b/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift index b0e4976..2a06850 100644 --- a/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift +++ b/Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift @@ -1,9 +1,13 @@ import SwiftUI +import SwiftData import SharedModels struct StoryQuizView: View { let story: Story + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + private var cloudModelContext: ModelContext { cloudModelContextProvider() } + @State private var currentIndex = 0 @State private var selectedOption: Int? @State private var correctCount = 0 @@ -85,6 +89,7 @@ struct StoryQuizView: View { currentIndex += 1 selectedOption = nil } else { + ReviewStore.recordActivity(context: cloudModelContext) withAnimation { isFinished = true } } } label: { diff --git a/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift b/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift index 7340004..03c42fb 100644 --- a/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift +++ b/Conjuga/Conjuga/Views/Practice/VocabReviewView.swift @@ -130,6 +130,7 @@ struct VocabReviewView: View { private func rate(quality: ReviewQuality) { guard let card = dueCards[safe: currentIndex] else { return } + ReviewStore.recordActivity(context: cloudContext) let store = CourseReviewStore(context: cloudContext) let result = SRSEngine.review( quality: quality, diff --git a/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift b/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift index d90c223..2e5ebfe 100644 --- a/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift +++ b/Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift @@ -21,7 +21,7 @@ struct FeatureReferenceView: View { title: "Full Table", details: [ "Shows all 6 person forms for one verb + tense", - "Random verb from your Level", + "Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary", "Random tense from your Enabled Tenses", ] ) @@ -197,7 +197,7 @@ struct FeatureReferenceView: View { } Section("Settings That Affect Practice") { - settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation") + settingRow(name: "Level", affects: "Verb practice, Quick Actions, Stories, Conversation (Full Table ignores level)") settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories") settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions") settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")