fix: ship the three files the overview page imports

`0700e68` and the two commits before it added the overview, its stylesheet and
the search-panel context, and committed only the page. App.jsx imports
`./context/SearchPanel` and DashboardPage.jsx imports `./DashboardPage.css`,
neither of which was in the tree — so master has not built from a clean clone
since. It built here because the files exist on this disk.

Nothing new: the four files as they already are, plus SearchOverlay's `tab`
prop, which is what the context passes it.

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 18:37:23 +02:00
parent 031de53034
commit 73ab2515f5
4 changed files with 291 additions and 3 deletions

View file

@ -36,8 +36,8 @@ function remember(query) {
* conversation and lets the answer arrive there, which is where the follow-up
* will be asked from anyway.
*/
export default function SearchOverlay({ open, onClose }) {
const [tab, setTab] = useState('search')
export default function SearchOverlay({ open, onClose, tab: opensOn = 'search' }) {
const [tab, setTab] = useState(opensOn)
const [query, setQuery] = useState('')
const [items, setItems] = useState([])
const [active, setActive] = useState(-1)
@ -47,13 +47,17 @@ export default function SearchOverlay({ open, onClose }) {
useEffect(() => {
if (!open) return undefined
// Whichever tab the opener asked for, every time it opens. Remembering the
// tab left behind would mean a control labelled AI Mode lands on the corpus
// search because of something done on another page an hour ago.
setTab(opensOn)
setHistory(readHistory())
// Focused on the next frame: the element does not exist until this render
// has been painted, and focusing a node that is not in the document does
// nothing at all.
const timer = requestAnimationFrame(() => field.current?.focus())
return () => cancelAnimationFrame(timer)
}, [open])
}, [open, opensOn])
useEffect(() => {
if (!open || tab !== 'search' || query.trim().length < 2) { setItems([]); return undefined }

View file

@ -0,0 +1,54 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import SearchOverlay from '../components/SearchOverlay'
/**
* Who may open the search panel: the header, the keyboard, and the hero on the
* overview page.
*
* There is one panel with two tabs, so none of those three is a search of its
* own each is another door to the same one. Two boxes that do nearly the
* same thing is how people learn to trust neither, and the header already
* settled that argument for itself; the hero would otherwise have reopened it.
*
* Which tab it lands on is the caller's to say. A control labelled AI Mode is a
* promise about where you end up, and landing on the corpus search instead
* breaks it.
*/
const Context = createContext({ open: () => {} })
export function SearchPanelProvider({ children }) {
//: The tab is the open state null is shut. A separate boolean beside it is
//: a second thing that has to agree about whether the panel is on screen.
const [tab, setTab] = useState(null)
const open = useCallback((which = 'search') => setTab(which), [])
const close = useCallback(() => setTab(null), [])
useEffect(() => {
const onKey = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
// Taken from the browser deliberately: Ctrl+K is what every tool this
// sits beside uses, and a learner who has learned it once should not
// have to learn ours.
event.preventDefault()
open('search')
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
const value = useMemo(() => ({ open }), [open])
return (
<Context.Provider value={value}>
{children}
{/* Over whatever is on screen: a question arrives while you are reading
something, and having to leave that page to ask is how it gets
dropped. */}
<SearchOverlay open={tab !== null} tab={tab ?? 'search'} onClose={close} />
</Context.Provider>
)
}
/** `open('search' | 'ai')`. Does nothing outside the authenticated shell. */
export const useSearchPanel = () => useContext(Context)

View file

@ -0,0 +1,83 @@
/* The signed-in overview: search, the session waiting for you, and a glance
at how it is going. The blocks themselves are shared components with their
own stylesheets; this file only greets, draws the hero, and decides where
things sit. */
.ov-greeting { margin: 0 0 16px; font-size: 1.5rem; font-weight: 700; color: var(--text); }
/* --- the hero ------------------------------------------------------------ */
.ov-hero { margin-bottom: 20px; }
.ov-hero-modes { display: inline-flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: var(--bg); }
.ov-hero-modes button {
border: 0; background: none; color: var(--text-muted); cursor: pointer;
font: inherit; font-size: 0.86rem; font-weight: 600; padding: 7px 16px; border-radius: 8px;
}
.ov-hero-modes button:hover { color: var(--primary); background: var(--card-bg); }
/* A door, not a field it looks like one because that is what people reach
for, and clicking anywhere on it opens the panel with the cursor already in
the real box. */
.ov-hero-field {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 52px; padding: 12px 16px; cursor: pointer; text-align: left;
font: inherit; font-size: 1rem; color: var(--text-subtle, var(--text-muted));
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
}
.ov-hero-field:hover { border-color: var(--primary); }
.ov-hero-field:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.ov-hero-icon { font-size: 1.2rem; line-height: 1; color: var(--text-muted); }
.ov-hero-prompt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ov-hero-field kbd {
flex-shrink: 0; padding: 3px 7px; border-radius: 6px; font: inherit; font-size: 0.7rem;
color: var(--text-muted); background: var(--bg); border: 1px solid var(--border);
}
/* --- progress at a glance ------------------------------------------------ */
.ov-glance, .ov-first {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
padding: 16px; margin-bottom: 20px;
}
.ov-glance-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.ov-glance-head h2 { margin: 0; font-size: 1.15rem; font-weight: 650; }
.ov-glance-head a { font-size: 0.82rem; color: var(--primary); text-decoration: none; white-space: nowrap; }
.ov-glance-head a:hover { text-decoration: underline; }
.ov-basis { margin: 12px 0 0; font-size: 0.78rem; color: var(--text-muted); }
/* --- a first visit ------------------------------------------------------- */
.ov-first h2 { margin: 0 0 12px; font-size: 1.15rem; font-weight: 650; }
.ov-first-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.ov-first-grid a {
display: flex; flex-direction: column; gap: 4px; height: 100%;
padding: 13px 14px; border: 1px solid var(--border); border-radius: 10px;
color: var(--text); text-decoration: none; background: var(--bg);
}
.ov-first-grid a:hover { border-color: var(--primary); }
.ov-first-grid strong { font-size: 0.92rem; font-weight: 650; }
.ov-first-grid span { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; }
/* --- where the blocks sit ------------------------------------------------ */
/* One column until there is room for two, which is the order they are written
in and the only order that makes sense on a phone: what to do now, what was
left open, then how it is going.
Two columns puts the ring beside the session plan rather than below two tall
panels. Dense packing rather than explicit rows because half these blocks
are conditional a fresh account has no ring and no notebook and fixed
row numbers would leave holes where those would have been. */
@media (min-width: 1000px) {
.ov-body {
display: grid; grid-auto-flow: row dense; align-items: start;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 20px;
}
.ov-body > .ps-panel, .ov-body > .cs-panel { grid-column: 1; }
.ov-body > .ov-glance, .ov-body > .ov-first, .ov-body > .mynote-card { grid-column: 2; }
/* Roughly the width of the column beneath it. A search box the full 1200px
with a short prompt at the left end reads as a page that ran out of
content, not as a generous target. */
.ov-hero { max-width: 720px; }
}

View file

@ -0,0 +1,147 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, expect, it, vi } from 'vitest'
import DashboardPage from './DashboardPage'
import { SearchPanelProvider } from '../context/SearchPanel'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({ user: { id: 1, name: 'Ada Lovelace', role: 'user' } }),
}))
const split = (latest, extra = {}) => ({
all: latest,
latest,
attempts: 3,
unique_questions: latest.total,
...extra,
})
const NOTHING = { correct: 0, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null }
const SOME = { correct: 12, correct_with_hints: 2, incorrect: 6, unanswered: 0, answered: 20, total: 20, percent_correct: 70 }
const COLD_PLAN = {
count: 20, basis: 'cold_start', new_count: 20, review_count: 0, available: 2900,
summary: "You haven't finished a session yet, so this is an even spread across the exam blueprint.",
length_reason: '20 to start with.',
topics: [{ category_id: 1, name: 'Cardiology', count: 20, new_count: 20, review_count: 0, accuracy: null, weight: 60, reason: 'Worth 60% of the exam' }],
}
const IN_FLIGHT = [
{ quiz_id: 5, title: 'Prepared session', mode: 'learning', state: 'in_progress', answered: 4, total: 20, last_attempt_id: 9 },
]
/** Everything the page and its blocks ask for, with the account's state swapped in. */
const server = ({ answers = NOTHING, sessions = [], plan = COLD_PLAN } = {}) => {
api.get.mockImplementation((url) => {
if (url === '/study-tools/answer-split') return Promise.resolve({ data: split(answers) })
if (url === '/questions/builder/prepared') return Promise.resolve({ data: plan })
if (url === '/quizzes/sessions') return Promise.resolve({ data: sessions })
if (url === '/articles/recent') return Promise.resolve({ data: [] })
if (url === '/exams/') return Promise.resolve({ data: { exams: [], active_exam_id: null } })
if (url === '/mynote') return Promise.resolve({ data: { content: 'Kawasaki criteria', updated_at: null } })
return Promise.resolve({ data: {} })
})
}
const mount = () => render(
<MemoryRouter>
<SearchPanelProvider><DashboardPage /></SearchPanelProvider>
</MemoryRouter>
)
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
server()
})
it('greets the learner by their first name', async () => {
mount()
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent(/, Ada$/)
})
/* The case anybody evaluating this product sees first, and the one a dashboard
built out of statistics gets wrong: an account with nothing behind it. */
it('gives a new account something to do rather than a shelf of dashes', async () => {
mount()
// A real session, planned and explained, is the opening act.
expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Start session' })).toBeInTheDocument()
// And the rest of the building, for somebody who would rather read first.
expect(screen.getByRole('heading', { name: 'Other ways to start' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Read first/ })).toHaveAttribute('href', '/articles')
expect(screen.getByRole('link', { name: /Follow a plan/ })).toHaveAttribute('href', '/study-plans')
expect(screen.getByRole('link', { name: /Ask a question/ })).toHaveAttribute('href', '/ai')
// Nothing that would be empty: no ring drawn from no answers, no half-done
// session, no notebook with nothing in it.
expect(screen.queryByRole('heading', { name: 'Where you stand' })).not.toBeInTheDocument()
expect(document.querySelector('.an-donut')).toBeNull()
expect(screen.queryByText('Continue your study')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Open MyNote' })).not.toBeInTheDocument()
expect(screen.queryByText('—')).not.toBeInTheDocument()
})
it('shows the ring once there are answers, and keeps the rest on the analysis page', async () => {
server({ answers: SOME, sessions: IN_FLIGHT })
mount()
expect(await screen.findByRole('heading', { name: 'Where you stand' })).toBeInTheDocument()
expect(screen.getByText('70%')).toBeInTheDocument()
expect(screen.getByText('12 correct')).toBeInTheDocument()
expect(screen.getByText('2 correct after a tip')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'See full analysis' }))
.toHaveAttribute('href', '/sessions?view=performance')
// A glance, not a second analysis page: the trend, the peer comparison and
// the focus-area table stay where they are.
expect(screen.queryByText('Performance over time')).not.toBeInTheDocument()
expect(screen.queryByText('Against everyone else')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Other ways to start' })).not.toBeInTheDocument()
})
it('offers the session left open, without going into it', async () => {
server({ answers: SOME, sessions: IN_FLIGHT })
mount()
const resume = await screen.findByRole('link', { name: 'Resume' })
expect(resume).toHaveAttribute('href', '/study/5')
})
it('opens the one search panel rather than searching for itself', async () => {
mount()
await screen.findByRole('heading', { name: 'Ready for you' })
// Nothing to type into until the panel is open the hero is a door.
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
expect(await screen.findByRole('dialog', { name: 'Search' })).toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: 'Search PedsHub' })).toBeInTheDocument()
})
it('lands on AI Mode when that is what was asked for', async () => {
mount()
await screen.findByRole('heading', { name: 'Ready for you' })
await userEvent.click(screen.getByRole('button', { name: 'AI Mode' }))
expect(await screen.findByRole('dialog', { name: 'AI Mode' })).toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: 'Ask AI Mode' })).toBeInTheDocument()
})
it('says nothing about progress it could not load', async () => {
api.get.mockImplementation((url) => (
url === '/study-tools/answer-split'
? Promise.reject(new Error('down'))
: Promise.resolve({ data: url === '/questions/builder/prepared' ? COLD_PLAN : [] })
))
mount()
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/study-tools/answer-split'))
// Neither the ring nor the first-visit block: one would be drawn from
// nothing and the other would call an established account new.
expect(screen.queryByRole('heading', { name: 'Where you stand' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Other ways to start' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
})