diff --git a/frontend/src/components/SearchOverlay.jsx b/frontend/src/components/SearchOverlay.jsx index 1ec5207..dea4cbf 100644 --- a/frontend/src/components/SearchOverlay.jsx +++ b/frontend/src/components/SearchOverlay.jsx @@ -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 } diff --git a/frontend/src/context/SearchPanel.jsx b/frontend/src/context/SearchPanel.jsx new file mode 100644 index 0000000..a46a4e4 --- /dev/null +++ b/frontend/src/context/SearchPanel.jsx @@ -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 ( + + {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. */} + + + ) +} + +/** `open('search' | 'ai')`. Does nothing outside the authenticated shell. */ +export const useSearchPanel = () => useContext(Context) diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css new file mode 100644 index 0000000..391ba9e --- /dev/null +++ b/frontend/src/pages/DashboardPage.css @@ -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; } +} diff --git a/frontend/src/pages/DashboardPage.test.jsx b/frontend/src/pages/DashboardPage.test.jsx new file mode 100644 index 0000000..b85a263 --- /dev/null +++ b/frontend/src/pages/DashboardPage.test.jsx @@ -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( + + + +) + +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() +})