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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-12 12:45:25 -05:00
parent a1dc17bf00
commit d372a5c77f
6 changed files with 287 additions and 7 deletions

View File

@@ -37,6 +37,7 @@
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.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 */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.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 = "<group>"; }; 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; }; 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; }; 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; }; 626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; }; 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
@@ -396,6 +398,7 @@
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */, 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */, 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */, 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
); );
path = Course; path = Course;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -584,6 +587,7 @@
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@@ -9,9 +9,15 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case handwritingEnToEs = "hw_en_to_es" case handwritingEnToEs = "hw_en_to_es"
case handwritingEsToEn = "hw_es_to_en" case handwritingEsToEn = "hw_es_to_en"
case completeSentenceES = "complete_sentence_es" case completeSentenceES = "complete_sentence_es"
case checkpoint = "checkpoint"
var id: String { rawValue } 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 { var label: String {
switch self { switch self {
case .mcEnToEs: "Multiple Choice: EN → ES" case .mcEnToEs: "Multiple Choice: EN → ES"
@@ -21,6 +27,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .handwritingEnToEs: "Handwriting: EN → ES" case .handwritingEnToEs: "Handwriting: EN → ES"
case .handwritingEsToEn: "Handwriting: ES → EN" case .handwritingEsToEn: "Handwriting: ES → EN"
case .completeSentenceES: "Complete the Sentence" case .completeSentenceES: "Complete the Sentence"
case .checkpoint: "Checkpoint Exam"
} }
} }
@@ -30,6 +37,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
case .typingEnToEs, .typingEsToEn: "keyboard" case .typingEnToEs, .typingEsToEn: "keyboard"
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline" case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
case .completeSentenceES: "text.badge.checkmark" 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 .handwritingEnToEs: "See English, handwrite the Spanish word"
case .handwritingEsToEn: "See Spanish, handwrite the English word" case .handwritingEsToEn: "See Spanish, handwrite the English word"
case .completeSentenceES: "Read a Spanish sentence and pick the missing 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 { var promptLanguage: String {
switch self { switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English" case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: "Spanish" case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish"
} }
} }
var answerLanguage: String { var answerLanguage: String {
switch self { switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish" case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English" case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English"
} }
} }
var isMultipleChoice: Bool { var isMultipleChoice: Bool {
switch self { switch self {
case .mcEnToEs, .mcEsToEn, .completeSentenceES: true case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
} }
} }
@@ -81,14 +90,14 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
func prompt(for card: VocabCard) -> String { func prompt(for card: VocabCard) -> String {
switch self { switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back 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 { func answer(for card: VocabCard) -> String {
switch self { switch self {
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back
} }
} }
} }

View File

@@ -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<VocabCard>(
predicate: #Predicate<VocabCard> { $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<TestResult>(
predicate: #Predicate<TestResult> {
$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)
}

View File

@@ -104,7 +104,7 @@ struct CourseQuizView: View {
.padding() .padding()
.adaptiveContainer() .adaptiveContainer()
} }
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test") .navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {

View File

@@ -33,11 +33,20 @@ struct CourseView: View {
private func bestScore(for week: Int) -> Int? { private func bestScore(for week: Int) -> Int? {
let results = testResults.filter { let results = testResults.filter {
$0.courseName == activeCourse && $0.weekNumber == week $0.courseName == activeCourse && $0.weekNumber == week
&& $0.quizType != QuizType.checkpoint.rawValue
} }
guard !results.isEmpty else { return nil } guard !results.isEmpty else { return nil }
return results.map(\.scorePercent).max() 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 { private func shortName(_ full: String) -> String {
full.replacingOccurrences(of: "LanGo Spanish | ", with: "") full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
.replacingOccurrences(of: "LanGo Spanish ", with: "") .replacingOccurrences(of: "LanGo Spanish ", with: "")
@@ -103,6 +112,32 @@ struct CourseView: View {
DeckRowView(deck: deck) 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: { } header: {
Text("Week \(week)") Text("Week \(week)")
} }
@@ -117,6 +152,9 @@ struct CourseView: View {
.navigationDestination(for: WeekTestDestination.self) { dest in .navigationDestination(for: WeekTestDestination.self) { dest in
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber) 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 let weekNumber: Int
} }
struct CheckpointDestination: Hashable {
let courseName: String
let throughWeek: Int
}
// MARK: - Deck Row // MARK: - Deck Row
private struct DeckRowView: View { private struct DeckRowView: View {

View File

@@ -56,7 +56,7 @@ struct WeekTestView: View {
.font(.headline) .font(.headline)
.padding(.horizontal) .padding(.horizontal)
ForEach(QuizType.allCases) { type in ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
NavigationLink { NavigationLink {
CourseQuizView( CourseQuizView(
cards: weekCards, cards: weekCards,