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:
parent
a9969d8d90
commit
5575fe39bb
4 changed files with 36 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue