Smart review: name the source deck on every card, break the counts down

"It contains mistakes I either did more than once or from decks that I've
removed - there's no way there's 34 to repeat with 2 decks." The counts were
honest (dueItems/mistakeItems only resolve live deck+activity pairs; a seeded
repro with 37 SRS rows and 30 live shows "30 due"), but nothing in the UI said
where an item came from: a deck created twice from the same notes shows every
question twice, and a deck that survived a delete or a sync looks like a ghost.
Both read as a broken list.

- SessionFeed gains showDeck (review + mistakes): a deck chip above every
  card, so two identical questions are visibly two decks.
- The Smart review card breaks its numbers down under the buttons:
  "Due by deck: Sky deck - 17 - Sky deck (2) - 17", capped at three decks
  plus "+n more" (groupByDeck in lib/learning).
- New i18n keys (en + cs, plural forms for "+n more").

review-test pins it: a twin-deck seed renders the breakdown and both deck
chips in the feed. Version 0.6.8, SW v90.
This commit is contained in:
Martin Dvořák 2026-10-15 19:27:04 +00:00
commit 1e8606a95b
9 changed files with 129 additions and 8 deletions

View file

@ -1492,6 +1492,19 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged. scene-heat rework unchanged.
99. **The smart review names its decks.** "It contains mistakes I either did more than once or
from decks that I've removed — there's no way there's 34 to repeat with 2 decks." The count
itself was honest: `dueItems`/`mistakeItems` only resolve live (deck, activity) pairs, so a
removed deck can never contribute — verified with a seeded repro (37 SRS rows, 30 live → "30
due"). What was missing was *where* each item comes from: a deck created twice from the same
notes shows every question twice, and a deck that survived a delete (or a sync) looks like a
ghost — with no deck name anywhere in the session, both read as a broken list. Now the session
carries a deck chip above every card (review and mistakes; per-deck mistakes sessions keep
their titled header instead), and the Smart review card breaks the counts down under the
buttons — "Due by deck: Sky deck · 17 · Sky deck (2) · 17", capped at three decks plus
"+n more" — so 34 over two visible decks is self-explanatory at a glance. The review suite
pins both: a twin-deck seed renders the breakdown and both deck chips in the feed.
97. **Nested tiles inside the real-blur chrome drop the baked backdrop.** "Frutiger aero theme — 97. **Nested tiles inside the real-blur chrome drop the baked backdrop.** "Frutiger aero theme —
the attachment button looks completely different to the other ones." The composer band, the the attachment button looks completely different to the other ones." The composer band, the
chat header and the study pager keep a real `backdrop-filter` (they float over scrolling chat header and the study pager keep a real `backdrop-filter` (they float over scrolling

View file

@ -16,7 +16,7 @@ import { createFolder, folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework"; import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { learningStore } from "@/lib/history"; import { learningStore } from "@/lib/history";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { dueItems, mistakeItems, todayAnswerCount } from "@/lib/learning"; import { dueItems, groupByDeck, mistakeItems, todayAnswerCount, type ReviewItem } from "@/lib/learning";
import { isReported, reportStore } from "@/lib/question-reports"; import { isReported, reportStore } from "@/lib/question-reports";
import { settingsStore } from "@/lib/settings"; import { settingsStore } from "@/lib/settings";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
@ -120,20 +120,22 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
deleting a deck or reporting a question must drop its due schedules and deleting a deck or reporting a question must drop its due schedules and
its mistakes right away, matching ReviewClient. */ its mistakes right away, matching ReviewClient. */
const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]); const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]);
const due = useMemo( const dueItemsList = useMemo(
() => { () => {
void reports; void reports;
return dueItems(srs).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id)).length; return dueItems(srs).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id));
}, },
[srs, deckIds, reports], [srs, deckIds, reports],
); );
const mistakes = useMemo( const mistakeItemsList = useMemo(
() => { () => {
void reports; void reports;
return mistakeItems(answers).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id)).length; return mistakeItems(answers).filter((item) => deckIds.has(item.deck.id) && !isReported(item.deck.id, item.activity.id));
}, },
[answers, deckIds, reports], [answers, deckIds, reports],
); );
const due = dueItemsList.length;
const mistakes = mistakeItemsList.length;
const todayCount = useMemo(() => todayAnswerCount(answers), [answers]); const todayCount = useMemo(() => todayAnswerCount(answers), [answers]);
const importRef = useRef<HTMLInputElement>(null); const importRef = useRef<HTMLInputElement>(null);
@ -370,6 +372,19 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<LinkLoadingScreen messageKey="review.preparing" /> <LinkLoadingScreen messageKey="review.preparing" />
</Link> </Link>
</div> </div>
{/* Where the numbers come from. "34 due" over a two-deck library read
as duplicated or ghost items; naming the decks (and how many each
contributes) makes the count verifiable at a glance. */}
{due > 0 ? (
<p className="text-[11.5px] leading-snug text-[var(--text-muted)]">
{t("review.dueByDeck", { list: deckBreakdown(dueItemsList, t) })}
</p>
) : null}
{mistakes > 0 ? (
<p className="text-[11.5px] leading-snug text-[var(--text-muted)]">
{t("review.mistakesByDeck", { list: deckBreakdown(mistakeItemsList, t) })}
</p>
) : null}
</section> </section>
{/* gentle backup nudge — dismissible, never blocks ---------------- */} {/* gentle backup nudge — dismissible, never blocks ---------------- */}
@ -664,3 +679,15 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</div> </div>
); );
} }
/** "Sky deck · 17 · Biology · 14 (+2 more)" — capped so the line stays short. */
function deckBreakdown(
items: ReviewItem[],
t: (key: string, vars?: Record<string, string | number>) => string,
): string {
const groups = groupByDeck(items);
const shown = groups.slice(0, 3).map((group) => `${group.label} · ${group.count}`);
const rest = groups.length - shown.length;
if (rest > 0) shown.push(t("review.moreDecks", { n: rest }));
return shown.join(" · ");
}

View file

@ -86,6 +86,7 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
<SessionFeed <SessionFeed
title={title} title={title}
items={sessionItems} items={sessionItems}
showDeck={!deckId}
testId="review-feed" testId="review-feed"
onExit={() => router.push("/")} onExit={() => router.push("/")}
onAnswered={(item, correct) => { onAnswered={(item, correct) => {

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities"; import { ActivityRenderer } from "@/components/activities";
import { AiDisclaimer } from "@/components/AiDisclaimer"; import { AiDisclaimer } from "@/components/AiDisclaimer";
import { XIcon } from "@/components/icons"; import { LayersIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton"; import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { ProgressBar } from "@/components/ui"; import { ProgressBar } from "@/components/ui";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
@ -32,6 +32,7 @@ export function SessionFeed({
items, items,
timerSeconds, timerSeconds,
noHints = false, noHints = false,
showDeck = false,
testId, testId,
onExit, onExit,
onAnswered, onAnswered,
@ -41,6 +42,9 @@ export function SessionFeed({
items: SessionItem[]; items: SessionItem[];
timerSeconds?: number; timerSeconds?: number;
noHints?: boolean; noHints?: boolean;
/** Review/mistakes: show which deck each card comes from. Two decks with
* the same question otherwise read as one item repeating itself. */
showDeck?: boolean;
testId?: string; testId?: string;
onExit: () => void; onExit: () => void;
onAnswered?: (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => void; onAnswered?: (item: SessionItem, correct: boolean, meta?: { userAnswer?: string }) => void;
@ -203,6 +207,14 @@ export function SessionFeed({
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain" 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] [&>*]:shrink-0"> <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] [&>*]:shrink-0">
{showDeck ? (
<div className="flex flex-wrap items-center gap-2">
<span className="glass-chip inline-flex max-w-full items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold text-frost-600 dark:text-frost-300">
<LayersIcon className="size-3.5 shrink-0" />
<span className="truncate">{item.deck.title}</span>
</span>
</div>
) : null}
<ActivityRenderer <ActivityRenderer
activity={item.activity} activity={item.activity}
active={index === itemIndex} active={index === itemIndex}

View file

@ -845,6 +845,11 @@ const en: Record<string, string> = {
"review.due.one": "{n} due", "review.due.one": "{n} due",
"review.due.few": "{n} due", "review.due.few": "{n} due",
"review.due.many": "{n} due", "review.due.many": "{n} due",
"review.dueByDeck": "Due by deck: {list}",
"review.mistakesByDeck": "Mistakes by deck: {list}",
"review.moreDecks.one": "+{n} more deck",
"review.moreDecks.few": "+{n} more decks",
"review.moreDecks.many": "+{n} more decks",
"review.mistakes.one": "{n} mistake", "review.mistakes.one": "{n} mistake",
"review.mistakes.few": "{n} mistakes", "review.mistakes.few": "{n} mistakes",
"review.mistakes.many": "{n} mistakes", "review.mistakes.many": "{n} mistakes",
@ -1846,6 +1851,11 @@ const cs: Record<string, string> = {
"review.due.one": "{n} k opakování", "review.due.one": "{n} k opakování",
"review.due.few": "{n} k opakování", "review.due.few": "{n} k opakování",
"review.due.many": "{n} k opakování", "review.due.many": "{n} k opakování",
"review.dueByDeck": "K opakování podle sady: {list}",
"review.mistakesByDeck": "Chyby podle sady: {list}",
"review.moreDecks.one": "+{n} další sada",
"review.moreDecks.few": "+{n} další sady",
"review.moreDecks.many": "+{n} dalších sad",
"review.mistakes.one": "{n} chyba", "review.mistakes.one": "{n} chyba",
"review.mistakes.few": "{n} chyby", "review.mistakes.few": "{n} chyby",
"review.mistakes.many": "{n} chyb", "review.mistakes.many": "{n} chyb",

View file

@ -71,6 +71,23 @@ export function mistakeCount(answers: AnswerRecord[], deckId?: string): number {
return mistakeItems(answers, deckId).length; return mistakeItems(answers, deckId).length;
} }
/**
* Where a review list comes from: one entry per deck, most items first. The
* Smart review card shows this under its buttons so a count that mixes decks
* ("34 due" with two decks on screen) is self-explanatory instead of looking
* like the same mistake twice or an item from a deck that was deleted.
*/
export function groupByDeck(items: ReviewItem[]): { label: string; count: number }[] {
const groups = new Map<string, { label: string; count: number }>();
for (const item of items) {
const key = item.deck.id;
const group = groups.get(key) ?? { label: item.deck.title, count: 0 };
group.count += 1;
groups.set(key, group);
}
return [...groups.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Stats */ /* Stats */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.6.7", "version": "0.6.8",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -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-v89"; const CACHE_VERSION = "freezmo-v90";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -167,6 +167,47 @@ async function makeReviewPage(locale = "en") {
await context.close(); await context.close();
} }
/* 4b. the review names its source decks ------------------------------ */
{
/* "It contains mistakes I either did more than once or from decks that I've
removed" — the count was honest (only live deck+activity pairs), but the
session never said WHERE an item came from, so a deck created twice from
the same notes read as one question repeating itself. Every card now
names its deck, and the Smart review card breaks the count down. */
const { context, page } = await makePage(browser);
const twin = { ...DECK, id: "seed_twin", title: "Twin deck" };
await seed(page, { decks: [DECK, twin] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await seedLearning(page, {
srs: [dueSrs("seed_deck", "a1"), dueSrs("seed_deck", "a2"), dueSrs("seed_twin", "a1"), dueSrs("seed_twin", "a2")],
});
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(900);
const breakdown = await page.evaluate(
() => document.body.innerText.split("\n").find((line) => line.includes("Due by deck")) ?? "",
);
check(
"review: the card breaks the count down by deck",
breakdown === "Due by deck: Sky deck · 2 · Twin deck · 2",
breakdown,
);
await page.getByText(/Review due · 4 due/).click();
await page.waitForTimeout(1500);
const chips = await page.evaluate(() => {
const feed = document.querySelector("[data-testid='review-feed']");
return [...(feed?.querySelectorAll(".glass-chip") ?? [])].map((el) => el.textContent?.trim());
});
check(
"review: every card names its deck",
chips.includes("Sky deck") && chips.includes("Twin deck"),
JSON.stringify(chips.slice(0, 8)),
);
check("review: the twin's question is distinguishable", (await page.getByText("Twin deck").count()) >= 1);
await context.close();
}
/* 5. health probes are deduplicated ---------------------------------- */ /* 5. health probes are deduplicated ---------------------------------- */
{ {
const { context, page } = await makePage(browser); const { context, page } = await makePage(browser);