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

@ -16,7 +16,7 @@ import { createFolder, folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { learningStore } from "@/lib/history";
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 { settingsStore } from "@/lib/settings";
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
its mistakes right away, matching ReviewClient. */
const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]);
const due = useMemo(
const dueItemsList = useMemo(
() => {
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],
);
const mistakes = useMemo(
const mistakeItemsList = useMemo(
() => {
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],
);
const due = dueItemsList.length;
const mistakes = mistakeItemsList.length;
const todayCount = useMemo(() => todayAnswerCount(answers), [answers]);
const importRef = useRef<HTMLInputElement>(null);
@ -370,6 +372,19 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<LinkLoadingScreen messageKey="review.preparing" />
</Link>
</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>
{/* gentle backup nudge — dismissible, never blocks ---------------- */}
@ -664,3 +679,15 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</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
title={title}
items={sessionItems}
showDeck={!deckId}
testId="review-feed"
onExit={() => router.push("/")}
onAnswered={(item, correct) => {

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityRenderer } from "@/components/activities";
import { AiDisclaimer } from "@/components/AiDisclaimer";
import { XIcon } from "@/components/icons";
import { LayersIcon, XIcon } from "@/components/icons";
import { ReportQuestionButton } from "@/components/ReportQuestionButton";
import { ProgressBar } from "@/components/ui";
import type { Activity, Deck } from "@/lib/schemas";
@ -32,6 +32,7 @@ export function SessionFeed({
items,
timerSeconds,
noHints = false,
showDeck = false,
testId,
onExit,
onAnswered,
@ -41,6 +42,9 @@ export function SessionFeed({
items: SessionItem[];
timerSeconds?: number;
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;
onExit: () => 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"
>
<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
activity={item.activity}
active={index === itemIndex}