How Re:Call works inside
A spaced-revision planner for Mac and Windows. One pure scheduling engine (FSRS-6 plus exam-date rules), a local data store, and a thin native shell on each platform.
Internal names still use the project's first name, SpacedRep (targets, bundle ID, data folder, environment variables). The product name everywhere a user can see is Re:Call; the built app file is ReCall.app / ReCall.exe.
Repository layout
Re-Call/
├── mac/ # the original app
│ ├── project.yml # XcodeGen spec → SpacedRep.xcodeproj
│ ├── PLAN.md # design + algorithm decisions
│ ├── SpacedRepCore/ # Swift package: the engine (no UI, no storage)
│ │ ├── Sources/SpacedRepCore/ # 15 files
│ │ └── Tests/SpacedRepCoreTests/ # 84 @Test cases
│ ├── SpacedRep/ # the Mac app target (PRODUCT_NAME = ReCall)
│ │ ├── SpacedRepApp.swift # windows, menu bar extra, commands, ⌘1–⌘6
│ │ ├── Models/ # SwiftData models, AppSettings (UserDefaults)
│ │ ├── Services/ # StudyStore, notifications, backup, presets, sample data
│ │ └── Views/ # one file per screen + Theme (Nocturne)
│ └── dist/Re-Call v1.0.dmg
├── windows/ReCall-Windows/ # the Windows port
│ ├── electron/ # main.js (shell) + preload.js (bridge)
│ ├── src/core/ # engine, line-by-line port of SpacedRepCore
│ ├── src/app/ # store, data/backup, features, presets
│ ├── src/ui/ # React screens
│ └── tests/ # ported tests + parity.test.ts + fixtures/mac-golden.json
├── Re-Call v1.0/ # release builds: .dmg, Setup .exe, source zip
└── docs/ # these docs
Architecture
Three layers. Only the middle one holds state; only the bottom one is pure. Everything that decides when lives in the engine, so it can be tested without a UI, a clock or a disk.
Notification), Dock or taskbar badge, open at login, file dialogs.One rating, end to end
store.act(.rate(.good), topic)→
Scheduler.apply → new TopicSchedule→
save schedule + insert ReviewRecord→
remember UndoEntry (8 s bar)→
agenda + badge refresh→
ReminderPlanner → notifications resync
| Engine file | Responsibility |
|---|---|
FSRS.swift | FSRS-6 formulas and the 21 default parameters. |
Models.swift | Rating, ReviewAction, Phase, TopicSchedule, skip-flag (3) and trouble (4 lapses) thresholds. |
Intensity.swift | Chill → Brutal: target retention, same-day steps, exam cap, final sweep, daily limit. |
Scheduler.swift | New topic, rate, skip, restart, replan, preview, exam adjustment, fuzz. |
Exams.swift | Several exams (v2.0): which exam each subject is preparing for. |
LoadBalancer.swift | Spreads overfull days in exam mode, keeping each topic clear of its own exam. |
QuietHours.swift | Moves anything inside quiet hours to the end of the window. |
Calibration.swift | Self-correction interval modifier from real outcomes. |
PersonalTuning.swift | Fits FSRS parameters to the user's history (log loss). |
CheckInRules.swift | Study-time edit windows and the lock. |
DailyAgenda.swift | Today's pending and done lists; morning summary text. |
ReminderPlanner.swift | Which notifications to schedule for the next 7 days. |
StudyGrid.swift, DailyGoal.swift, Syllabus.swift, WeeklyReport.swift | The maths behind Stats, streaks, syllabus pace and the weekly report. |
FSRS-6 memory model
Each topic carries a stability S (days until recall probability falls to 90%), a difficulty D (1–10) and, derived from them, retrievability R (recall probability now). Formulas follow the open-spaced-repetition FSRS-6 reference.
A new topic is treated as "learned well": S₀ = w₂ = 2.3065 days and D₀(Good) ≈ 2.12. Default parameters (highlighted ones are tuned by Personal tuning):
Scheduler
Scheduler is a pure struct built from a SchedulerConfig (intensity, exam date, quiet hours, interval modifier, fuzz on/off, calendar, FSRS parameters). Every topic is in one of three phases:
| Phase | Again | Hard | Good | Easy |
|---|---|---|---|---|
learning(step) | back to step 0 | stay on this step | next step, or graduate to review | graduate; gap ≥ Good's gap + 1 |
relearning(step) | back to step 0 | stay on this step | graduate (marked "from relearning") | graduate |
review | lapse +1, S′forget, → relearning(0) | S′recall for each button; gaps forced to Hard < Good < Easy (like Anki), then exam-adjusted | ||
- Same-day steps: 1 hour (Brutal: 20 min, then 1 hour). Due time = now + step, moved out of quiet hours.
- Day gap:
baseDays = round(interval(targetRetention, S) × intervalModifier × fuzz), clamped to 1…36,500 days. - Fuzz: gaps of 7+ days get ±5%, from a SplitMix64 hash of the topic's fixed
seedand its rep count, so results are repeatable (and identical on Windows). - Skip: due → tomorrow (quiet-adjusted);
lastReviewis untouched so R keeps falling.skipStreak≥ 3 flags the topic. If tomorrow is the exam day or later, it's retried 2 hours later instead. - Restart ("Re-studied"): a fresh
newTopicwith the same seed; history records are kept. - Replan: recomputes
duefromlastReviewwith current settings. Skips topics in same-day steps or with a skip streak. - Preview: runs
applyfor all four ratings to show "next: 13d" on the buttons.
Intensity table
| Level | Target R | Same-day | Exam cap | Final sweep | Daily limit | Good-every-time gaps (no exam) |
|---|---|---|---|---|---|---|
| 😌 Chill | 0.85 | 1h | 50% | 3 d | 15 | 4 → 31 → 173 |
| 🙂 Steady | 0.90 | 1h | 45% | 5 d | 25 | 2 → 11 → 46 → 163 |
| 💪 Focused (default) | 0.93 | 1h | 40% | 7 d | 40 | 1 → 4 → 14 → 42 → 110 |
| 🔥 Intense | 0.95 | 1h | 35% | 7 d | 60 | 1 → 3 → 8 → 19 → 43 → 89 |
| 💀 Brutal | 0.97 | 20m, 1h | 30% | 10 d | none | 1 → 2 → 4 → 7 → 13 → 22 → 36 |
Exam mode
Since v2.0 you can have several exams (name, date, and the subjects each one covers, empty = all). Each topic is planned for one of them: ExamPlan.target picks the soonest exam after today that covers the topic's subject, and the store builds that topic's Scheduler with that exam's date. On an exam's own day, topics already aim at the next one. Exam mode is active for a topic while it has such a target. Every gap that comes out of the memory model passes through examAdjusted, based on Cepeda et al. (2008): the optimal gap is a shrinking share of the time left.
func examAdjusted(days, rating, fromRelearning, at date) -> Int
left = calendar days from date to exam day // no exam or passed → return days
sweep = intensity.finalSweepDays
cap = max(1, round(intensity.examCapFraction × left))
if left ≤ sweep: // inside the final sweep
solid = (Good or Easy) and not fromRelearning
if solid or left < 2: return max(days, left + 1) // that was the last pre-exam revision
return max(1, min(days, cap, left − 1)) // went badly: one more before the exam
d = min(days, cap)
if d > left − sweep: d = left − sweep // land on the first day of the sweep
return max(1, d)
Load balancing (LoadBalancer.balance), exam mode only (any upcoming exam): topics are bucketed by day. On any day over the limit, the topics with the lowest stability keep their day; the rest move to the next day. Same-day steps never move. Overflow that would land on a topic's own exam day is pulled to the nearest earlier day with room.
After a topic's exam passes, it moves on to the next exam that covers its subject. With none left, it has no target, so caps switch off and normal long-term FSRS spacing resumes. The syllabus pace uses each subject's own deadline (the start of its exam's final sweep) and needs the slowest steady pace that meets every deadline.
Daily maintenance
A 30-second timer calls tick(). On the first tick of a new day (and at launch), runDailyMaintenanceIfNeeded() runs once:
- Personal tuning, if switched on, there are 300+ day-based revisions, and 7 days have passed since the last fit.
- Self-correction: recompute the interval modifier from all day-based ratings.
- Replan every topic with the current settings.
- Load-balance crowded days (exam mode with a daily limit).
- Automatic backup, once a day, last 14 kept.
Changing intensity, an exam's date or subjects, or quiet hours calls replanAll() immediately.
Self-tuning
Self-correction (interval modifier)
actual = share of the last 200 day-based revisions rated Hard/Good/Easy. Starts after 30 revisions and is trusted fully at 200 (Anki's interval-modifier formula, phased in). Shown in Stats as "gap multiplier".
Personal tuning (FSRS parameter fit)
- Needs 300 day-based revisions (gaps ≥ 24 h). On by default (switch in Settings → Features); until there's enough history the standard model is used. Re-fits at most once every 7 days.
- Replays every topic's history and scores predicted R against what happened with log loss.
- Tunes 17 of the 21 parameters (w₀, w₁, w₃, w₅ only affect first ratings the app never uses) within the FSRS-6 optimizer's bounds.
- Coordinate search: nudge each parameter ±step (start 0.2, halve when stuck, stop below 0.005 or after 300 rounds).
- Regularised towards the defaults with weight 20 / n, so small histories can't overfit.
- Adopted only if loss improves by ≥ 1%; switching rescales each topic's stability and replans. "Forget my model" reverts.
Check-in rules
CheckInRules(trackingStart, calendar) decides what can be written. Days before trackingStart are never asked for.
- Missing days: every empty day from tracking start to today, oldest first.
- Overdue: missing days before yesterday. Any overdue day ⇒
isLocked: no adding topics, no logging other days, no keyboard review. - New entry: only the oldest empty day may be filled (days fill in order).
- Edit: only today or yesterday, and only when not locked.
Agenda & reminders
DailyAgenda: pending = active topics due before tomorrow, overdue first then by due time. Done = the latest ✓/✗ per topic today, unless the topic is due again later today (e.g. Again → 1 h), in which case it shows only as pending.
ReminderPlanner runs after every change and rebuilds the whole set:
| Kind | When | Rules |
|---|---|---|
| Morning list | End of quiet hours, each of the next 7 days | Everything due that day, overdue included. Text: "📚 Today: 6 revisions — Physics Ch 4, … +3 more". |
| Due reminder | Each topic's due time | Topics within 15 min of each other share one notification with a "✗ Skip" action. Max 50 groups. Anything within 60 s of a morning list is left to the list. |
| Evening check-in | 21:00 | Only if today's study time isn't logged. |
| Weekly report | Sunday 19:00 | Opens the Weekly Report screen. |
Each kind has its own switch in Settings → Features. The Dock (Mac) or taskbar overlay (Windows) shows the number of topics due now.
Progress maths
| Feature | Rule |
|---|---|
| Study grid | One cell per day from period start to exam. Columns = ⌈√days⌉. Glow = minutes / max(360, best day), with a floor of 0.15 for any study. Today outlined; future faint; last cell is the exam. |
| Daily goal & streak | Goal 0–16 h in 30-min steps. A day counts when logged minutes ≥ goal. Today counts only once hit; an unfinished today never breaks the streak. |
| Syllabus pace | Deadline = exam − final-sweep days. Needed/week = remaining ÷ days to deadline × 7. Recent pace = chapters covered in the last 14 days ÷ 2. On track if recent ≥ needed. Projected finish = remaining ÷ recent pace. |
| Chapter covered | A topic exists with that subject and chapter number, or it was marked by hand (doneAt). |
| Weekly report | Monday–Sunday. Hours, days studied, revisions, % remembered vs last week. Best / Needs work need ≥ 3 revisions in a subject. |
| Trouble topic | lapses ≥ 4 (Again pressed after reaching day-based revisions). |
Data & storage
Everything is local. Nothing is sent anywhere.
| Mac | Windows | |
|---|---|---|
| Data | SwiftData store ~/Library/Application Support/SpacedRep/SpacedRep.store | One JSON file %APPDATA%\SpacedRep\SpacedRep.json, written atomically (tmp + rename) |
| Settings | UserDefaults | Inside the same JSON file |
| Backups | …/SpacedRep/Backups/: automatic daily backup (last 14 kept) plus a safety copy before every import, sample-data load or start-fresh | |
Entities
| Entity | Fields |
|---|---|
Topic | id (unique), subject, chapter, name, startedAt, endedAt (free text), createdAt (auto), archived, due (copied out for sorting), scheduleData (TopicSchedule as JSON) |
TopicSchedule | phase, stability, difficulty, lastReview, due, reps, lapses, skipStreak, lastRating, lastFromRelearning, seed |
ReviewRecord | topicID, at, actionRaw (0 skip, 1–4 rating), wasDayBased, stabilityBefore, stabilityAfter, dueAfter |
DayLog | day (unique, start of day), minutes, updatedAt |
SyllabusChapter | id, subject, number, title (optional), createdAt, doneAt (marked by hand) |
| Settings | intensity, examDate, periodStart, quietStart/EndHour, trackingStart, intervalModifier, dailyGoalMinutes, usePersonalModel, personalParameters[21], lastTuning, disabledFeatures, lastMaintenanceDay |
TopicSchedule and the inserted ReviewRecord. Undo restores the schedule and deletes the record, so it leaves no trace in stats or tuning.Backup format
One JSON file, version 1, dates as ISO 8601 with fractional seconds. The same file works on Mac and Windows in both directions. Fields added after 1.0 shipped are optional, so older backups still import.
{
"version": 1,
"exportedAt": "2026-11-01T14:30:00.000Z",
"settings": {
"intensity": 2, "examDate": "2027-02-10T00:00:00.000Z", "periodStart": "…",
"quietStartHour": 23, "quietEndHour": 7, "trackingStart": "…", "intervalModifier": 1.08,
"dailyGoalMinutes": 240, "usePersonalModel": false, "personalParameters": null,
"lastTuning": null, "disabledFeatures": []
},
"topics": [{ "id": "…", "subject": "Physics", "chapter": 6, "name": "Electromagnetic Induction",
"startedAt": "Faraday's law", "endedAt": "AC generator", "createdAt": "…",
"archived": false, "schedule": { "phase": …, "stability": 14.2, "difficulty": 4.1, … } }],
"reviews": [{ "topicID": "…", "at": "…", "actionRaw": 3, "wasDayBased": true,
"stabilityBefore": 4.3, "stabilityAfter": 14.2, "dueAfter": "…" }],
"dayLogs": [{ "day": "…", "minutes": 360, "updatedAt": "…" }],
"syllabus": [{ "id": "…", "subject": "Physics", "number": 6, "title": "Electromagnetic Induction",
"createdAt": "…", "doneAt": null }]
}Import shows a summary (topics, revisions, study days, export date), saves the current data to Backups/ first, then replaces everything.
Windows port
The Windows app is a rebuild, not a wrapper. src/core is a line-by-line TypeScript port of SpacedRepCore (using BigInt for the 64-bit fuzz hash), and the screens copy the Mac app's text, colours and layout. The rule: only differences Windows forces (Ctrl for ⌘, tray for menu bar, window buttons on the right, Inter for SF Pro).
| Piece | What it does |
|---|---|
electron/main.js | Main, tray and About windows; single-instance lock; tray panel positioned near the icon; notifications as timers (only those within 26 h, re-sent often by the app) with a "✗ Skip to tomorrow" action; taskbar overlay badge; atomic file writes; open at login with --hidden. |
electron/preload.js | Exposes a small recall:* IPC bridge: loadData, saveData, writeBackup, listBackups, exportBackup, importBackup, setNotifications, setBadge, get/setOpenAtLogin, setTrayEnabled, openAbout, openMain… |
src/app/bridge.ts | Uses the preload bridge in Electron; in a plain browser (npm run dev) falls back to localStorage. ?sample=1 loads sample data, ?view=tray / ?view=about render those windows. |
tests/parity.test.ts | 60 long histories (5 intensities × with/without exam × 3 seeds × 2 modifiers, a fixed 20-action pattern) must give identical due dates and stabilities to fixtures/mac-golden.json, produced by the Swift engine. Also checks self-correction values and load-balancer moves. |
Build, test, release
Mac
# engine tests
cd mac/SpacedRepCore && swift test
# regenerate the Xcode project and build Release
cd mac && xcodegen generate
xcodebuild -project SpacedRep.xcodeproj -scheme SpacedRep -configuration Release -derivedDataPath build buildSigned ad-hoc (CODE_SIGN_IDENTITY "-"), no sandbox, no hardened runtime, not notarized. Packaged as a drag-to-Applications .dmg.
Windows
cd windows/ReCall-Windows
npm install
npm test # ported engine tests + Mac parity
npm run dev # in a browser, data in localStorage
npm start # Electron on this machine
npm run dist # → release/Re-Call-Setup.exe (NSIS, x64, per-user, unsigned)Testing without real data
| Variable | Effect |
|---|---|
SPACEDREP_DATA_DIR | Use a separate data folder (and separate settings). Both platforms. |
SPACEDREP_LOAD_SAMPLE=1 | Fill with 3 weeks of sample data. |
SPACEDREP_DEMO | Mac debug: demo data set, notifications off. |
SPACEDREP_SNAPSHOT + _VIEW | Mac debug: save a PNG of one screen (today, stats, syllabus, weekly, settings, about, about-egg, eggs, search) and quit. |
SPACEDREP_EGG_DAY=7 | Mac debug: pretend it's the 7th (logo pulse). |
SPACEDREP_ADD_PRESET | Add a syllabus preset at launch. |
SPACEDREP_BACKUP_SELFTEST | Mac debug: round-trip a backup and check nothing is lost. |
Design rules
Decisions that shaped the code. Keep to them when changing it.
- The app only reminds. No notes, cards or "show answer". Study material stays in the user's books.
- Study time is typed by hand and the check-in stays mandatory. The app never estimates it.
- The engine stays pure. No dates from the system clock, no storage, no UI inside
SpacedRepCore. - Every extra feature has its own place and an on/off switch. Today, All Topics, Stats, the check-in and the scheduler are always on.
- Easter eggs never touch the schedule, only looks.
- Mac and Windows stay identical apart from platform-forced differences, and the parity test must pass.
- Local only. No accounts, sync or analytics.
Sources
- Ebbinghaus, H. (1885). Über das Gedächtnis. Forgetting curve and spacing effect.
- open-spaced-repetition: FSRS-6 algorithm and default parameters.
- Anki manual, Deck Options: learning steps, Hard/Easy multipliers, desired retention, interval modifier.
- Cepeda, N. J. et al. (2008). Spacing effects in learning: a temporal ridgeline of optimal retention. Psychological Science, 19(11).