@panaversity/ksor 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.mjs +67 -4
  3. package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
  4. package/dist/gateway.mjs +1 -1
  5. package/package.json +1 -1
  6. package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
  7. package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
  8. package/templates/scaffold/AGENTS.md +49 -0
  9. package/templates/scaffold/Dockerfile +10 -6
  10. package/templates/scaffold/README.md +3 -2
  11. package/templates/scaffold/dockerignore +5 -0
  12. package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
  13. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
  14. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
  15. package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
  16. package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
  17. package/templates/scaffold/system/site/components/governance.tsx +17 -25
  18. package/templates/scaffold/system/site/components/record-views.tsx +241 -0
  19. package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
  20. package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
  21. package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
  22. package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
  23. package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
  24. package/templates/scaffold/system/site/lib/attachments.ts +105 -0
  25. package/templates/scaffold/system/site/lib/deck.ts +59 -0
  26. package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
  27. package/templates/scaffold/system/site/lib/srs.ts +219 -0
  28. package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
  29. package/templates/scaffold/system/site/source.config.ts +54 -1
  30. package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
@@ -0,0 +1,29 @@
1
+ // Generated by `pnpm dlx shadcn@latest add progress`. Yours to edit.
2
+ "use client";
3
+
4
+ import * as React from "react";
5
+ import { Progress as ProgressPrimitive } from "radix-ui";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ function Progress({
10
+ className,
11
+ value,
12
+ ...props
13
+ }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
14
+ return (
15
+ <ProgressPrimitive.Root
16
+ data-slot="progress"
17
+ className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
18
+ {...props}
19
+ >
20
+ <ProgressPrimitive.Indicator
21
+ data-slot="progress-indicator"
22
+ className="h-full w-full flex-1 bg-primary transition-all"
23
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
24
+ />
25
+ </ProgressPrimitive.Root>
26
+ );
27
+ }
28
+
29
+ export { Progress };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * What makes a file in the record an ATTACHMENT rather than a document.
3
+ *
4
+ * A document may carry study attachments named after it — `x.summary.md` and
5
+ * `x.flashcards.yaml` belong to `x.md` in the same directory. An attachment is
6
+ * PART OF its parent: no route, no sidebar entry, no llms.txt line, no stable
7
+ * id, no MCP node, and its parent's governance rather than its own.
8
+ *
9
+ * This rule is duplicated by construction — the kernel's ingest decides what
10
+ * becomes a node, the site's staging decides what is copied, the site's build
11
+ * decides what is a page, and the record's checker decides what is well-formed.
12
+ * Four readers of one rule is exactly the shape decision 18 names, so this file
13
+ * is canonical and every other copy is asserted against it rather than trusted.
14
+ *
15
+ * A LEAF: no imports, so any of those four can take it without taking anything
16
+ * else with it.
17
+ */
18
+
19
+ /** The suffix that marks each kind, longest-match first. */
20
+ export const ATTACHMENT_SUFFIXES = [
21
+ { suffix: ".summary.md", kind: "summary" },
22
+ { suffix: ".summary.mdx", kind: "summary" },
23
+ { suffix: ".flashcards.yaml", kind: "deck" },
24
+ ] as const;
25
+
26
+ export type AttachmentKind = (typeof ATTACHMENT_SUFFIXES)[number]["kind"];
27
+
28
+ /**
29
+ * The near-miss extensions, refused BY NAME rather than left to fail later.
30
+ *
31
+ * `.yml` is the one an author reaches for by habit, and fumadocs' meta loader
32
+ * accepts `.yaml`/`.json` only — anything else throws `Unknown file type`,
33
+ * naming the path and nothing about the rule (verified in fumadocs-mdx@15.3.0,
34
+ * dist/meta-BR_rkCyY.js). A refusal here costs one line and replaces that.
35
+ */
36
+ export const ATTACHMENT_NEAR_MISSES = [
37
+ { suffix: ".flashcards.yml", want: ".flashcards.yaml" },
38
+ { suffix: ".flashcards.json", want: ".flashcards.yaml" },
39
+ { suffix: ".summary.markdown", want: ".summary.md" },
40
+ ] as const;
41
+
42
+ /**
43
+ * The attachment kind this file name carries, or null when it is not one.
44
+ *
45
+ * Matched on the whole base name, never on a path: `.summary.md` in a directory
46
+ * called `summary` is not an attachment, and a file called exactly
47
+ * `.summary.md` (a dotfile with no stem) has no parent to attach to and is not
48
+ * one either — the same "a dotfile has no suffix" boundary ingest's isDoc uses.
49
+ */
50
+ export function attachmentKindOf(baseName: string): AttachmentKind | null {
51
+ for (const entry of ATTACHMENT_SUFFIXES) {
52
+ if (baseName.length > entry.suffix.length && baseName.endsWith(entry.suffix)) {
53
+ return entry.kind;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** True when this file name is an attachment of some document. */
60
+ export function isAttachment(baseName: string): boolean {
61
+ return attachmentKindOf(baseName) !== null;
62
+ }
63
+
64
+ /**
65
+ * The base name of the document this attachment belongs to, or null when the
66
+ * name is not an attachment. Always `<stem>.md`: the record is CommonMark, so
67
+ * a parent is a `.md` file even where the attachment itself is `.mdx`.
68
+ */
69
+ export function parentDocumentOf(baseName: string): string | null {
70
+ for (const entry of ATTACHMENT_SUFFIXES) {
71
+ if (baseName.length > entry.suffix.length && baseName.endsWith(entry.suffix)) {
72
+ return `${baseName.slice(0, -entry.suffix.length)}.md`;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * The extension an author probably meant, when a name is one character off a
80
+ * real attachment. Null when the name is not a near miss.
81
+ */
82
+ export function nearMissOf(
83
+ baseName: string,
84
+ ): { readonly is: string; readonly want: string } | null {
85
+ for (const entry of ATTACHMENT_NEAR_MISSES) {
86
+ if (baseName.length > entry.suffix.length && baseName.endsWith(entry.suffix)) {
87
+ return { is: entry.suffix, want: entry.want };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * The cases every implementation of this rule must agree on.
95
+ *
96
+ * The rule lives in four languages — TypeScript here, TypeScript again in the
97
+ * site's staging, a glob in source.config.ts, and plain JS in the record's
98
+ * checker — and the three copies cannot import this file. So the TABLE is the
99
+ * rule, asserted against each implementation, the way AUDIENCE_CASES is
100
+ * (decision 18). A copy that drifts fails on the ROW it broke.
101
+ */
102
+ export const ATTACHMENT_CASES = [
103
+ { name: "returns.summary.md", kind: "summary", parent: "returns.md" },
104
+ { name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
105
+ { name: "index.summary.md", kind: "summary", parent: "index.md" },
106
+ // A stem containing dots keeps every one of them: the parent is the same
107
+ // name with the attachment suffix removed, never "up to the first dot".
108
+ { name: "v1.2.policy.summary.md", kind: "summary", parent: "v1.2.policy.md" },
109
+ // Ordinary documents, including ones whose names merely CONTAIN the words.
110
+ { name: "returns.md", kind: null, parent: null },
111
+ { name: "summary.md", kind: null, parent: null },
112
+ { name: "flashcards.yaml", kind: null, parent: null },
113
+ { name: "my-summary.md", kind: null, parent: null },
114
+ // A dotfile with no stem attaches to nothing — refused as an attachment so
115
+ // it is refused as an unexpected file instead, which is the honest error.
116
+ { name: ".summary.md", kind: null, parent: null },
117
+ { name: ".flashcards.yaml", kind: null, parent: null },
118
+ // Case matters: the record already refuses two names differing only in case,
119
+ // so an uppercase suffix is a different file, not the same rule.
120
+ { name: "returns.SUMMARY.md", kind: null, parent: null },
121
+ // Not attachments — near misses, which get their own refusal.
122
+ { name: "returns.flashcards.yml", kind: null, parent: null },
123
+ { name: "returns.flashcards.json", kind: null, parent: null },
124
+ ] as const;
@@ -0,0 +1,105 @@
1
+ import { decks, summaries } from "collections/server";
2
+
3
+ import { ATTACHMENT_SUFFIXES } from "./attachment-rule";
4
+ import { cardHash, type Card, type Deck } from "./deck";
5
+ import { newCard, type CardSchedule } from "./srs";
6
+
7
+ /**
8
+ * Finding a document's study attachments.
9
+ *
10
+ * Both collections are keyed by their record-relative path (fumadocs'
11
+ * `info.path`), which is the same shape as a page's own `page.path` — so the
12
+ * lookup is a suffix swap, not a second index to keep in step. `x.md` finds
13
+ * `x.summary.md` and `x.flashcards.yaml`, and nothing else can be reached.
14
+ *
15
+ * Neither collection is ever handed to `loader()`, which is what keeps an
16
+ * attachment off the route table, the sidebar, llms.txt, llms-full.txt, the
17
+ * markdown twin and the search index — see source.config.ts.
18
+ */
19
+
20
+ const DOC_SUFFIX = /\.mdx?$/;
21
+
22
+ /** `policies/returns.md` + `.summary.md` → `policies/returns.summary.md`. */
23
+ function attachmentPath(documentPath: string, suffix: string): string {
24
+ return documentPath.replace(DOC_SUFFIX, "") + suffix;
25
+ }
26
+
27
+ export interface SummaryEntry {
28
+ readonly body: (props: { components?: Record<string, unknown> }) => React.ReactElement;
29
+ readonly toc: unknown;
30
+ /**
31
+ * The summary's processed markdown, for counting its reading time. The
32
+ * collection enables `includeProcessedMarkdown` so this is in memory —
33
+ * `"raw"` would go back to disk and resolve against the wrong base.
34
+ */
35
+ readonly getText: (type: "raw" | "processed") => Promise<string>;
36
+ }
37
+
38
+ /**
39
+ * The summary for a document, or null when it has none.
40
+ *
41
+ * Null is the ordinary case and never an error: the feature is presence-driven,
42
+ * so a document with no summary renders no tab strip at all rather than an
43
+ * empty one.
44
+ */
45
+ export function summaryFor(documentPath: string): SummaryEntry | null {
46
+ const wanted = attachmentPath(documentPath, ".summary.md");
47
+ const wantedMdx = attachmentPath(documentPath, ".summary.mdx");
48
+ const hit = summaries.find(
49
+ (entry) => entry.info.path === wanted || entry.info.path === wantedMdx,
50
+ );
51
+ return hit === undefined ? null : (hit as unknown as SummaryEntry);
52
+ }
53
+
54
+ /** One card as the deck UI consumes it: authored text plus its identity. */
55
+ export interface DeckCard extends Card {
56
+ /** Identity: a hash of the card's own text, so an edit resets only this card. */
57
+ readonly hash: string;
58
+ }
59
+
60
+ export interface DeckEntry {
61
+ readonly title: string;
62
+ readonly description?: string;
63
+ readonly cards: readonly DeckCard[];
64
+ /**
65
+ * The deck's identity, used to key persisted review state. The record-relative
66
+ * path — never an authored id, because the path IS the identity here.
67
+ */
68
+ readonly path: string;
69
+ }
70
+
71
+ /** The deck for a document, or null when it has none. */
72
+ export function deckFor(documentPath: string): DeckEntry | null {
73
+ const wanted = attachmentPath(documentPath, ".flashcards.yaml");
74
+ const hit = decks.find((entry) => entry.info.path === wanted);
75
+ if (hit === undefined) return null;
76
+
77
+ const parsed = hit as unknown as Deck & { readonly info: { readonly path: string } };
78
+ return {
79
+ title: parsed.deck.title,
80
+ description: parsed.deck.description,
81
+ path: parsed.info.path,
82
+ cards: parsed.cards.map((card) => ({ ...card, hash: cardHash(card) })),
83
+ };
84
+ }
85
+
86
+ /** True when a document has either attachment — the presence gate for the UI. */
87
+ export function hasAttachments(documentPath: string): boolean {
88
+ return summaryFor(documentPath) !== null || deckFor(documentPath) !== null;
89
+ }
90
+
91
+ /**
92
+ * A fresh schedule for every card in a deck, all due now.
93
+ *
94
+ * Exported so the deck's first render and its reset path agree by construction
95
+ * rather than by two similar-looking object literals.
96
+ */
97
+ export function freshSchedules(
98
+ cards: readonly DeckCard[],
99
+ now: number,
100
+ ): Record<string, CardSchedule> {
101
+ return Object.fromEntries(cards.map((card) => [card.hash, newCard(card.hash, now)]));
102
+ }
103
+
104
+ /** Every attachment suffix, for the surfaces that need the list rather than the rule. */
105
+ export const ATTACHMENT_SUFFIX_LIST: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The shape of a `<doc>.flashcards.yaml` deck.
5
+ *
6
+ * NO AUTHORED IDS, anywhere — not on the deck, not on a card. The record's
7
+ * third principle is that identity derives from the file path, and the checker
8
+ * refuses `id:`/`name:` on a document for exactly this reason. A deck's
9
+ * identity is its path; a CARD's identity is its own text, hashed
10
+ * (`cardHash`), which is also what makes a per-card reset possible: an edited
11
+ * card is a different card, and every card the author did not touch keeps its
12
+ * review history.
13
+ *
14
+ * The predecessor authored `deck.id`, `card.id` AND `deck.version` by hand. The
15
+ * version decided nothing (it was logged, never acted on) and the ids were a
16
+ * second identity to keep in step with the filename.
17
+ */
18
+ export const CardSchema = z.object({
19
+ front: z.string().min(1).max(300),
20
+ back: z.string().min(1).max(600),
21
+ /** An optional prompt that turns a recall check into a thinking one. */
22
+ why: z.string().max(240).optional(),
23
+ });
24
+
25
+ export const DeckSchema = z.object({
26
+ deck: z.object({
27
+ title: z.string().min(1).max(120),
28
+ description: z.string().max(300).optional(),
29
+ }),
30
+ cards: z.array(CardSchema).min(1).max(60),
31
+ });
32
+
33
+ export type Deck = z.infer<typeof DeckSchema>;
34
+ export type Card = z.infer<typeof CardSchema>;
35
+
36
+ /**
37
+ * A card's identity: a stable hash of the text a learner actually sees.
38
+ *
39
+ * FNV-1a, 32-bit, hand-rolled — the site has no crypto import at build time and
40
+ * this needs to run identically in the browser. Collisions cost a single card's
41
+ * review history, never correctness, so 32 bits is the right size of hammer.
42
+ *
43
+ * `why` is deliberately NOT hashed: it is a hint about the card, not the card,
44
+ * and rewording it should not throw away a learner's history with the card.
45
+ */
46
+ export function cardHash(card: Card): string {
47
+ // NUL as the separator, written as an escape rather than embedded: a raw
48
+ // NUL in the source makes git treat this file as binary. It has to be a
49
+ // separator of some kind — without one, front "ab"/back "c" hashes the same
50
+ // as front "a"/back "bc" — and NUL is the one character authored card text
51
+ // cannot contain.
52
+ const text = `${card.front}\u0000${card.back}`;
53
+ let hash = 0x811c9dc5;
54
+ for (let i = 0; i < text.length; i += 1) {
55
+ hash ^= text.charCodeAt(i);
56
+ hash = Math.imul(hash, 0x01000193) >>> 0;
57
+ }
58
+ return hash.toString(16).padStart(8, "0");
59
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * How long a document takes to read.
3
+ *
4
+ * Computed at BUILD time from the document's own markdown, so the figure is in
5
+ * the server-rendered HTML — a reader with a failed bundle, a crawler and an
6
+ * agent parsing the page all get it. The predecessor measured `article.
7
+ * textContent` in the browser after paint, which put the number out of reach of
8
+ * every one of them.
9
+ *
10
+ * A LEAF: no imports, so it can be tested in isolation.
11
+ */
12
+
13
+ /**
14
+ * Words per minute for prose.
15
+ *
16
+ * 200 is the conventional figure for adult silent reading of ordinary text and
17
+ * is what the predecessor used. It is a rough number, and the clock beside it
18
+ * is what says so — nobody reads a time next to a clock icon as a promise. A
19
+ * governed record's prose is denser than a novel's, so if anything this reads
20
+ * slightly fast.
21
+ */
22
+ export const WORDS_PER_MINUTE = 200;
23
+
24
+ /**
25
+ * Fenced code is not read at prose speed — it is scanned, or studied, but
26
+ * either way counting its tokens as words inflates the estimate badly on a
27
+ * technical document. It is removed before counting rather than weighted:
28
+ * a weight would be a second invented number on top of the first.
29
+ */
30
+ const FENCED_CODE = /^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[ \t]*$/gm;
31
+
32
+ /** Frontmatter is metadata, not prose, and is never shown to the reader. */
33
+ const FRONTMATTER = /^?---\n[\s\S]*?\n---[ \t]*\n/;
34
+
35
+ /**
36
+ * Minutes to read this markdown, rounded to the nearest minute and never zero:
37
+ * a document that exists takes some time to read, and "0 min read" is a
38
+ * sentence no reader has use for.
39
+ */
40
+ export function readingMinutes(markdown: string): number {
41
+ const prose = markdown.replace(/\r\n/g, "\n").replace(FRONTMATTER, "").replace(FENCED_CODE, " ");
42
+ const words = prose.split(/\s+/).filter((word) => /[\p{L}\p{N}]/u.test(word)).length;
43
+ if (words === 0) return 1;
44
+ return Math.max(1, Math.round(words / WORDS_PER_MINUTE));
45
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Review scheduling for flashcard decks.
3
+ *
4
+ * A two-grade SM-2 variant. It is NOT FSRS, it models no memory, and it makes
5
+ * no retention guarantee — see `SCHEDULER_POLICY` and the note at the foot of
6
+ * this file. Naming it for what it is costs nothing and stops a future reader
7
+ * assuming a probabilistic guarantee that was never here.
8
+ *
9
+ * A LEAF and a pure function: `(schedule, rating, now) -> schedule`. No React,
10
+ * no storage, no ambient clock — `now` is always passed in, which is what makes
11
+ * the whole transition table assertable against a frozen clock.
12
+ */
13
+
14
+ /** Persisted beside the state, so a stored record always says what wrote it. */
15
+ export const SCHEDULER_POLICY = "ksor-sm2-v1";
16
+
17
+ /** New | Learning | Review | Relearning. */
18
+ export type CardState = 0 | 1 | 2 | 3;
19
+
20
+ export type Rating = "again" | "good";
21
+
22
+ export interface CardSchedule {
23
+ readonly state: CardState;
24
+ /** Index into the active step ladder while sub-day; 0 in Review. */
25
+ readonly step: number;
26
+ /** The last scheduled interval in days; 0 while sub-day. */
27
+ readonly intervalDays: number;
28
+ /** The interval a lapse interrupted, remembered so re-graduation can halve it. */
29
+ readonly lapsedIntervalDays: number;
30
+ /** Growth multiplier, clamped to [EASE_MIN, EASE_START]. */
31
+ readonly ease: number;
32
+ readonly reps: number;
33
+ readonly lapses: number;
34
+ readonly dueMs: number;
35
+ readonly lastReviewMs?: number;
36
+ /** Hash of the card's authored text — a change resets THIS card only. */
37
+ readonly hash: string;
38
+ }
39
+
40
+ const MINUTE_MS = 60_000;
41
+ const DAY_MS = 86_400_000;
42
+
43
+ // Every constant below is a choice. FSRS's own defaults are named where ours
44
+ // match them, so a future reader can see which numbers are inherited and which
45
+ // are ours.
46
+ /** Matches FSRS's default learning steps. */
47
+ export const LEARNING_STEPS_MIN: readonly number[] = [1, 10];
48
+ /** Matches FSRS's default relearning steps. */
49
+ export const RELEARNING_STEPS_MIN: readonly number[] = [10];
50
+ /** Matches FSRS's observed New → Good → Good interval. */
51
+ export const GRADUATING_INTERVAL_DAYS = 2;
52
+ export const EASE_START = 2.5;
53
+ export const EASE_MIN = 1.3;
54
+ export const EASE_LAPSE_PENALTY = 0.2;
55
+ /** A re-graduated card returns at half the interval its lapse interrupted. */
56
+ export const LAPSE_INTERVAL_FACTOR = 0.5;
57
+ /** Ten years. FSRS ships a hundred; a century is not a claim this can make. */
58
+ export const MAX_INTERVAL_DAYS = 3650;
59
+ export const MIN_REVIEW_INTERVAL_DAYS = 1;
60
+
61
+ export function newCard(hash: string, now: number): CardSchedule {
62
+ return {
63
+ state: 0,
64
+ step: 0,
65
+ intervalDays: 0,
66
+ lapsedIntervalDays: 0,
67
+ ease: EASE_START,
68
+ reps: 0,
69
+ lapses: 0,
70
+ dueMs: now,
71
+ hash,
72
+ };
73
+ }
74
+
75
+ function clamp(value: number, low: number, high: number): number {
76
+ return Math.min(high, Math.max(low, value));
77
+ }
78
+
79
+ /** Advance along a sub-day ladder, or null when the ladder is finished. */
80
+ function nextRung(steps: readonly number[], step: number): number | null {
81
+ return step + 1 < steps.length ? step + 1 : null;
82
+ }
83
+
84
+ /**
85
+ * The next schedule for a card, given how the learner graded it and when.
86
+ *
87
+ * Never mutates its input: review state is persisted, and an in-place update
88
+ * that a failed write leaves half-applied is a corrupt record.
89
+ */
90
+ export function schedule(card: CardSchedule, rating: Rating, now: number): CardSchedule {
91
+ const base = { ...card, reps: card.reps + 1, lastReviewMs: now };
92
+
93
+ if (card.state === 0) {
94
+ // New. Again starts the ladder; Good skips its first rung — a card the
95
+ // learner already knows should not be asked again in sixty seconds.
96
+ const step = rating === "again" ? 0 : Math.min(1, LEARNING_STEPS_MIN.length - 1);
97
+ return {
98
+ ...base,
99
+ state: 1,
100
+ step,
101
+ dueMs: now + (LEARNING_STEPS_MIN[step] ?? 1) * MINUTE_MS,
102
+ };
103
+ }
104
+
105
+ if (card.state === 1 || card.state === 3) {
106
+ const relearning = card.state === 3;
107
+ const steps = relearning ? RELEARNING_STEPS_MIN : LEARNING_STEPS_MIN;
108
+
109
+ if (rating === "again") {
110
+ return { ...base, step: 0, dueMs: now + (steps[0] ?? 1) * MINUTE_MS };
111
+ }
112
+
113
+ const advanced = nextRung(steps, card.step);
114
+ if (advanced !== null) {
115
+ return { ...base, step: advanced, dueMs: now + (steps[advanced] ?? 1) * MINUTE_MS };
116
+ }
117
+
118
+ // Graduating. A first graduation takes the fixed interval; a RE-graduation
119
+ // takes half the interval its lapse interrupted, so a card that was on a
120
+ // 40-day interval does not restart from two days.
121
+ const intervalDays = relearning
122
+ ? clamp(
123
+ Math.round(card.lapsedIntervalDays * LAPSE_INTERVAL_FACTOR),
124
+ MIN_REVIEW_INTERVAL_DAYS,
125
+ MAX_INTERVAL_DAYS,
126
+ )
127
+ : GRADUATING_INTERVAL_DAYS;
128
+
129
+ return { ...base, state: 2, step: 0, intervalDays, dueMs: now + intervalDays * DAY_MS };
130
+ }
131
+
132
+ // Review.
133
+ if (rating === "again") {
134
+ return {
135
+ ...base,
136
+ state: 3,
137
+ step: 0,
138
+ lapses: card.lapses + 1,
139
+ ease: Math.max(EASE_MIN, card.ease - EASE_LAPSE_PENALTY),
140
+ lapsedIntervalDays: card.intervalDays,
141
+ dueMs: now + (RELEARNING_STEPS_MIN[0] ?? 10) * MINUTE_MS,
142
+ };
143
+ }
144
+
145
+ // The `intervalDays + 1` floor is load-bearing: at the minimum ease,
146
+ // round(1 * 1.3) is 1, and the card would be scheduled at the same interval
147
+ // forever. One clamp prevents a stall that takes months of use to notice.
148
+ const grown = clamp(
149
+ Math.round(card.intervalDays * card.ease),
150
+ card.intervalDays + 1,
151
+ MAX_INTERVAL_DAYS,
152
+ );
153
+ return { ...base, state: 2, step: 0, intervalDays: grown, dueMs: now + grown * DAY_MS };
154
+ }
155
+
156
+ /**
157
+ * The cards due at `now`, soonest first, with never-seen cards ahead of
158
+ * overdue ones so a first session walks the deck in its authored order.
159
+ *
160
+ * This is the function the predecessor computed and then never called — its
161
+ * deck rendered `deck.cards` directly, so its spaced repetition influenced
162
+ * nothing a learner ever saw (`useFSRS.ts:253-256` against `Flashcards.tsx:176`).
163
+ * Here it is what the session reads.
164
+ */
165
+ export function dueOrder<T>(
166
+ cards: readonly T[],
167
+ scheduleOf: (card: T) => CardSchedule,
168
+ now: number,
169
+ ): readonly T[] {
170
+ return cards
171
+ .map((card, index) => ({ card, index, state: scheduleOf(card) }))
172
+ .filter((entry) => entry.state.dueMs <= now)
173
+ .sort((a, b) =>
174
+ a.state.state === 0 && b.state.state !== 0
175
+ ? -1
176
+ : b.state.state === 0 && a.state.state !== 0
177
+ ? 1
178
+ : a.state.dueMs !== b.state.dueMs
179
+ ? a.state.dueMs - b.state.dueMs
180
+ : a.index - b.index,
181
+ )
182
+ .map((entry) => entry.card);
183
+ }
184
+
185
+ /**
186
+ * What this gives up against FSRS, recorded beside the code rather than in a
187
+ * document that can drift from it:
188
+ *
189
+ * 1. No memory model. `ease` is a heuristic multiplier with no probabilistic
190
+ * meaning; it cannot answer "how likely is recall today".
191
+ * 2. Elapsed time is ignored — the largest single loss. FSRS feeds
192
+ * retrievability into every update, so a card recalled sixty days late
193
+ * earns a much larger interval. Here the interval grows the same whether
194
+ * the review was on time or a year late.
195
+ * 3. No retention target. FSRS derives intervals to hit a requested
196
+ * retention; "roughly 90%" is a sentence this is not entitled to.
197
+ * 4. No optimisation. FSRS re-fits parameters per learner; these are
198
+ * constants, identical for everyone.
199
+ * 5. No graded lapse recovery — a flat half-interval and a flat ease penalty,
200
+ * whatever the card.
201
+ * 6. Four grades reduced to two, which costs nothing: the UI never exposed
202
+ * Hard or Easy, so no information that was ever collected was discarded.
203
+ */
204
+
205
+ /**
206
+ * How far through the deck the reader is, as a percentage, counting the card
207
+ * they are ON rather than the ones behind it.
208
+ *
209
+ * Extracted because it was wrong and silently so: the bar read `position` while
210
+ * its caption read `position + 1`, so the two disagreed by one card the whole
211
+ * way — "1 / 5" over an empty bar, "5 / 5" over a bar at 80%, and a full bar
212
+ * never once on screen, because reaching the end swaps the bar for the
213
+ * completion panel. One number, one place, one test.
214
+ */
215
+ export function progressPercent(position: number, total: number): number {
216
+ if (total <= 0) return 0;
217
+ const shown = Math.min(Math.max(position, 0) + 1, total);
218
+ return (shown / total) * 100;
219
+ }