"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 { 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(); 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(); 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(); const subjects = new Map(); const topics = new Map(); 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), }; }