freezmo/components/exam/ExamScreen.tsx
Martin Dvorak 9cea13496a Report question: skip a broken card and tell the server
A generated matching card arrived on a phone with three identical
definitions (the generation guards now drop those), but there was no way
to get past a bad card from inside a session. The feed chrome (deck
study, review, mistakes and exam) now has the red "!" control: one tap
records the question, skips to the next card and shows an Undo toast.

- lib/question-reports.ts: the local list (freezmo.reports.v1, zod on
  read, capped 200) is the skip state — reported cards leave every
  session type, count as "not attempted" in the results instead of
  wrong, and Undo takes a report back. A deck where everything is
  reported stays studiable so it can be regenerated.
- POST /api/reports appends one JSON line per report to
  REPORTS_DIR/reports.jsonl (shared/freezmo/reports in production, the
  dev data dir in dev). Deliberately write-only: the question text is
  readable on the server (that is the point — prompt tuning) but never
  over HTTP, since the app has no auth. Best effort: offline reports stay
  local.
- The button: a red outlined rounded square with "!", the chrome's
  control size, in both feed headers (hidden on the results slide).
- config: REPORTS_DIR + RATE_LIMIT_REPORTS (20); the deploy creates the
  shared reports dir; the dev unit and AGENTS.md note the new server
  data location.
- Suites: phase-1 (append + validation + no GET), phase-2 (button, toast,
  server POST, local copy, skip counter, hidden next session, Undo, exam
  pool). SW -> freezmo-v38.
2026-10-12 07:12:55 +00:00

257 lines
10 KiB
TypeScript

"use client";
import { useMemo, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { SessionFeed, type SessionItem } from "@/components/SessionFeed";
import { isReported } from "@/lib/question-reports";
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 })))
// Reported questions are out of rotation (see lib/question-reports).
.filter(({ deck, activity }) => !isReported(deck.id, activity.id)),
[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>
);
}