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:
parent
855ce82836
commit
98537cee64
7 changed files with 161 additions and 20 deletions
14
README.md
14
README.md
|
|
@ -1434,6 +1434,20 @@ Things I chose while building, with the reasoning:
|
||||||
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
|
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
|
||||||
"cut off next to the sidebar".
|
"cut off next to the sidebar".
|
||||||
|
|
||||||
|
96. **Skipping a card is not a mistake.** "You can just scroll all the way down and it counts
|
||||||
|
everything as incorrect even if you technically just skip it to do it later." The study feed's
|
||||||
|
results treated *every* card without a result as missed: swiping to the end recorded a 0%
|
||||||
|
attempt against the deck and listed all twelve cards under "Missed items" with their answers.
|
||||||
|
Now only answered cards count: the score line is `correct / answered`, the attempt records the
|
||||||
|
answered count, an empty run records nothing at all, and the cards you scrolled past appear
|
||||||
|
under **Not answered (n)** — listed as prompts, labelled "not counted as mistakes", with
|
||||||
|
**Answer the rest (n)** starting a catch-up run of exactly those cards (the same `restart`
|
||||||
|
path as retry-missed, so history and the SRS see the answers as usual). "Aced all" now also
|
||||||
|
requires that nothing was left waiting. Phase 2 pins the whole flow: all-skipped → no attempt,
|
||||||
|
no mistake list, no score line; one-of-two → "You got 1 of 1 right", "Not answered (1)",
|
||||||
|
`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.
|
||||||
|
|
||||||
95. **Aero, second pass: a heavier shared blur, and a field that reads as a field.** Two follow-ups
|
95. **Aero, second pass: a heavier shared blur, and a field that reads as a field.** Two follow-ups
|
||||||
on 94. The composer's input was still a flat 60% white bar (the "clean, even tile" from 91) on
|
on 94. The composer's input was still a flat 60% white bar (the "clean, even tile" from 91) on
|
||||||
a panel that had — rightly — become translucent, so it read as a white slab: "especially the
|
a panel that had — rightly — become translucent, so it read as a white slab: "especially the
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,16 @@ import type { Activity, Deck } from "@/lib/schemas";
|
||||||
import { downloadDeck } from "@/lib/storage";
|
import { downloadDeck } from "@/lib/storage";
|
||||||
import { useIsPhone } from "@/lib/use-phone";
|
import { useIsPhone } from "@/lib/use-phone";
|
||||||
import { cn } from "@/lib/utils";
|
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 { Button, Chip } from "@/components/ui";
|
||||||
import { AskButton } from "@/components/chat/AskButton";
|
import { AskButton } from "@/components/chat/AskButton";
|
||||||
|
|
||||||
|
|
@ -62,9 +71,11 @@ export function ResultsScreen({
|
||||||
total,
|
total,
|
||||||
correct,
|
correct,
|
||||||
missed,
|
missed,
|
||||||
|
unanswered,
|
||||||
results,
|
results,
|
||||||
retryMode,
|
retryMode,
|
||||||
onRetryMissed,
|
onRetryMissed,
|
||||||
|
onStudyUnanswered,
|
||||||
onRestart,
|
onRestart,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
regenerating,
|
regenerating,
|
||||||
|
|
@ -73,12 +84,17 @@ export function ResultsScreen({
|
||||||
deck: Deck;
|
deck: Deck;
|
||||||
xp: number;
|
xp: number;
|
||||||
bestStreak: number;
|
bestStreak: number;
|
||||||
|
/** Cards that were answered (right or wrong). */
|
||||||
total: number;
|
total: number;
|
||||||
correct: number;
|
correct: number;
|
||||||
|
/** Answered, but wrong. */
|
||||||
missed: Activity[];
|
missed: Activity[];
|
||||||
|
/** Scrolled past without answering — waiting, not wrong. */
|
||||||
|
unanswered: Activity[];
|
||||||
results: Record<string, AttemptResult>;
|
results: Record<string, AttemptResult>;
|
||||||
retryMode: boolean;
|
retryMode: boolean;
|
||||||
onRetryMissed: () => void;
|
onRetryMissed: () => void;
|
||||||
|
onStudyUnanswered: () => void;
|
||||||
onRestart: () => void;
|
onRestart: () => void;
|
||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
regenerating: boolean;
|
regenerating: boolean;
|
||||||
|
|
@ -90,7 +106,7 @@ export function ResultsScreen({
|
||||||
/* Phones: the score and XP count up; desktop jumps straight in. */
|
/* Phones: the score and XP count up; desktop jumps straight in. */
|
||||||
const percentShown = useCountUp(percent, !isPhone);
|
const percentShown = useCountUp(percent, !isPhone);
|
||||||
const xpShown = useCountUp(xp, !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";
|
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,6 +123,8 @@ export function ResultsScreen({
|
||||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||||
{perfect
|
{perfect
|
||||||
? t("results.acedAll", { n: total, title: deck.title })
|
? t("results.acedAll", { n: total, title: deck.title })
|
||||||
|
: total === 0
|
||||||
|
? t("results.noneAnswered")
|
||||||
: t("results.gotOf", {
|
: t("results.gotOf", {
|
||||||
correct,
|
correct,
|
||||||
total,
|
total,
|
||||||
|
|
@ -164,21 +182,45 @@ export function ResultsScreen({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="flex flex-col gap-2">
|
||||||
{missed.length > 0 ? (
|
{missed.length > 0 ? (
|
||||||
<Button size="lg" onClick={onRetryMissed}>
|
<Button size="lg" onClick={onRetryMissed}>
|
||||||
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
|
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
|
||||||
</Button>
|
</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}>
|
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||||
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
|
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : (
|
||||||
{missed.length > 0 ? (
|
|
||||||
<Button size="lg" variant="secondary" onClick={onRestart}>
|
<Button size="lg" variant="secondary" onClick={onRestart}>
|
||||||
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
|
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,14 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
if (!finished) return;
|
if (!finished) return;
|
||||||
if (!retryMode && !recordedRef.current) {
|
if (!retryMode && !recordedRef.current) {
|
||||||
recordedRef.current = true;
|
recordedRef.current = true;
|
||||||
const answered = Object.values(results);
|
const answered = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]);
|
||||||
const correct = queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length;
|
const correct = answered.filter((activity) => results[activity.id]?.correct).length;
|
||||||
const attempted = Math.max(0, total - skipped.length);
|
const attempted = answered.length;
|
||||||
const score = attempted > 0 ? (correct / attempted) * 100 : 0;
|
const score = attempted > 0 ? (correct / attempted) * 100 : 0;
|
||||||
// Only a run with at least one answer counts. Without this, the scroll
|
// 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
|
// 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) {
|
if (answered.length > 0) {
|
||||||
recordAttempt(currentDeck.id, { scorePercent: score, correct, total: attempted });
|
recordAttempt(currentDeck.id, { scorePercent: score, correct, total: attempted });
|
||||||
setCurrentDeck((prev) => ({
|
setCurrentDeck((prev) => ({
|
||||||
|
|
@ -212,13 +213,24 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
goTo(index + 1);
|
goTo(index + 1);
|
||||||
}, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
|
}, [timed, remaining, timedActivity, timedAnswered, handleAnswered, goTo, index, t]);
|
||||||
|
|
||||||
|
/** Answered, but landed wrong. */
|
||||||
const missed = useMemo(
|
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],
|
[queue, results, skipped],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** What the results screen counts: reported cards were never attempted. */
|
/** Scrolled past without answering — waiting, not wrong. */
|
||||||
const attemptedTotal = Math.max(0, total - skipped.length);
|
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(
|
const correctCount = useMemo(
|
||||||
() => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length,
|
() => queue.filter((activity) => !skipped.includes(activity.id) && results[activity.id]?.correct).length,
|
||||||
|
|
@ -454,9 +466,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
||||||
total={attemptedTotal}
|
total={attemptedTotal}
|
||||||
correct={correctCount}
|
correct={correctCount}
|
||||||
missed={missed}
|
missed={missed}
|
||||||
|
unanswered={unanswered}
|
||||||
results={results}
|
results={results}
|
||||||
retryMode={retryMode}
|
retryMode={retryMode}
|
||||||
onRetryMissed={() => restart(missed, true)}
|
onRetryMissed={() => restart(missed, true)}
|
||||||
|
onStudyUnanswered={() => restart(unanswered, true)}
|
||||||
onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
|
onRestart={() => restart(withoutReported(currentDeck.id, currentDeck.activities), false)}
|
||||||
onRegenerate={regenerate}
|
onRegenerate={regenerate}
|
||||||
regenerating={regenerating}
|
regenerating={regenerating}
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,10 @@ const en: Record<string, string> = {
|
||||||
"results.difficulty": "{level} difficulty",
|
"results.difficulty": "{level} difficulty",
|
||||||
"results.best": "🏆 Best {n}%",
|
"results.best": "🏆 Best {n}%",
|
||||||
"results.missed": "Missed items ({n})",
|
"results.missed": "Missed items ({n})",
|
||||||
|
"results.unanswered": "Not answered ({n})",
|
||||||
|
"results.unansweredNote": "Skipped cards are not counted as mistakes — they wait for you here.",
|
||||||
|
"results.retryUnanswered": "Answer the rest ({n})",
|
||||||
|
"results.noneAnswered": "Nothing answered this time — pick up where you left off.",
|
||||||
"results.yourAnswer": "Your answer: {answer}",
|
"results.yourAnswer": "Your answer: {answer}",
|
||||||
"results.answer": "Answer:",
|
"results.answer": "Answer:",
|
||||||
"results.retryMissed": "Retry missed ones ({n})",
|
"results.retryMissed": "Retry missed ones ({n})",
|
||||||
|
|
@ -1548,6 +1552,10 @@ const cs: Record<string, string> = {
|
||||||
"results.difficulty": "Obtížnost: {level}",
|
"results.difficulty": "Obtížnost: {level}",
|
||||||
"results.best": "🏆 Nejlepší {n} %",
|
"results.best": "🏆 Nejlepší {n} %",
|
||||||
"results.missed": "Chybné položky ({n})",
|
"results.missed": "Chybné položky ({n})",
|
||||||
|
"results.unanswered": "Nezodpovězené ({n})",
|
||||||
|
"results.unansweredNote": "Přeskočené karty se nepočítají jako chyby — čekají tady na tebe.",
|
||||||
|
"results.retryUnanswered": "Zodpovědět zbytek ({n})",
|
||||||
|
"results.noneAnswered": "Tentokrát nic zodpovězeno — pokračuj tam, kde jsi skončil.",
|
||||||
"results.yourAnswer": "Tvoje odpověď: {answer}",
|
"results.yourAnswer": "Tvoje odpověď: {answer}",
|
||||||
"results.answer": "Odpověď:",
|
"results.answer": "Odpověď:",
|
||||||
"results.retryMissed": "Zopakovat chybné ({n})",
|
"results.retryMissed": "Zopakovat chybné ({n})",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.6.4",
|
"version": "0.6.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v86";
|
const CACHE_VERSION = "freezmo-v87";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,69 @@ const browser = await launchBrowser();
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 1c. skipping a card is not a mistake ------------------------------- */
|
||||||
|
{
|
||||||
|
/* Swiping through a deck without answering used to record a 0% attempt and
|
||||||
|
list every card under "Missed items" — "you can just scroll all the way
|
||||||
|
down and it counts everything as incorrect". Skipped cards now wait under
|
||||||
|
"Not answered", and the score counts the cards that were answered. */
|
||||||
|
const skipped = await makePage(browser);
|
||||||
|
await seed(skipped.page, { decks: [DECK] });
|
||||||
|
await skipped.page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
|
||||||
|
await skipped.page.waitForTimeout(900);
|
||||||
|
await skipped.page.keyboard.press("End");
|
||||||
|
await skipped.page.waitForTimeout(1000);
|
||||||
|
check("skip: the all-skipped run offers the waiting cards", (await skipped.page.getByText("Not answered (2)").count()) === 1);
|
||||||
|
check("skip: nothing is listed as a mistake", (await skipped.page.getByText(/Missed items/).count()) === 0);
|
||||||
|
check(
|
||||||
|
"skip: an empty run gets no score line",
|
||||||
|
(await skipped.page.getByText("Nothing answered this time — pick up where you left off.").count()) === 1,
|
||||||
|
);
|
||||||
|
const skippedDeck = await skipped.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0]);
|
||||||
|
check(
|
||||||
|
"skip: no attempt is recorded for an empty run",
|
||||||
|
(skippedDeck.attempts ?? 0) === 0 && skippedDeck.bestScore === undefined,
|
||||||
|
JSON.stringify({ attempts: skippedDeck.attempts, bestScore: skippedDeck.bestScore }),
|
||||||
|
);
|
||||||
|
await skipped.context.close();
|
||||||
|
|
||||||
|
/* Answer one of two: the score counts the answered card only, and the
|
||||||
|
waiting one can be picked up from the results screen. */
|
||||||
|
const partial = await makePage(browser);
|
||||||
|
await seed(partial.page, { decks: [DECK] });
|
||||||
|
await partial.page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
|
||||||
|
await partial.page.waitForTimeout(900);
|
||||||
|
await partial.page.getByRole("button", { name: "Blue" }).click();
|
||||||
|
await partial.page.waitForTimeout(500);
|
||||||
|
await partial.page.keyboard.press("End");
|
||||||
|
await partial.page.waitForTimeout(1000);
|
||||||
|
check("skip: the score counts answered cards only", (await partial.page.getByText("You got 1 of 1 right.").count()) === 1);
|
||||||
|
check("skip: the unanswered card waits under its own heading", (await partial.page.getByText("Not answered (1)").count()) === 1);
|
||||||
|
check("skip: it is not listed as missed", (await partial.page.getByText(/Missed items/).count()) === 0);
|
||||||
|
const partialDeck = await partial.page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.decks.v1"))[0]);
|
||||||
|
check(
|
||||||
|
"skip: the attempt records the answered card",
|
||||||
|
partialDeck.attempts === 1 && partialDeck.bestScore === 100,
|
||||||
|
JSON.stringify({ attempts: partialDeck.attempts, bestScore: partialDeck.bestScore }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await partial.page.getByRole("button", { name: "Answer the rest (1)" }).click();
|
||||||
|
await partial.page.waitForTimeout(1000);
|
||||||
|
check(
|
||||||
|
"skip: catching up opens on the waiting card",
|
||||||
|
await partial.page.getByRole("region", { name: /Activity 1 of/ }).getByText("What makes the sky blue?").isVisible(),
|
||||||
|
);
|
||||||
|
await partial.page.getByRole("button", { name: "Scattering" }).click();
|
||||||
|
await partial.page.waitForTimeout(600);
|
||||||
|
const caughtUp = await readLearning(partial.page);
|
||||||
|
check(
|
||||||
|
"skip: catching up records the second answer",
|
||||||
|
caughtUp.answers.length === 2 && caughtUp.srs.length === 2,
|
||||||
|
JSON.stringify({ answers: caughtUp.answers.length, srs: caughtUp.srs.length }),
|
||||||
|
);
|
||||||
|
await partial.context.close();
|
||||||
|
}
|
||||||
|
|
||||||
/* 1b. AI grading feedback carries the disclaimer (stubbed model call) - */
|
/* 1b. AI grading feedback carries the disclaimer (stubbed model call) - */
|
||||||
{
|
{
|
||||||
const { context, page } = await makePage(browser);
|
const { context, page } = await makePage(browser);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue