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")