Files
Spanish/Conjuga/Conjuga/Services/ExtraStudyStore.swift
T
Trey T ade091f108 Add Extra Study — star cards during review, study just the marked ones
Per-week "Extra Study (N)" row appears at the end of each LanGo week
section when at least one card is marked. Cards are marked from inside
VocabFlashcardView via a star next to the speaker on reveal. Marks are
keyed by the same SHA256 hash CourseReviewCard uses, so a mark and its
SRS state describe the same logical card.

ExtraStudyMark is CloudKit-synced (private DB), with uniqueness enforced
by fetch-or-create on id since CloudKit forbids @Attribute(.unique).
Skipped for textbook courses: DeckStudyView nils out the mark context
when the deck's courseName matches a TextbookChapter, and CourseView
hides the row when the active course is a textbook — so there are no
orphan marks the user can't reach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:38:39 -05:00

89 lines
2.8 KiB
Swift

import Foundation
import SharedModels
import SwiftData
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
struct ExtraStudyStore {
let context: ModelContext
private func fetchMark(id: String) -> ExtraStudyMark? {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
)
return (try? context.fetch(descriptor))?.first
}
func contains(card: VocabCard) -> Bool {
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
}
/// Toggle a mark for the given card. Returns the new "is marked" state.
@discardableResult
func toggle(
card: VocabCard,
courseName: String,
weekNumber: Int
) -> Bool {
let id = CourseCardStore.reviewKey(for: card)
if let existing = fetchMark(id: id) {
context.delete(existing)
try? context.save()
return false
}
let mark = ExtraStudyMark(
id: id,
deckId: card.deckId,
courseName: courseName,
weekNumber: weekNumber,
front: card.front,
back: card.back
)
context.insert(mark)
try? context.save()
return true
}
func count(courseName: String, weekNumber: Int) -> Int {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> {
$0.courseName == courseName && $0.weekNumber == weekNumber
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
func countsByWeek(courseName: String) -> [Int: Int] {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
)
let marks = (try? context.fetch(descriptor)) ?? []
var counts: [Int: Int] = [:]
for mark in marks {
counts[mark.weekNumber, default: 0] += 1
}
return counts
}
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> {
$0.courseName == courseName && $0.weekNumber == weekNumber
},
sortBy: [SortDescriptor(\.markedAt)]
)
return (try? context.fetch(descriptor)) ?? []
}
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
}
}
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
/// button knows which week/course to attribute a mark to.
struct ExtraStudyMarkContext: Equatable {
let courseName: String
let weekNumber: Int
}