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
- **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.

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,
* 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. */}
<div className="block-sessions">
<h3>Sessions</h3>
<h3>Progress</h3>
<div className="block-open">
<span className="block-meta">
{block.question_count === 0 ? 'No questions in this block yet.'
: block.completed ? 'Completed'
: block.quiz_id ? 'In progress' : 'Not started'}
<span className="block-counts">
<span className={readCount(block) === block.articles.length && block.articles.length > 0 ? 'is-done' : undefined}>
{readCount(block)}/{block.articles.length} article{block.articles.length === 1 ? '' : 's'}
</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>
<Link className="btn btn-primary btn-sm" to={`/study-plans/${plan.id}/blocks/${block.id}`}>
Open {block.title}

View file

@ -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()
})

View file

@ -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); }