From d372a5c77fcdcd537d28b35f8afbbc282ceacb5e Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 12 Apr 2026 12:45:25 -0500 Subject: [PATCH] Add checkpoint exams with cumulative vocabulary review per course Checkpoint exams appear after each week in the course view, testing all words from week 1 through the current week within the same course. Users can choose 25, 50, or 100 questions with even distribution across weeks. Results are tracked separately from weekly tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 4 + Conjuga/Conjuga/Models/QuizType.swift | 19 +- .../Views/Course/CheckpointExamView.swift | 224 ++++++++++++++++++ .../Conjuga/Views/Course/CourseQuizView.swift | 2 +- Conjuga/Conjuga/Views/Course/CourseView.swift | 43 ++++ .../Conjuga/Views/Course/WeekTestView.swift | 2 +- 6 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 Conjuga/Conjuga/Views/Course/CheckpointExamView.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index d642b68..604d7b8 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; + 968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; }; 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; }; @@ -139,6 +140,7 @@ 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = ""; }; 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = ""; }; 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = ""; }; + EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = ""; }; 626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = ""; }; 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = ""; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = ""; }; @@ -396,6 +398,7 @@ 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */, 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */, 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */, + EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */, ); path = Course; sourceTree = ""; @@ -584,6 +587,7 @@ 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, + 968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Conjuga/Conjuga/Models/QuizType.swift b/Conjuga/Conjuga/Models/QuizType.swift index afd4958..dc3fae4 100644 --- a/Conjuga/Conjuga/Models/QuizType.swift +++ b/Conjuga/Conjuga/Models/QuizType.swift @@ -9,9 +9,15 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable { case handwritingEnToEs = "hw_en_to_es" case handwritingEsToEn = "hw_es_to_en" case completeSentenceES = "complete_sentence_es" + case checkpoint = "checkpoint" var id: String { rawValue } + /// Quiz types shown in the weekly test picker (excludes checkpoint). + static var weeklyQuizTypes: [QuizType] { + allCases.filter { $0 != .checkpoint } + } + var label: String { switch self { case .mcEnToEs: "Multiple Choice: EN → ES" @@ -21,6 +27,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable { case .handwritingEnToEs: "Handwriting: EN → ES" case .handwritingEsToEn: "Handwriting: ES → EN" case .completeSentenceES: "Complete the Sentence" + case .checkpoint: "Checkpoint Exam" } } @@ -30,6 +37,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable { case .typingEnToEs, .typingEsToEn: "keyboard" case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline" case .completeSentenceES: "text.badge.checkmark" + case .checkpoint: "checkmark.seal" } } @@ -42,6 +50,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable { case .handwritingEnToEs: "See English, handwrite the Spanish word" case .handwritingEsToEn: "See Spanish, handwrite the English word" case .completeSentenceES: "Read a Spanish sentence and pick the missing word" + case .checkpoint: "Cumulative review of all weeks so far" } } @@ -49,20 +58,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable { var promptLanguage: String { switch self { case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English" - case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: "Spanish" + case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish" } } var answerLanguage: String { switch self { case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish" - case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English" + case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English" } } var isMultipleChoice: Bool { switch self { - case .mcEnToEs, .mcEsToEn, .completeSentenceES: true + case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false } } @@ -81,14 +90,14 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable { func prompt(for card: VocabCard) -> String { switch self { case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back - case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: card.front + case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: card.front } } func answer(for card: VocabCard) -> String { switch self { case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front - case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back + case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back } } } diff --git a/Conjuga/Conjuga/Views/Course/CheckpointExamView.swift b/Conjuga/Conjuga/Views/Course/CheckpointExamView.swift new file mode 100644 index 0000000..ed78c66 --- /dev/null +++ b/Conjuga/Conjuga/Views/Course/CheckpointExamView.swift @@ -0,0 +1,224 @@ +import SwiftUI +import SharedModels +import SwiftData + +struct CheckpointExamView: View { + let courseName: String + let throughWeek: Int + + @Environment(\.modelContext) private var modelContext + @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider + @Query private var allDecks: [CourseDeck] + + @State private var cardsByWeek: [Int: [VocabCard]] = [:] + @State private var checkpointResults: [TestResult] = [] + @State private var selectedCount = 25 + + private let questionCounts = [25, 50, 100] + + private var cloudModelContext: ModelContext { cloudModelContextProvider() } + + private var totalAvailable: Int { + cardsByWeek.values.reduce(0) { $0 + $1.count } + } + + /// Sample evenly across weeks, then fill remainder round-robin. + private var sampledCards: [VocabCard] { + let weekNumbers = cardsByWeek.keys.sorted() + guard !weekNumbers.isEmpty else { return [] } + + let target = min(selectedCount, totalAvailable) + let perWeek = target / weekNumbers.count + var remainder = target - (perWeek * weekNumbers.count) + + var result: [VocabCard] = [] + for week in weekNumbers { + guard let pool = cardsByWeek[week] else { continue } + let shuffled = pool.shuffled() + var take = min(perWeek, shuffled.count) + // Distribute remainder one extra card per week until exhausted + if remainder > 0 && take < shuffled.count { + take += 1 + remainder -= 1 + } + result.append(contentsOf: shuffled.prefix(take)) + } + + // If some weeks had fewer cards than perWeek, fill from weeks with surplus + if result.count < target { + let used = Set(result.map { ObjectIdentifier($0) }) + var extras: [VocabCard] = [] + for week in weekNumbers { + guard let pool = cardsByWeek[week] else { continue } + extras.append(contentsOf: pool.filter { !used.contains(ObjectIdentifier($0)) }) + } + extras.shuffle() + result.append(contentsOf: extras.prefix(target - result.count)) + } + + return result.shuffled() + } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + VStack(spacing: 4) { + Image(systemName: "checkmark.seal") + .font(.system(size: 44)) + .foregroundStyle(.blue) + Text("Checkpoint Exam") + .font(.largeTitle.weight(.bold)) + Text("Weeks 1–\(throughWeek)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + + // Question count picker + VStack(spacing: 8) { + Text("Questions") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + ForEach(questionCounts, id: \.self) { count in + let available = count <= totalAvailable + Button { + withAnimation { selectedCount = count } + } label: { + Text("\(count)") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.bordered) + .tint(selectedCount == count ? .blue : .secondary) + .disabled(!available) + .opacity(available ? 1 : 0.4) + } + } + .padding(.horizontal) + } + + VStack(spacing: 8) { + Label("Multiple choice", systemImage: "list.bullet") + Label("Cumulative vocabulary", systemImage: "books.vertical") + Label("\(totalAvailable) words available", systemImage: "character.book.closed") + } + .font(.subheadline) + .foregroundStyle(.secondary) + + if cardsByWeek.isEmpty { + ProgressView("Loading vocabulary...") + .padding(.top, 20) + } else { + NavigationLink { + CourseQuizView( + cards: sampledCards, + quizType: .checkpoint, + courseName: courseName, + weekNumber: throughWeek, + isFocusMode: false + ) + } label: { + Text("Begin Exam") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .padding(.horizontal) + } + + // Score History + if !checkpointResults.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Score History") + .font(.headline) + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(Array(checkpointResults.prefix(10).enumerated()), id: \.offset) { _, result in + HStack { + Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("\(result.scorePercent)%") + .font(.title3.weight(.bold)) + .foregroundStyle(scoreColor(result.scorePercent)) + Text("\(result.correctCount)/\(result.totalQuestions)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + Divider().padding(.leading, 14) + } + } + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + } + } + } + .padding(.vertical) + .adaptiveContainer() + } + .navigationTitle("Checkpoint") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + loadCumulativeCards() + loadResults() + } + } + + private func loadCumulativeCards() { + let course = courseName + let maxWeek = throughWeek + let weekDecks = allDecks.filter { + $0.courseName == course && $0.weekNumber <= maxWeek && !$0.isReversed + } + var grouped: [Int: [VocabCard]] = [:] + for deck in weekDecks { + let deckId = deck.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.deckId == deckId } + ) + if let fetched = try? modelContext.fetch(descriptor) { + grouped[deck.weekNumber, default: []].append(contentsOf: fetched) + } + } + cardsByWeek = grouped + } + + private func loadResults() { + let course = courseName + let week = throughWeek + let checkpointType = QuizType.checkpoint.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.courseName == course && $0.weekNumber == week && $0.quizType == checkpointType + }, + sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)] + ) + checkpointResults = (try? cloudModelContext.fetch(descriptor)) ?? [] + } + + private func scoreColor(_ percent: Int) -> Color { + if percent >= 90 { return .green } + if percent >= 70 { return .orange } + return .red + } +} + +#Preview { + NavigationStack { + CheckpointExamView(courseName: "LanGo Spanish | Beginner I", throughWeek: 3) + } + .modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true) +} diff --git a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift index a977ced..e8a2baa 100644 --- a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift @@ -104,7 +104,7 @@ struct CourseQuizView: View { .padding() .adaptiveContainer() } - .navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test") + .navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/Conjuga/Conjuga/Views/Course/CourseView.swift b/Conjuga/Conjuga/Views/Course/CourseView.swift index 4021f69..92325c8 100644 --- a/Conjuga/Conjuga/Views/Course/CourseView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseView.swift @@ -33,11 +33,20 @@ struct CourseView: View { private func bestScore(for week: Int) -> Int? { let results = testResults.filter { $0.courseName == activeCourse && $0.weekNumber == week + && $0.quizType != QuizType.checkpoint.rawValue } guard !results.isEmpty else { return nil } return results.map(\.scorePercent).max() } + private func bestCheckpointScore(for week: Int) -> Int? { + let results = testResults.filter { + $0.courseName == activeCourse && $0.weekNumber == week + && $0.quizType == QuizType.checkpoint.rawValue + } + return results.map(\.scorePercent).max() + } + private func shortName(_ full: String) -> String { full.replacingOccurrences(of: "LanGo Spanish | ", with: "") .replacingOccurrences(of: "LanGo Spanish ", with: "") @@ -103,6 +112,32 @@ struct CourseView: View { DeckRowView(deck: deck) } } + + // Checkpoint exam + NavigationLink(value: CheckpointDestination(courseName: activeCourse, throughWeek: week)) { + HStack(spacing: 12) { + Image(systemName: "checkmark.seal") + .font(.title3) + .foregroundStyle(.blue) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Checkpoint Exam") + .font(.subheadline.weight(.semibold)) + Text("Cumulative review: Weeks 1–\(week)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if let best = bestCheckpointScore(for: week) { + Text("\(best)%") + .font(.subheadline.weight(.bold)) + .foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red) + } + } + } } header: { Text("Week \(week)") } @@ -117,6 +152,9 @@ struct CourseView: View { .navigationDestination(for: WeekTestDestination.self) { dest in WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber) } + .navigationDestination(for: CheckpointDestination.self) { dest in + CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek) + } } } @@ -132,6 +170,11 @@ struct WeekTestDestination: Hashable { let weekNumber: Int } +struct CheckpointDestination: Hashable { + let courseName: String + let throughWeek: Int +} + // MARK: - Deck Row private struct DeckRowView: View { diff --git a/Conjuga/Conjuga/Views/Course/WeekTestView.swift b/Conjuga/Conjuga/Views/Course/WeekTestView.swift index b1857b5..7803df2 100644 --- a/Conjuga/Conjuga/Views/Course/WeekTestView.swift +++ b/Conjuga/Conjuga/Views/Course/WeekTestView.swift @@ -56,7 +56,7 @@ struct WeekTestView: View { .font(.headline) .padding(.horizontal) - ForEach(QuizType.allCases) { type in + ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in NavigationLink { CourseQuizView( cards: weekCards,