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
|
|
@ -50,9 +50,9 @@ export function DataSection() {
|
|||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const apply = (mode: "merge" | "replace") => {
|
||||
const apply = async (mode: "merge" | "replace") => {
|
||||
if (!pending) return;
|
||||
const done = applyImport(pending.backup, mode);
|
||||
const done = await applyImport(pending.backup, mode);
|
||||
setNotice(t("settings.importDone", { decks: done.decks, homework: done.homework }));
|
||||
reset();
|
||||
};
|
||||
|
|
@ -69,7 +69,7 @@ export function DataSection() {
|
|||
<p className="text-[11.5px] leading-relaxed text-[var(--text-subtle)]">{t("settings.dataNote")}</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => downloadBackup()}>
|
||||
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => void downloadBackup()}>
|
||||
{t("settings.exportAll")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" className="touch-target w-full" onClick={() => fileRef.current?.click()}>
|
||||
|
|
@ -97,10 +97,10 @@ export function DataSection() {
|
|||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importReplaceNote")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => apply("merge")}>
|
||||
<Button size="sm" onClick={() => void apply("merge")}>
|
||||
{t("settings.importMerge")}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => apply("replace")}>
|
||||
<Button size="sm" variant="danger" onClick={() => void apply("replace")}>
|
||||
{t("settings.importReplace")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={reset}>
|
||||
|
|
|
|||
209
components/LearningSection.tsx
Normal file
209
components/LearningSection.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { haptic } from "@/lib/haptics";
|
||||
import { localeHeader, useI18n } from "@/lib/i18n";
|
||||
import { authHeaders, saveSettings, settingsStore } from "@/lib/settings";
|
||||
|
||||
const GOALS = [10, 20, 30, 50];
|
||||
|
||||
type PushStatus = { enabled: boolean; publicKey?: string };
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(normalized);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let index = 0; index < raw.length; index++) bytes[index] = raw.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Learning: the daily goal, plus push reminders — but only when
|
||||
* the server has VAPID keys configured. Without them the reminder block is
|
||||
* hidden completely, exactly as if the feature did not exist.
|
||||
*/
|
||||
export function LearningSection() {
|
||||
const { t } = useI18n();
|
||||
const dailyGoal = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSnapshot().dailyGoal,
|
||||
() => 20,
|
||||
);
|
||||
const [status, setStatus] = useState<PushStatus | null>(null);
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [endpoint, setEndpoint] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const pushSupported =
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pushSupported) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/push", {
|
||||
headers: { ...authHeaders(), ...localeHeader() },
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = (await response.json()) as PushStatus;
|
||||
if (cancelled) return;
|
||||
setStatus(body);
|
||||
if (body.enabled) {
|
||||
const registration = await navigator.serviceWorker.ready.catch(() => null);
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (!cancelled && subscription) {
|
||||
setSubscribed(true);
|
||||
setEndpoint(subscription.endpoint);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setStatus({ enabled: false });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushSupported]);
|
||||
|
||||
const enable = async () => {
|
||||
if (!status?.publicKey) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
setNotice(t("settings.reminderDenied"));
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(status.publicKey) as unknown as BufferSource,
|
||||
});
|
||||
const response = await fetch("/api/push", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
if (!response.ok) throw new Error("subscribe failed");
|
||||
setSubscribed(true);
|
||||
setEndpoint(subscription.endpoint);
|
||||
setNotice(t("settings.reminderOn"));
|
||||
haptic("toggle");
|
||||
} catch {
|
||||
setNotice(t("settings.reminderError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready.catch(() => null);
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await fetch(`/api/push?endpoint=${encodeURIComponent(subscription.endpoint)}`, {
|
||||
method: "DELETE",
|
||||
headers: { ...authHeaders(), ...localeHeader() },
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
setSubscribed(false);
|
||||
setEndpoint(null);
|
||||
setNotice(t("settings.reminderOff"));
|
||||
} catch {
|
||||
setNotice(t("settings.reminderError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!endpoint) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/push/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
if (!response.ok) throw new Error("test failed");
|
||||
setNotice(t("settings.reminderOn"));
|
||||
} catch {
|
||||
setNotice(t("settings.reminderError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const iosNeedsInstall =
|
||||
typeof navigator !== "undefined" &&
|
||||
/iPhone|iPad|iPod/.test(navigator.userAgent) &&
|
||||
!(navigator as Navigator & { standalone?: boolean }).standalone;
|
||||
|
||||
return (
|
||||
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.dailyGoal")}</p>
|
||||
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("settings.dailyGoalNote")}</p>
|
||||
<div className="segmented" role="group" aria-label={t("settings.dailyGoal")}>
|
||||
{GOALS.map((goal) => (
|
||||
<button
|
||||
key={goal}
|
||||
type="button"
|
||||
aria-pressed={dailyGoal === goal}
|
||||
onClick={() => {
|
||||
haptic("toggle");
|
||||
saveSettings({ dailyGoal: goal });
|
||||
}}
|
||||
>
|
||||
<span className="seg-label">{goal}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pushSupported && status?.enabled ? (
|
||||
<div className="space-y-2 border-t border-[var(--hairline-soft)] pt-2.5">
|
||||
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.reminders")}</p>
|
||||
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("settings.remindersNote")}</p>
|
||||
{iosNeedsInstall ? (
|
||||
<p className="text-[11px] font-medium leading-snug text-amber-700 dark:text-amber-300">
|
||||
{t("settings.remindersIos")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscribed ? (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" loading={busy} onClick={() => void test()}>
|
||||
{t("settings.reminderTest")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" loading={busy} onClick={() => void disable()}>
|
||||
{t("settings.reminderDisable")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" loading={busy} onClick={() => void enable()}>
|
||||
{t("settings.reminderEnable")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{notice ? <p className="text-[12px] font-medium text-[var(--text-muted)]">{notice}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,10 +9,13 @@ import { FolderBar } from "@/components/FolderBar";
|
|||
import { HomeworkCard } from "@/components/HomeworkCard";
|
||||
import { FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
import { Button, EmptyState } from "@/components/ui";
|
||||
import { Button, EmptyState, ProgressBar } from "@/components/ui";
|
||||
import { folderStore, renameFolder } from "@/lib/folders";
|
||||
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
|
||||
import { learningStore } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { computeStats, dueCount, mistakeCount } from "@/lib/learning";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
|
|
@ -52,6 +55,26 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
useEffect(() => {
|
||||
setNudgeDue(backupNudgeDue(decks.length));
|
||||
}, [decks.length]);
|
||||
|
||||
/* Learning strip: SRS due count, open mistakes, today's goal. */
|
||||
const answers = useSyncExternalStore(
|
||||
learningStore.subscribe,
|
||||
learningStore.getAnswersSnapshot,
|
||||
learningStore.getServerAnswers,
|
||||
);
|
||||
const srs = useSyncExternalStore(
|
||||
learningStore.subscribe,
|
||||
learningStore.getSrsSnapshot,
|
||||
learningStore.getServerSrs,
|
||||
);
|
||||
const dailyGoal = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSnapshot().dailyGoal,
|
||||
() => 20,
|
||||
);
|
||||
const due = useMemo(() => dueCount(srs), [srs]);
|
||||
const mistakes = useMemo(() => mistakeCount(answers), [answers]);
|
||||
const todayCount = useMemo(() => computeStats(answers).todayCount, [answers]);
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sorted = useMemo(() => sortDecks(decks), [decks]);
|
||||
|
|
@ -233,6 +256,47 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined}
|
||||
transition={springGentle}
|
||||
>
|
||||
{/* learning: review due, mistakes, exam, daily goal ---------------- */}
|
||||
<section className="card mb-5 space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("review.cardTitle")}
|
||||
</p>
|
||||
<p className="text-[11.5px] font-medium text-[var(--text-muted)]">
|
||||
{t("library.learningToday", { n: todayCount, goal: dailyGoal })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar value={Math.min(todayCount, dailyGoal)} max={dailyGoal} label={t("stats.today")} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/review"
|
||||
aria-disabled={due === 0}
|
||||
className={cn(
|
||||
"touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition",
|
||||
due > 0 ? "btn-primary" : "btn-secondary pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
{t("review.title")} · {plural(due, "review.due")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/review?mistakes=1"
|
||||
aria-disabled={mistakes === 0}
|
||||
className={cn(
|
||||
"touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition",
|
||||
mistakes > 0 ? "btn-secondary" : "btn-secondary pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
{t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/exam"
|
||||
className="btn-ghost touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition"
|
||||
>
|
||||
{t("exam.title")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* gentle backup nudge — dismissible, never blocks ---------------- */}
|
||||
{nudgeDue ? (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3.5 py-3">
|
||||
|
|
|
|||
117
components/ReviewClient.tsx
Normal file
117
components/ReviewClient.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, 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 { ensureLearningHydrated, learningStore, recordAnswer } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { dueItems, mistakeItems, type ReviewItem } from "@/lib/learning";
|
||||
|
||||
/**
|
||||
* Review / mistakes session: built from due items (SRS) across every deck, or
|
||||
* from the activities whose latest answer was wrong. Every answer is recorded,
|
||||
* so mistakes clear themselves once answered correctly.
|
||||
*/
|
||||
export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?: string }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const answers = useSyncExternalStore(
|
||||
learningStore.subscribe,
|
||||
learningStore.getAnswersSnapshot,
|
||||
learningStore.getServerAnswers,
|
||||
);
|
||||
const srs = useSyncExternalStore(
|
||||
learningStore.subscribe,
|
||||
learningStore.getSrsSnapshot,
|
||||
learningStore.getServerSrs,
|
||||
);
|
||||
const [ready, setReady] = useState(false);
|
||||
/** Frozen at session start: answering must not shrink the live queue. */
|
||||
const [session, setSession] = useState<ReviewItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void ensureLearningHydrated().then(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => (mistakes ? mistakeItems(answers, deckId) : dueItems(srs)),
|
||||
[mistakes, deckId, answers, srs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || session || items.length === 0) return;
|
||||
setSession(items);
|
||||
}, [ready, session, items]);
|
||||
|
||||
const title = mistakes
|
||||
? deckId
|
||||
? t("review.mistakesDeck")
|
||||
: t("review.mistakesTitle")
|
||||
: t("review.title");
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||
<div className="w-full max-w-xl space-y-4" aria-busy="true" aria-label={title}>
|
||||
<div className="skeleton h-24 rounded-3xl" />
|
||||
<div className="skeleton h-12 rounded-2xl" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||
<div className="card max-w-md space-y-3 p-6 text-center">
|
||||
<h1 className="text-xl font-extrabold text-[var(--text)]">{title}</h1>
|
||||
<p className="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
{mistakes ? t("review.noMistakes") : t("review.nothingDue")}
|
||||
</p>
|
||||
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
|
||||
{t("review.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity }));
|
||||
|
||||
return (
|
||||
<SessionFeed
|
||||
title={title}
|
||||
items={sessionItems}
|
||||
testId="review-feed"
|
||||
onExit={() => router.push("/")}
|
||||
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={({ correct, total, score }) => (
|
||||
<div className="card space-y-4 p-5 text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{title}</p>
|
||||
<p className="text-[42px] font-extrabold leading-none text-[var(--text)]">{score}%</p>
|
||||
<p className="text-sm font-medium text-[var(--text-muted)]">
|
||||
{t("results.gotOf", { correct, total, retry: "" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
|
||||
{t("review.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
232
components/SessionFeed.tsx
Normal file
232
components/SessionFeed.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { XIcon } from "@/components/icons";
|
||||
import { ProgressBar } from "@/components/ui";
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useWakeLock } from "@/lib/use-wake-lock";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SessionItem = { deck: Deck; activity: Activity };
|
||||
export type SessionResult = { correct: boolean; userAnswer?: string };
|
||||
export type SessionRenderResults = (state: {
|
||||
results: Record<string, SessionResult>;
|
||||
items: SessionItem[];
|
||||
correct: number;
|
||||
total: number;
|
||||
score: number;
|
||||
}) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* The scroll-snap activity feed shared by review, mistakes and exam sessions.
|
||||
* Same mechanics as the study feed (remount per run, scroll tracking, keyboard
|
||||
* navigation) but with a pluggable results slide and an optional timer.
|
||||
*/
|
||||
export function SessionFeed({
|
||||
title,
|
||||
items,
|
||||
timerSeconds,
|
||||
noHints = false,
|
||||
testId,
|
||||
onExit,
|
||||
onAnswered,
|
||||
renderResults,
|
||||
}: {
|
||||
title: string;
|
||||
items: SessionItem[];
|
||||
timerSeconds?: number;
|
||||
noHints?: boolean;
|
||||
testId?: string;
|
||||
onExit: () => void;
|
||||
onAnswered?: (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => void;
|
||||
renderResults: SessionRenderResults;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
useWakeLock(true);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [results, setResults] = useState<Record<string, SessionResult>>({});
|
||||
const [runId] = useState(0);
|
||||
const [remaining, setRemaining] = useState(timerSeconds ?? 0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const total = items.length;
|
||||
const slideCount = total + 1;
|
||||
|
||||
const goTo = useCallback(
|
||||
(target: number) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clamped = Math.max(0, Math.min(target, slideCount - 1));
|
||||
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
|
||||
},
|
||||
[slideCount],
|
||||
);
|
||||
|
||||
const next = useCallback(() => goTo(index + 1), [goTo, index]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const height = container.clientHeight || 1;
|
||||
const current = Math.round(container.scrollTop / height);
|
||||
setIndex((previous) => (previous === current ? previous : current));
|
||||
});
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
|
||||
event.preventDefault();
|
||||
next();
|
||||
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
goTo(index - 1);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goTo, index, next]);
|
||||
|
||||
// Countdown (exam mode); reaching zero jumps to the results slide.
|
||||
useEffect(() => {
|
||||
if (!timerSeconds) return;
|
||||
const interval = window.setInterval(() => {
|
||||
setRemaining((value) => {
|
||||
if (value <= 1) {
|
||||
window.clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return value - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [timerSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerSeconds && remaining === 0) goTo(slideCount - 1);
|
||||
}, [remaining, timerSeconds, goTo, slideCount]);
|
||||
|
||||
const handleAnswered = useCallback(
|
||||
(item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => {
|
||||
setResults((current) => {
|
||||
if (current[item.activity.id]) return current;
|
||||
return { ...current, [item.activity.id]: { correct, userAnswer: meta?.userAnswer } };
|
||||
});
|
||||
onAnswered?.(item, correct, meta);
|
||||
},
|
||||
[onAnswered],
|
||||
);
|
||||
|
||||
const finished = index >= total;
|
||||
const correct = items.filter((item) => results[item.activity.id]?.correct).length;
|
||||
const score = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 flex flex-col fade-in-mobile">
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
aria-label={t("feed.back")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={Math.min(index + 1, slideCount)}
|
||||
max={slideCount}
|
||||
label={t("feed.activityOf", { n: Math.min(index + 1, total), total })}
|
||||
/>
|
||||
</div>
|
||||
{timerSeconds ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-xl px-2 py-1 text-xs font-extrabold tabular-nums",
|
||||
remaining <= 30 ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||
)}
|
||||
aria-label={t("exam.timeLeft", { time: formatClock(remaining) })}
|
||||
>
|
||||
{formatClock(remaining)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{Math.min(index + 1, total)}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
key={runId}
|
||||
ref={scrollRef}
|
||||
data-testid={testId ?? "session-feed"}
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
|
||||
>
|
||||
{items.map((item, itemIndex) => (
|
||||
<section
|
||||
key={`${item.deck.id}-${item.activity.id}-${runId}`}
|
||||
aria-label={t("feed.activityOf", { n: itemIndex + 1, total })}
|
||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<ActivityRenderer
|
||||
activity={item.activity}
|
||||
active={index === itemIndex}
|
||||
language={item.deck.language}
|
||||
noHints={noHints}
|
||||
onAnswered={(correct, meta) => handleAnswered(item, correct, meta)}
|
||||
onNext={next}
|
||||
isLast={itemIndex === total - 1}
|
||||
nextLabel={itemIndex === total - 1 ? t("review.seeResults") : undefined}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section
|
||||
aria-label={t("review.results")}
|
||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
{finished
|
||||
? renderResults({ results, items, correct, total, score })
|
||||
: (
|
||||
<div className="card space-y-2 p-5 text-center">
|
||||
<p className="text-sm font-semibold text-[var(--text-muted)]">{t("review.results")}</p>
|
||||
<p className="text-[13px] text-[var(--text-subtle)]">{t("review.finishFirst")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatClock(seconds: number): string {
|
||||
const safe = Math.max(0, seconds);
|
||||
const minutes = Math.floor(safe / 60);
|
||||
return `${minutes}:${String(safe % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { modelErrorCopy } from "@/lib/model-errors";
|
|||
import { springSheet } from "@/lib/motion";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { DataSection } from "@/components/DataSection";
|
||||
import { LearningSection } from "@/components/LearningSection";
|
||||
import { SyncSection } from "@/components/SyncSection";
|
||||
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
|
||||
import { useBackClose } from "@/lib/use-back-close";
|
||||
|
|
@ -393,6 +394,7 @@ export function SettingsDialog({
|
|||
</section>
|
||||
|
||||
{/* ------------------------------------------- data & sync */}
|
||||
<LearningSection />
|
||||
<DataSection />
|
||||
<SyncSection />
|
||||
|
||||
|
|
@ -708,6 +710,7 @@ export function SettingsDialog({
|
|||
</section>
|
||||
|
||||
{/* ------------------------------------------- data & sync */}
|
||||
<LearningSection />
|
||||
<DataSection />
|
||||
<SyncSection />
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
FlameIcon,
|
||||
FolderIcon,
|
||||
GearIcon,
|
||||
LayersIcon,
|
||||
|
|
@ -294,6 +296,24 @@ function NavContent({
|
|||
|
||||
{/* footer ---------------------------------------------------------- */}
|
||||
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
|
||||
<Link
|
||||
href="/stats"
|
||||
onClick={onNavigate}
|
||||
title={t("nav.stats")}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<FlameIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.stats")}</span> : null}
|
||||
</Link>
|
||||
<Link
|
||||
href="/exam"
|
||||
onClick={onNavigate}
|
||||
title={t("nav.exam")}
|
||||
className={cn("nav-row", collapsed && "justify-center px-2")}
|
||||
>
|
||||
<CompassIcon className="size-4 shrink-0 opacity-70" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.exam")}</span> : null}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings()}
|
||||
|
|
|
|||
214
components/StatsScreen.tsx
Normal file
214
components/StatsScreen.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
import { ensureLearningHydrated, learningStore } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { computeStats, dayKey } from "@/lib/learning";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Progress: day streak, daily-goal progress, a five-week calendar, accuracy
|
||||
* per subject and per deck, and the weakest topics (at least three answers).
|
||||
*/
|
||||
export function StatsScreen() {
|
||||
const { t, plural, locale } = useI18n();
|
||||
const answers = useSyncExternalStore(
|
||||
learningStore.subscribe,
|
||||
learningStore.getAnswersSnapshot,
|
||||
learningStore.getServerAnswers,
|
||||
);
|
||||
const dailyGoal = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSnapshot().dailyGoal,
|
||||
() => 20,
|
||||
);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void ensureLearningHydrated().then(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => computeStats(answers), [answers]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<div className="skeleton h-24 rounded-3xl" />
|
||||
<div className="skeleton h-40 rounded-3xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.total === 0) {
|
||||
return (
|
||||
<div className="card mx-auto max-w-md space-y-3 p-6 text-center">
|
||||
<p className="text-3xl" aria-hidden>
|
||||
📈
|
||||
</p>
|
||||
<h1 className="text-xl font-extrabold text-[var(--text)]">{t("stats.empty")}</h1>
|
||||
<p className="text-sm leading-relaxed text-[var(--text-muted)]">{t("stats.emptyBody")}</p>
|
||||
<Button className="touch-target" onClick={() => window.history.back()}>
|
||||
{t("stats.start")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calendar: five weeks, columns aligned to weekdays (Monday first).
|
||||
const today = new Date();
|
||||
const weekdayIndex = (today.getDay() + 6) % 7;
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - weekdayIndex - 28);
|
||||
const byDay = new Map(stats.days.map((entry) => [entry.day, entry]));
|
||||
const cells = Array.from({ length: 35 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
const key = dayKey(date.getTime());
|
||||
return { key, date, future: date.getTime() > today.getTime() + 86_400_000, entry: byDay.get(key) };
|
||||
});
|
||||
const weekdayLabels = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(date);
|
||||
});
|
||||
const intensity = (count: number) =>
|
||||
count === 0
|
||||
? "bg-[var(--surface-sunken)]"
|
||||
: count <= 2
|
||||
? "bg-sky-200/80 dark:bg-sky-400/20"
|
||||
: count <= 5
|
||||
? "bg-sky-400/70 dark:bg-sky-400/40"
|
||||
: "grad-ice";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-1.5">
|
||||
<h1 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
||||
{t("stats.title")}
|
||||
</h1>
|
||||
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("stats.subtitle")}</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="card space-y-1 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.streak")}</p>
|
||||
<p className="text-[28px] font-extrabold leading-none text-[var(--text)]">{stats.streak}</p>
|
||||
<p className="text-[11.5px] text-[var(--text-muted)]">{plural(stats.total, "stats.answers")}</p>
|
||||
</div>
|
||||
<div className="card space-y-2 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.today")}</p>
|
||||
<p className="text-[15px] font-bold text-[var(--text)]">
|
||||
{t("stats.todayOf", { n: stats.todayCount, goal: dailyGoal })}
|
||||
</p>
|
||||
<ProgressBar value={Math.min(stats.todayCount, dailyGoal)} max={dailyGoal} label={t("stats.today")} />
|
||||
{stats.todayCount >= dailyGoal ? (
|
||||
<p className="text-[11.5px] font-semibold text-emerald-700 dark:text-emerald-300">{t("stats.goalDone")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="card space-y-1 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.accuracy")}</p>
|
||||
<p className="text-[28px] font-extrabold leading-none text-[var(--text)]">
|
||||
{Math.round(stats.accuracy * 100)}%
|
||||
</p>
|
||||
<p className="text-[11.5px] text-[var(--text-muted)]">
|
||||
{t("stats.correctOf", { correct: stats.correct, total: stats.total })}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card space-y-3 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.calendar")}</p>
|
||||
<p className="flex items-center gap-1.5 text-[10.5px] text-[var(--text-subtle)]">
|
||||
{t("stats.legendLess")}
|
||||
<span className="size-3 rounded-[4px] bg-[var(--surface-sunken)]" />
|
||||
<span className="size-3 rounded-[4px] bg-sky-200/80 dark:bg-sky-400/20" />
|
||||
<span className="size-3 rounded-[4px] bg-sky-400/70 dark:bg-sky-400/40" />
|
||||
<span className="size-3 rounded-[4px] grad-ice" />
|
||||
{t("stats.legendMore")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{weekdayLabels.map((label, index) => (
|
||||
<span key={index} className="text-center text-[10px] font-semibold text-[var(--text-subtle)]">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{cells.map((cell) => (
|
||||
<span
|
||||
key={cell.key}
|
||||
title={`${cell.key}: ${cell.entry?.count ?? 0}`}
|
||||
aria-label={`${cell.key}: ${cell.entry?.count ?? 0}`}
|
||||
className={cn(
|
||||
"aspect-square rounded-[6px]",
|
||||
cell.future ? "opacity-30" : "",
|
||||
intensity(cell.entry?.count ?? 0),
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{stats.subjects.length > 0 ? (
|
||||
<section className="card space-y-2 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.bySubject")}</p>
|
||||
{stats.subjects.map((entry) => (
|
||||
<div key={entry.label} className="flex items-center justify-between gap-3 text-[13px]">
|
||||
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
|
||||
{Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% ·{" "}
|
||||
{t("stats.correctOf", { correct: entry.correct, total: entry.answered })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="card space-y-2 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.byDeck")}</p>
|
||||
{stats.decks.map((entry) => (
|
||||
<div key={entry.label} className="flex items-center justify-between gap-3 text-[13px]">
|
||||
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
|
||||
{Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}% ·{" "}
|
||||
{t("stats.correctOf", { correct: entry.correct, total: entry.answered })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{stats.weakestTopics.length > 0 ? (
|
||||
<section className="card space-y-2 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("stats.weakest")}</p>
|
||||
{stats.weakestTopics.map((entry) => (
|
||||
<div key={entry.label} className="flex items-center justify-between gap-3 text-[13px]">
|
||||
<span className="min-w-0 truncate font-medium text-[var(--text)]">{entry.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--text-muted)]">
|
||||
{Math.round((entry.correct / Math.max(1, entry.answered)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/review"
|
||||
className="inline-flex h-11 items-center justify-center rounded-[14px] btn-secondary px-5 text-[15px] font-medium touch-target"
|
||||
>
|
||||
{t("review.title")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/exam"
|
||||
className="inline-flex h-11 items-center justify-center rounded-[14px] btn-ghost px-5 text-[15px] font-medium touch-target"
|
||||
>
|
||||
{t("exam.title")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { celebrate } from "@/lib/confetti";
|
|||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { materialiseActivities } from "@/lib/deck-ops";
|
||||
import { haptic } from "@/lib/haptics";
|
||||
import { recordAnswer } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity, Deck } from "@/lib/schemas";
|
||||
import { recordAttempt, upsertDeck } from "@/lib/storage";
|
||||
|
|
@ -124,6 +125,15 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
if (current[activity.id]) return current; // first answer counts
|
||||
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
|
||||
});
|
||||
// Every answer feeds the history, the SRS schedule and the mistakes set.
|
||||
recordAnswer({
|
||||
deckId: currentDeck.id,
|
||||
deckTitle: currentDeck.title,
|
||||
activityId: activity.id,
|
||||
type: activity.type,
|
||||
topic: activity.topic,
|
||||
correct,
|
||||
});
|
||||
if (correct) {
|
||||
haptic("success");
|
||||
const nextStreak = streak + 1;
|
||||
|
|
@ -136,7 +146,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
setStreak(0);
|
||||
}
|
||||
},
|
||||
[streak],
|
||||
[currentDeck.id, currentDeck.title, streak],
|
||||
);
|
||||
|
||||
const missed = useMemo(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Hint,
|
||||
PromptCard,
|
||||
QuestionText,
|
||||
useNoHints,
|
||||
type ActivityComponentProps,
|
||||
} from "./shared";
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ type GradeState =
|
|||
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
const { t } = useI18n();
|
||||
const noHints = useNoHints();
|
||||
const [value, setValue] = useState("");
|
||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
|
@ -129,9 +131,11 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
{t("common.retry")}
|
||||
</Button>
|
||||
<ModelErrorActions kind={state.kind} />
|
||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||
{t("short.showAnswer")}
|
||||
</Button>
|
||||
{!noHints ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||
{t("short.showAnswer")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Ordering } from "./Ordering";
|
|||
import { ShortAnswer } from "./ShortAnswer";
|
||||
import { TrueFalse } from "./TrueFalse";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { type ActivityComponentProps } from "./shared";
|
||||
import { NoHintsProvider, type ActivityComponentProps } from "./shared";
|
||||
|
||||
export type { ActivityComponentProps } from "./shared";
|
||||
export { TYPE_META } from "./shared";
|
||||
|
|
@ -18,28 +18,32 @@ export { TYPE_META } from "./shared";
|
|||
* Every activity type is a self-contained React component.
|
||||
*/
|
||||
export function ActivityRenderer(props: ActivityComponentProps) {
|
||||
const { activity } = props;
|
||||
const { activity, noHints } = props;
|
||||
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return <Flashcard {...props} activity={activity} />;
|
||||
case "multiple_choice":
|
||||
return <MultipleChoice {...props} activity={activity} />;
|
||||
case "true_false":
|
||||
return <TrueFalse {...props} activity={activity} />;
|
||||
case "fill_blank":
|
||||
return <FillBlank {...props} activity={activity} />;
|
||||
case "matching":
|
||||
return <Matching {...props} activity={activity} />;
|
||||
case "ordering":
|
||||
return <Ordering {...props} activity={activity} />;
|
||||
case "short_answer":
|
||||
return <ShortAnswer {...props} activity={activity} />;
|
||||
default: {
|
||||
// Exhaustiveness guard: adding a new activity type without a component
|
||||
// becomes a TypeScript error.
|
||||
const never: never = activity;
|
||||
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
|
||||
const rendered = (() => {
|
||||
switch (activity.type) {
|
||||
case "flashcard":
|
||||
return <Flashcard {...props} activity={activity} />;
|
||||
case "multiple_choice":
|
||||
return <MultipleChoice {...props} activity={activity} />;
|
||||
case "true_false":
|
||||
return <TrueFalse {...props} activity={activity} />;
|
||||
case "fill_blank":
|
||||
return <FillBlank {...props} activity={activity} />;
|
||||
case "matching":
|
||||
return <Matching {...props} activity={activity} />;
|
||||
case "ordering":
|
||||
return <Ordering {...props} activity={activity} />;
|
||||
case "short_answer":
|
||||
return <ShortAnswer {...props} activity={activity} />;
|
||||
default: {
|
||||
// Exhaustiveness guard: adding a new activity type without a component
|
||||
// becomes a TypeScript error.
|
||||
const never: never = activity;
|
||||
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return <NoHintsProvider value={Boolean(noHints)}>{rendered}</NoHintsProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
|
|
@ -21,6 +22,8 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
|
|||
isLast: boolean;
|
||||
/** Overrides the feedback button label (the homework guide uses this). */
|
||||
nextLabel?: string;
|
||||
/** Exam mode: hide hint rows and answer-reveal affordances. */
|
||||
noHints?: boolean;
|
||||
};
|
||||
|
||||
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
|
||||
|
|
@ -213,5 +216,22 @@ export function FeedbackPanel({
|
|||
|
||||
/** Small hint row under a question, e.g. "Tap a term, then its definition". */
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
const noHints = useNoHints();
|
||||
if (noHints) return null;
|
||||
return <p className="text-sm text-frost-600 dark:text-frost-300">{children}</p>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exam mode sets this through ActivityRenderer's `noHints` prop so the
|
||||
* affordances that give the answer away disappear without touching every
|
||||
* activity component.
|
||||
*/
|
||||
const NoHintsContext = createContext(false);
|
||||
|
||||
export function NoHintsProvider({ value, children }: { value: boolean; children: React.ReactNode }) {
|
||||
return <NoHintsContext.Provider value={value}>{children}</NoHintsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoHints(): boolean {
|
||||
return useContext(NoHintsContext);
|
||||
}
|
||||
|
|
|
|||
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