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.
This commit is contained in:
parent
ead6bf94db
commit
e4a8e0ba01
33 changed files with 2504 additions and 47 deletions
173
lib/learning.ts
Normal file
173
lib/learning.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"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),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue