Files
Spanish/Conjuga/Conjuga/Services/PracticeSessionService.swift
T
Trey t dce2cc1f51 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>
2026-04-26 01:24:27 -05:00

376 lines
14 KiB
Swift

import Foundation
import SharedModels
import SwiftData
struct PracticeSettings: Sendable {
let selectedLevel: String
let selectedLevels: Set<String>
let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool
let showReflexiveVerbsOnly: Bool
let reflexiveBaseInfinitives: Set<String>
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
self.enabledTenses = Set(resolvedTenses)
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
self.showVosotros = progress?.showVosotros ?? true
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
}
var selectionTenseIDs: [String] {
enabledTenses.isEmpty ? TenseID.defaultPracticeIDs : Array(enabledTenses)
}
}
struct PracticeCardLoad {
let verb: Verb
let form: VerbForm
let spans: [IrregularSpan]
let tenseInfo: TenseInfo?
let person: String
}
struct FullTablePrompt {
let verb: Verb
let tenseInfo: TenseInfo
let forms: [VerbForm]
}
struct PracticeSessionService {
let localContext: ModelContext
let cloudContext: ModelContext
let reflexiveBaseInfinitives: Set<String>
private let referenceStore: ReferenceStore
init(
localContext: ModelContext,
cloudContext: ModelContext,
reflexiveBaseInfinitives: Set<String> = []
) {
self.localContext = localContext
self.cloudContext = cloudContext
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
self.referenceStore = ReferenceStore(context: localContext)
}
func settings() -> PracticeSettings {
PracticeSettings(
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
reflexiveBaseInfinitives: reflexiveBaseInfinitives
)
}
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
switch focusMode {
case .weakVerbs:
if let form = pickWeakForm() {
return loadCard(from: form)
}
case .irregularity(let filter):
if let form = pickIrregularForm(filter: filter) {
return loadCard(from: form)
}
case .commonTenses:
if let form = pickCommonTenseForm() {
return loadCard(from: form)
}
case .none:
break
}
if let dueCard = fetchDueCard(excluding: lastVerbId) {
return loadCard(from: dueCard)
}
if let form = pickRandomForm() {
return loadCard(from: form)
}
return nil
}
func randomFullTablePrompt() -> FullTablePrompt? {
let settings = settings()
// 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(),
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 = candidateTenseIds.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
return prompt
}
}
// 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,
tenseId: tenseId,
personIndex: personIndex,
quality: quality,
context: cloudContext,
referenceContext: localContext
)
}
func recordFullTableReview(verbId: Int, tenseId: String, results: [Int: Bool]) -> [Badge] {
ReviewStore.recordFullTableReview(
verbId: verbId,
tenseId: tenseId,
results: results,
context: cloudContext,
referenceContext: localContext
)
}
func loadCard(from reviewCard: ReviewCard) -> PracticeCardLoad? {
guard let verb = referenceStore.fetchVerb(id: reviewCard.verbId),
let form = referenceStore.fetchForm(
verbId: reviewCard.verbId,
tenseId: reviewCard.tenseId,
personIndex: reviewCard.personIndex
) else { return nil }
return buildCardLoad(verb: verb, form: form)
}
func loadCard(from form: VerbForm) -> PracticeCardLoad? {
guard let verb = referenceStore.fetchVerb(id: form.verbId) else { return nil }
return buildCardLoad(verb: verb, form: form)
}
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
/// verb-id set to IDs whose infinitive is in the curated list.
/// No-op otherwise.
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return ids
}
let matching = ids.filter { id in
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
}
return matching
}
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return verbs
}
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
}
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans(
verbId: form.verbId,
tenseId: form.tenseId,
personIndex: form.personIndex
)
let person = TenseInfo.persons.indices.contains(form.personIndex)
? TenseInfo.persons[form.personIndex]
: ""
return PracticeCardLoad(
verb: verb,
form: form,
spans: spans,
tenseInfo: TenseInfo.find(form.tenseId),
person: person
)
}
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings()
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let now = Date()
var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
)
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
let cards = (try? cloudContext.fetch(descriptor)) ?? []
let eligible = cards.filter { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
// Prefer a card from a different verb than the last one shown.
// Fall back to the same verb only if it's the sole due card.
if let lastVerbId {
if let different = eligible.first(where: { $0.verbId != lastVerbId }) {
return different
}
}
return eligible.first
}
private func pickWeakForm() -> VerbForm? {
let settings = settings()
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
)
let cards = ((try? cloudContext.fetch(descriptor)) ?? []).filter { card in
allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4)
}
guard let card = cards.randomElement() else { return nil }
return referenceStore.fetchForm(
verbId: card.verbId,
tenseId: card.tenseId,
personIndex: card.personIndex
)
}
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings()
// Focus mode explicitly selects one irregular category, so the user's
// settings-level irregular filter is deliberately skipped here.
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: []
),
settings: settings
)
let typeRange: ClosedRange<Int>
switch filter {
case .spelling:
typeRange = 100...199
case .stemChange:
typeRange = 200...299
case .uniqueIrregular:
typeRange = 300...399
}
var descriptor = FetchDescriptor<IrregularSpan>(
predicate: #Predicate<IrregularSpan> { span in
span.spanType >= typeRange.lowerBound &&
span.spanType <= typeRange.upperBound
}
)
descriptor.fetchLimit = 500
let spans = ((try? localContext.fetch(descriptor)) ?? []).filter { span in
allowedVerbIds.contains(span.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
(settings.showVosotros || span.personIndex != 4)
}
guard let span = spans.randomElement() else { return nil }
return referenceStore.fetchForm(
verbId: span.verbId,
tenseId: span.tenseId,
personIndex: span.personIndex
)
}
private func pickCommonTenseForm() -> VerbForm? {
let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
coreTenseIDs.contains(form.tenseId) &&
(settings.showVosotros || form.personIndex != 4)
}
return forms.randomElement()
}
private func pickRandomForm() -> VerbForm? {
let settings = settings()
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(form.tenseId)) &&
(settings.showVosotros || form.personIndex != 4)
}
return forms.randomElement()
}
}