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>
227 lines
7.6 KiB
Swift
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
|
|
}
|
|
}
|