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:
parent
5f121533e9
commit
acb954972a
11 changed files with 588 additions and 194 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue