feat: per-block counts on a study plan, not a status word

'1/1 article · 50/50 questions' beside each block, the way a course
module reports itself. Counts rather than a percentage: '0/6 articles'
is something you can act on, '0%' only tells you how to feel about it.
The state stays alongside for the one-word answer.

Frontend 308/308.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 13:34:56 +02:00
parent a9969d8d90
commit 5575fe39bb
4 changed files with 36 additions and 8 deletions

View file

@ -340,7 +340,8 @@ exam-scoped disciplines.
## From the 11 Sep recordings — still open ## 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. - **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 - [x] **ArticleSplitView flake** — fixed. It hovered a link, which starts a
350ms timer before the preview card appears; it now focuses instead, which 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. reveals at once. The failures were it losing CPU to the backend suite.

View file

@ -22,6 +22,9 @@ const apiError = (err, fallback) => {
* thing you are changing and the thing a learner sees are then the same object, * 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. * 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() { export default function StudyPlanPage() {
const { user } = useAuth() const { user } = useAuth()
const { id } = useParams() const { id } = useParams()
@ -290,13 +293,24 @@ export default function StudyPlanPage() {
{/* The session itself — mode, progress, start / resume / analysis {/* The session itself — mode, progress, start / resume / analysis
lives on the block's own page. This is the plan's table of lives on the block's own page. This is the plan's table of
contents, and a table of contents does not start things. */} 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. */}
<div className="block-sessions"> <div className="block-sessions">
<h3>Sessions</h3> <h3>Progress</h3>
<div className="block-open"> <div className="block-open">
<span className="block-meta"> <span className="block-counts">
{block.question_count === 0 ? 'No questions in this block yet.' <span className={readCount(block) === block.articles.length && block.articles.length > 0 ? 'is-done' : undefined}>
: block.completed ? 'Completed' {readCount(block)}/{block.articles.length} article{block.articles.length === 1 ? '' : 's'}
: block.quiz_id ? 'In progress' : 'Not started'} </span>
<span className={block.completed ? 'is-done' : undefined}>
{block.completed ? block.question_count : 0}/{block.question_count} question{block.question_count === 1 ? '' : 's'}
</span>
<span className="block-state">
{block.question_count === 0 ? 'No questions yet'
: block.completed ? 'Completed'
: block.quiz_id ? 'In progress' : 'Not started'}
</span>
</span> </span>
<Link className="btn btn-primary btn-sm" to={`/study-plans/${plan.id}/blocks/${block.id}`}> <Link className="btn btn-primary btn-sm" to={`/study-plans/${plan.id}/blocks/${block.id}`}>
Open {block.title} Open {block.title}

View file

@ -62,7 +62,7 @@ describe('study plans', () => {
mountPlan() mountPlan()
const block = (await screen.findByText('Block 2')).closest('.block') const block = (await screen.findByText('Block 2')).closest('.block')
const headings = [...block.querySelectorAll('h3')].map(h => h.textContent) 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 () => { 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).getByRole('link', { name: 'Open Block 1' })).toHaveAttribute('href', '/study-plans/1/blocks/10')
expect(within(started).getByText('Completed')).toBeInTheDocument() 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') const fresh = screen.getByText('Block 2').closest('.block')
expect(within(fresh).getByText('Not started')).toBeInTheDocument() 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(within(fresh).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument()
expect(api.post).not.toHaveBeenCalled() 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 () => { it('says a block is empty rather than offering a test with nothing in it', async () => {
mountPlan() mountPlan()
const empty = (await screen.findByText('Block 3')).closest('.block') 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() expect(within(empty).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument()
}) })

View file

@ -86,3 +86,8 @@
.block-head h2 a { color: var(--text); text-decoration: none; } .block-head h2 a { color: var(--text); text-decoration: none; }
.block-head h2 a:hover { color: var(--primary); } .block-head h2 a:hover { color: var(--primary); }
.block-open { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .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); }