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.
173 lines
6.2 KiB
TypeScript
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),
|
|
};
|
|
}
|