Make Full Table level-agnostic, fix the streak system end-to-end

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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-26 01:22:58 -05:00
parent 06b47d37cf
commit dce2cc1f51
20 changed files with 196 additions and 54 deletions
+5
View File
@@ -141,6 +141,11 @@ struct ConjugaApp: App {
localContainer: localContainer, localContainer: localContainer,
cloudContainer: cloudContainer 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( WidgetDataService.update(
localContainer: localContainer, localContainer: localContainer,
cloudContainer: cloudContainer cloudContainer: cloudContainer
+14 -5
View File
@@ -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() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd" 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 { static func todayString() -> String {
@@ -43,8 +54,6 @@ final class DailyLog {
} }
static func date(from string: String) -> Date? { static func date(from string: String) -> Date? {
let formatter = DateFormatter() makeFormatter().date(from: string)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: string)
} }
} }
+18
View File
@@ -123,6 +123,24 @@ final class UserProgress {
unlockedBadgeIDs = values.sorted() 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() { func migrateLegacyStorageIfNeeded() {
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty { if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
enabledTenseIDs = enabledTenses enabledTenseIDs = enabledTenses
@@ -97,32 +97,62 @@ struct PracticeSessionService {
func randomFullTablePrompt() -> FullTablePrompt? { func randomFullTablePrompt() -> FullTablePrompt? {
let settings = settings() let settings = settings()
// Full Table practice is regular-only, so the irregular-category setting is // Full Table is testing the user's grasp of regular conjugation patterns,
// deliberately ignored here (applying it would empty the pool). // 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( let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels), to: referenceStore.fetchVerbs(),
settings: settings settings: settings
) )
guard !verbs.isEmpty else { return nil } 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 { for _ in 0..<40 {
guard let verb = verbs.randomElement(), guard let verb = verbs.randomElement(),
let tenseId = settings.selectionTenseIDs.randomElement(), let tenseId = candidateTenseIds.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue } 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) // Guarantee: if any eligible (verb, tense) combo exists in the data we
if forms.isEmpty { continue } // 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).
// Full Table practice is for regular patterns only skip combos let shuffledVerbs = verbs.shuffled()
// where any form in this (verb, tense) is irregular. let shuffledTenseIds = candidateTenseIds.shuffled()
if forms.contains(where: { $0.regularity != "regular" }) { continue } for verb in shuffledVerbs {
for tenseId in shuffledTenseIds {
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms) guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
return prompt
}
}
} }
return nil 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] { func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
ReviewStore.recordReview( ReviewStore.recordReview(
verbId: verbId, verbId: verbId,
+23 -7
View File
@@ -72,13 +72,13 @@ struct ReviewStore {
return newCard 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 @discardableResult
static func updateProgress( static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context) let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date) let todayString = DailyLog.dateString(from: date)
@@ -97,9 +97,25 @@ struct ReviewStore {
progress.todayCount = 0 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.todayCount += reviewIncrement
progress.totalReviewed += reviewIncrement progress.totalReviewed += reviewIncrement
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
let log = fetchOrCreateDailyLog(dateString: todayString, context: context) let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement log.reviewCount += reviewIncrement
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
) )
cloudModelContext.insert(result) cloudModelContext.insert(result)
try? cloudModelContext.save() try? cloudModelContext.save()
ReviewStore.recordActivity(context: cloudModelContext)
} }
} }
@@ -5,6 +5,8 @@ import SwiftData
struct DeckStudyView: View { struct DeckStudyView: View {
let deck: CourseDeck let deck: CourseDeck
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var isStudying = false @State private var isStudying = false
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@State private var deckCards: [VocabCard] = [] @State private var deckCards: [VocabCard] = []
@@ -24,7 +26,10 @@ struct DeckStudyView: View {
VocabFlashcardView( VocabFlashcardView(
cards: deckCards.shuffled(), cards: deckCards.shuffled(),
speechService: speechService, speechService: speechService,
onDone: { isStudying = false }, onDone: {
ReviewStore.recordActivity(context: cloudModelContext)
isStudying = false
},
deckTitle: deck.title deckTitle: deck.title
) )
.toolbar { .toolbar {
@@ -249,6 +249,7 @@ struct TextbookExerciseView: View {
} }
grades = newGrades grades = newGrades
isChecked = true isChecked = true
ReviewStore.recordActivity(context: cloudModelContext)
saveAttempt(states: states, exerciseId: b.exerciseId ?? "") saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
} }
@@ -288,7 +288,10 @@ struct DashboardView: View {
} }
private func loadData() { 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<DailyLog>( let dailyDescriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)] sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
) )
@@ -1,9 +1,12 @@
import SwiftUI import SwiftUI
import SwiftData
struct GrammarExerciseView: View { struct GrammarExerciseView: View {
let noteId: String let noteId: String
let noteTitle: String let noteTitle: String
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var exercises: [GrammarExercise] = [] @State private var exercises: [GrammarExercise] = []
@State private var currentIndex = 0 @State private var currentIndex = 0
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -15,6 +15,8 @@ struct VideoActionsButtonRow: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var downloadService = VideoDownloadService.shared @State private var downloadService = VideoDownloadService.shared
@State private var isDownloaded: Bool @State private var isDownloaded: Bool
@@ -89,6 +91,7 @@ struct VideoActionsButtonRow: View {
private var streamButton: some View { private var streamButton: some View {
Button { Button {
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") { if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
ReviewStore.recordActivity(context: cloudModelContext)
openURL(url) openURL(url)
} }
} label: { } label: {
@@ -151,6 +154,7 @@ struct VideoActionsButtonRow: View {
private var playButton: some View { private var playButton: some View {
Button { Button {
ReviewStore.recordActivity(context: cloudModelContext)
playerVideoId = video.videoId playerVideoId = video.videoId
} label: { } label: {
Label("Play", systemImage: "play.fill") Label("Play", systemImage: "play.fill")
@@ -173,6 +177,7 @@ struct VideoActionsButtonRow: View {
into: modelContext into: modelContext
) )
isDownloaded = true isDownloaded = true
ReviewStore.recordActivity(context: cloudModelContext)
} catch { } catch {
downloadError = error.localizedDescription downloadError = error.localizedDescription
} }
@@ -119,6 +119,7 @@ struct ChatView: View {
messages = conversation.decodedMessages messages = conversation.decodedMessages
inputText = "" inputText = ""
try? cloudContext.save() try? cloudContext.save()
ReviewStore.recordActivity(context: cloudContext)
Task { Task {
do { do {
@@ -98,6 +98,7 @@ struct ClozeView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -20,6 +20,7 @@ struct FullTableView: View {
@State private var useHandwriting = false @State private var useHandwriting = false
@State private var sessionCount = 0 @State private var sessionCount = 0
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
@State private var noEligibleVerbs = false
// Handwriting state per field // Handwriting state per field
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6) @State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
@@ -53,6 +54,9 @@ struct FullTableView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
if noEligibleVerbs {
emptyPoolError
} else {
VStack(spacing: 32) { VStack(spacing: 32) {
// Header // Header
if let verb = currentVerb, let tense = currentTense { if let verb = currentVerb, let tense = currentTense {
@@ -83,6 +87,7 @@ struct FullTableView: View {
.padding() .padding()
.adaptiveContainer() .adaptiveContainer()
} }
}
.navigationTitle("Full Table") .navigationTitle("Full Table")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear { .onAppear {
@@ -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 // MARK: - Header
private func headerSection(verb: Verb, tense: TenseInfo) -> some View { private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
@@ -249,13 +270,18 @@ struct FullTableView: View {
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
) )
guard let prompt = service.randomFullTablePrompt() else { 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 currentVerb = nil
currentTense = nil currentTense = nil
userAnswers = Array(repeating: "", count: 6) userAnswers = Array(repeating: "", count: 6)
focusedField = nil focusedField = nil
noEligibleVerbs = true
return return
} }
noEligibleVerbs = false
currentVerb = prompt.verb currentVerb = prompt.verb
currentTense = prompt.tenseInfo currentTense = prompt.tenseInfo
correctForms = prompt.forms correctForms = prompt.forms
@@ -4,6 +4,8 @@ import SwiftData
struct ListeningView: View { struct ListeningView: View {
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var pronunciation = PronunciationService() @State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@@ -122,6 +124,7 @@ struct ListeningView: View {
Button { Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput) let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} label: { } label: {
Text("Check") Text("Check")
@@ -164,6 +167,7 @@ struct ListeningView: View {
score = result.score score = result.score
wordMatches = result.matches wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} else { } else {
pronunciation.startRecording() pronunciation.startRecording()
@@ -1,10 +1,13 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels import SharedModels
struct LyricsReaderView: View { struct LyricsReaderView: View {
let song: SavedSong let song: SavedSong
@Environment(DictionaryService.self) private var dictionary @Environment(DictionaryService.self) private var dictionary
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var selectedWord: LyricsWordLookup? @State private var selectedWord: LyricsWordLookup?
@State private var lookupCache: [String: LyricsWordLookup] = [:] @State private var lookupCache: [String: LyricsWordLookup] = [:]
@@ -98,6 +101,7 @@ struct LyricsReaderView: View {
return LyricsFlowLayout(spacing: 0) { return LyricsFlowLayout(spacing: 0) {
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
ReviewStore.recordActivity(context: cloudModelContext)
selectedWord = word selectedWord = word
} }
} }
@@ -4,6 +4,8 @@ import SwiftData
struct SentenceBuilderView: View { struct SentenceBuilderView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentCard: VocabCard? @State private var currentCard: VocabCard?
@State private var exampleIndex: Int = 0 @State private var exampleIndex: Int = 0
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
if isCorrect { if isCorrect {
sessionCorrect += 1 sessionCorrect += 1
} }
ReviewStore.recordActivity(context: cloudModelContext)
} }
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? { private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
@@ -1,9 +1,13 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels import SharedModels
struct StoryQuizView: View { struct StoryQuizView: View {
let story: Story let story: Story
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var selectedOption: Int? @State private var selectedOption: Int?
@State private var correctCount = 0 @State private var correctCount = 0
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
private func rate(quality: ReviewQuality) { private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return } guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let store = CourseReviewStore(context: cloudContext) let store = CourseReviewStore(context: cloudContext)
let result = SRSEngine.review( let result = SRSEngine.review(
quality: quality, quality: quality,
@@ -21,7 +21,7 @@ struct FeatureReferenceView: View {
title: "Full Table", title: "Full Table",
details: [ details: [
"Shows all 6 person forms for one verb + tense", "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", "Random tense from your Enabled Tenses",
] ]
) )
@@ -197,7 +197,7 @@ struct FeatureReferenceView: View {
} }
Section("Settings That Affect Practice") { 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: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions") settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only") settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")