dce2cc1f51
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>
376 lines
14 KiB
Swift
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()
|
|
}
|
|
}
|