Re:Call
Re:Call 1.0 · Technical reference

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.

macOS 14+ · SwiftUI · SwiftData Windows 10/11 · Electron · React Bundle ID com.ishan.SpacedRep Version 1.0 (1)
1,343lines in the Swift engine
6,433lines in the Mac app
7,271lines in the Windows app
84 + 87tests (Mac engine + Windows)
0network calls, accounts or servers

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.

Shell & screensSwiftUI · React/Electron
Six screens, the menu bar / tray panel, About window, quick search, keyboard review. Notifications (UserNotifications / Electron Notification), Dock or taskbar badge, open at login, file dialogs.
↓ calls · observes ↑
StoreStudyStore.swift · store.ts
Owns topics, review records, day logs, syllabus and settings. Applies actions through the engine, writes records, keeps one-step undo, runs the daily maintenance, the 30-second clock tick, backups and notification resync.
↓ pure function calls
EngineSpacedRepCore · src/core
FSRS-6, the scheduler state machine, exam caps and final sweep, load balancing, quiet hours, self-correction, personal tuning, check-in rules, today's agenda, reminder plan, study grid, syllabus pace, daily goal, weekly report. Value types in, value types out.

One rating, end to end

✓ + Good on Today→ 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 fileResponsibility
FSRS.swiftFSRS-6 formulas and the 21 default parameters.
Models.swiftRating, ReviewAction, Phase, TopicSchedule, skip-flag (3) and trouble (4 lapses) thresholds.
Intensity.swiftChill → Brutal: target retention, same-day steps, exam cap, final sweep, daily limit.
Scheduler.swiftNew topic, rate, skip, restart, replan, preview, exam adjustment, fuzz.
Exams.swiftSeveral exams (v2.0): which exam each subject is preparing for.
LoadBalancer.swiftSpreads overfull days in exam mode, keeping each topic clear of its own exam.
QuietHours.swiftMoves anything inside quiet hours to the end of the window.
Calibration.swiftSelf-correction interval modifier from real outcomes.
PersonalTuning.swiftFits FSRS parameters to the user's history (log loss).
CheckInRules.swiftStudy-time edit windows and the lock.
DailyAgenda.swiftToday's pending and done lists; morning summary text.
ReminderPlanner.swiftWhich notifications to schedule for the next 7 days.
StudyGrid.swift, DailyGoal.swift, Syllabus.swift, WeeklyReport.swiftThe 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.

R(t, S) = (1 + f·t / S)−w₂₀    f = 0.9−1/w₂₀ − 1 ≈ 0.98039    (R = 0.9 exactly when t = S)
interval(r, S) = S / f · (r−1/w₂₀ − 1)    days until R falls to target r
S′recall = S · (1 + ew₈ · (11 − D) · S−w₉ · (ew₁₀(1−R) − 1) · hard · easy)   hard = w₁₅ for Hard, easy = w₁₆ for Easy, else 1
S′forget = min( w₁₁ · D−w₁₂ · ((S+1)w₁₃ − 1) · ew₁₄(1−R),  S / ew₁₇·w₁₈ )
S′same-day = S · ew₁₇(G − 3 + w₁₈) · S−w₁₉    G = rating 1–4; Good/Easy never shrink S
D′ = w₇·D₀(Easy) + (1 − w₇)·(D − w₆(G − 3)·(10 − D)/9), clamped to 1…10    D₀(G) = w₄ − ew₅(G−1) + 1

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:

PhaseAgainHardGoodEasy
learning(step)back to step 0stay on this stepnext step, or graduate to reviewgraduate; gap ≥ Good's gap + 1
relearning(step)back to step 0stay on this stepgraduate (marked "from relearning")graduate
reviewlapse +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 seed and its rep count, so results are repeatable (and identical on Windows).
  • Skip: due → tomorrow (quiet-adjusted); lastReview is 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 newTopic with the same seed; history records are kept.
  • Replan: recomputes due from lastReview with current settings. Skips topics in same-day steps or with a skip streak.
  • Preview: runs apply for all four ratings to show "next: 13d" on the buttons.

Intensity table

LevelTarget RSame-dayExam capFinal sweepDaily limitGood-every-time gaps (no exam)
😌 Chill0.851h50%3 d154 → 31 → 173
🙂 Steady0.901h45%5 d252 → 11 → 46 → 163
💪 Focused (default)0.931h40%7 d401 → 4 → 14 → 42 → 110
🔥 Intense0.951h35%7 d601 → 3 → 8 → 19 → 43 → 89
💀 Brutal0.9720m, 1h30%10 dnone1 → 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:

  1. Personal tuning, if switched on, there are 300+ day-based revisions, and 7 days have passed since the last fit.
  2. Self-correction: recompute the interval modifier from all day-based ratings.
  3. Replan every topic with the current settings.
  4. Load-balance crowded days (exam mode with a daily limit).
  5. 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)

full = clamp( ln(target) / ln(clamp(actual, 0.5, 0.99)), 0.5, 1.5 )    modifier = 1 + (full − 1) · min(1, n / 200)

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:

KindWhenRules
Morning listEnd of quiet hours, each of the next 7 daysEverything due that day, overdue included. Text: "📚 Today: 6 revisions — Physics Ch 4, … +3 more".
Due reminderEach topic's due timeTopics 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-in21:00Only if today's study time isn't logged.
Weekly reportSunday 19:00Opens 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

FeatureRule
Study gridOne 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 & streakGoal 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 paceDeadline = 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 coveredA topic exists with that subject and chapter number, or it was marked by hand (doneAt).
Weekly reportMonday–Sunday. Hours, days studied, revisions, % remembered vs last week. Best / Needs work need ≥ 3 revisions in a subject.
Trouble topiclapses ≥ 4 (Again pressed after reaching day-based revisions).

Data & storage

Everything is local. Nothing is sent anywhere.

MacWindows
DataSwiftData store ~/Library/Application Support/SpacedRep/SpacedRep.storeOne JSON file %APPDATA%\SpacedRep\SpacedRep.json, written atomically (tmp + rename)
SettingsUserDefaultsInside 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

EntityFields
Topicid (unique), subject, chapter, name, startedAt, endedAt (free text), createdAt (auto), archived, due (copied out for sorting), scheduleData (TopicSchedule as JSON)
TopicSchedulephase, stability, difficulty, lastReview, due, reps, lapses, skipStreak, lastRating, lastFromRelearning, seed
ReviewRecordtopicID, at, actionRaw (0 skip, 1–4 rating), wasDayBased, stabilityBefore, stabilityAfter, dueAfter
DayLogday (unique, start of day), minutes, updatedAt
SyllabusChapterid, subject, number, title (optional), createdAt, doneAt (marked by hand)
Settingsintensity, examDate, periodStart, quietStart/EndHour, trackingStart, intervalModifier, dailyGoalMinutes, usePersonalModel, personalParameters[21], lastTuning, disabledFeatures, lastMaintenanceDay
UndoKeeps exactly one step: the topic's previous 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).

PieceWhat it does
electron/main.jsMain, 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.jsExposes a small recall:* IPC bridge: loadData, saveData, writeBackup, listBackups, exportBackup, importBackup, setNotifications, setBadge, get/setOpenAtLogin, setTrayEnabled, openAbout, openMain…
src/app/bridge.tsUses 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.ts60 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 build

Signed 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

VariableEffect
SPACEDREP_DATA_DIRUse a separate data folder (and separate settings). Both platforms.
SPACEDREP_LOAD_SAMPLE=1Fill with 3 weeks of sample data.
SPACEDREP_DEMOMac debug: demo data set, notifications off.
SPACEDREP_SNAPSHOT + _VIEWMac debug: save a PNG of one screen (today, stats, syllabus, weekly, settings, about, about-egg, eggs, search) and quit.
SPACEDREP_EGG_DAY=7Mac debug: pretend it's the 7th (logo pulse).
SPACEDREP_ADD_PRESETAdd a syllabus preset at launch.
SPACEDREP_BACKUP_SELFTESTMac 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).