freezmo/lib/learning.ts
Martin Dvorak 71c56ad06f Review breakdown: show each deck's own activity total
Follow-up to the smart-review breakdown: "I only have 2 decks" turned out to
be three deck cards (one clipped at the window edge) and 15 + 13 + 6 = 34
due. The line now carries each deck's activity total next to its due count -
"Due by deck: Sky deck - 15 of 15 - Humanismus - 13 of 15 - ... - 6 of 13" -
so the arithmetic is checkable inline without opening a deck.

groupByDeck returns {label, count, total}; new i18n key review.dueOfTotal
(en + cs). review-test pins the format. Version 0.6.9, SW v91.
2026-10-15 21:20:53 +00:00

202 lines
7.5 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;
}
/**
* Where a review list comes from: one entry per deck, most items first. The
* Smart review card shows this under its buttons so a count that mixes decks
* ("34 due" with two decks on screen) is self-explanatory instead of looking
* like the same mistake twice or an item from a deck that was deleted.
*/
/** `total` is the deck's activity count, so "34 due" can be checked against
* the decks themselves ("15 of 15 · 13 of 15 · 6 of 13"). */
export function groupByDeck(items: ReviewItem[]): { label: string; count: number; total: number }[] {
const groups = new Map<string, { label: string; count: number; total: number }>();
for (const item of items) {
const key = item.deck.id;
const group = groups.get(key) ?? { label: item.deck.title, count: 0, total: item.deck.activities.length };
group.count += 1;
groups.set(key, group);
}
return [...groups.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/* ------------------------------------------------------------------ */
/* 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")}`;
}
/** Fast count of answers recorded today (avoids building the full stats buckets). */
export function todayAnswerCount(answers: AnswerRecord[], now = Date.now()): number {
const today = dayKey(now);
let count = 0;
for (const answer of answers) {
if (dayKey(answer.at) === today) count += 1;
}
return count;
}
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),
};
}