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