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
254
components/exam/ExamScreen.tsx
Normal file
254
components/exam/ExamScreen.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
|
||||
import { Button } from "@/components/ui";
|
||||
import { haptic } from "@/lib/haptics";
|
||||
import { recordAnswer } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const COUNTS = [5, 10, 15, 20];
|
||||
const TIMERS = [0, 300, 600, 900];
|
||||
|
||||
/**
|
||||
* Exam mode: pick one or more decks, a question count and an optional timer,
|
||||
* then answer without hints. Results show the score plus a per-deck and
|
||||
* per-topic breakdown; every wrong answer lands in the mistakes set (the
|
||||
* normal history does that automatically).
|
||||
*/
|
||||
export function ExamScreen() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [count, setCount] = useState(10);
|
||||
const [timer, setTimer] = useState(0);
|
||||
const [running, setRunning] = useState<SessionItem[] | null>(null);
|
||||
|
||||
const pool = useMemo(
|
||||
() =>
|
||||
decks
|
||||
.filter((deck) => selected.has(deck.id))
|
||||
.flatMap((deck) => deck.activities.map((activity) => ({ deck, activity }))),
|
||||
[decks, selected],
|
||||
);
|
||||
const effectiveCount = Math.min(count, pool.length);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
haptic("toggle");
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const shuffled = [...pool];
|
||||
for (let index = shuffled.length - 1; index > 0; index--) {
|
||||
const swap = Math.floor(Math.random() * (index + 1));
|
||||
[shuffled[index], shuffled[swap]] = [shuffled[swap], shuffled[index]];
|
||||
}
|
||||
setRunning(shuffled.slice(0, effectiveCount));
|
||||
};
|
||||
|
||||
if (running) {
|
||||
return (
|
||||
<SessionFeed
|
||||
title={t("exam.title")}
|
||||
items={running}
|
||||
timerSeconds={timer || undefined}
|
||||
noHints
|
||||
testId="exam-feed"
|
||||
onExit={() => setRunning(null)}
|
||||
onAnswered={(item, correct) => {
|
||||
haptic(correct ? "success" : "error");
|
||||
recordAnswer({
|
||||
deckId: item.deck.id,
|
||||
deckTitle: item.deck.title,
|
||||
activityId: item.activity.id,
|
||||
type: item.activity.type,
|
||||
topic: item.activity.topic,
|
||||
correct,
|
||||
});
|
||||
}}
|
||||
renderResults={({ results, items, correct, total, score }) => {
|
||||
const byDeck = new Map<string, { title: string; answered: number; correct: number }>();
|
||||
const byTopic = new Map<string, { answered: number; correct: number }>();
|
||||
for (const item of items) {
|
||||
const result = results[item.activity.id];
|
||||
if (!result) continue;
|
||||
const deck = byDeck.get(item.deck.id) ?? { title: item.deck.title, answered: 0, correct: 0 };
|
||||
deck.answered += 1;
|
||||
if (result.correct) deck.correct += 1;
|
||||
byDeck.set(item.deck.id, deck);
|
||||
if (item.activity.topic) {
|
||||
const topic = byTopic.get(item.activity.topic) ?? { answered: 0, correct: 0 };
|
||||
topic.answered += 1;
|
||||
if (result.correct) topic.correct += 1;
|
||||
byTopic.set(item.activity.topic, topic);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="card space-y-4 p-5">
|
||||
<div className="text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("exam.results")}
|
||||
</p>
|
||||
<p className="mt-2 text-[42px] font-extrabold leading-none text-[var(--text)]">{score}%</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-muted)]">
|
||||
{t("results.gotOf", { correct, total, retry: "" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("exam.byDeck")}
|
||||
</p>
|
||||
{[...byDeck.entries()].map(([id, entry]) => (
|
||||
<div key={id} className="flex items-center justify-between gap-3 text-[13px]">
|
||||
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.title}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
|
||||
{entry.correct}/{entry.answered}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{byTopic.size > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("exam.byTopic")}
|
||||
</p>
|
||||
{[...byTopic.entries()].map(([topic, entry]) => (
|
||||
<div key={topic} className="flex items-center justify-between gap-3 text-[13px]">
|
||||
<span className="min-w-0 truncate font-medium text-[var(--text)]">{topic}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
|
||||
{entry.correct}/{entry.answered}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
|
||||
{t("review.done")}
|
||||
</Button>
|
||||
{correct < total ? (
|
||||
<Button size="lg" variant="secondary" className="touch-target" onClick={() => router.push("/review?mistakes=1")}>
|
||||
{t("exam.studyMistakes")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-5">
|
||||
<section className="space-y-1.5">
|
||||
<h1 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
||||
{t("exam.title")}
|
||||
</h1>
|
||||
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("exam.subtitle")}</p>
|
||||
</section>
|
||||
|
||||
<section className="card space-y-2 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("exam.pickDecks")}</p>
|
||||
{decks.length === 0 ? (
|
||||
<p className="text-[13px] text-[var(--text-muted)]">{t("exam.noDecks")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{decks.map((deck) => {
|
||||
const active = selected.has(deck.id);
|
||||
return (
|
||||
<li key={deck.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => toggle(deck.id)}
|
||||
className={cn(
|
||||
"touch-target flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition",
|
||||
active
|
||||
? "border-accent-400 bg-[var(--surface-strong)]"
|
||||
: "border-[var(--hairline)] bg-[var(--surface)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"grid size-5 shrink-0 place-items-center rounded-md border text-[11px] font-bold",
|
||||
active ? "grad-ice border-transparent text-[#06111f]" : "border-[var(--hairline)]",
|
||||
)}
|
||||
>
|
||||
{active ? "✓" : ""}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[14px] font-semibold text-[var(--text)]">{deck.title}</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||||
{t("deck.activities", { n: deck.activities.length })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card space-y-3 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("exam.questions")}</p>
|
||||
<div className="segmented" role="group" aria-label={t("exam.questions")}>
|
||||
{COUNTS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={count === option}
|
||||
onClick={() => {
|
||||
haptic("toggle");
|
||||
setCount(option);
|
||||
}}
|
||||
>
|
||||
<span className="seg-label">{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("exam.timer")}</p>
|
||||
<div className="segmented" role="group" aria-label={t("exam.timer")}>
|
||||
{TIMERS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={timer === option}
|
||||
onClick={() => {
|
||||
haptic("toggle");
|
||||
setTimer(option);
|
||||
}}
|
||||
>
|
||||
<span className="seg-label">{option === 0 ? t("exam.timerOff") : `${option / 60} min`}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="touch-target w-full"
|
||||
disabled={pool.length === 0}
|
||||
onClick={start}
|
||||
>
|
||||
{t("exam.start", { n: effectiveCount })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue