feat: the session takes the window, and favourites leave collections

The section bar is gone while a session is open. Every link on it leaves
the session you are sitting, and the 46px it takes is 46px the question
wanted — which is the whole argument for the player being a fixed-height
box in the first place. It comes back when you leave. The page's gutters
and its 1200px cap go with it: those are for reading, and a player is a
box whose columns scroll inside it.

Skip and Next are two words again. They are not the same decision —
moving on from a question you have not answered is a choice — and the
only reason to merge them was a pair of arrows at the top of the screen
that said Next for both. Those arrows are gone instead: the bar at the
foot is sticky and carries Prev and Next already.

Favourites are out of Collections. They are marked while sitting a
session and sat again from the builder, filtered by system or discipline
like anything else, so a card in Collections was a third place to meet
the same star and the one furthest from where it is used. Mark is called
Favourite everywhere now, including the builder's own filter.

AI Mode is a page: a rail grouped by age in whole local days rather than
elapsed hours — a chat from 23:00 last night reads as yesterday — a
centred ask box, and starting prompts that are all questions a library
can answer. Attach was left out rather than drawn: there is no endpoint
behind it, and a button that does nothing is worse on a page whose whole
argument is that it only says what it can source.

Two real defects turned up in that work. A brand-new conversation's first
question was wiped by the fetch that followed creating it, which was a
flicker before and fatal for a question arriving from the search overlay.
And `updated_at` is serialised naive, which JavaScript reads as local
time and can slide a chat a whole offset into the wrong day; handled in
the page, but the honest fix is timezone-aware timestamps on the API.

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-12 07:07:44 +02:00
parent 5f121533e9
commit acb954972a
11 changed files with 588 additions and 194 deletions

View file

@ -63,6 +63,7 @@ function AppLayout() {
// Keyed by path so navigating away from a broken page clears the error.
const location = useLocation()
const [searching, setSearching] = useState(false)
const inSession = location.pathname.startsWith('/study/')
useEffect(() => {
const onKey = (event) => {
@ -93,7 +94,11 @@ function AppLayout() {
dropped. */}
<SearchOverlay open={searching} onClose={() => setSearching(false)} />
<Navbar onSearch={() => setSearching(true)} />
<div className="container app-main">
{/* A session takes the window. The page's own gutters and 1200px cap
are for reading; a player is a fixed-height box whose columns scroll
inside it, and every pixel the container reserves is a pixel the
question does not get. */}
<div className={`app-main${inSession ? ' is-session' : ' container'}`}>
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>

View file

@ -190,6 +190,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
const sessionDrawer = useSessionDrawer()
const [jobs, setJobs] = useState([])
const location = useLocation()
const inSession = location.pathname.startsWith('/study/')
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
// Educators granted a category also manage questions, without a moderator role.
const [canManageQuestions, setCanManageQuestions] = useState(false)
@ -304,7 +305,11 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
</div>
</div>
{user && (
{/* Gone while a session is open. Every link on it leaves the session
you are sitting, and the room it takes is room the question wanted
which is the whole argument for the player being a fixed-height box.
It comes back when you leave. */}
{user && !inSession && (
/* Focus-within keeps it open for a keyboard user tabbing into links
that are visually gone. */
<div className={`navbar-sections${sectionBarHidden ? ' is-hidden' : ''}`}>

View file

@ -1,13 +1,32 @@
/* AI Mode: a rail of threads beside the conversation. */
.ai-page { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; }
.ai-page { display: grid; grid-template-columns: 250px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; }
/* Folded, the rail keeps only its two controls, and the conversation takes the
width back rather than leaving a column of nothing beside it. */
.ai-page.is-folded { grid-template-columns: 52px 1fr; }
.ai-rail {
position: sticky; top: 76px; max-height: calc(100dvh - 100px); overflow-y: auto;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; padding: 12px;
border-radius: 12px; padding: 10px;
}
.ai-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 8px; }
.ai-page.is-folded .ai-rail-head { flex-direction: column; }
.ai-rail-fold, .ai-compose {
flex-shrink: 0; width: 32px; height: 32px; padding: 0; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
background: none; border: 1px solid transparent; border-radius: 8px;
color: var(--text-muted); font: inherit; font-size: 0.95rem;
}
.ai-rail-fold:hover, .ai-compose:hover { border-color: var(--border); background: var(--bg); color: var(--primary); }
.ai-rail-group + .ai-rail-group { margin-top: 12px; }
/* The age is a label on the list, not a heading anyone reads down the page. */
.ai-rail-age {
margin: 0 0 4px; padding: 0 8px;
font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-subtle);
}
.ai-new { width: 100%; margin-bottom: 10px; }
.ai-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-rail li { display: flex; align-items: center; gap: 4px; }
.ai-thread {
@ -27,11 +46,14 @@
.ai-rail-toggle { display: none; margin-bottom: 10px; }
.ai-main { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
/* Nothing asked yet: the box is the page, so it sits in the middle of the
screen rather than clinging to the bottom of an empty one. */
.ai-main.is-blank { min-height: calc(100dvh - 180px); justify-content: center; align-items: center; gap: 18px; }
.ai-intro { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
.ai-intro h1 { margin: 0 0 8px; font-size: 1.3rem; }
.ai-intro p { margin: 0 0 10px; color: var(--text-muted); font-size: 0.92rem; line-height: 1.65; max-width: 60ch; }
.ai-intro-note { font-size: 0.86rem !important; color: var(--text-subtle) !important; }
.ai-hero-title {
margin: 0; text-align: center; font-size: 1.7rem; line-height: 1.25;
color: var(--text); max-width: 20ch;
}
.ai-thread-view { display: flex; flex-direction: column; gap: 12px; }
.ai-msg { max-width: 100%; }
@ -72,26 +94,55 @@
.ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; }
/* The field and its controls are one object with one outline, so the microphone
reads as part of the question rather than as a button parked next to it. */
.ai-composer {
display: flex; gap: 8px; align-items: flex-end;
position: sticky; bottom: 0; background: var(--bg);
/* Clear of the home indicator, or the send button sits under it. */
padding-bottom: calc(12px + env(safe-area-inset-bottom));
width: 100%; display: flex; flex-direction: column; gap: 6px;
position: sticky; bottom: 0; z-index: 1;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
background: var(--input-bg); border: 1px solid var(--border); border-radius: 14px;
}
.ai-composer:focus-within { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.ai-composer textarea {
flex: 1; min-width: 0; resize: vertical; padding: 11px 14px;
border: 1px solid var(--border); border-radius: 10px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.92rem;
width: 100%; min-width: 0; resize: none; padding: 6px 8px;
background: none; border: 0; color: var(--text); font: inherit; font-size: 0.95rem; line-height: 1.5;
}
.ai-composer textarea:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: var(--primary); }
.ai-composer .btn { min-height: 44px; }
.ai-composer textarea:focus { outline: none; }
.ai-composer-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.ai-send { min-height: 34px; }
.ai-composer.is-hero {
position: static; max-width: 640px;
padding: 12px 12px 10px; border-radius: 18px; box-shadow: var(--card-shadow);
}
.ai-composer.is-hero textarea { font-size: 1rem; }
.ai-hero-foot { width: 100%; max-width: 640px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.ai-tip {
margin: 0; text-align: center; max-width: 56ch;
font-size: 0.83rem; line-height: 1.6; color: var(--text-muted);
}
.ai-starters { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.ai-starter {
cursor: pointer; padding: 8px 14px; border-radius: 20px;
background: var(--card-bg); border: 1px solid var(--border); color: var(--text);
font: inherit; font-size: 0.82rem; text-align: left;
}
.ai-starter:hover { border-color: var(--primary); color: var(--primary); }
.ai-starters-more {
cursor: pointer; background: none; border: 0; padding: 4px 6px;
color: var(--text-subtle); font: inherit;
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
}
.ai-starters-more:hover { color: var(--primary); }
@media (max-width: 820px) {
.ai-page { grid-template-columns: 1fr; }
.ai-page, .ai-page.is-folded { grid-template-columns: 1fr; }
.ai-rail { position: static; display: none; max-height: none; }
.ai-rail.is-open { display: block; }
.ai-rail-toggle { display: inline-block; }
.ai-msg.is-user { max-width: 88%; }
.ai-hero-title { font-size: 1.35rem; }
}
/* Reading, then practice. Quiet next to the answer an offer, not the point
@ -108,7 +159,8 @@
/* Speaking instead of typing. Red while it is listening, because a microphone
you have forgotten is on is the one thing this must never be. */
.ai-mic {
flex-shrink: 0; width: 42px; height: 42px; font-size: 1rem; cursor: pointer;
flex-shrink: 0; margin-right: auto;
width: 34px; height: 34px; font-size: 0.95rem; cursor: pointer;
border: 1px solid var(--border); border-radius: 10px;
background: var(--card-bg); color: var(--text-muted);
}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import api from '../api/client'
@ -23,6 +23,73 @@ export const citationHref = (citation) => {
return '/flashcards'
}
const DAY = 86400000
/**
* When a thread was last touched, in milliseconds.
*
* The API sends `updated_at` as a naive UTC timestamp, and JavaScript reads a
* zoneless ISO string as local time which would slide a chat a whole
* timezone's worth into the wrong day for anyone who is not on UTC. Putting the
* marker back says what the server meant.
*/
const asTime = (value) => {
if (!value) return null
const text = String(value)
const ms = Date.parse(/(?:Z|[+-]\d{2}:?\d{2})$/.test(text) ? text : `${text}Z`)
return Number.isNaN(ms) ? null : ms
}
/**
* The rail's headings, oldest last.
*
* Ages are measured in whole local days rather than in elapsed hours because
* that is how the reader counts them: a chat from eleven last night is
* yesterday's, not "twelve hours ago".
*/
export function groupByAge(threads, now = Date.now()) {
const midnight = new Date(now)
midnight.setHours(0, 0, 0, 0)
const today = midnight.getTime()
const buckets = new Map([['Today', []], ['Previous 7 days', []],
['Previous 30 days', []], ['Older', []]])
for (const thread of threads) {
const at = asTime(thread.updated_at)
// A thread with no timestamp is one this page created a moment ago and has
// not heard back about yet, so it belongs at the top with the rest of today.
let label = 'Today'
if (at !== null && at < today) {
if (at >= today - 6 * DAY) label = 'Previous 7 days'
else if (at >= today - 29 * DAY) label = 'Previous 30 days'
else label = 'Older'
}
buckets.get(label).push(thread)
}
return [...buckets].filter(([, rows]) => rows.length).map(([label, rows]) => ({ label, threads: rows }))
}
/**
* Openers for someone who has not asked anything yet.
*
* Every one of them is a question about a topic, because a topic is what
* retrieval can find in this learner's own articles, questions and cards.
* Nothing here asks the chat to write, upload, mark or remember anything: it
* cannot, and an opener that fails is worse than no opener at all.
*/
const STARTERS = [
'What does my library say about managing bronchiolitis?',
'Explain the difference between Kawasaki disease and scarlet fever',
'Walk me through fluid management in paediatric dehydration',
'What are the red flags in a limping child?',
'Summarise the causes of failure to thrive in an infant',
'How is asthma severity graded in children?',
'What does my library cover on neonatal jaundice?',
'Which vaccines are due at the twelve-month visit?',
]
// Four is enough to show what the box is for; the rest are there for anyone
// still deciding.
const STARTERS_SHOWN = 4
/**
* Turn the markers left in the prose into numbered links.
*
@ -73,6 +140,11 @@ function Answer({ content, citations, onPractise, practising }) {
* what people already know; what is different is underneath. Retrieval decides
* what the model may cite, the server deletes anything else, and the answer
* carries its sources so a claim can be checked rather than believed.
*
* The page is also a destination for a question asked somewhere else: `?ask=`
* in the address is a question handed over by the search overlay, and it is
* opened in a thread of its own and answered without the learner having to type
* it a second time.
*/
export default function AiModePage() {
const [threads, setThreads] = useState([])
@ -82,11 +154,28 @@ export default function AiModePage() {
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Two different rails: an overlay on a narrow screen, a column that can be
// folded away on a wide one.
const [railOpen, setRailOpen] = useState(false)
const [railFolded, setRailFolded] = useState(false)
const [moreStarters, setMoreStarters] = useState(false)
// Which answer is being turned into a session, if any.
const [practising, setPractising] = useState(null)
const endRef = useRef(null)
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const incoming = searchParams.get('ask')
// Whether the question now in the address has already been sent. It is not a
// piece of state because nothing on screen depends on it, and a render caused
// by setting it would be a render in which the question could be sent again.
const consumed = useRef(false)
// Fixed on the first render: a page opened with a question in it opens a
// thread of its own for the answer, and the thread list must not quietly open
// the most recent chat underneath it.
const fromUrl = useRef(Boolean(incoming))
// The thread whose first answer is still in flight. Its transcript lives only
// in this tab so far, so fetching it would wipe the question sitting in it.
const inFlight = useRef(null)
// Speaking instead of typing. The browser's own recogniser where there is
// one, our transcriber where there is not.
const dictation = useDictation({
@ -100,13 +189,14 @@ export default function AiModePage() {
useEffect(() => {
loadThreads().then(rows => {
if (rows.length) setActiveId(rows[0].id)
if (rows.length && !fromUrl.current) setActiveId(rows[0].id)
setLoading(false)
})
}, [loadThreads])
useEffect(() => {
if (activeId == null) { setMessages([]); return }
if (inFlight.current === activeId) return
api.get(`/ai/conversations/${activeId}`)
.then(res => setMessages(res.data.messages || []))
.catch(() => setError('Could not open that conversation'))
@ -114,11 +204,13 @@ export default function AiModePage() {
useEffect(() => { endRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages, sending])
const groups = useMemo(() => groupByAge(threads), [threads])
const startThread = async () => {
setError('')
try {
const res = await api.post('/ai/conversations')
setThreads(prev => [{ id: res.data.id, title: res.data.title, message_count: 0 }, ...prev])
setThreads(prev => [{ id: res.data.id, title: res.data.title, message_count: 0, updated_at: null }, ...prev])
setActiveId(res.data.id)
setMessages([])
setRailOpen(false)
@ -149,73 +241,123 @@ export default function AiModePage() {
}
}
const send = async (event) => {
event?.preventDefault?.()
const text = draft.trim()
if (!text || sending) return
/**
* Ask, from wherever the question came from the box, a starting prompt, or
* the address bar. `fresh` forces a thread of its own, which is what a
* question handed over from elsewhere deserves: it has nothing to do with
* whatever chat happened to be open.
*/
const ask = async (text, { fresh = false } = {}) => {
const question = (text ?? '').trim()
if (!question || sending) return
let threadId = activeId
let threadId = fresh ? null : activeId
setError('')
setSending(true)
// The question appears immediately; waiting on a round trip to see your own
// words makes the whole thing feel broken.
setMessages(prev => [...prev, { id: `pending-${Date.now()}`, role: 'user', content: text, citations: [] }])
const pending = { id: `pending-${Date.now()}`, role: 'user', content: question, citations: [] }
setMessages(prev => (fresh ? [pending] : [...prev, pending]))
setDraft('')
try {
if (threadId == null) {
const created = await api.post('/ai/conversations')
threadId = created.data.id
inFlight.current = threadId
setActiveId(threadId)
setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0 }, ...prev])
setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0, updated_at: null }, ...prev])
}
const res = await api.post(`/ai/conversations/${threadId}/messages`, { message: text })
const res = await api.post(`/ai/conversations/${threadId}/messages`, { message: question })
setMessages(prev => [...prev, res.data.message])
setThreads(prev => prev.map(t => t.id === threadId ? { ...t, title: res.data.title } : t))
} catch (err) {
setError(apiError(err, 'AI Mode is unavailable right now'))
setMessages(prev => prev.filter(m => !String(m.id).startsWith('pending-')))
setDraft(text) // Handing the question back rather than losing it.
} finally { setSending(false) }
setDraft(question) // Handing the question back rather than losing it.
} finally {
inFlight.current = null
setSending(false)
}
}
// `ask` reads state that changes on every render, so the mount effect below
// reaches it through a ref rather than through a copy taken on the first
// render. Declared before that effect so it is already current when it runs.
const askRef = useRef(ask)
useEffect(() => { askRef.current = ask })
useEffect(() => {
if (!incoming) {
// The question has gone from the address, so the next one to appear there
// is a new one including the same words asked a second time.
consumed.current = false
return
}
// Marked before the request goes out, so that the renders between here and
// the address being rewritten cannot send it again.
if (consumed.current) return
consumed.current = true
// Taken out of the address, replacing rather than pushing, so that a
// refresh does not ask it a second time and Back does not either.
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('ask')
return next
}, { replace: true })
askRef.current(incoming, { fresh: true })
}, [incoming, setSearchParams])
const submit = (event) => {
event?.preventDefault?.()
ask(draft)
}
const blank = messages.length === 0 && !sending
const starters = moreStarters ? STARTERS : STARTERS.slice(0, STARTERS_SHOWN)
return (
<div className="ai-page">
<div className={`ai-page${railFolded ? ' is-folded' : ''}`}>
<button className="ai-rail-toggle" aria-expanded={railOpen}
onClick={() => setRailOpen(v => !v)}>
{railOpen ? '✕ Close chats' : '☰ Chats'}
</button>
<aside className={`ai-rail${railOpen ? ' is-open' : ''}`}>
<button className="btn btn-primary btn-sm ai-new" onClick={startThread}>New chat</button>
<ul>
{threads.map(thread => (
<li key={thread.id}>
<button className={`ai-thread${thread.id === activeId ? ' is-active' : ''}`}
onClick={() => { setActiveId(thread.id); setRailOpen(false) }}>
{thread.title}
</button>
<button className="ai-thread-delete" aria-label={`Delete ${thread.title}`}
onClick={() => removeThread(thread.id)}></button>
</li>
))}
</ul>
{!loading && threads.length === 0 && <p className="ai-rail-empty">No chats yet.</p>}
<div className="ai-rail-head">
<button className="ai-rail-fold" aria-expanded={!railFolded}
aria-label={railFolded ? 'Show chats' : 'Hide chats'}
onClick={() => setRailFolded(v => !v)}>{railFolded ? '»' : '«'}</button>
<button className="ai-compose" aria-label="New chat" onClick={startThread}>
<span aria-hidden="true"></span>
</button>
</div>
{!railFolded && (
<div className="ai-rail-body">
{groups.map(group => (
<section className="ai-rail-group" key={group.label}>
<h2 className="ai-rail-age">{group.label}</h2>
<ul>
{group.threads.map(thread => (
<li key={thread.id}>
<button className={`ai-thread${thread.id === activeId ? ' is-active' : ''}`}
onClick={() => { setActiveId(thread.id); setRailOpen(false) }}>
{thread.title}
</button>
<button className="ai-thread-delete" aria-label={`Delete ${thread.title}`}
onClick={() => removeThread(thread.id)}></button>
</li>
))}
</ul>
</section>
))}
{!loading && threads.length === 0 && <p className="ai-rail-empty">No chats yet.</p>}
</div>
)}
</aside>
<main className="ai-main">
{messages.length === 0 && !sending ? (
<div className="ai-intro">
<h1>AI Mode</h1>
<p>
Ask about anything in your library. Answers come from your own reading,
questions and cards and every claim carries the source it came from,
so you can check it rather than take its word.
</p>
<p className="ai-intro-note">
If your library does not cover something, it says so instead of
filling the gap with something you cannot verify.
</p>
</div>
<main className={`ai-main${blank ? ' is-blank' : ''}`}>
{blank ? (
<h1 className="ai-hero-title">How can PedsHub help you today?</h1>
) : (
<div className="ai-thread-view">
{messages.map(message => (
@ -238,24 +380,54 @@ export default function AiModePage() {
{error && <p className="ai-error" role="alert">{error}</p>}
<form className="ai-composer" onSubmit={send}>
<textarea value={draft} rows={2} aria-label="Ask AI Mode"
{/* One box, in two places. It is written once and moved rather than
duplicated, so a half-typed question survives the first answer
arriving and the page rearranging itself around it. */}
<form className={`ai-composer${blank ? ' is-hero' : ''}`} onSubmit={submit}>
<textarea value={draft} rows={blank ? 3 : 2} aria-label="Ask AI Mode"
placeholder="Ask about anything in your library…"
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) send(e) }} />
{canDictate() && (
<button type="button" className={`ai-mic${dictation.listening ? ' is-live' : ''}`}
disabled={dictation.transcribing}
aria-pressed={dictation.listening}
aria-label={dictation.listening ? 'Stop dictating' : 'Dictate your question'}
onClick={dictation.toggle}>
{dictation.transcribing ? '…' : dictation.listening ? '■' : '🎤'}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) submit(e) }} />
<div className="ai-composer-tools">
{canDictate() && (
<button type="button" className={`ai-mic${dictation.listening ? ' is-live' : ''}`}
disabled={dictation.transcribing}
aria-pressed={dictation.listening}
aria-label={dictation.listening ? 'Stop dictating' : 'Dictate your question'}
onClick={dictation.toggle}>
{dictation.transcribing ? '…' : dictation.listening ? '■' : '🎤'}
</button>
)}
<button className="btn btn-primary btn-sm ai-send" type="submit" disabled={sending || !draft.trim()}>
{sending ? 'Asking…' : 'Ask'}
</button>
)}
<button className="btn btn-primary" type="submit" disabled={sending || !draft.trim()}>
{sending ? 'Asking…' : 'Ask'}
</button>
</div>
</form>
{blank && (
<div className="ai-hero-foot">
<p className="ai-tip">
Answers come from your own articles, questions and cards, and each one
carries the source it came from. If your library does not cover
something, it says so instead of filling the gap.
</p>
<ul className="ai-starters">
{starters.map(starter => (
<li key={starter}>
{/* A starting prompt is already a whole question, so it is
asked rather than typed out for you to press Ask again. */}
<button type="button" className="ai-starter" onClick={() => ask(starter)}>
{starter}
</button>
</li>
))}
</ul>
<button type="button" className="ai-starters-more"
aria-expanded={moreStarters} onClick={() => setMoreStarters(v => !v)}>
{moreStarters ? 'Show less' : 'Show more'}
</button>
</div>
)}
</main>
</div>
)

View file

@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import AiModePage, { citationHref } from './AiModePage'
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'
import AiModePage, { citationHref, groupByAge } from './AiModePage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
@ -23,7 +23,18 @@ const mockApi = (rows = threads, messages = []) => api.get.mockImplementation(ur
return Promise.resolve({ data: [] })
})
const mount = () => render(<MemoryRouter><AiModePage /></MemoryRouter>)
/** So a test can read the address the page has left behind. */
function Probe() {
const location = useLocation()
return <span data-testid="where">{location.pathname}{location.search}</span>
}
const mount = (entry = '/ai') => render(
<MemoryRouter initialEntries={[entry]}><AiModePage /><Probe /></MemoryRouter>)
const DAY = 86400000
/** How the API sends a timestamp: UTC, with nothing to say so. */
const naiveAgo = (ms) => new Date(Date.now() - ms).toISOString().slice(0, -1)
describe('AI Mode', () => {
beforeEach(() => { vi.clearAllMocks(); mockApi() })
@ -84,7 +95,7 @@ describe('AI Mode', () => {
it('starts a thread on the first question when none is open', async () => {
mockApi([])
mount()
await screen.findByRole('heading', { name: 'AI Mode' })
await screen.findByRole('heading', { name: 'How can PedsHub help you today?' })
api.post.mockImplementation(url => url === '/ai/conversations'
? Promise.resolve({ data: { id: 5, title: 'New chat' } })
: Promise.resolve({ data: { message: answer, title: 'What is jaundice?' } }))
@ -133,3 +144,172 @@ describe('AI Mode', () => {
expect(await screen.findByText(/No questions in your bank match/)).toBeInTheDocument()
})
})
describe('AI Mode — the rail by age', () => {
const now = new Date('2026-09-12T15:00:00').getTime()
const midnight = (() => { const d = new Date(now); d.setHours(0, 0, 0, 0); return d.getTime() })()
// Mid-morning on the day `n` days back, so no case sits on a boundary it was
// not written to test.
const daysBack = (n) => new Date(midnight - n * DAY + 9 * 3600000).toISOString()
const label = (rows) => groupByAge(rows, now).map(g => g.label)
beforeEach(() => { vi.clearAllMocks(); mockApi() })
it('counts in whole days, so last night is yesterday rather than twelve hours', () => {
expect(label([{ updated_at: new Date(midnight).toISOString() }])).toEqual(['Today'])
expect(label([{ updated_at: new Date(midnight - 1000).toISOString() }])).toEqual(['Previous 7 days'])
})
it('puts each age in its own heading', () => {
expect(label([{ updated_at: daysBack(0) }])).toEqual(['Today'])
expect(label([{ updated_at: daysBack(6) }])).toEqual(['Previous 7 days'])
expect(label([{ updated_at: daysBack(7) }])).toEqual(['Previous 30 days'])
expect(label([{ updated_at: daysBack(29) }])).toEqual(['Previous 30 days'])
expect(label([{ updated_at: daysBack(30) }])).toEqual(['Older'])
})
it('reads a timestamp with no zone as the UTC the server meant', () => {
const zoned = new Date(midnight - 3 * DAY + 9 * 3600000).toISOString()
expect(label([{ updated_at: zoned.slice(0, -1) }])).toEqual(label([{ updated_at: zoned }]))
})
it('keeps a thread it has no timestamp for, rather than dropping it off the rail', () => {
// One this page created a moment ago and has not heard back about.
expect(label([{ updated_at: null }])).toEqual(['Today'])
})
it('names only the ages that have something in them, newest first', () => {
const rows = [{ id: 1, updated_at: daysBack(40) }, { id: 2, updated_at: daysBack(0) }]
expect(label(rows)).toEqual(['Today', 'Older'])
})
it('files every thread under the age it was last touched', async () => {
mockApi([
{ id: 1, title: 'Febrile seizures', updated_at: naiveAgo(0) },
{ id: 2, title: 'Croup', updated_at: naiveAgo(2 * DAY) },
{ id: 3, title: 'Neonatal sepsis', updated_at: naiveAgo(12 * DAY) },
{ id: 4, title: 'Viral rashes', updated_at: naiveAgo(200 * DAY) },
])
mount()
const under = async (age, title) => {
const section = (await screen.findByRole('heading', { name: age })).closest('section')
expect(within(section).getByRole('button', { name: title })).toBeInTheDocument()
}
await under('Today', 'Febrile seizures')
await under('Previous 7 days', 'Croup')
await under('Previous 30 days', 'Neonatal sepsis')
await under('Older', 'Viral rashes')
})
it('folds away, and composes a new chat', async () => {
mount()
await screen.findByRole('button', { name: 'Febrile seizures' })
api.post.mockResolvedValue({ data: { id: 9, title: 'New chat' } })
await userEvent.click(screen.getByRole('button', { name: 'New chat' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/ai/conversations'))
await userEvent.click(screen.getByRole('button', { name: 'Hide chats' }))
// Folded, the threads are gone but the way back is not.
expect(screen.queryByRole('button', { name: 'Febrile seizures' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show chats' })).toBeInTheDocument()
})
})
describe('AI Mode — the empty state', () => {
beforeEach(() => { vi.clearAllMocks(); mockApi([]) })
it('offers openers this product can actually answer', async () => {
mount()
await screen.findByRole('heading', { name: 'How can PedsHub help you today?' })
expect(screen.getByLabelText('Ask AI Mode')).toBeInTheDocument()
// Four to show what the box is for, the rest for anyone still deciding.
expect(document.querySelectorAll('.ai-starter')).toHaveLength(4)
await userEvent.click(screen.getByRole('button', { name: 'Show more' }))
expect(document.querySelectorAll('.ai-starter').length).toBeGreaterThan(4)
await userEvent.click(screen.getByRole('button', { name: 'Show less' }))
expect(document.querySelectorAll('.ai-starter')).toHaveLength(4)
})
it('asks a starting prompt outright, rather than typing it out for you', async () => {
api.post.mockImplementation(url => url === '/ai/conversations'
? Promise.resolve({ data: { id: 5, title: 'New chat' } })
: Promise.resolve({ data: { message: answer, title: 'Bronchiolitis' } }))
mount()
const starter = await screen.findByRole('button', { name: /managing bronchiolitis/ })
const asked = starter.textContent
await userEvent.click(starter)
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/ai/conversations/5/messages', { message: asked }))
// The empty state has done its job and gone.
expect(screen.queryByRole('heading', { name: 'How can PedsHub help you today?' })).not.toBeInTheDocument()
})
it('gives way to the conversation once there is one', async () => {
mockApi(threads, [answer])
mount()
await screen.findByText(/Fever first/)
expect(document.querySelectorAll('.ai-starter')).toHaveLength(0)
// The same box, moved, not a second one.
expect(screen.getAllByLabelText('Ask AI Mode')).toHaveLength(1)
})
})
describe('AI Mode — a question handed over in the address', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApi()
api.post.mockImplementation(url => url === '/ai/conversations'
? Promise.resolve({ data: { id: 5, title: 'New chat' } })
: Promise.resolve({ data: { message: answer, title: 'What is croup?' } }))
})
it('asks it on arrival, in a thread of its own', async () => {
mount('/ai?ask=What%20is%20croup%3F')
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/ai/conversations/5/messages',
{ message: 'What is croup?' }))
// The question is on screen from the start, not only once it is answered.
expect(screen.getByText('What is croup?', { selector: 'p' })).toBeInTheDocument()
await screen.findByText(/Fever first/)
// The chat that happened to be most recent is left where it was: the answer
// to a handed-over question does not belong at the bottom of it.
expect(api.get).not.toHaveBeenCalledWith('/ai/conversations/1')
})
it('takes the question out of the address so a refresh cannot ask it twice', async () => {
mount('/ai?ask=What%20is%20croup%3F&mode=learning')
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(2))
// Only the question is removed; anything else in the address is not ours.
expect(screen.getByTestId('where')).toHaveTextContent('/ai?mode=learning')
})
it('asks it when the address changes under a page that is already open', async () => {
// The overlay opens over whatever you are on, including AI Mode itself, and
// navigating to a page you are already on does not mount it again.
function Handover() {
const navigate = useNavigate()
return <button onClick={() => navigate('/ai?ask=What is croup?')}>hand over</button>
}
render(<MemoryRouter initialEntries={['/ai']}><AiModePage /><Probe /><Handover /></MemoryRouter>)
await screen.findByRole('heading', { name: 'How can PedsHub help you today?' })
await userEvent.click(screen.getByRole('button', { name: 'hand over' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/ai/conversations/5/messages',
{ message: 'What is croup?' }))
expect(screen.getByTestId('where')).toHaveTextContent(/^\/ai$/)
expect(api.post).toHaveBeenCalledTimes(2)
})
it('asks it once, however many times the page renders again', async () => {
const { rerender } = mount('/ai?ask=What%20is%20croup%3F')
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(2))
rerender(<MemoryRouter initialEntries={['/ai']}><AiModePage /><Probe /></MemoryRouter>)
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'and then?')
expect(api.post).toHaveBeenCalledTimes(2)
})
})

View file

@ -15,8 +15,10 @@ const when = (value) => (value
? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
: '—')
/** The Favorites row is not a collection; it is the star, given a place to live. */
const FAVORITES = 'favorites'
// Favourites are not a collection. They are marked while sitting a session
// and sat again as their own session from the builder a card here would be a
// third place to meet the same star, and the one furthest from where it is
// used.
/** What is on one shelf, opened in place. */
function Shelf({ rows, onDrop }) {
@ -49,7 +51,6 @@ function Shelf({ rows, onDrop }) {
*/
export default function CollectionsPage() {
const [rows, setRows] = useState(null)
const [favorites, setFavorites] = useState([])
const [view, setView] = useState(() => localStorage.getItem('collections.view') || 'card')
const [sort, setSort] = useState('used')
const [descending, setDescending] = useState(true)
@ -66,27 +67,16 @@ export default function CollectionsPage() {
const navigate = useNavigate()
const load = useCallback(() => {
Promise.all([
api.get('/collections/').catch(() => ({ data: [] })),
api.get('/favorites').catch(() => ({ data: [] })),
]).then(([collections, stars]) => {
setRows(Array.isArray(collections.data) ? collections.data : [])
setFavorites(Array.isArray(stars.data) ? stars.data : [])
}).catch(() => setError('Could not load your collections'))
api.get('/collections/')
.then(res => setRows(Array.isArray(res.data) ? res.data : []))
.catch(() => setError('Could not load your collections'))
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { localStorage.setItem('collections.view', view) }, [view])
const all = useMemo(() => {
if (rows === null) return null
const star = {
id: FAVORITES, title: 'Favorites', question_count: favorites.length,
created_at: null, last_used_at: null, private: true, fixed: true,
}
return [star, ...rows]
}, [rows, favorites])
const all = rows
const shown = useMemo(() => {
if (!all) return null
@ -107,8 +97,7 @@ export default function CollectionsPage() {
if (x === y) return a.title.localeCompare(b.title)
return (x > y ? 1 : -1) * (descending ? -1 : 1)
})
// Favorites always leads: it is the one shelf nobody made and everybody has.
return [...sorted.filter(r => r.fixed), ...sorted.filter(r => !r.fixed)]
return sorted
}, [all, query, sort, descending])
const run = async (fn, failure) => {
@ -153,10 +142,8 @@ export default function CollectionsPage() {
}
}
const questionsIn = useCallback(async (row) => (row.id === FAVORITES
? (await api.get('/questions/bank', { params: { favorites_only: true, limit: 200 } }))
.data.questions.map(q => ({ id: q.id, question_text: q.question_text }))
: (await api.get(`/collections/${row.id}/questions`)).data), [])
const questionsIn = useCallback(
async (row) => (await api.get(`/collections/${row.id}/questions`)).data, [])
const toggle = async (row) => {
if (openId === row.id) { setOpenId(null); setContents(null); return }
@ -168,8 +155,7 @@ export default function CollectionsPage() {
}
const drop = (row, questionId) => run(async () => {
if (row.id === FAVORITES) await api.delete(`/favorites/${questionId}`)
else await api.delete(`/collections/${row.id}/questions/${questionId}`)
await api.delete(`/collections/${row.id}/questions/${questionId}`)
setContents(list => (list || []).filter(q => q.id !== questionId))
}, 'Could not take that question out')
@ -181,14 +167,10 @@ export default function CollectionsPage() {
</button>
<button type="button" className="mm-item"
onClick={() => { close(); practise(row) }}>Practise these</button>
{!row.fixed && (
<>
<button type="button" className="mm-item"
onClick={() => { close(); setNaming({ id: row.id, title: row.title }) }}>Rename</button>
<button type="button" className="mm-item is-danger" disabled={busy}
onClick={() => { close(); remove(row) }}>Delete</button>
</>
)}
<button type="button" className="mm-item"
onClick={() => { close(); setNaming({ id: row.id, title: row.title }) }}>Rename</button>
<button type="button" className="mm-item is-danger" disabled={busy}
onClick={() => { close(); remove(row) }}>Delete</button>
</>
)
@ -196,7 +178,11 @@ export default function CollectionsPage() {
<div className="col-page">
<div className="col-head">
<h1>Collections</h1>
<p>Your saved questions: the star, and any libraries you keep.</p>
<p>
The question libraries you keep. Favourites are not here they are
marked while you sit a session, and sat again from the session
builder, filtered by system or discipline like anything else.
</p>
</div>
{error && <p className="col-error" role="alert">{error}</p>}
@ -245,11 +231,11 @@ export default function CollectionsPage() {
) : view === 'card' ? (
<ul className="col-cards">
{shown.map(row => (
<li key={row.id} className={`col-card${row.fixed ? ' is-fixed' : ''}`}>
<li key={row.id} className="col-card">
<div className="col-card-top">
<button type="button" className="col-card-name"
aria-expanded={openId === row.id} onClick={() => toggle(row)}>
{row.fixed && <span aria-hidden="true"> </span>}{row.title}
{row.title}
</button>
<MoreMenu label={`Options for ${row.title}`}>{menu(row)}</MoreMenu>
</div>
@ -258,7 +244,7 @@ export default function CollectionsPage() {
{row.private && <span className="col-private">Private</span>}
</p>
<p className="col-card-when">
{row.fixed ? 'Always here' : `Last used ${when(row.last_used_at)}`}
Last used {when(row.last_used_at)}
</p>
{openId === row.id && <Shelf rows={contents} onDrop={id => drop(row, id)} />}
</li>
@ -283,13 +269,13 @@ export default function CollectionsPage() {
<td>
<button type="button" className="col-linkish"
aria-expanded={openId === row.id} onClick={() => toggle(row)}>
{row.fixed && <span aria-hidden="true"> </span>}{row.title}
{row.title}
</button>
</td>
<td className="col-num">{row.question_count}</td>
<td>{row.private ? 'Private' : 'Shared'}</td>
<td>{row.fixed ? '—' : when(row.created_at)}</td>
<td>{row.fixed ? '—' : when(row.last_used_at)}</td>
<td>{when(row.created_at)}</td>
<td>{when(row.last_used_at)}</td>
<td className="col-num">
<MoreMenu label={`Options for ${row.title}`}>{menu(row)}</MoreMenu>
</td>

View file

@ -29,60 +29,63 @@ beforeEach(() => {
const mount = () => render(<MemoryRouter><CollectionsPage /></MemoryRouter>)
it('leads with Favorites and counts what is on the shelf', async () => {
it('lists the libraries and counts what is on each', async () => {
mount()
expect(await screen.findByText('Showing: 3 collections')).toBeInTheDocument()
expect(await screen.findByText('Showing: 2 collections')).toBeInTheDocument()
const cards = document.querySelectorAll('.col-card')
// The star is nobody's creation and everybody's, so it comes first.
expect(within(cards[0]).getByText(/Favorites/)).toBeInTheDocument()
expect(within(cards[0]).getByText('3 questions')).toBeInTheDocument()
expect(within(cards[1]).getByText(/Cardiology misses/)).toBeInTheDocument()
expect(within(cards[0]).getByText(/Cardiology misses/)).toBeInTheDocument()
expect(within(cards[0]).getByText('12 questions')).toBeInTheDocument()
})
it('does not keep favourites here', async () => {
mount()
await screen.findByText('Showing: 2 collections')
// They are marked while sitting a session and sat again from the builder. A
// card here would be a third place to meet the same star, and the one
// furthest from where it is used.
expect(screen.queryByText(/Favorites/i)).not.toBeInTheDocument()
expect(api.get).not.toHaveBeenCalledWith('/favorites')
})
it('sorts by last used, and a library nobody has opened falls back to its age', async () => {
mount()
await screen.findByText('Showing: 3 collections')
await screen.findByText('Showing: 2 collections')
const names = [...document.querySelectorAll('.col-card-name')].map(n => n.textContent)
expect(names).toEqual(['★ Favorites', 'Cardiology misses', 'Airway emergencies'])
expect(names).toEqual(['Cardiology misses', 'Airway emergencies'])
// By name, ascending: the direction control means what it says.
await userEvent.selectOptions(screen.getByLabelText('Sort by'), 'title')
await userEvent.click(screen.getByRole('button', { name: /Sorted newest first/ }))
const byName = [...document.querySelectorAll('.col-card-name')].map(n => n.textContent)
expect(byName).toEqual(['★ Favorites', 'Airway emergencies', 'Cardiology misses'])
expect(byName).toEqual(['Airway emergencies', 'Cardiology misses'])
})
it('searches by name and says how many of how many', async () => {
mount()
await screen.findByText('Showing: 3 collections')
await screen.findByText('Showing: 2 collections')
await userEvent.type(screen.getByLabelText('Search collections'), 'airway')
expect(await screen.findByText('Showing: 1 collection of 3')).toBeInTheDocument()
expect(await screen.findByText('Showing: 1 collection of 2')).toBeInTheDocument()
expect(screen.queryByText(/Cardiology misses/)).not.toBeInTheDocument()
})
it('shows the same shelves as a table, and remembers which view was chosen', async () => {
const { unmount } = mount()
await screen.findByText('Showing: 3 collections')
await screen.findByText('Showing: 2 collections')
await userEvent.click(screen.getByRole('tab', { name: 'Table view' }))
const table = screen.getByRole('table')
expect(within(table).getAllByRole('row')).toHaveLength(4) // header + three
expect(within(table).getAllByText('Private')).toHaveLength(3)
expect(within(table).getAllByRole('row')).toHaveLength(3) // header + two
expect(within(table).getAllByText('Private')).toHaveLength(2)
unmount()
mount()
expect(await screen.findByRole('table')).toBeInTheDocument()
})
it('renames a library, and will not rename Favorites', async () => {
it('renames a library', async () => {
mount()
await screen.findByText('Showing: 3 collections')
await screen.findByText('Showing: 2 collections')
const cards = document.querySelectorAll('.col-card')
// Favorites is not a collection, so it has nothing to rename or delete.
await userEvent.click(within(cards[0]).getByRole('button', { name: 'Options for Favorites' }))
expect(screen.queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await userEvent.click(within(cards[1]).getByRole('button', { name: 'Options for Cardiology misses' }))
await userEvent.click(within(cards[0]).getByRole('button', { name: 'Options for Cardiology misses' }))
await userEvent.click(screen.getByRole('button', { name: 'Rename' }))
const field = screen.getByLabelText('Collection name')
await userEvent.clear(field)
@ -99,7 +102,7 @@ it('opens a shelf in place and takes a question back out', async () => {
? [{ id: 11, question_text: 'A 2-year-old with a barking cough.' }] : [],
}))
mount()
await screen.findByText('Showing: 3 collections')
await screen.findByText('Showing: 2 collections')
await userEvent.click(screen.getByRole('button', { name: 'Cardiology misses' }))
expect(await screen.findByText('A 2-year-old with a barking cough.')).toBeInTheDocument()
@ -107,20 +110,6 @@ it('opens a shelf in place and takes a question back out', async () => {
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/collections/1/questions/11'))
})
it('takes a favourite off the star rather than out of a library', async () => {
api.get.mockImplementation(url => Promise.resolve({
data: url === '/collections/' ? LIBRARIES
: url === '/favorites' ? [7]
: url === '/questions/bank'
? { questions: [{ id: 7, question_text: 'A neonate with bilious vomiting.' }] } : [],
}))
mount()
await screen.findByText('Showing: 3 collections')
await userEvent.click(screen.getByRole('button', { name: 'Favorites' }))
await userEvent.click(await screen.findByRole('button', { name: 'Take question 7 out' }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/favorites/7'))
})
it('starts a session from the questions in a shelf', async () => {
api.get.mockImplementation(url => Promise.resolve({
data: url === '/collections/' ? LIBRARIES
@ -128,8 +117,8 @@ it('starts a session from the questions in a shelf', async () => {
: url === '/collections/1/questions' ? [{ id: 11 }, { id: 12 }] : [],
}))
mount()
await screen.findByText('Showing: 3 collections')
const card = [...document.querySelectorAll('.col-card')][1]
await screen.findByText('Showing: 2 collections')
const card = [...document.querySelectorAll('.col-card')][0]
await userEvent.click(within(card).getByRole('button', { name: 'Options for Cardiology misses' }))
await userEvent.click(screen.getByRole('button', { name: 'Practise these' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/builder',
@ -139,14 +128,14 @@ it('starts a session from the questions in a shelf', async () => {
it('says so rather than starting an empty session', async () => {
api.get.mockImplementation(url => Promise.resolve({
data: url === '/collections/' ? LIBRARIES
: url === '/favorites' ? []
: url === '/questions/bank' ? { questions: [] } : [],
: url === '/collections/2/questions' ? [] : [],
}))
mount()
await screen.findByText('Showing: 3 collections')
const card = document.querySelectorAll('.col-card')[0]
await userEvent.click(within(card).getByRole('button', { name: 'Options for Favorites' }))
await screen.findByText('Showing: 2 collections')
const card = document.querySelectorAll('.col-card')[1]
await userEvent.click(within(card).getByRole('button', { name: 'Options for Airway emergencies' }))
await userEvent.click(screen.getByRole('button', { name: 'Practise these' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Favorites has no questions in it yet.')
expect(await screen.findByRole('alert'))
.toHaveTextContent('Airway emergencies has no questions in it yet.')
expect(api.post).not.toHaveBeenCalled()
})

View file

@ -151,7 +151,7 @@ export default function CustomQuizPage() {
const diseases = summarise(tagIds.map(tagName).filter(n => n && diseaseTags.some(t => t.name === n)))
const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
const savedNames = [
...(state === 'bookmarked' ? ['Bookmarked questions'] : []),
...(state === 'bookmarked' ? ['Favourites'] : []),
...collections.filter(c => presetIds.includes(c.id)).map(c => c.title),
]
const saved = summarise(savedNames)
@ -458,7 +458,7 @@ export default function CustomQuizPage() {
<label>
<input type="checkbox" checked={state === 'bookmarked'}
onChange={e => setState(e.target.checked ? 'bookmarked' : 'all')} />
Bookmarked questions
Favourites
</label>
)}
{checkList(collections, c => presetIds.includes(c.id), c => togglePreset(c), query,

View file

@ -1381,14 +1381,14 @@ const timerStarted = timeLeft !== null
{isStudy ? 'Finish session' : 'End block'}
</button>
) : (
/* One name. It used to read Skip on an unanswered question and Next on
an answered one, while the arrow at the top of the screen said Next
for both two words for one action, an inch apart. In review it is
only a way through what has already been marked, so on the last
question there is nothing left for it to do. */
/* Skip and Next are not the same decision. Moving on from a question
you have not answered is a choice, and the button says which one it
is rather than calling both of them Next. In review everything is
already marked, so there is nothing to skip it is only a way
through. */
<button className="btn btn-primary" disabled={isLast}
onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>
Next
{reviewing || answers[current?.id] ? 'Next →' : 'Skip →'}
</button>
)}
</div>
@ -1736,12 +1736,11 @@ const timerStarted = timeLeft !== null
{/* An exam moves between items from the middle of this bar, and
ends the block from the bar at the foot of it. Repeating
either here is the same action under a second name. */}
{!examChrome && (
<>
<button type="button" aria-label="Previous question" disabled={currentIdx === 0} onClick={() => safeNavigate(currentIdx - 1)}></button>
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next </button>
</>
)}
{/* No arrows here. The bar at the foot of the player is sticky
and carries Prev and Next already; a second pair above the
question is the same control twice on one screen. The exam
chrome keeps its own, because there the foot of the screen is
the block bar rather than the navigation. */}
</div>
</div>
@ -1809,8 +1808,9 @@ const timerStarted = timeLeft !== null
</MoreActions>
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
{favorites.includes(current.id) ? '★' : '☆'} <span>Mark</span>
title={favorites.includes(current.id)
? 'Remove from favourites' : 'Add to favourites — sit them as their own session later'}>
{favorites.includes(current.id) ? '★' : '☆'} <span>Favourite</span>
</button>
{voices.length > 0 && (
<TTSButton
@ -2059,7 +2059,6 @@ const timerStarted = timeLeft !== null
<div className="quiz-reveal">
<button type="button" className="quiz-reveal-button"
onClick={() => revealAnswer(current.id)}>Show answer</button>
<span>Reads the explanation without answering the question stays unanswered.</span>
</div>
)}
{answerRevealed && (

View file

@ -163,9 +163,10 @@ describe('quiz player', () => {
// of a session you are part-way through.
expect(within(meta).queryByRole('link', { name: 'Neonatology' })).not.toBeInTheDocument()
// Mark moved off the stem into the action bar, so the stem is text only.
// Favouriting moved off the stem into the action bar, so the stem is text
// only. It says what it is for: these are sat again as their own session.
const bar = screen.getByRole('toolbar', { name: 'Question actions' })
expect(within(bar).getByTitle('Add to favorites')).toBeInTheDocument()
expect(within(bar).getByTitle(/Add to favourites/)).toBeInTheDocument()
expect(document.querySelector('.quiz-stem button')).toBeNull()
})
@ -313,7 +314,7 @@ describe('quiz player', () => {
await userEvent.click(inCard().getByText('Second answer').closest('.option'))
expect(document.querySelector('.option.selected')).toBeNull()
await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0])
await userEvent.click(screen.getAllByRole('button', { name: /Skip/ })[0])
await findStem('Full second clinical question.')
await userEvent.click(screen.getByRole('button', { name: 'Finish session' }))
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' }))
@ -413,7 +414,7 @@ describe('quiz player', () => {
expect(bar.closest('.quiz-layout')).toBeNull()
expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument()
// Nothing answered yet, so moving on is a skip and the button says so.
expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument()
expect(within(bar).getByRole('button', { name: /Skip/ })).toBeInTheDocument()
})
it('keeps notes with the question, not in a second notepad floating over it', async () => {
@ -531,7 +532,7 @@ describe('quiz player', () => {
// And there is a way back into the block, not only a way out of it.
expect(within(review).getByRole('button', { name: 'Remain in Block' })).toBeInTheDocument()
await userEvent.click(within(review).getByRole('button', { name: 'Remain in Block' }))
await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0])
await userEvent.click(screen.getAllByRole('button', { name: /Skip|Next/ })[0])
await findStem('Full second clinical question.')
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
await userEvent.click(endBlock())
@ -706,7 +707,7 @@ describe('quiz player', () => {
expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull()
const bar = document.querySelector('.quiz-footbar')
expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument()
expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument()
expect(within(bar).getByRole('button', { name: /Skip/ })).toBeInTheDocument()
})
it('says so when five minutes are left, once', async () => {

View file

@ -320,10 +320,15 @@
the navigation off screen exactly when you wanted them. The player is now a
box the height of the window: the rail and the bar stay, and the question
scrolls inside its own column. */
/* Full occupancy. The section bar is gone while a session is open, so the
only thing above the player is the header itself and the player is given
what is left of the window rather than a column in the middle of it. */
.app-main.is-session { width: 100%; padding: 0; }
.quiz-player.is-boxed {
/* The two header bars above it. They do not collapse here, because with no
page scroll there is no scrolling for them to react to. */
height: calc(100dvh - 98px);
/* Only the header remains above it: the section bar hides for the duration
of a session, and that row of links is 46px the question wanted. */
height: calc(100dvh - 52px);
display: flex; flex-direction: column;
padding-bottom: 0; overflow: hidden;
}
@ -337,7 +342,7 @@
.quiz-footbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border); background: #fff;
}
.quiz-footbar .quiz-nav-controls { flex: 1; justify-content: center; margin: 0; }