ade091f108
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>
89 lines
2.8 KiB
Swift
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
|
|
}
|