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

  • INTERVALS is the single source of truth for the schedule — change the cadence in one object.
  • getMethod encodes the has_flashcards fallback so the dashboard tells you how to review, not just that you should.
  • The date-format guard exists because a silent DD/MM/YYYY typo otherwise throws deep inside Luxon; instead it surfaces a fixable warning and excludes the bad note rather than breaking the whole dashboard.