Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8c966685 | |||
| 0ad448a600 |
@@ -93,6 +93,7 @@
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
|
||||
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */; };
|
||||
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */; };
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
|
||||
@@ -286,6 +287,7 @@
|
||||
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
||||
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordStatusMetrics.swift; sourceTree = "<group>"; };
|
||||
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = "<group>"; };
|
||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
@@ -416,6 +418,7 @@
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */,
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||
);
|
||||
path = Services;
|
||||
@@ -924,6 +927,7 @@
|
||||
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */,
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -130,6 +130,21 @@ enum LexemePool {
|
||||
return stored == 0 ? 20 : stored
|
||||
}
|
||||
|
||||
/// Max brand-new words to introduce per session for a POS, from its
|
||||
/// "New words per session" setting. 0 is a valid value (review-only), so
|
||||
/// "unset" is distinguished from 0 and defaults to 10. 999 means "no
|
||||
/// throttle" (fill whatever room reviews leave).
|
||||
static func newWordsPerSession(for partOfSpeech: String) -> Int {
|
||||
let key: String
|
||||
switch partOfSpeech {
|
||||
case "noun": key = "nounNewWordsPerSession"
|
||||
case "adjective": key = "adjectiveNewWordsPerSession"
|
||||
default: key = "lexemeNewWordsPerSession"
|
||||
}
|
||||
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
|
||||
return UserDefaults.standard.integer(forKey: key)
|
||||
}
|
||||
|
||||
static func sessionLexemes(
|
||||
partOfSpeech: String,
|
||||
drillMode: String,
|
||||
@@ -179,8 +194,15 @@ enum LexemePool {
|
||||
return lhs.baseForm < rhs.baseForm
|
||||
}
|
||||
|
||||
let ordered = due.map(\.lexeme) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech)))
|
||||
// Reviews take priority: due cards fill the session first, then up to
|
||||
// `newMax` fresh words take whatever room is left (Anki-style new-card
|
||||
// throttle). 999 = no throttle (old behavior: fill the cap with fresh).
|
||||
let cap = sessionCardLimit(for: partOfSpeech)
|
||||
let newMax = newWordsPerSession(for: partOfSpeech)
|
||||
let dueTaken = Array(due.map(\.lexeme).prefix(cap))
|
||||
let remaining = cap - dueTaken.count
|
||||
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
|
||||
return dueTaken + newTaken
|
||||
}
|
||||
|
||||
/// Lexemes the user has already studied at least once for `(POS, drill)`,
|
||||
|
||||
@@ -142,6 +142,15 @@ enum VocabVerbPool {
|
||||
return stored == 0 ? 20 : stored
|
||||
}
|
||||
|
||||
/// Max brand-new verbs to introduce per session, from the "New verbs per
|
||||
/// session" setting. 0 is valid (review-only), so "unset" defaults to 10;
|
||||
/// 999 means "no throttle". Mirrors `LexemePool.newWordsPerSession`.
|
||||
static var newWordsPerSession: Int {
|
||||
let key = "vocabNewWordsPerSession"
|
||||
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
|
||||
return UserDefaults.standard.integer(forKey: key)
|
||||
}
|
||||
|
||||
static func sessionVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
@@ -178,8 +187,14 @@ enum VocabVerbPool {
|
||||
due.sort { $0.dueDate < $1.dueDate }
|
||||
fresh.sort { $0.rank < $1.rank }
|
||||
|
||||
let ordered = due.map(\.verb) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit))
|
||||
// Reviews take priority: due verbs fill the session first, then up to
|
||||
// `newMax` fresh verbs take whatever room is left. 999 = no throttle.
|
||||
let cap = sessionCardLimit
|
||||
let newMax = newWordsPerSession
|
||||
let dueTaken = Array(due.map(\.verb).prefix(cap))
|
||||
let remaining = cap - dueTaken.count
|
||||
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
|
||||
return dueTaken + newTaken
|
||||
}
|
||||
|
||||
/// Verbs the user has already studied at least once (have a
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Status breakdown for a word type, scoped to the levels the user currently
|
||||
/// has enabled. Buckets are mutually exclusive and sum to `total` (the count
|
||||
/// of enabled-level words of that type).
|
||||
struct StatusCounts: Equatable {
|
||||
var new = 0 // never studied (no review card)
|
||||
var overdue = 0 // card due before today
|
||||
var dueToday = 0 // card due today
|
||||
var upcoming = 0 // scheduled for the future, still maturing (interval < mature)
|
||||
var learned = 0 // scheduled for the future and mature (interval >= mature)
|
||||
|
||||
var total: Int { new + overdue + dueToday + upcoming + learned }
|
||||
}
|
||||
|
||||
/// Counts words by SRS status for the Settings progress readout. Pure counting
|
||||
/// over the level-scoped reference pool + review cards — no mutation.
|
||||
enum WordStatusMetrics {
|
||||
/// Anki convention: a card with an interval of three weeks or more is
|
||||
/// considered "mature" rather than still being learned.
|
||||
private static let matureIntervalDays = 21
|
||||
|
||||
static func verbCounts(
|
||||
selectedLevels: Set<VerbLevel>,
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> StatusCounts {
|
||||
let store = ReferenceStore(context: localContext)
|
||||
let levelStrings = Set(selectedLevels.map(\.rawValue))
|
||||
// Mirror `VocabVerbPool.sessionVerbs`: an empty selection means "all".
|
||||
let verbs = levelStrings.isEmpty ? store.fetchVerbs() : store.fetchVerbs(selectedLevels: levelStrings)
|
||||
|
||||
let cards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let byId = Dictionary(cards.map { ($0.verbId, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
|
||||
var counts = StatusCounts()
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
for verb in verbs {
|
||||
if let card = byId[verb.id] {
|
||||
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
|
||||
} else {
|
||||
counts.new += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
static func lexemeCounts(
|
||||
partOfSpeech: String,
|
||||
drillMode: String = "recall",
|
||||
selectedLevels: Set<LexemeLevel>,
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> StatusCounts {
|
||||
let lexDescriptor = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
|
||||
)
|
||||
let all = (try? localContext.fetch(lexDescriptor)) ?? []
|
||||
// Scope to the enabled CEFR levels — same gate the fresh pool uses.
|
||||
let pool = all.filter { selectedLevels.contains(LexemeLevel.level(forRank: $0.frequencyRank)) }
|
||||
|
||||
let cardDescriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
|
||||
}
|
||||
)
|
||||
let cards = (try? cloudContext.fetch(cardDescriptor)) ?? []
|
||||
let byId = Dictionary(cards.map { ($0.lexemeId, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
|
||||
var counts = StatusCounts()
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
for lexeme in pool {
|
||||
if let card = byId[lexeme.id] {
|
||||
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
|
||||
} else {
|
||||
counts.new += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/// Classify one studied card into a bucket. Due-state takes precedence over
|
||||
/// maturity: a mature card that's due still counts as overdue/due-today.
|
||||
private static func bump(
|
||||
_ counts: inout StatusCounts,
|
||||
interval: Int,
|
||||
dueDate: Date,
|
||||
now: Date,
|
||||
calendar: Calendar
|
||||
) {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let dueDay = calendar.startOfDay(for: dueDate)
|
||||
if dueDay < today {
|
||||
counts.overdue += 1
|
||||
} else if dueDay == today {
|
||||
counts.dueToday += 1
|
||||
} else if interval >= matureIntervalDays {
|
||||
counts.learned += 1
|
||||
} else {
|
||||
counts.upcoming += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
/// Local reference store (verbs, lexemes) — needed for the status metrics.
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@State private var progress: UserProgress?
|
||||
|
||||
@State private var dailyGoal: Double = 50
|
||||
@@ -16,6 +18,19 @@ struct SettingsView: View {
|
||||
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
|
||||
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
|
||||
|
||||
/// New (never-studied) words introduced per session, per type. 0 =
|
||||
/// review-only; 999 = "All" (no throttle).
|
||||
@AppStorage("vocabNewWordsPerSession") private var vocabNewWordsPerSession: Int = 10
|
||||
@AppStorage("nounNewWordsPerSession") private var nounNewWordsPerSession: Int = 10
|
||||
@AppStorage("adjectiveNewWordsPerSession") private var adjectiveNewWordsPerSession: Int = 10
|
||||
private let newWordsSizes: [Int] = [0, 5, 10, 15, 20, 999]
|
||||
|
||||
/// SRS status breakdowns, scoped to the enabled levels. Recomputed on
|
||||
/// appear and whenever the level toggles change.
|
||||
@State private var verbStatus = StatusCounts()
|
||||
@State private var nounStatus = StatusCounts()
|
||||
@State private var adjectiveStatus = StatusCounts()
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||
.spelling, .stemChange, .uniqueIrregular
|
||||
@@ -58,6 +73,16 @@ struct SettingsView: View {
|
||||
Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
|
||||
}
|
||||
|
||||
Section {
|
||||
newWordsPicker("New verbs per session", selection: $vocabNewWordsPerSession)
|
||||
newWordsPicker("New nouns per session", selection: $nounNewWordsPerSession)
|
||||
newWordsPicker("New adjectives per session", selection: $adjectiveNewWordsPerSession)
|
||||
} header: {
|
||||
Text("New Words Per Session")
|
||||
} footer: {
|
||||
Text("How many brand-new words a session introduces. Overdue reviews are shown first; new words fill whatever room is left, up to this number. 0 = reviews only.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
@@ -68,6 +93,7 @@ struct SettingsView: View {
|
||||
guard let progress else { return }
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
refreshMetrics()
|
||||
}
|
||||
))
|
||||
}
|
||||
@@ -77,6 +103,14 @@ struct SettingsView: View {
|
||||
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
||||
}
|
||||
|
||||
Section {
|
||||
statusRows(verbStatus)
|
||||
} header: {
|
||||
Text("Verb Status")
|
||||
} footer: {
|
||||
Text("Counts reflect only the verb levels enabled above.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(LexemeLevel.allCases, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
@@ -87,6 +121,7 @@ struct SettingsView: View {
|
||||
guard let progress else { return }
|
||||
progress.setLexemeLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
refreshMetrics()
|
||||
}
|
||||
))
|
||||
}
|
||||
@@ -96,6 +131,18 @@ struct SettingsView: View {
|
||||
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
|
||||
}
|
||||
|
||||
Section {
|
||||
statusRows(nounStatus)
|
||||
} header: {
|
||||
Text("Noun Status")
|
||||
} footer: {
|
||||
Text("Counts reflect only the vocabulary levels enabled above.")
|
||||
}
|
||||
|
||||
Section("Adjective Status") {
|
||||
statusRows(adjectiveStatus)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
Toggle(tense.english, isOn: Binding(
|
||||
@@ -180,12 +227,53 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func newWordsPicker(_ title: String, selection: Binding<Int>) -> some View {
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(newWordsSizes, id: \.self) { size in
|
||||
Text(size == 999 ? "All" : "\(size)").tag(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusRows(_ counts: StatusCounts) -> some View {
|
||||
LabeledContent("New", value: "\(counts.new)")
|
||||
LabeledContent("Overdue", value: "\(counts.overdue)")
|
||||
LabeledContent("Due today", value: "\(counts.dueToday)")
|
||||
LabeledContent("Upcoming", value: "\(counts.upcoming)")
|
||||
LabeledContent("Learned", value: "\(counts.learned)")
|
||||
LabeledContent("Total", value: "\(counts.total)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress = resolved
|
||||
dailyGoal = Double(resolved.dailyGoal)
|
||||
showVosotros = resolved.showVosotros
|
||||
autoFillStem = resolved.autoFillStem
|
||||
refreshMetrics()
|
||||
}
|
||||
|
||||
private func refreshMetrics() {
|
||||
guard let progress else { return }
|
||||
verbStatus = WordStatusMetrics.verbCounts(
|
||||
selectedLevels: progress.selectedVerbLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
nounStatus = WordStatusMetrics.lexemeCounts(
|
||||
partOfSpeech: "noun",
|
||||
selectedLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
adjectiveStatus = WordStatusMetrics.lexemeCounts(
|
||||
partOfSpeech: "adjective",
|
||||
selectedLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
}
|
||||
|
||||
private func saveProgress() {
|
||||
|
||||
Reference in New Issue
Block a user