"It contains mistakes I either did more than once or from decks that I've removed - there's no way there's 34 to repeat with 2 decks." The counts were honest (dueItems/mistakeItems only resolve live deck+activity pairs; a seeded repro with 37 SRS rows and 30 live shows "30 due"), but nothing in the UI said where an item came from: a deck created twice from the same notes shows every question twice, and a deck that survived a delete or a sync looks like a ghost. Both read as a broken list. - SessionFeed gains showDeck (review + mistakes): a deck chip above every card, so two identical questions are visibly two decks. - The Smart review card breaks its numbers down under the buttons: "Due by deck: Sky deck - 17 - Sky deck (2) - 17", capped at three decks plus "+n more" (groupByDeck in lib/learning). - New i18n keys (en + cs, plural forms for "+n more"). review-test pins it: a twin-deck seed renders the breakdown and both deck chips in the feed. Version 0.6.8, SW v90.
200 lines
7.2 KiB
TypeScript
200 lines
7.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;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function groupByDeck(items: ReviewItem[]): { label: string; count: number }[] {
|
|
const groups = new Map<string, { label: string; count: number }>();
|
|
for (const item of items) {
|
|
const key = item.deck.id;
|
|
const group = groups.get(key) ?? { label: item.deck.title, count: 0 };
|
|
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),
|
|
};
|
|
}
|