Files
Spanish/Conjuga/Conjuga/Services/ReviewStore.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

227 lines
7.6 KiB
Swift

import Foundation
import SwiftData
struct ReviewStore {
static let progressID = "main"
static func defaultEnabledTenses() -> [String] {
TenseID.defaultPracticeIDs
}
static func fetchUserProgress(context: ModelContext) -> UserProgress? {
let descriptor = FetchDescriptor<UserProgress>(
predicate: #Predicate<UserProgress> { $0.id == progressID }
)
if let progress = (try? context.fetch(descriptor))?.first {
progress.migrateLegacyStorageIfNeeded()
return progress
}
let fallback = (try? context.fetch(FetchDescriptor<UserProgress>()))?.first
fallback?.migrateLegacyStorageIfNeeded()
return fallback
}
@discardableResult
static func fetchOrCreateUserProgress(context: ModelContext) -> UserProgress {
if let progress = fetchUserProgress(context: context) {
progress.id = progressID
if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = defaultEnabledTenses()
}
return progress
}
let progress = UserProgress()
progress.id = progressID
progress.enabledTenseIDs = defaultEnabledTenses()
context.insert(progress)
return progress
}
@discardableResult
static func fetchOrCreateReviewCard(
verbId: Int,
tenseId: String,
personIndex: Int,
context: ModelContext
) -> ReviewCard {
let key = ReviewCard.makeKey(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
let keyedDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.key == key }
)
if let existing = (try? context.fetch(keyedDescriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let legacyDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { card in
card.verbId == verbId &&
card.tenseId == tenseId &&
card.personIndex == personIndex
}
)
if let existing = (try? context.fetch(legacyDescriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let newCard = ReviewCard(verbId: verbId, tenseId: tenseId, personIndex: personIndex)
context.insert(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
static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date)
if progress.todayDate != todayString {
if !progress.todayDate.isEmpty {
if isConsecutiveDay(previous: progress.todayDate, current: todayString) {
progress.currentStreak += 1
} else {
progress.currentStreak = 1
}
} else {
progress.currentStreak = 1
}
progress.todayDate = todayString
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
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement
log.correctCount += correctIncrement
return progress
}
static func recordReview(
verbId: Int,
tenseId: String,
personIndex: Int,
quality: ReviewQuality,
context: ModelContext,
referenceContext: ModelContext
) -> [Badge] {
let card = fetchOrCreateReviewCard(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
context: context
)
applyReview(quality: quality, to: card)
let progress = updateProgress(
reviewIncrement: 1,
correctIncrement: quality.rawValue >= 3 ? 1 : 0,
context: context
)
let badges = AchievementService.checkAchievements(
progress: progress,
reviewContext: context,
referenceContext: referenceContext
)
try? context.save()
return badges
}
static func recordFullTableReview(
verbId: Int,
tenseId: String,
results: [Int: Bool],
context: ModelContext,
referenceContext: ModelContext
) -> [Badge] {
for (personIndex, isCorrect) in results {
let card = fetchOrCreateReviewCard(
verbId: verbId,
tenseId: tenseId,
personIndex: personIndex,
context: context
)
applyReview(quality: isCorrect ? .good : .again, to: card)
}
let allCorrect = results.values.allSatisfy { $0 }
let progress = updateProgress(
reviewIncrement: 1,
correctIncrement: allCorrect ? 1 : 0,
context: context
)
let badges = AchievementService.checkAchievements(
progress: progress,
reviewContext: context,
referenceContext: referenceContext
)
try? context.save()
return badges
}
static func fetchOrCreateDailyLog(dateString: String, context: ModelContext) -> DailyLog {
let descriptor = FetchDescriptor<DailyLog>(
predicate: #Predicate<DailyLog> { $0.id == dateString || $0.dateString == dateString }
)
if let existing = (try? context.fetch(descriptor))?.first {
existing.refreshIdentityIfNeeded()
return existing
}
let log = DailyLog(dateString: dateString)
context.insert(log)
return log
}
static func applyReview(quality: ReviewQuality, to card: ReviewCard) {
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
card.refreshIdentityIfNeeded()
}
private static func isConsecutiveDay(previous: String, current: String) -> Bool {
guard let prevDate = DailyLog.date(from: previous),
let currDate = DailyLog.date(from: current) else { return false }
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: currDate)
return diff.day == 1
}
}