managarten/packages/cards-core/src/fsrs.ts
Till JS 0a544ac410 feat(cards): Phase-1 Spinoff — standalone cards.mana.how + cards-core extraction
Builds out the Cards spinoff end-to-end so the standalone app at
cards.mana.how shares its data layer with the in-mana cards module
through a single pure-utility package.

Why a spinoff and not just a deeper module: per the GUIDELINES, Cards
gets its own brand + URL while reusing mana-auth, mana-sync, and the
mana-credits/billing stack. The in-mana module under mana.how/cards
stays untouched as the integrated experience.

Phase 0 — mana-modul foundation
  • New tables cardReviews + cardStudyBlocks (Dexie v61) + plaintext
    classification in the crypto registry.
  • LocalCard learns a {type, fields} shape; legacy front/back columns
    kept as a back-compat mirror so older builds keep rendering.
  • FSRS v6 scheduler + Cloze parser + Markdown render pipeline.
  • UI in apps/mana/.../routes/(app)/cards/ gets a learn session
    (learn/[deckId]), 4-type card editor, due-counter, markdown lists.

Phase 1 — standalone (apps/cards/apps/web)
  • SvelteKit 2 + Svelte 5 + Tailwind 4, port 5180.
  • Own Dexie 'cards' DB with a slim 5-table schema.
  • Own sync engine: pending-changes hooks, 1 s push / 5 s pull against
    POST /sync/cards, server-apply with suppression to avoid ping-pong.
  • Auth-Gate via @mana/shared-auth-ui (LoginPage / RegisterPage).
  • Encryption hooks at every write/read/apply path, currently no-op
    stubs — flipping to real vault-backed AES-GCM is a single-file
    change in src/lib/data/crypto.ts.

Shared package — @mana/cards-core
  • Pulls types, cloze, card-reviews, FSRS wrapper, and Markdown
    renderer out of the mana module so both frontends import from one
    source. mana-modul keeps thin re-export shims so consumers don't
    need to change imports.
  • 19 vitest tests carried over from the mana module.

Server-side wiring
  • cards.mana.how added to mana-auth PRODUCTION_TRUSTED_ORIGINS and
    its CORS_ORIGINS env (sso-config.spec.ts stays green).
  • New cards-web container in docker-compose.macmini.yml (mirrors
    manavoxel-web pattern, 128m, depends on mana-auth healthy).
  • cloudflared-config.yml repoints cards.mana.how from :5000 (the
    unified mana-web container) to :5180. mana.how/cards is unchanged.

Cleanup
  • Removed an unrelated 2026-03/04 NestJS+Supabase+Expo experiment
    that was lingering under apps/cards/ (apps/landing, supabase/,
    .github/workflows, MANA_CORE_*.md, etc.). It predated this plan
    and would have confused future readers.

Validation
  • svelte-check on mana-web: 0 errors over 7697 files
  • svelte-check on cards-web: 0 errors over 3481 files
  • vitest on cards-core: 19/19 pass
  • pnpm check:crypto: 214 tables classified
  • bun test sso-config.spec.ts: 8/8 pass
  • vite build on cards-web: green

Not done in this commit (deliberate)
  • Real encryption (vault roundtrip) — Phase 2.
  • WebSocket-driven pull (5 s polling for now).
  • Mobile/landing standalone surfaces — Phase 2/3.
  • The actual production cutover on the Mac mini (build, deploy,
    cloudflared sync) — config is staged, deploy is a user action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:20:43 +02:00

91 lines
2.6 KiB
TypeScript

/**
* FSRS wrapper — Free Spaced Repetition Scheduler v6 via `ts-fsrs`.
*
* Translates between ts-fsrs's `Card` (Date objects, snake_case) and
* our `LocalCardReview` (ISO strings, camelCase). Stores never see
* ts-fsrs types directly. One place to swap params for per-user-tuning
* later.
*/
import { fsrs, createEmptyCard, State, type Card, type Grade } from 'ts-fsrs';
import type { LocalCardReview, ReviewGrade } from './types';
const STATE_TO_STRING: Record<State, LocalCardReview['state']> = {
[State.New]: 'new',
[State.Learning]: 'learning',
[State.Review]: 'review',
[State.Relearning]: 'relearning',
};
const STRING_TO_STATE: Record<LocalCardReview['state'], State> = {
new: State.New,
learning: State.Learning,
review: State.Review,
relearning: State.Relearning,
};
function toLocalReview(id: string, cardId: string, subIndex: number, card: Card): LocalCardReview {
return {
id,
cardId,
subIndex,
state: STATE_TO_STRING[card.state],
stability: card.stability,
difficulty: card.difficulty,
due: card.due.toISOString(),
reps: card.reps,
lapses: card.lapses,
lastReview: card.last_review ? card.last_review.toISOString() : undefined,
elapsedDays: card.elapsed_days,
scheduledDays: card.scheduled_days,
};
}
function toFsrsCard(review: LocalCardReview): Card {
return {
due: new Date(review.due),
stability: review.stability,
difficulty: review.difficulty,
elapsed_days: review.elapsedDays,
scheduled_days: review.scheduledDays,
learning_steps: 0,
reps: review.reps,
lapses: review.lapses,
state: STRING_TO_STATE[review.state],
last_review: review.lastReview ? new Date(review.lastReview) : undefined,
};
}
/**
* Build a fresh review row for a new learnable unit (basic card,
* one cloze cluster, one direction of basic-reverse).
*/
export function newReview(opts: { cardId: string; subIndex: number; now?: Date }): LocalCardReview {
const id = crypto.randomUUID();
const empty = createEmptyCard(opts.now ?? new Date());
return toLocalReview(id, opts.cardId, opts.subIndex, empty);
}
/**
* Apply a grade to a review and return the next-state row.
*/
export function gradeReview(
review: LocalCardReview,
grade: ReviewGrade,
now: Date = new Date()
): LocalCardReview {
const scheduler = getScheduler();
const fsrsCard = toFsrsCard(review);
const result = scheduler.next(fsrsCard, now, gradeToRating(grade));
return toLocalReview(review.id, review.cardId, review.subIndex, result.card);
}
function gradeToRating(grade: ReviewGrade): Grade {
return grade as unknown as Grade;
}
let cached: ReturnType<typeof fsrs> | null = null;
function getScheduler() {
if (!cached) cached = fsrs();
return cached;
}