Files
Spanish/Conjuga/Conjuga/Views/Dashboard/DashboardView.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

343 lines
12 KiB
Swift

import SwiftUI
import SwiftData
import Charts
struct DashboardView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(StudyTimerService.self) private var studyTimer
@State private var userProgress: UserProgress?
@State private var dailyLogs: [DailyLog] = []
@State private var testResults: [TestResult] = []
@State private var reviewCards: [ReviewCard] = []
@State private var showingSettings = false
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Summary stats
statsGrid
// Study time + Activity side by side on iPad, stacked on iPhone
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 12) {
studyTimeCard
streakCalendar
}
VStack(spacing: 12) {
studyTimeCard
streakCalendar
}
}
// Accuracy chart
accuracyChart
// Test scores
if !testResults.isEmpty {
testScoresSection
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.onAppear(perform: loadData)
}
}
// MARK: - Stats Grid
private var statsGrid: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
StatCard(title: "Total Reviews", value: "\(userProgress?.totalReviewed ?? 0)", icon: "checkmark.circle", color: .blue)
StatCard(title: "Current Streak", value: "\(userProgress?.currentStreak ?? 0)d", icon: "flame.fill", color: .orange)
StatCard(title: "Longest Streak", value: "\(userProgress?.longestStreak ?? 0)d", icon: "trophy.fill", color: .yellow)
let mastered = reviewCards.filter { $0.interval >= 21 }.count
StatCard(title: "Verbs Mastered", value: "\(mastered)", icon: "star.fill", color: .green)
let avgAccuracy = averageAccuracy
StatCard(title: "Avg Accuracy", value: "\(avgAccuracy)%", icon: "target", color: .purple)
StatCard(title: "Tests Taken", value: "\(testResults.count)", icon: "pencil.and.list.clipboard", color: .indigo)
}
}
// MARK: - Study Time Card
private var studyTimeCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Study Time")
.font(.headline)
let todaySeconds = todayStudySeconds + studyTimer.currentSessionSeconds
HStack(spacing: 0) {
VStack(spacing: 4) {
Text(formatStudyTime(todaySeconds))
.font(.title3.bold().monospacedDigit())
Text("Today")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
VStack(spacing: 4) {
Text(formatStudyTime(totalStudySeconds))
.font(.title3.bold().monospacedDigit())
Text("Total")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
if weeklyStudyData.allSatisfy({ $0.minutes == 0 }) {
Text("Start studying to see your time")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 80)
} else {
Chart(weeklyStudyData) { day in
BarMark(
x: .value("Day", day.label),
y: .value("Minutes", day.minutes)
)
.foregroundStyle(.mint.gradient)
.cornerRadius(4)
}
.chartYAxis(.hidden)
.frame(height: 80)
}
}
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Streak Calendar
private var streakCalendar: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity")
.font(.headline)
StreakCalendarView(dailyLogs: dailyLogs)
}
.padding()
.frame(maxWidth: .infinity)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Accuracy Chart
private var accuracyChart: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Accuracy (Last 30 Days)")
.font(.headline)
if recentLogs.isEmpty {
Text("Start practicing to see your accuracy trend")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(height: 150)
} else {
Chart(recentLogs, id: \.dateString) { log in
LineMark(
x: .value("Date", DailyLog.date(from: log.dateString) ?? Date()),
y: .value("Accuracy", log.accuracy * 100)
)
.foregroundStyle(.blue)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", DailyLog.date(from: log.dateString) ?? Date()),
y: .value("Accuracy", log.accuracy * 100)
)
.foregroundStyle(.blue.opacity(0.1))
.interpolationMethod(.catmullRom)
}
.chartYScale(domain: 0...100)
.chartYAxis {
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
AxisValueLabel {
Text("\(value.as(Int.self) ?? 0)%")
.font(.caption2)
}
AxisGridLine()
}
}
.frame(height: 180)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Test Scores
private var testScoresSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Recent Tests")
.font(.headline)
Spacer()
if let best = testResults.map(\.scorePercent).max() {
Text("Best: \(best)%")
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
}
}
ForEach(Array(testResults.sorted { $0.dateTaken > $1.dateTaken }.prefix(5).enumerated()), id: \.offset) { _, result in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Week \(result.weekNumber)")
.font(.subheadline.weight(.medium))
Text(result.dateTaken.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text("\(result.scorePercent)%")
.font(.title3.weight(.bold))
.foregroundStyle(result.scorePercent >= 90 ? .green : result.scorePercent >= 70 ? .orange : .red)
}
.padding(.vertical, 4)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
// MARK: - Study Time Computed
private var todayStudySeconds: Int {
let today = DailyLog.todayString()
return dailyLogs.first { $0.dateString == today }?.studySeconds ?? 0
}
private var totalStudySeconds: Int {
dailyLogs.reduce(0) { $0 + $1.studySeconds } + studyTimer.currentSessionSeconds
}
private var weeklyStudySeconds: Int {
weeklyStudyData.reduce(0) { $0 + $1.minutes } * 60
}
private var weeklyStudyData: [StudyDay] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
return (0..<7).reversed().map { daysAgo in
let date = calendar.date(byAdding: .day, value: -daysAgo, to: today)!
let dateStr = DailyLog.dateString(from: date)
let seconds = dailyLogs.first { $0.dateString == dateStr }?.studySeconds ?? 0
let dayLabel = daysAgo == 0 ? "Today" : Self.shortDayFormatter.string(from: date)
return StudyDay(label: dayLabel, minutes: seconds / 60)
}
}
private static let shortDayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}()
private func formatStudyTime(_ totalSeconds: Int) -> String {
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
// MARK: - Computed
private var recentLogs: [DailyLog] {
return dailyLogs
.filter { $0.reviewCount > 0 }
.suffix(30)
.reversed()
}
private var averageAccuracy: Int {
let logsWithData = dailyLogs.filter { $0.reviewCount > 0 }
guard !logsWithData.isEmpty else { return 0 }
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
return Int(total / Double(logsWithData.count) * 100)
}
private func loadData() {
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>(
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
)
dailyLogs = (try? cloudModelContext.fetch(dailyDescriptor)) ?? []
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
reviewCards = (try? cloudModelContext.fetch(FetchDescriptor<ReviewCard>())) ?? []
try? cloudModelContext.save()
}
}
// MARK: - Stat Card
private struct StatCard: View {
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(color)
Text(value)
.font(.title2.bold().monospacedDigit())
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 14))
}
}
private struct StudyDay: Identifiable {
let label: String
let minutes: Int
var id: String { label }
}
#Preview {
DashboardView()
.environment(StudyTimerService())
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
}