diff --git a/docs/TODO.md b/docs/TODO.md index ab32fa3..cd571d7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -340,7 +340,8 @@ exam-scoped disciplines. ## From the 11 Sep recordings — still open - **Quiz player, "let it not be showing when you're taking the quiz"** — the recording does not say what "it" is. Most likely reading: in exam mode the right-hand question rail must not reveal right/wrong as you go (study mode may). Confirm before changing. -- **Study plan overview cards** — AMBOSS shows per-block progress bars ("0/6 articles · 0/40 questions"); ours shows a status word. Polish once the block page has been used. +- [x] **Study plan block counts** — done. "1/1 article · 50/50 questions" beside + each block, counts rather than a percentage, with the state alongside. - [x] **ArticleSplitView flake** — fixed. It hovered a link, which starts a 350ms timer before the preview card appears; it now focuses instead, which reveals at once. The failures were it losing CPU to the backend suite. diff --git a/frontend/src/pages/StudyPlanPage.jsx b/frontend/src/pages/StudyPlanPage.jsx index 83680ec..76d3708 100644 --- a/frontend/src/pages/StudyPlanPage.jsx +++ b/frontend/src/pages/StudyPlanPage.jsx @@ -22,6 +22,9 @@ const apiError = (err, fallback) => { * thing you are changing and the thing a learner sees are then the same object, * and there is no second layout to keep in step. */ +/** How much of a block's reading the learner has ticked off. */ +const readCount = (block) => (block.articles || []).filter(a => a.read).length + export default function StudyPlanPage() { const { user } = useAuth() const { id } = useParams() @@ -290,13 +293,24 @@ export default function StudyPlanPage() { {/* The session itself — mode, progress, start / resume / analysis — lives on the block's own page. This is the plan's table of contents, and a table of contents does not start things. */} + {/* What is in the block and how much of it is done — counts, not + a percentage, because "0/6 articles" is something you can act + on and "0%" only tells you how to feel about it. */}
-

Sessions

+

Progress

- - {block.question_count === 0 ? 'No questions in this block yet.' - : block.completed ? 'Completed' - : block.quiz_id ? 'In progress' : 'Not started'} + + 0 ? 'is-done' : undefined}> + {readCount(block)}/{block.articles.length} article{block.articles.length === 1 ? '' : 's'} + + + {block.completed ? block.question_count : 0}/{block.question_count} question{block.question_count === 1 ? '' : 's'} + + + {block.question_count === 0 ? 'No questions yet' + : block.completed ? 'Completed' + : block.quiz_id ? 'In progress' : 'Not started'} + Open {block.title} diff --git a/frontend/src/pages/StudyPlanPage.test.jsx b/frontend/src/pages/StudyPlanPage.test.jsx index 5f0e719..04b3062 100644 --- a/frontend/src/pages/StudyPlanPage.test.jsx +++ b/frontend/src/pages/StudyPlanPage.test.jsx @@ -62,7 +62,7 @@ describe('study plans', () => { mountPlan() const block = (await screen.findByText('Block 2')).closest('.block') const headings = [...block.querySelectorAll('h3')].map(h => h.textContent) - expect(headings).toEqual(['Articles', 'Sessions']) + expect(headings).toEqual(['Articles', 'Progress']) }) it('marks reading as read, and lets that be taken back', async () => { @@ -89,8 +89,15 @@ describe('study plans', () => { expect(within(started).getByRole('link', { name: 'Open Block 1' })).toHaveAttribute('href', '/study-plans/1/blocks/10') expect(within(started).getByText('Completed')).toBeInTheDocument() + // Counts against what is in the block, so "what is left" is readable + // without opening it. Block 1: one article, read; its questions done. + expect(within(started).getByText('1/1 article')).toBeInTheDocument() + expect(within(started).getByText('50/50 questions')).toBeInTheDocument() + const fresh = screen.getByText('Block 2').closest('.block') expect(within(fresh).getByText('Not started')).toBeInTheDocument() + expect(within(fresh).getByText('0/1 article')).toBeInTheDocument() + expect(within(fresh).getByText('0/50 questions')).toBeInTheDocument() expect(within(fresh).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument() expect(api.post).not.toHaveBeenCalled() }) @@ -98,7 +105,8 @@ describe('study plans', () => { it('says a block is empty rather than offering a test with nothing in it', async () => { mountPlan() const empty = (await screen.findByText('Block 3')).closest('.block') - expect(within(empty).getByText('No questions in this block yet.')).toBeInTheDocument() + expect(within(empty).getByText('No questions yet')).toBeInTheDocument() + expect(within(empty).getByText('0/0 questions')).toBeInTheDocument() expect(within(empty).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument() }) diff --git a/frontend/src/pages/StudyPlansPage.css b/frontend/src/pages/StudyPlansPage.css index d649723..c89edb6 100644 --- a/frontend/src/pages/StudyPlansPage.css +++ b/frontend/src/pages/StudyPlansPage.css @@ -86,3 +86,8 @@ .block-head h2 a { color: var(--text); text-decoration: none; } .block-head h2 a:hover { color: var(--primary); } .block-open { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } + +/* Counts, not a percentage: "0/6 articles" is actionable. */ +.block-counts { display: flex; gap: 14px; flex-wrap: wrap; align-items: baseline; font-size: 0.8rem; color: var(--text-muted); } +.block-counts .is-done { color: var(--correct-fg); font-weight: 650; } +.block-state { font-size: 0.74rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); }