I open this page every morning. It reads the frontmatter of every topic note and computes each topic’s due date from stage + last_reviewed, and sorts everything into five buckets: Due today, Coming up (7d), In progress, In maintenance, Archived.
It needs the Dataview plugin with JavaScript queries enabled. After a review I edit two frontmatter fields in the topic note (stage and last_reviewed) and the dashboard updates itself.
This renders live only in Obsidian
Dataview executes inside Obsidian, so the block below shows here as source. Copy it into a note in your vault to get the live tables.
The dashboard note
# Recall Dashboard
Open this note each morning to see what's due. After completing a recall, update **two fields** in the note's frontmatter: bump `stage` by 1 (or drop by 1 on failure) and set `last_reviewed` to today's date in `YYYY-MM-DD` format.
**Failure rules:** Active stages (1–4): drop back one stage. Stage 1 failure: stay at stage 1. Maintenance (stage 5) failure: drop back to stage 3.
**Archiving:** Set `archived: true` in frontmatter to permanently hide a topic from the dashboard.
---
```dataviewjs
// ─── config ───────────────────────────────────────────────────────────────────
const INTERVALS = { 1: 1, 2: 3, 3: 7, 4: 14, 5: 60 };
const getMethod = (stage, hasFlashcards) => {
if (stage === 1) return "Free recall";
if (stage === 2) return "Q&A callouts";
// has_flashcards defaults to true (absent = flashcards exist); opt out with has_flashcards: false
if (stage === 3) return hasFlashcards === false ? "Teach-back" : "NotebookLM flashcards";
if (stage === 4) return hasFlashcards === false ? "Free recall" : "Teach-back";
if (stage === 5) return "Free recall (maintenance)";
return "—";
};
const stageLabel = (stage) => stage < 5 ? `${stage} → ${stage + 1}` : "5 (maint.)";
// ─── gather notes ─────────────────────────────────────────────────────────────
const today = dv.date("today");
const notes = dv.pages()
.where(p => p.stage != null && p.topic != null && p.last_reviewed != null);
// Dataview only parses YYYY-MM-DD as a Luxon DateTime. Other formats (e.g. DD/MM/YYYY)
// become plain strings and will throw TypeError on .plus(). Split them out for a warning.
const isDateTime = (d) => typeof d === 'object' && d != null && typeof d.plus === 'function';
const validNotes = notes.where(p => isDateTime(p.last_reviewed));
const invalidNotes = notes.where(p => !isDateTime(p.last_reviewed));
const rows = validNotes.map(p => ({
link: p.file.link,
topic: p.topic,
stage: p.stage,
method: getMethod(p.stage, p.has_flashcards),
stageLabel: stageLabel(p.stage),
lastReviewed: p.last_reviewed,
archived: p.archived === true,
daysUntil: Math.ceil(
p.last_reviewed.plus({ days: INTERVALS[p.stage] ?? 90 })
.diff(today, "days").days
)
}));
const active = rows.filter(r => !r.archived && r.stage >= 1 && r.stage <= 5);
// ─── date format warnings ─────────────────────────────────────────────────────
if (invalidNotes.length > 0) {
dv.header(2, "⚠️ Fix required — wrong date format");
dv.paragraph("These notes have `last_reviewed` in an unrecognised format. **Change to YYYY-MM-DD** (e.g. `2026-05-13`, not `13/05/2026`). They are excluded from all sections below until fixed.");
dv.table(
["Note", "Current value"],
invalidNotes.map(p => [p.file.link, String(p.last_reviewed)])
);
}
// ─── section 1: due today ─────────────────────────────────────────────────────
dv.header(2, "Due today");
const due = active.filter(r => r.daysUntil <= 0).sort(r => r.daysUntil);
if (due.length === 0) {
dv.paragraph("*Nothing due today — enjoy the free morning.*");
} else {
dv.table(
["Topic", "Stage", "Method", "Overdue"],
due.map(r => [
r.link,
r.stageLabel,
r.method,
r.daysUntil === 0 ? "today" : `${Math.abs(r.daysUntil)}d overdue`
])
);
}
// ─── section 2: coming up (next 7 days) ──────────────────────────────────────
dv.header(2, "Coming up (next 7 days)");
const upcoming = active.filter(r => r.daysUntil > 0 && r.daysUntil <= 7).sort(r => r.daysUntil);
if (upcoming.length === 0) {
dv.paragraph("*Nothing in the next 7 days.*");
} else {
dv.table(
["Topic", "Due in", "Stage", "Method"],
upcoming.map(r => [r.link, `${r.daysUntil}d`, r.stageLabel, r.method])
);
}
// ─── section 3: in progress (stages 1–4) ─────────────────────────────────────
dv.header(2, "In progress");
const inProgress = active.filter(r => r.stage >= 1 && r.stage <= 4).sort(r => r.daysUntil);
if (inProgress.length === 0) {
dv.paragraph("*No active topics.*");
} else {
dv.table(
["Topic", "Stage", "Next review", "Method"],
inProgress.map(r => [
r.link,
r.stageLabel,
r.daysUntil <= 0 ? "**Due now**" : `in ${r.daysUntil}d`,
r.method
])
);
}
// ─── section 4: in maintenance (stage 5, 60-day pings) ───────────────────────
dv.header(2, "In maintenance");
const maintenance = active.filter(r => r.stage === 5).sort(r => r.daysUntil);
if (maintenance.length === 0) {
dv.paragraph("*No topics in maintenance yet.*");
} else {
dv.table(
["Topic", "Next ping", "Last reviewed"],
maintenance.map(r => [
r.link,
r.daysUntil <= 0 ? "**Due now**" : `in ${r.daysUntil}d`,
r.lastReviewed.toFormat("yyyy-MM-dd")
])
);
}
// ─── section 5: archived ──────────────────────────────────────────────────────
dv.header(2, "Archived");
const archived = rows.filter(r => r.archived).sort(r => r.topic);
if (archived.length === 0) {
dv.paragraph("*No archived topics.*");
} else {
dv.table(
["Topic", "Last stage"],
archived.map(r => [r.link, r.stage])
);
}
```Design notes
INTERVALSis the single source of truth for the schedule — change the cadence in one object.getMethodencodes thehas_flashcardsfallback so the dashboard tells you how to review, not just that you should.- The date-format guard exists because a silent
DD/MM/YYYYtypo otherwise throws deep inside Luxon; instead it surfaces a fixable warning and excludes the bad note rather than breaking the whole dashboard.