Skipping a card is not a mistake

Scrolling through a deck without answering recorded a 0% attempt and listed
every card under "Missed items" with its answer revealed. Only answered cards
count now: the score is correct/answered, the attempt stores the answered
count, an empty run records nothing, and the cards scrolled past wait under
"Not answered (n)" - listed as prompts with "not counted as mistakes" - and
"Answer the rest (n)" starts a catch-up run of just those cards (same restart
path as retry-missed, so history and the SRS still see the answers). "Aced
all" requires nothing left waiting too.

phase2 pins the flow: all-skipped records no attempt and no mistake list;
one-of-two reads "You got 1 of 1 right" + "Not answered (1)" with attempts:1,
bestScore:100; the catch-up run records the remaining answer. Exam mode keeps
its own rule: in a timed exam an unanswered question is a miss.

Version 0.6.5, SW v87.
This commit is contained in:
Martin Dvořák 2026-10-15 13:59:55 +00:00
commit 98537cee64
7 changed files with 161 additions and 20 deletions

View file

@ -9,7 +9,16 @@ import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
import { BoltIcon, DownloadIcon, FlameIcon, HomeIcon, RefreshIcon, SnowflakeIcon, SparklesIcon } from "@/components/icons";
import {
BoltIcon,
DownloadIcon,
FlameIcon,
HistoryIcon,
HomeIcon,
RefreshIcon,
SnowflakeIcon,
SparklesIcon,
} from "@/components/icons";
import { Button, Chip } from "@/components/ui";
import { AskButton } from "@/components/chat/AskButton";
@ -62,9 +71,11 @@ export function ResultsScreen({
total,
correct,
missed,
unanswered,
results,
retryMode,
onRetryMissed,
onStudyUnanswered,
onRestart,
onRegenerate,
regenerating,
@ -73,12 +84,17 @@ export function ResultsScreen({
deck: Deck;
xp: number;
bestStreak: number;
/** Cards that were answered (right or wrong). */
total: number;
correct: number;
/** Answered, but wrong. */
missed: Activity[];
/** Scrolled past without answering — waiting, not wrong. */
unanswered: Activity[];
results: Record<string, AttemptResult>;
retryMode: boolean;
onRetryMissed: () => void;
onStudyUnanswered: () => void;
onRestart: () => void;
onRegenerate: () => void;
regenerating: boolean;
@ -90,7 +106,7 @@ export function ResultsScreen({
/* Phones: the score and XP count up; desktop jumps straight in. */
const percentShown = useCountUp(percent, !isPhone);
const xpShown = useCountUp(xp, !isPhone);
const perfect = missed.length === 0 && total > 0;
const perfect = missed.length === 0 && unanswered.length === 0 && total > 0;
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
return (
@ -107,11 +123,13 @@ export function ResultsScreen({
<p className="text-sm text-frost-600 dark:text-frost-300">
{perfect
? t("results.acedAll", { n: total, title: deck.title })
: t("results.gotOf", {
correct,
total,
retry: retryMode ? t("results.retrySuffix") : "",
})}
: total === 0
? t("results.noneAnswered")
: t("results.gotOf", {
correct,
total,
retry: retryMode ? t("results.retrySuffix") : "",
})}
</p>
<div className="mt-1 flex flex-wrap justify-center gap-2">
@ -164,21 +182,45 @@ export function ResultsScreen({
</div>
) : null}
{unanswered.length > 0 ? (
<div className="card space-y-3 p-5">
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
<HistoryIcon className="size-4" /> {t("results.unanswered", { n: unanswered.length })}
</h2>
<p className="text-xs leading-relaxed text-frost-600 dark:text-frost-300">{t("results.unansweredNote")}</p>
<ul className="space-y-2">
{unanswered.map((activity) => (
<li
key={activity.id}
className="rounded-2xl border border-white/50 bg-white/45 p-3 text-sm font-semibold text-frost-950 backdrop-blur-xl dark:border-white/10 dark:bg-white/5 dark:text-frost-50"
>
{promptFor(activity)}
</li>
))}
</ul>
</div>
) : null}
<div className="flex flex-col gap-2">
{missed.length > 0 ? (
<Button size="lg" onClick={onRetryMissed}>
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
</Button>
) : (
) : null}
{unanswered.length > 0 ? (
<Button size="lg" variant={missed.length > 0 ? "secondary" : "primary"} onClick={onStudyUnanswered}>
<RefreshIcon className="size-5" /> {t("results.retryUnanswered", { n: unanswered.length })}
</Button>
) : null}
{missed.length === 0 && unanswered.length === 0 ? (
<Button size="lg" variant="secondary" onClick={onRestart}>
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
</Button>
)}
{missed.length > 0 ? (
) : (
<Button size="lg" variant="secondary" onClick={onRestart}>
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
</Button>
) : null}
)}
<Button
size="lg"
variant="secondary"

View file

@ -120,13 +120,14 @@ export function StudyFeed({ deck }: { deck: Deck }) {
if (!finished) return;
if (!retryMode && !recordedRef.current) {
recordedRef.current = true;
const answered = Object.values(results);
const correct = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length;
const attempted = Math.max(0, total - skipped.length);
const answered = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]);
const correct = answered.filter((activity) => results[activity.id]?.correct).length;
const attempted = answered.length;
const score = attempted > 0 ? (correct / attempted) * 100 : 0;
// Only a run with at least one answer counts. Without this, the scroll
// anchoring shift when a regenerated deck swaps in could fire a bogus
// 0% attempt before the feed had snapped back to the first slide.
// 0% attempt before the feed had snapped back to the first slide — and
// swiping through a deck without answering is a pass, not a zero.
if (answered.length > 0) {
recordAttempt(currentDeck.id, { scorePercent: score, correct, total: attempted });
setCurrentDeck((prev) => ({
@ -212,13 +213,24 @@ export function StudyFeed({ deck }: { deck: Deck }) {
goTo(index + 1);
}, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
/** Answered, but landed wrong. */
const missed = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]?.correct),
() =>
queue.filter(
(activity) => !skipped.includes(activity.id) && results[activity.id] && !results[activity.id]?.correct,
),
[queue, results, skipped],
);
/** What the results screen counts: reported cards were never attempted. */
const attemptedTotal = Math.max(0, total - skipped.length);
/** Scrolled past without answering — waiting, not wrong. */
const unanswered = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && !results[activity.id]),
[queue, results, skipped],
);
/** What the results screen counts: only cards that were answered (reported
* cards were never attempted, skipped ones are not mistakes). */
const attemptedTotal = total - skipped.length - unanswered.length;
const correctCount = useMemo(
() => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length,
@ -454,9 +466,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
total={attemptedTotal}
correct={correctCount}
missed={missed}
unanswered={unanswered}
results={results}
retryMode={retryMode}
onRetryMissed={() => restart(missed, true)}
onStudyUnanswered={() => restart(unanswered, true)}
onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
onRegenerate={regenerate}
regenerating={regenerating}