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:
parent
2a956a1cd4
commit
1e8606a95b
9 changed files with 129 additions and 8 deletions
13
README.md
13
README.md
|
|
@ -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
|
||||
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 —
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(" · ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -845,6 +845,11 @@ const en: Record<string, string> = {
|
|||
"review.due.one": "{n} due",
|
||||
"review.due.few": "{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.few": "{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.few": "{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.few": "{n} chyby",
|
||||
"review.mistakes.many": "{n} chyb",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,23 @@ export function mistakeCount(answers: AnswerRecord[], deckId?: string): number {
|
|||
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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.6.7",
|
||||
"version": "0.6.8",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* 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 ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,47 @@ async function makeReviewPage(locale = "en") {
|
|||
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 ---------------------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue