freezmo/lib/learning.ts
Martin Dvorak e4a8e0ba01 Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders
Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
  in a versioned IndexedDB log (freezmo-learning v1, stores answers +
  srs), mirrored in memory for synchronous React reads and capped at
  20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
  ease; wrong answers return in ten minutes with the ease nudged down.

Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
  answer wrong — clears itself when answered correctly), today's goal
  progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
  deck) built on a shared SessionFeed with the study feed's snap/keyboard
  mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
  per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).

Exam mode
- New /exam: pick decks, question count, optional timer; no hints
  (a NoHints context hides hint rows and the self-check escape);
  results with score, per-deck and per-topic breakdown; wrong answers
  land in the mistakes set through the normal history.

Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
  the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
  Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
  UI hidden entirely without keys; iOS install note; service worker
  push/notificationclick handlers; scripts/send-reminders.mjs for a
  cron/systemd timer; web-push added as the one server dependency.

Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
2026-10-10 14:15:21 +00:00

173 lines
6.2 KiB
TypeScript

"use client";
/**
* Pure selectors over the learning history: due items (SRS), mistakes,
* streaks and accuracy breakdowns. No React, no storage side effects —
* easy to reason about and to test.
*/
import type { AnswerRecord, SrsRecord } from "./history";
import type { Activity, Deck } from "./schemas";
import { deckStore } from "./storage";
export type ReviewItem = { deck: Deck; activity: Activity; dueAt?: number };
function deckMap(): Map<string, Deck> {
return new Map(deckStore.getSnapshot().map((deck) => [deck.id, deck]));
}
export function itemKey(deckId: string, activityId: string): string {
return `${deckId}:${activityId}`;
}
/* ------------------------------------------------------------------ */
/* Review (SRS) */
/* ------------------------------------------------------------------ */
export function dueCount(schedules: SrsRecord[], now = Date.now()): number {
return schedules.filter((entry) => entry.dueAt <= now).length;
}
/** Activities whose schedule is due, soonest first, across every deck. */
export function dueItems(schedules: SrsRecord[], now = Date.now()): ReviewItem[] {
const decks = deckMap();
const items: ReviewItem[] = [];
for (const schedule of schedules) {
if (schedule.dueAt > now) continue;
const deck = decks.get(schedule.deckId);
const activity = deck?.activities.find((entry) => entry.id === schedule.activityId);
if (deck && activity) items.push({ deck, activity, dueAt: schedule.dueAt });
}
return items.sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0));
}
/* ------------------------------------------------------------------ */
/* Mistakes */
/* ------------------------------------------------------------------ */
/**
* Activities whose **latest** answer was wrong (optionally within one deck).
* Answering one correctly later removes it — no separate store to maintain.
*/
export function mistakeItems(answers: AnswerRecord[], deckId?: string): ReviewItem[] {
const latest = new Map<string, AnswerRecord>();
for (const answer of answers) {
if (deckId && answer.deckId !== deckId) continue;
const key = itemKey(answer.deckId, answer.activityId);
if (!latest.has(key)) latest.set(key, answer);
}
const decks = deckMap();
const items: ReviewItem[] = [];
for (const answer of latest.values()) {
if (answer.correct) continue;
const deck = decks.get(answer.deckId);
const activity = deck?.activities.find((entry) => entry.id === answer.activityId);
if (deck && activity) items.push({ deck, activity });
}
return items;
}
export function mistakeCount(answers: AnswerRecord[], deckId?: string): number {
return mistakeItems(answers, deckId).length;
}
/* ------------------------------------------------------------------ */
/* Stats */
/* ------------------------------------------------------------------ */
export type DayStat = { day: string; count: number; correct: number };
export type BucketStat = { label: string; answered: number; correct: number };
export type LearningStats = {
total: number;
correct: number;
accuracy: number;
todayCount: number;
streak: number;
days: DayStat[];
decks: BucketStat[];
subjects: BucketStat[];
weakestTopics: BucketStat[];
};
export function dayKey(at: number): string {
const date = new Date(at);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
export function computeStats(answers: AnswerRecord[], now = Date.now()): LearningStats {
const total = answers.length;
const correct = answers.filter((entry) => entry.correct).length;
const byDay = new Map<string, DayStat>();
for (const answer of answers) {
const key = dayKey(answer.at);
const entry = byDay.get(key) ?? { day: key, count: 0, correct: 0 };
entry.count += 1;
if (answer.correct) entry.correct += 1;
byDay.set(key, entry);
}
// Streak: consecutive days with at least one answer, ending today or
// yesterday (a day that hasn't started yet shouldn't break it).
let streak = 0;
const cursor = new Date(now);
if (!byDay.has(dayKey(now))) cursor.setDate(cursor.getDate() - 1);
for (;;) {
const key = dayKey(cursor.getTime());
if (!byDay.has(key)) break;
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
const days: DayStat[] = [];
const dayCursor = new Date(now);
dayCursor.setDate(dayCursor.getDate() - 34);
for (let index = 0; index < 35; index++) {
const key = dayKey(dayCursor.getTime());
days.push(byDay.get(key) ?? { day: key, count: 0, correct: 0 });
dayCursor.setDate(dayCursor.getDate() + 1);
}
const decks = new Map<string, BucketStat>();
const subjects = new Map<string, BucketStat>();
const topics = new Map<string, BucketStat>();
const lookup = deckMap();
for (const answer of answers) {
const deck = decks.get(answer.deckId) ?? { label: answer.deckTitle, answered: 0, correct: 0 };
deck.answered += 1;
if (answer.correct) deck.correct += 1;
decks.set(answer.deckId, deck);
const subject = lookup.get(answer.deckId)?.subject?.trim();
if (subject) {
const bucket = subjects.get(subject) ?? { label: subject, answered: 0, correct: 0 };
bucket.answered += 1;
if (answer.correct) bucket.correct += 1;
subjects.set(subject, bucket);
}
if (answer.topic) {
const bucket = topics.get(answer.topic) ?? { label: answer.topic, answered: 0, correct: 0 };
bucket.answered += 1;
if (answer.correct) bucket.correct += 1;
topics.set(answer.topic, bucket);
}
}
const accuracyOf = (bucket: BucketStat) => bucket.correct / Math.max(1, bucket.answered);
return {
total,
correct,
accuracy: total > 0 ? correct / total : 0,
todayCount: byDay.get(dayKey(now))?.count ?? 0,
streak,
days,
decks: [...decks.values()].sort((a, b) => b.answered - a.answered),
subjects: [...subjects.values()].sort((a, b) => b.answered - a.answered),
weakestTopics: [...topics.values()]
.filter((bucket) => bucket.answered >= 3)
.sort((a, b) => accuracyOf(a) - accuracyOf(b))
.slice(0, 5),
};
}