The Core Problem
A lot of language apps feel good in demos but collapse as soon as you add spaced repetition, cross-device sync, and mixed-script content. The fix is not “more features.” The fix is better state boundaries.
Let’s build a model that keeps card state predictable while the UI stays fast.
Step 1: Split content from learning state
Your vocabulary entry should not carry scheduling internals. Separate static content from progress state.
struct Lexeme: Identifiable, Codable {
let id: String
let term: String
let reading: String
let meaning: String
}
struct LearningState: Codable {
var intervalDays: Int
var ease: Double
var dueAt: Date
}
Step 2: Make scheduling rules explicit
Think of your scheduler as a coach. Good answer: push next drill further away. Bad answer: bring it back sooner.
func scheduleNext(_ state: LearningState, grade: Int, now: Date) -> LearningState {
var next = state
if grade >= 4 {
next.intervalDays = max(1, Int(Double(state.intervalDays) * state.ease))
next.ease = min(2.8, state.ease + 0.05)
} else {
next.intervalDays = 1
next.ease = max(1.3, state.ease - 0.2)
}
next.dueAt = Calendar.current.date(byAdding: .day, value: next.intervalDays, to: now)!
return next
}
Step 3: Build a session queue from due cards only
func dueQueue(lexemes: [Lexeme], states: [String: LearningState], now: Date) -> [Lexeme] {
lexemes.filter { item in
guard let s = states[item.id] else { return true }
return s.dueAt <= now
}
}
Step 4: Add offline-first writes
When network is unstable, persist locally first and sync in background. Never block study flow on a request timeout.
Pitfalls to Avoid
- Using one giant view model for content, settings, and schedule logic.
- Mutating schedule state inside random UI callbacks.
- Skipping script/locale tests for CJK-heavy datasets.
Verification
- Failed sync does not lose progress.
- Due queue is stable for the same timestamp input.
- Grade distribution shifts intervals as expected in tests.