'use client' import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react' import { cn } from '../../lib/cn' import { Button } from '../Button' export interface SpacedRepetitionCard { id: string /** The prompt. */ front: string /** The answer, shown after the reveal. */ back: string /** SM-2 easiness factor. New cards start at 2.5; it never drops below 1.3. */ ease: number /** Days from the last review to the next. */ interval: number /** Passing reviews in a row since the last lapse. */ repetitions: number /** Times a card that had been learned was forgotten. */ lapses: number /** When it is next due, in epoch milliseconds. New cards use 0. */ due: number /** Every review, and the ones graded 3 or better — retention is their ratio. */ reviews: number passed: number } /** SM-2 quality: 0–2 is a failure to recall, 3 is hard, 4 is good, 5 is easy. */ export type SpacedRepetitionGrade = 0 | 1 | 2 | 3 | 4 | 5 export interface SpacedRepetitionProps { /** The deck, controlled. Every review replaces a card through `onCardsChange`. */ cards?: SpacedRepetitionCard[] /** The deck, uncontrolled. */ defaultCards?: SpacedRepetitionCard[] onCardsChange?: (cards: SpacedRepetitionCard[]) => void /** Called after each scheduled review with the card before and after. */ onReview?: (before: SpacedRepetitionCard, grade: SpacedRepetitionGrade, after: SpacedRepetitionCard) => void /** The current time in epoch ms. Inject it to test schedules or demo the passing of days. */ now?: number /** Four buttons mapped onto SM-2, or the raw 0–5 scale. */ grading?: 'buttons' | 'scale' /** Days shown in the review forecast. */ forecastDays?: number /** Merged last, so it wins. */ className?: string } const DAY = 86_400_000 /** A new card, due immediately, with SM-2’s starting ease. */ export function spacedRepetitionCard(id: string, front: string, back: string): SpacedRepetitionCard { return { id, front, back, ease: 2.5, interval: 0, repetitions: 0, lapses: 0, due: 0, reviews: 0, passed: 0 } } /** * One SM-2 review, as SuperMemo published it in 1987. * * A pass (3 or more) moves the card to the next interval — 1 day, then 6, then * the previous interval times the ease — and adjusts the ease by * 0.1 − (5 − q)(0.08 + (5 − q)·0.02), floored at 1.3. A failure starts the * repetitions again at one day and leaves the ease alone, as the original does. */ export function spacedRepetitionReview(card: SpacedRepetitionCard, grade: SpacedRepetitionGrade, now: number): SpacedRepetitionCard { const pass = grade >= 3 let { ease, interval, repetitions, lapses } = card if (pass) { interval = repetitions === 0 ? 1 : repetitions === 1 ? 6 : Math.round(interval * ease) repetitions += 1 ease = Math.max(1.3, ease + (0.1 - (5 - grade) * (0.08 + (5 - grade) * 0.02))) } else { if (repetitions > 0) lapses += 1 repetitions = 0 interval = 1 } return { ...card, ease, interval, repetitions, lapses, due: now + interval * DAY, reviews: card.reviews + 1, passed: card.passed + (pass ? 1 : 0) } } const BUTTONS: { grade: SpacedRepetitionGrade; label: string; key: string }[] = [ { grade: 1, label: 'Again', key: '1' }, { grade: 3, label: 'Hard', key: '2' }, { grade: 4, label: 'Good', key: '3' }, { grade: 5, label: 'Easy', key: '4' }, ] const SCALE: { grade: SpacedRepetitionGrade; label: string; key: string }[] = [0, 1, 2, 3, 4, 5].map((grade) => ({ grade: grade as SpacedRepetitionGrade, label: String(grade), key: String(grade), })) function spanOf(days: number) { if (days < 30) return `${days} d` if (days < 365) return `${(days / 30).toFixed(days < 300 ? 1 : 0)} mo` return `${(days / 365).toFixed(1)} yr` } const endOfDay = (time: number) => { const date = new Date(time) date.setHours(23, 59, 59, 999) return date.getTime() } interface Turn { id: string /** A re-run of a card already scheduled this session — SM-2 repeats anything below 4 until it sticks. */ drill: boolean } /** * Flashcards on the SM-2 schedule. * * SM-2 is small enough to read in one sitting and still the base of most * spaced-repetition tools, so it is implemented exactly rather than * approximated: the ease formula, the 1-then-6-day start, and the rule that a * failure resets the run but not the ease. Its other rule is kept too — any * card answered below 4 comes back at the end of the session until it is * answered well, without being rescheduled a second time. * * The deck is plain data the caller owns, so it can be stored anywhere, and * the clock is a prop, so a schedule can be tested — or demonstrated — by * moving `now` forward instead of waiting a week. Each grade button shows the * interval it would set, the same preview dedicated apps give. */ export function SpacedRepetition({ cards: cardsProp, defaultCards = [], onCardsChange, onReview, now: nowProp, grading = 'buttons', forecastDays = 14, className, }: SpacedRepetitionProps) { const [inner, setInner] = useState(defaultCards) const cards = cardsProp ?? inner const [queue, setQueue] = useState(null) const [revealed, setRevealed] = useState(false) const [done, setDone] = useState(0) const [message, setMessage] = useState('') const revealRef = useRef(null) const gradeRef = useRef(null) const id = useId() /** Where focus goes once the controls an action swapped in exist: the answer button, or the default grade. */ const focusNext = useRef<'reveal' | 'grade' | null>(null) const studyRef = useRef(null) useEffect(() => { if (!focusNext.current) return const target = focusNext.current === 'grade' ? gradeRef.current?.querySelector('[data-default]') : revealRef.current?.querySelector('button') focusNext.current = null // Session over: nothing to answer, so focus lands on the study panel rather than the page. ;(target ?? studyRef.current)?.focus() }) const now = nowProp ?? Date.now() const today = endOfDay(now) const due = cards.filter((card) => card.due <= today).sort((a, b) => a.due - b.due) const turn = queue?.[0] const card = turn ? cards.find((item) => item.id === turn.id) : undefined const options = grading === 'buttons' ? BUTTONS : SCALE const setCards = (next: SpacedRepetitionCard[]) => { if (cardsProp === undefined) setInner(next) onCardsChange?.(next) } const start = () => { setQueue(due.map((item) => ({ id: item.id, drill: false }))) setDone(0) setRevealed(false) setMessage(`${due.length} ${due.length === 1 ? 'card' : 'cards'} to review.`) focusNext.current = 'reveal' } const reveal = () => { if (!card) return setRevealed(true) setMessage(`Answer: ${card.back}`) focusNext.current = 'grade' } const grade = (value: SpacedRepetitionGrade) => { if (!card || !turn || !queue) return let rest = queue.slice(1) let note = '' if (!turn.drill) { const after = spacedRepetitionReview(card, value, now) setCards(cards.map((item) => (item.id === card.id ? after : item))) onReview?.(card, value, after) note = `Next review in ${spanOf(after.interval)}.` } if (value < 4) { rest = [...rest, { id: card.id, drill: true }] note += ' It will come back this session.' } setQueue(rest) setDone(done + 1) setRevealed(false) setMessage(`${note.trim()}${rest.length ? ` ${rest.length} left.` : ' Session complete.'}`) focusNext.current = 'reveal' } const onKeyDown = (event: KeyboardEvent) => { if (!card || event.ctrlKey || event.metaKey || event.altKey) return const option = revealed ? options.find((item) => item.key === event.key) : undefined if (option) { event.preventDefault() grade(option.grade) } } // Stats and the forecast read the deck as it stands. const fresh = cards.filter((item) => item.reviews === 0).length const learned = cards.filter((item) => item.repetitions > 0).length const reviews = cards.reduce((sum, item) => sum + item.reviews, 0) const passed = cards.reduce((sum, item) => sum + item.passed, 0) const startOfToday = endOfDay(now) - DAY + 1 const forecast = Array.from({ length: forecastDays }, (_, day) => { const from = startOfToday + day * DAY const count = cards.filter((item) => (day === 0 ? item.due < from + DAY : item.due >= from && item.due < from + DAY)).length return { day, count, label: new Date(from).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' }) } }) const peak = Math.max(1, ...forecast.map((item) => item.count)) return (
{[ ['Due today', due.length], ['New', fresh], ['Learned', learned], ['Retention', reviews ? `${Math.round((passed / reviews) * 100)}%` : '—'], ].map(([name, value]) => (
{name}
{value}
))}

Study

{queue && card && ( {done} done · {queue.length} left{turn?.drill ? ' · repeat' : ''} )}
{!queue || !card ? (

{queue ? 'Session complete.' : due.length ? `${due.length} ${due.length === 1 ? 'card is' : 'cards are'} due.` : 'Nothing due today.'}

{queue &&

{due.length ? `${due.length} still due — start another round.` : 'Come back when the forecast says so.'}

} {due.length > 0 && ( )}
) : ( <>

{card.front}

{revealed ? ( <>

Reviews over the next {forecastDays} days

    {forecast.map((item) => (
  1. ))}

{message}

) }