Memory

Forgetting is the plan.

Memory fades on a curve. Review at the right moment and the curve flattens. That is the whole idea, and below you can play with it.

Spaced repetition, by hand · 2026

In 1885 Hermann Ebbinghaus memorised lists of nonsense syllables and tested himself for months. He found that a new memory loses most of its strength within a day, then fades more slowly. The curve is steep at first and flat later.

Two things change its shape. Recalling something just before it slips makes the next fade slower. And the effort of recall does the work: dragging an answer out of your own head builds the memory, re-reading it barely does.

Spaced repetition is the schedule that follows. Test yourself on each item, wait, test again a little later than last time. Each success buys a longer gap: a day, then three, then a week, a month, a season. A few minutes of review a day holds thousands of items.

01

The curve

One new fact: a word, a name, a date. The line is your chance of recalling it, falling day by day. Press Recall it when you think it is about to slip. A success lifts it to 100% and doubles the memory’s strength, the number of days it takes to fade; a miss halves it.

0102030405060dayschance you still recall it ↓0%50%100%
day0.0
strength3.0 d
recall100%
reviews0
lapses0
press once to start the clock
see the code

No chart library. The curve is one SVG <path> whose points come from R = exp(-t/S). plot.ts holds that model and the geometry every toy on this page shares.

CurveToy.tsx · 176 lines
'use client'
// app/memory/components/CurveToy.tsx
// The hero toy: one word fading along R = exp(-t/S). Time runs on its own;
// pressing "Recall it" snaps the dot to 100% and doubles (or halves) S. The
// old curve stays as a dashed ghost so you can see what you avoided.
import { useCallback, useEffect, useRef, useState } from 'react'
import { curvePath, mono, retention, serif, useNarrow, xOf, yOf, type Frame } from './plot'

const WIDE: Frame = { w: 640, h: 300, l: 44, r: 16, t: 22, b: 34, days: 60 }
const NARROW: Frame = { ...WIDE, w: 360, h: 240 }
const DAY_MS = 2000 // 0.5 sim day per real second
const S0 = 3
const MAX_DAYS = 365
const LAPSE = 0.5

type Review = { day: number; S: number }
type Ghost = { from: number; S: number }

export default function CurveToy({ footer }: { footer?: React.ReactNode }) {
  const narrow = useNarrow()
  const [day, setDay] = useState(0)
  const [playing, setPlaying] = useState(false)
  const [reviews, setReviews] = useState<Review[]>([{ day: 0, S: S0 }])
  const [ghosts, setGhosts] = useState<Ghost[]>([])
  const [lapses, setLapses] = useState<number[]>([])
  // Axis: 0 to 60 days, extended in 30-day steps once the run passes it.
  const days = Math.max(60, Math.ceil(day / 30) * 30)
  const F: Frame = { ...(narrow ? NARROW : WIDE), days }
  const last = reviews[reviews.length - 1]
  const R = retention(day - last.day, last.S)
  const pressed = reviews.length > 1
  const dayRef = useRef(day)
  dayRef.current = day
  const reviewsRef = useRef(reviews)
  reviewsRef.current = reviews

  useEffect(() => {
    if (!playing) return
    let raf = 0
    let prev = performance.now()
    const tick = (now: number) => {
      const dt = (now - prev) / DAY_MS
      prev = now
      setDay(d => Math.min(MAX_DAYS, d + dt))
      raf = requestAnimationFrame(tick)
    }
    raf = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(raf)
  }, [playing])

  useEffect(() => {
    if (day >= MAX_DAYS) setPlaying(false)
  }, [day])

  const recall = useCallback(() => {
    const d = dayRef.current
    const rs = reviewsRef.current
    const l = rs[rs.length - 1]
    const ok = retention(d - l.day, l.S) >= LAPSE
    if (!ok) setLapses(ls => [...ls, d])
    setGhosts(g => [...g, { from: l.day, S: l.S }])
    setReviews([...rs, { day: d, S: ok ? l.S * 2 : l.S * 0.5 }])
    if (d < MAX_DAYS) setPlaying(true)
  }, [])

  const reset = () => {
    setDay(0); setReviews([{ day: 0, S: S0 }]); setGhosts([]); setLapses([]); setPlaying(false)
  }

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.code !== 'Space' || e.target instanceof HTMLInputElement || e.target instanceof HTMLButtonElement) return
      e.preventDefault()
      recall()
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [recall])

  const step = days > 60 ? 30 : 10
  const ticks = Array.from({ length: days / step + 1 }, (_, i) => i * step)

  return (
    <div className="!mt-6 rounded-2xl border border-bd-subtle bg-surface-elevated p-4 sm:p-6">
      <div className="flex flex-col gap-4 sm:flex-row">
        <svg viewBox={`0 0 ${F.w} ${F.h}`} width="100%" className="min-w-0 flex-1 text-th-primary" role="img" aria-label="Retention over days">
          {/* axes */}
          <g stroke="currentColor" strokeWidth="1.25" fill="none" strokeLinecap="round">
            <path d={`M${F.l},${F.t} L${F.l},${F.h - F.b} L${F.w - F.r},${F.h - F.b}`} />
          </g>
          <g style={mono} fontSize="10.5" className="text-th-faint" fill="currentColor">
            {ticks.map(d => (
              <text key={d} x={xOf(F, d)} y={F.h - F.b + 16} textAnchor="middle">{d}</text>
            ))}
            <text x={F.w - F.r} y={F.h - 2} textAnchor="end">days</text>
            <text x={F.l} y={F.t - 7} textAnchor="start">chance you still recall it ↓</text>
            {[0, 0.5, 1].map(v => (
              <text key={v} x={F.l - 8} y={yOf(F, v) + 3.5} textAnchor="end">{Math.round(v * 100)}%</text>
            ))}
          </g>
          {/* sweet spot */}
          {pressed && (
            <g className="text-th-faint" stroke="currentColor" fill="currentColor">
              <line x1={F.l} x2={F.w - F.r} y1={yOf(F, 0.9)} y2={yOf(F, 0.9)} strokeDasharray="3 5" strokeWidth="1" />
              <text style={mono} fontSize="10" x={F.w - F.r} y={yOf(F, 0.9) - 5} textAnchor="end" stroke="none">sweet spot · 90%</text>
            </g>
          )}
          {/* ghosts */}
          <g className="text-th-faint" stroke="currentColor" fill="none" strokeWidth="1" strokeDasharray="4 4">
            {ghosts.map((g, i) => <path key={i} d={curvePath(F, g.S, g.from, days)} />)}
          </g>
          {/* past curves (solid, up to the next review) */}
          <g stroke="currentColor" strokeWidth="1.25" fill="none" strokeLinecap="round">
            {reviews.slice(0, -1).map((r, i) => (
              <path key={i} d={curvePath(F, r.S, r.day, reviews[i + 1].day)} />
            ))}
            <path d={curvePath(F, last.S, last.day, days)} />
          </g>
          {/* review marks */}
          <g className="text-accent-rose" stroke="currentColor" fill="none" strokeWidth="1.25">
            {reviews.slice(1).map((r, i) => (
              <line key={i} x1={xOf(F, r.day)} x2={xOf(F, r.day)} y1={F.h - F.b} y2={F.h - F.b - 6} />
            ))}
          </g>
          <g className="text-accent-rose" stroke="currentColor" strokeWidth="1.25">
            {lapses.map((d, i) => {
              const x = xOf(F, d), y = F.h - F.b - 13
              return <path key={i} d={`M${x - 3.5},${y - 3.5} L${x + 3.5},${y + 3.5} M${x + 3.5},${y - 3.5} L${x - 3.5},${y + 3.5}`} />
            })}
          </g>
          {/* the dot */}
          <circle cx={xOf(F, day)} cy={yOf(F, R)} r="5" fill="currentColor" className="text-accent-rose" />
        </svg>

        <div style={mono} className="flex shrink-0 flex-row flex-wrap gap-x-6 gap-y-1 text-[12px] leading-relaxed text-th-secondary sm:w-[128px] sm:flex-col sm:gap-0">
          <Stat k="day" v={day.toFixed(1)} />
          <Stat k="strength" v={`${fmt(last.S)} d`} />
          <Stat k="recall" v={`${Math.round(R * 100)}%`} />
          <Stat k="reviews" v={String(reviews.length - 1)} />
          <Stat k="lapses" v={String(lapses.length)} />
          <div className="mt-2 flex gap-4 sm:mt-3">
            <button type="button" onClick={() => setPlaying(p => !p)} className="underline decoration-bd-subtle underline-offset-4 hover:text-th-primary">
              {playing ? 'pause' : 'play'}
            </button>
            <button type="button" onClick={reset} className="underline decoration-bd-subtle underline-offset-4 hover:text-th-primary">reset</button>
          </div>
        </div>
      </div>

      <div className="mt-5 flex items-center gap-4">
        <button
          type="button"
          onClick={recall}
          data-testid="recall"
          className="rounded-full bg-th-primary px-7 py-3 text-[15px] font-medium text-surface-base transition-transform active:scale-[0.98]"
          style={serif}
        >
          Recall it
        </button>
        <span className="text-[12px] text-th-faint">{pressed ? 'or press space' : 'press once to start the clock'}</span>
      </div>
      {footer}
    </div>
  )
}

function Stat({ k, v }: { k: string; v: string }) {
  return (
    <div className="flex justify-between gap-3">
      <span className="text-th-faint">{k}</span>
      <span className="tabular-nums text-th-primary">{v}</span>
    </div>
  )
}

const fmt = (n: number) => (n >= 10 ? Math.round(n).toString() : n.toFixed(n >= 1 ? 1 : 2))
plot.ts · 45 lines
// app/memory/components/plot.ts
// Shared geometry for the hand-drawn SVG plots on /memory. One model,
// R = exp(-t / S): chance of recalling after t days with stability S days.
import { useEffect, useState } from 'react'

export const serif = { fontFamily: 'var(--font-serif)' } as const
export const mono = { fontFamily: 'var(--font-geist-mono)' } as const

export const retention = (t: number, S: number) => Math.exp(-t / S)

export type Frame = { w: number; h: number; l: number; r: number; t: number; b: number; days: number }

export const xOf = (f: Frame, day: number) => f.l + (day / f.days) * (f.w - f.l - f.r)
export const yOf = (f: Frame, R: number) => f.t + (1 - R) * (f.h - f.t - f.b)

// A decay curve from `from` to `to` days, sampled finely enough to look smooth.
export function curvePath(f: Frame, S: number, from: number, to: number, cap = 1): string {
  const n = 60
  const pts: string[] = []
  for (let i = 0; i <= n; i++) {
    const d = from + ((to - from) * i) / n
    const R = Math.min(cap, retention(d - from, S))
    pts.push(`${i === 0 ? 'M' : 'L'}${xOf(f, d).toFixed(1)},${yOf(f, R).toFixed(1)}`)
  }
  return pts.join(' ')
}

// How much a successful recall grows stability. A recall that came close to
// being forgotten builds more than an easy one (the desirable-difficulty
// shape). At R = 0.9 this is exactly the ×2 the schedule toy uses.
export const gainAt = (R: number) => 1 + 10 * (1 - R)

// Narrow viewports get a narrower viewBox so in-SVG mono labels stay near
// their nominal size instead of shrinking with the scale.
export function useNarrow(): boolean {
  const [narrow, setNarrow] = useState(false)
  useEffect(() => {
    const mq = window.matchMedia('(max-width: 639px)')
    const on = () => setNarrow(mq.matches)
    on()
    mq.addEventListener('change', on)
    return () => mq.removeEventListener('change', on)
  }, [])
  return narrow
}

Press too early and you spend a review for little. Too late and you lose it. The flatter each new curve, the longer you can wait. The dotted line is the sweet spot most schedulers aim for: review when you are about 90% sure.

02

Cram vs. space

The spacing effect is the most replicated finding in memory research: the same number of reviews spread out beats the same number done at once. Here are ten reviews of one item. Only the timing differs. Drag the day.

Ten reviews, all on day one
0601201800%100%
remembered: 5%
Ten reviews, spaced
0601201800%100%
remembered: 98%
a recall builds more the closer the word came to being forgotten
see the code
CramVsSpace.tsx · 82 lines
'use client'
// app/memory/components/CramVsSpace.tsx
// Ten reviews on day one vs ten reviews spaced out. Same model, same count.
// A shared slider picks the day; each panel reports recall on that day.
import { useState } from 'react'
import { curvePath, gainAt, mono, retention, serif, xOf, yOf, type Frame } from './plot'

const F: Frame = { w: 320, h: 200, l: 36, r: 12, t: 14, b: 28, days: 180 }
const CRAM = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
const SPACED = [0, 1, 3, 7, 14, 25, 40, 60, 90, 130]

// Each review grows S by gainAt(R): a recall that had nearly faded builds
// far more than one repeated minutes later. Ten hits in a day barely add up.
function segments(days: number[]) {
  let S = 1
  const out: { from: number; to: number; S: number }[] = []
  for (let i = 0; i < days.length; i++) {
    if (i > 0) S *= gainAt(retention(days[i] - days[i - 1], S))
    out.push({ from: days[i], to: i + 1 < days.length ? days[i + 1] : F.days, S })
  }
  return out
}
// Recall on a given day: the segment active at that day.
function recallAt(days: number[], day: number) {
  const seg = segments(days).filter(s => s.from <= day)
  const s = seg[seg.length - 1]
  return retention(day - s.from, s.S)
}

export default function CramVsSpace({ footer }: { footer?: React.ReactNode }) {
  const [day, setDay] = useState(30)
  return (
    <div className="!mt-6 rounded-2xl border border-bd-subtle bg-surface-elevated p-4 sm:p-6">
      <div className="grid gap-6 sm:grid-cols-2">
        <Panel title="Ten reviews, all on day one" days={CRAM} day={day} />
        <Panel title="Ten reviews, spaced" days={SPACED} day={day} />
      </div>
      <label style={mono} className="mt-5 flex items-center gap-4 text-[12px] text-th-secondary">
        <span className="text-th-faint">day</span>
        <input
          type="range" min={0} max={F.days} value={day} data-testid="day-slider"
          onChange={e => setDay(Number(e.target.value))}
          className="h-px flex-1 cursor-pointer appearance-none bg-th-faint accent-accent-rose"
          style={{ accentColor: 'var(--accent-rose)' }}
        />
        <span className="w-8 text-right tabular-nums text-th-primary">{day}</span>
      </label>
      <div style={mono} className="mt-3 text-[11.5px] leading-relaxed text-th-faint">
        a recall builds more the closer the word came to being forgotten
      </div>
      {footer}
    </div>
  )
}

function Panel({ title, days, day }: { title: string; days: number[]; day: number }) {
  const segs = segments(days)
  const R = recallAt(days, day)
  return (
    <div className="min-w-0">
      <div style={serif} className="text-[16px] text-th-primary">{title}</div>
      <svg viewBox={`0 0 ${F.w} ${F.h}`} width="100%" className="mt-2 text-th-primary" role="img" aria-label={title}>
        <path d={`M${F.l},${F.t} L${F.l},${F.h - F.b} L${F.w - F.r},${F.h - F.b}`} stroke="currentColor" strokeWidth="1.25" fill="none" strokeLinecap="round" />
        <g style={mono} fontSize="9.5" className="text-th-faint" fill="currentColor">
          {[0, 60, 120, 180].map(d => <text key={d} x={xOf(F, d)} y={F.h - F.b + 14} textAnchor="middle">{d}</text>)}
          {[0, 1].map(v => <text key={v} x={F.l - 6} y={yOf(F, v) + 3.5} textAnchor="end">{v * 100}%</text>)}
        </g>
        <g stroke="currentColor" strokeWidth="1.25" fill="none" strokeLinecap="round">
          {segs.map((s, i) => s.to > s.from && <path key={i} d={curvePath(F, s.S, s.from, s.to)} />)}
        </g>
        <g className="text-accent-rose" stroke="currentColor" strokeWidth="1.25">
          {days.map((d, i) => <line key={i} x1={xOf(F, d)} x2={xOf(F, d)} y1={F.h - F.b} y2={F.h - F.b - 5} />)}
        </g>
        <line x1={xOf(F, day)} x2={xOf(F, day)} y1={F.t} y2={F.h - F.b} stroke="currentColor" strokeWidth="1" strokeDasharray="3 4" className="text-th-faint" />
        <circle cx={xOf(F, day)} cy={yOf(F, R)} r="4.5" fill="currentColor" className="text-accent-rose" />
      </svg>
      <div style={mono} className="mt-1 text-[12px] text-th-secondary">
        remembered: <span className="tabular-nums text-th-primary" data-testid="remembered">{Math.round(R * 100)}%</span>
      </div>
    </div>
  )
}

Cramming feels productive because the item is fresh while you work. It is gone within days. Spacing wins by a wide margin over months, at no extra cost. Cepeda et al. 2006; Bahrick et al. 1993.

03

A simple algorithm

The strength number above is all a scheduler really needs. Kvela keeps exactly one number per card and calls it the interval: days until you see it again. Your grade multiplies it, the same way a recall doubled the curve in section one, with a finer scale for hard and easy. Grade the card a few times.

next in
1day
today30 d
1
see the code
ScheduleToy.tsx · 76 lines
'use client'
// app/memory/components/ScheduleToy.tsx
// The schedule Kvela actually uses (lib/v2, whitepaper section 8): one
// interval per card, multiplied by the grade, clamped to [1, 120] days.
import { useState } from 'react'
import { mono, serif, useNarrow } from './plot'

const FACTOR = { miss: 0.5, hard: 1.2, good: 2, easy: 3 } as const
type Grade = keyof typeof FACTOR
const GRADES: Grade[] = ['miss', 'hard', 'good', 'easy']
const clamp = (n: number) => Math.min(120, Math.max(1, n))
const MAX_DAY = 120

type Step = { grade: Grade; interval: number; due: number }

export default function ScheduleToy({ footer }: { footer?: React.ReactNode }) {
  const [steps, setSteps] = useState<Step[]>([])
  const interval = steps.length ? steps[steps.length - 1].interval : 1
  const due = steps.length ? steps[steps.length - 1].due : 0
  const grade = (g: Grade) => {
    const next = clamp(Math.round(interval * FACTOR[g]))
    setSteps(s => [...s, { grade: g, interval: next, due: due + next }])
  }
  const log = ['1', ...steps.flatMap(s => [s.grade, String(s.interval)])].join(' → ')
  const span = Math.max(30, Math.min(MAX_DAY * 4, Math.ceil((due + interval) / 30) * 30))

  const W = useNarrow() ? 360 : 640, H = 64, L = 12, R = 12
  const x = (d: number) => L + (d / span) * (W - L - R)
  const ticks = steps.filter(s => s.due <= span)

  return (
    <div className="!mt-6 rounded-2xl border border-bd-subtle bg-surface-elevated p-4 sm:p-6">
      <div className="flex flex-wrap items-end justify-between gap-4">
        <div>
          <div className="text-[11px] font-medium uppercase tracking-[0.2em] text-th-faint">next in</div>
          <div style={serif} className="mt-1 text-[56px] leading-none tracking-tight text-th-primary" data-testid="interval">
            {interval}<span className="ml-2 text-[22px] text-th-muted">{interval === 1 ? 'day' : 'days'}</span>
          </div>
        </div>
        <div className="flex gap-2">
          {GRADES.map(g => (
            <button
              key={g} type="button" onClick={() => grade(g)} data-testid={`grade-${g}`}
              className="rounded-full border border-bd-default px-4 py-2 text-[13px] text-th-primary transition-colors hover:bg-surface-hover"
            >
              {g}
            </button>
          ))}
        </div>
      </div>

      <svg viewBox={`0 0 ${W} ${H}`} width="100%" className="mt-6 text-th-primary" role="img" aria-label="Due dates">
        <line x1={L} x2={W - R} y1={H - 18} y2={H - 18} stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
        <g style={mono} fontSize="10" className="text-th-faint" fill="currentColor">
          <text x={L} y={H - 4}>today</text>
          <text x={W - R} y={H - 4} textAnchor="end">{span} d</text>
        </g>
        <line x1={L} x2={L} y1={H - 18} y2={H - 24} stroke="currentColor" strokeWidth="1.25" />
        {ticks.map((s, i) => (
          <g key={i} className={i === ticks.length - 1 ? 'text-accent-rose' : 'text-th-primary'}>
            <line x1={x(s.due)} x2={x(s.due)} y1={H - 18} y2={H - 26 - (i % 2) * 11} stroke="currentColor" strokeWidth="1.25" />
            <text style={mono} fontSize="10" x={x(s.due)} y={H - 31 - (i % 2) * 11} textAnchor="middle" fill="currentColor">{s.interval}</text>
          </g>
        ))}
      </svg>

      <div style={mono} className="mt-3 flex items-start justify-between gap-4 text-[12px] leading-relaxed">
        <div className="break-words text-th-secondary" data-testid="log">{log}</div>
        {steps.length > 0 && (
          <button type="button" onClick={() => setSteps([])} className="shrink-0 text-th-faint underline decoration-bd-subtle underline-offset-4 hover:text-th-primary">reset</button>
        )}
      </div>
      {footer}
    </div>
  )
}
next = round( interval × factor )   clamped to 1 to 120 days
miss × 0.5 · hard × 1.2 · good × 2 · easy × 3

A miss halves, never resets. Forgetting something after a month should not cost you the month. Popular schedulers track two or three hidden variables to the same end; this one you can run on paper.

04

Why not aim for 100%

A scheduler has to pick how faded an item should be before it comes back. Review while it is still fresh and you never forget, but you pay for it in reviews. Pick a target.

reviews per word per year
12
the default
words kept, of 100
90
670%880%1290%2195%8799%
review when recall falls to 90% · each recall grows stability ×2.0
see the code
TargetToy.tsx · 78 lines
'use client'
// app/memory/components/TargetToy.tsx
// Target retention p. Review when R falls to p, so each interval is
// -S ln p and each recall grows S by gainAt(p): ×2 at 90%, barely anything
// at 99%. Count reviews to cover a year from S = 1.
import { useState } from 'react'
import { gainAt, mono, serif, useNarrow } from './plot'

function reviewsPerYear(p: number) {
  let S = 1, t = 0, n = 0
  while (t < 365) { t += -S * Math.log(p); S *= gainAt(p); n++ }
  return n
}

export default function TargetToy({ footer }: { footer?: React.ReactNode }) {
  const [p, setP] = useState(90)
  const n = reviewsPerYear(p / 100)
  const n90 = reviewsPerYear(0.9)
  return (
    <div className="!mt-6 rounded-2xl border border-bd-subtle bg-surface-elevated p-4 sm:p-6">
      <label style={mono} className="flex items-center gap-4 text-[12px] text-th-secondary">
        <span className="text-th-faint">target</span>
        <input
          type="range" min={70} max={99} value={p} data-testid="target-slider"
          onChange={e => setP(Number(e.target.value))}
          className="h-px flex-1 cursor-pointer appearance-none bg-th-faint"
          style={{ accentColor: 'var(--accent-rose)' }}
        />
        <span className="w-10 text-right tabular-nums text-th-primary">{p}%</span>
      </label>
      <div className="mt-6 grid grid-cols-2 gap-6">
        <Readout label="reviews per word per year" value={String(n)} note={p === 90 ? 'the default' : `${(n / n90).toFixed(1)}× the cost of 90%`} testId="reviews" />
        <Readout label="words kept, of 100" value={String(p)} note={p === 90 ? '' : `${p - 90 > 0 ? '+' : ''}${p - 90} vs 90%`} />
      </div>
      <Bars p={p} />
      <div style={mono} className="mt-4 text-[11.5px] leading-relaxed text-th-faint">
        review when recall falls to {p}% · each recall grows stability ×{gainAt(p / 100).toFixed(1)}
      </div>
      {footer}
    </div>
  )
}

function Readout({ label, value, note, testId }: { label: string; value: string; note: string; testId?: string }) {
  return (
    <div>
      <div className="text-[11px] font-medium uppercase tracking-[0.2em] text-th-faint">{label}</div>
      <div style={serif} className="mt-1 text-[48px] leading-none tracking-tight text-th-primary tabular-nums" data-testid={testId}>{value}</div>
      <div style={mono} className="mt-1 h-4 text-[11.5px] text-th-muted">{note}</div>
    </div>
  )
}

// A small strip: reviews needed at each target, the chosen one in rose.
function Bars({ p }: { p: number }) {
  const targets = [70, 80, 90, 95, 99]
  const W = useNarrow() ? 360 : 640, H = 110, L = 12, R = 12, B = 22
  const counts = targets.map(t => reviewsPerYear(t / 100))
  const max = Math.max(...counts)
  const slot = (W - L - R) / targets.length
  return (
    <svg viewBox={`0 0 ${W} ${H}`} width="100%" className="mt-6 text-th-primary" role="img" aria-label="Reviews per year by target">
      <line x1={L} x2={W - R} y1={H - B} y2={H - B} stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
      {targets.map((t, i) => {
        const h = (counts[i] / max) * (H - B - 18)
        const x = L + slot * i + slot / 2
        const on = t === p
        return (
          <g key={t} className={on ? 'text-accent-rose' : 'text-th-primary'}>
            <rect x={x - 14} y={H - B - h} width={28} height={h} fill="none" stroke="currentColor" strokeWidth="1.25" />
            <text style={mono} fontSize="10" x={x} y={H - B - h - 5} textAnchor="middle" fill="currentColor">{counts[i]}</text>
            <text style={mono} fontSize="10" x={x} y={H - 6} textAnchor="middle" fill="currentColor" className="text-th-faint">{t}%</text>
          </g>
        )
      })}
    </svg>
  )
}

Near-perfect retention costs several times the reviews of 90%, and buys almost nothing. The slight strain of nearly forgetting is what makes the memory stick; Bjork calls it a desirable difficulty. Around 90% is where most schedulers, and this one, settle.

05

Sources

  1. 01Ebbinghaus, H. (1885). Memory: A Contribution to Experimental Psychology.
  2. 02Cepeda, N. J. et al. (2006). Distributed practice in verbal recall tasks. Psychological Bulletin.
  3. 03Bahrick, H. P. et al. (1993). Maintenance of foreign language vocabulary. Psychological Science.
  4. 04Roediger, H. L. & Karpicke, J. D. (2006). Test-enhanced learning. Psychological Science.
  5. 05Bjork, R. A. (1994). Memory and metamemory considerations in the training of human beings (desirable difficulties).
  6. 06Pimsleur, P. (1967). A memory schedule. Modern Language Journal, 51(2).