diff --git a/README.md b/README.md index e1331c2..2a27916 100644 --- a/README.md +++ b/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 diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 4f134a7..a935926 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -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(null); @@ -370,6 +372,19 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | + {/* 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 ? ( +

+ {t("review.dueByDeck", { list: deckBreakdown(dueItemsList, t) })} +

+ ) : null} + {mistakes > 0 ? ( +

+ {t("review.mistakesByDeck", { list: deckBreakdown(mistakeItemsList, t) })} +

+ ) : null} {/* gentle backup nudge — dismissible, never blocks ---------------- */} @@ -664,3 +679,15 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | ); } + +/** "Sky deck · 17 · Biology · 14 (+2 more)" — capped so the line stays short. */ +function deckBreakdown( + items: ReviewItem[], + t: (key: string, vars?: Record) => 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(" · "); +} diff --git a/components/ReviewClient.tsx b/components/ReviewClient.tsx index dfb4598..c1c3ec9 100644 --- a/components/ReviewClient.tsx +++ b/components/ReviewClient.tsx @@ -86,6 +86,7 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?: router.push("/")} onAnswered={(item, correct) => { diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index 8f5dd1f..ea66095 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -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" >
+ {showDeck ? ( +
+ + + {item.deck.title} + +
+ ) : null} = { "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 = { "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", diff --git a/lib/learning.ts b/lib/learning.ts index a158113..21995d3 100644 --- a/lib/learning.ts +++ b/lib/learning.ts @@ -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(); + 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 */ /* ------------------------------------------------------------------ */ diff --git a/package.json b/package.json index 9ec5f31..7cbade6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.6.7", + "version": "0.6.8", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 3ba3a8f..36621e1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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`; diff --git a/tests/review-test.mjs b/tests/review-test.mjs index 1f1dd12..3c63728 100644 --- a/tests/review-test.mjs +++ b/tests/review-test.mjs @@ -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);