The export bundle is the input to this port, not a sketch: the curriculum, the tutor prompt and the five logic modules are finished and tested. They land here byte-identical and stay that way. diff -r export/data data && diff -r export/lib lib diff -r export/prompt prompt && diff export/validate.mjs validate.mjs data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs runs verbatim with no path edits. All four are excluded from lint and formatting — they are not ours to restyle. Types for lib/ live alongside in types/ rather than as sibling .d.ts files, so the verbatim check stays a plain directory diff. CI runs the curriculum gate first, before anything else can pass: node validate.mjs PASS — 0 blocking, 0 advisory Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
47 lines
2.2 KiB
JavaScript
47 lines
2.2 KiB
JavaScript
/* SM-2 lite. Four grades, day-granularity intervals. */
|
|
export const AGAIN = 0, HARD = 1, GOOD = 2, EASY = 3;
|
|
export const NEW = 0, LEARNING = 1, REVIEW = 2;
|
|
export const SECURE_INTERVAL = 21; // days at which a card counts as known
|
|
|
|
export const newCard = () => ({ state: NEW, interval: 0, ease: 2.5, due: 0, reps: 0, lapses: 0 });
|
|
|
|
export function grade(card, g, today) {
|
|
const c = { ...card };
|
|
if (c.state === NEW || c.state === LEARNING) {
|
|
if (g <= HARD) { c.state = LEARNING; c.interval = 0; c.due = today; }
|
|
else if (g === GOOD){ c.state = REVIEW; c.interval = 1; c.due = today + 1; }
|
|
else { c.state = REVIEW; c.interval = 4; c.due = today + 4; }
|
|
} else {
|
|
if (g === AGAIN) { c.ease = Math.max(1.3, c.ease - 0.2); c.lapses++; c.state = LEARNING; c.interval = 0; c.due = today; }
|
|
else if (g === HARD) { c.ease = Math.max(1.3, c.ease - 0.15); c.interval = Math.max(1, Math.round(c.interval * 1.2)); c.due = today + c.interval; }
|
|
else if (g === GOOD) { c.interval = Math.max(1, Math.round(c.interval * c.ease)); c.due = today + c.interval; }
|
|
else { c.ease = Math.min(3, c.ease + 0.15); c.interval = Math.max(2, Math.round(c.interval * c.ease * 1.3)); c.due = today + c.interval; }
|
|
c.interval = Math.min(c.interval, 365);
|
|
c.due = Math.min(c.due, today + 365);
|
|
}
|
|
c.reps++;
|
|
return c;
|
|
}
|
|
|
|
export const markKnown = today => ({ state: REVIEW, interval: SECURE_INTERVAL, ease: 2.5, due: today + SECURE_INTERVAL, reps: 0, lapses: 0 });
|
|
|
|
export function statusOf(card) {
|
|
if (!card || card.state === NEW) return "new";
|
|
if (card.state === LEARNING) return "learning";
|
|
return card.interval >= SECURE_INTERVAL ? "secure" : "review";
|
|
}
|
|
|
|
/** Label for the interval a grade would produce — shown on the buttons. */
|
|
export function preview(card, g, today) {
|
|
const c = grade(card || newCard(), g, today);
|
|
if (c.interval === 0) return "again now";
|
|
if (c.interval === 1) return "1 day";
|
|
if (c.interval < 30) return `${c.interval} days`;
|
|
const mo = Math.round(c.interval / 30);
|
|
return `${mo} month${mo === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
/** Local day number, DST-safe. */
|
|
export const dayNumber = (d = new Date()) =>
|
|
Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);
|