feat: three coach marks on the overview, once per browser
A tour is a confession that the product does not explain itself, so this one names the three things a learner would otherwise go looking for and stops: the session chosen for them, search against AI Mode, and what the ring is measuring. Anything needing six steps wants rebuilding rather than narrating. Steps are declared against a `data-tour` attribute rather than a class or a ref, because a tour that reaches into another component's internals breaks the first time somebody renames something for a visual reason. A step whose target is not on the page is dropped from the count rather than shown pointing at nothing — an educator and a learner do not see the same dashboard, and "Step 2 of 6" must not promise a step that will never come. The count is fixed when the tour opens, so a block arriving late cannot renumber it under somebody midway. Only on the overview, and after the objective rather than beside it: two things asking for attention at once is one of them being dismissed unread. Seen-ness is per browser, because a tour is about the interface rather than the account. The dimming is four rectangles rather than one enormous box-shadow, which would repaint the whole screen on every scroll event — and this follows scrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
bd321b002d
commit
0700e68eaf
7 changed files with 528 additions and 76 deletions
31
docs/TODO.md
31
docs/TODO.md
|
|
@ -277,8 +277,13 @@ Captured so nothing is lost while the article writing runs.
|
|||
global notes tab that floated over the same screen, so it was never clear
|
||||
which notepad you were writing in. The global note stays on the dashboard.
|
||||
- [ ] **Per-question feedback** to the educator.
|
||||
- [x] ~~**Tutorial mode**~~ — not wanted. Coach marks over a product somebody
|
||||
is trying to use are a substitute for the product explaining itself.
|
||||
- [x] **Tutorial mode** — done 2026-09-12. Three coach marks on the overview,
|
||||
once per browser: the prepared session, search versus AI Mode, and what
|
||||
the ring is measuring. Steps are declared against a `data-tour`
|
||||
attribute rather than a class, and one whose target is not on the page is
|
||||
dropped from the count rather than shown pointing at nothing — an
|
||||
educator and a learner do not see the same dashboard, and "Step 2 of 6"
|
||||
must not promise a step that will never come.
|
||||
- [x] **Per-question performance table** — done 2026-09-11 on the session
|
||||
analysis: position, excerpt, difficulty, time, peer percentage; sortable
|
||||
by position / slowest / hardest, ten a page.
|
||||
|
|
@ -289,9 +294,25 @@ Captured so nothing is lost while the article writing runs.
|
|||
|
||||
## Dashboard
|
||||
|
||||
- [ ] **Overview page for signed-in users** — search hero with Search / AI Mode
|
||||
toggle, "Continue your study", and a study-analysis donut. The current
|
||||
dashboard becomes this; a separate signed-out landing page comes later.
|
||||
- [x] **Overview page for signed-in users** — done 2026-09-12. The dashboard is
|
||||
now the overview: greeting, a search hero whose Search and AI Mode
|
||||
controls open the one Ctrl+K panel on the tab they name (a second search
|
||||
box would be a worse copy of the panel's history, typeahead and hand-off
|
||||
into a conversation), the prepared session, "Continue your study", and a
|
||||
donut. One column on a phone, two from 1000px.
|
||||
**The donut is a glance, not a page** — the ring, its key and one line of
|
||||
basis, on the latest-attempt reading, with "See full analysis" beside the
|
||||
heading. The trend, the peer comparison and the focus-area table stay on
|
||||
`/sessions`, where they are worth the room.
|
||||
**A new account is the good case, not the degraded one** — one request to
|
||||
`/study-tools/answer-split` decides. With answers behind it: the ring, and
|
||||
MyNote under it. With none: no ring, no notebook, no dashes — the
|
||||
cold-start prepared session (which already plans an even spread across the
|
||||
blueprint and says so) plus three doors to reading, study plans and AI
|
||||
Mode. A failed request shows neither rather than calling an established
|
||||
account new. The old dashboard fetched `/attempts/stats/dashboard` and
|
||||
`/attempts/history` on every visit and rendered neither; both are gone.
|
||||
Still on the signed-out landing page as a separate job.
|
||||
|
||||
## Analysis, as specified 2026-09-11 (evening)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { SessionDrawerProvider } from './context/SessionDrawer'
|
||||
|
|
@ -7,9 +7,10 @@ import { ThemeProvider } from './context/ThemeContext'
|
|||
import Navbar from './components/Navbar'
|
||||
import SiteFooter from './components/SiteFooter'
|
||||
import ChooseObjective from './components/ChooseObjective'
|
||||
import SearchOverlay from './components/SearchOverlay'
|
||||
import { SearchPanelProvider, useSearchPanel } from './context/SearchPanel'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import lazyPage from './utils/lazyPage'
|
||||
import FirstRunTour from './components/FirstRunTour'
|
||||
|
||||
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
|
||||
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
|
||||
|
|
@ -61,26 +62,23 @@ function LoadingFallback() {
|
|||
|
||||
// Layout wrapper for authenticated app pages (Navbar + container + footer)
|
||||
function AppLayout() {
|
||||
// The panel, its keyboard shortcut and the overlay itself live in the
|
||||
// provider, because the header is no longer the only thing that opens it —
|
||||
// the overview page's hero does too, and it is inside the Outlet.
|
||||
return (
|
||||
<SearchPanelProvider>
|
||||
<AppFrame />
|
||||
</SearchPanelProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AppFrame() {
|
||||
// Keyed by path so navigating away from a broken page clears the error.
|
||||
const location = useLocation()
|
||||
const [searching, setSearching] = useState(false)
|
||||
const { open: openSearch } = useSearchPanel()
|
||||
const inSession = useInSession()
|
||||
const fullBleed = useFullBleed()
|
||||
|
||||
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()
|
||||
setSearching(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
/* A column the height of the window, so the footer is at the bottom of the
|
||||
screen rather than at the bottom of the content — a page still loading
|
||||
|
|
@ -91,11 +89,10 @@ function AppLayout() {
|
|||
means the whole bank, which is a reasonable default and a poor thing
|
||||
to arrive at without being asked. */}
|
||||
<ChooseObjective />
|
||||
{/* 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={searching} onClose={() => setSearching(false)} />
|
||||
<Navbar onSearch={() => setSearching(true)} />
|
||||
{/* After the objective, never beside it: two things asking for attention
|
||||
at once is one of them being dismissed unread. */}
|
||||
<FirstRunTour />
|
||||
<Navbar onSearch={() => openSearch('search')} />
|
||||
{/* 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
|
||||
|
|
|
|||
40
frontend/src/components/CoachMarks.css
Normal file
40
frontend/src/components/CoachMarks.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/* ── The first-run walk ───────────────────────────────────────────────
|
||||
A hole in a dimmed page, a ring around what is being named, and a card
|
||||
beside it. The dimming is four rectangles rather than one enormous
|
||||
box-shadow: a shadow that size repaints the whole screen on every scroll
|
||||
event, and this thing follows scrolling. */
|
||||
.tour { position: absolute; inset: 0; z-index: 1400; }
|
||||
.tour-shade { position: absolute; left: 0; right: 0; background: rgba(15, 23, 42, 0.55); }
|
||||
.tour-ring {
|
||||
position: absolute; pointer-events: none;
|
||||
border: 2px solid var(--primary); border-radius: 10px;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, .65);
|
||||
}
|
||||
|
||||
.tour-card {
|
||||
position: absolute; width: min(320px, calc(100vw - 24px));
|
||||
padding: 16px 18px; background: var(--card-bg); color: var(--text);
|
||||
border-radius: 12px; box-shadow: 0 18px 44px rgba(15, 23, 42, .3);
|
||||
}
|
||||
.tour-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.tour-step {
|
||||
margin: 0 0 6px; font-size: 0.7rem; font-weight: 700;
|
||||
letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.tour-card h2 { margin: 0 0 8px; font-size: 1rem; }
|
||||
.tour-body { margin: 0 0 14px; font-size: 0.88rem; line-height: 1.55; color: var(--text-muted); }
|
||||
|
||||
.tour-actions { display: flex; align-items: center; gap: 8px; }
|
||||
/* Skip sits apart from the two that move you along, so leaving is never the
|
||||
button your hand is already on. */
|
||||
.tour-skip {
|
||||
margin-right: auto; padding: 6px 2px; cursor: pointer;
|
||||
font: inherit; font-size: 0.83rem; color: var(--text-subtle);
|
||||
background: none; border: 0;
|
||||
}
|
||||
.tour-skip:hover { color: var(--text); text-decoration: underline; }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.tour-card { animation: tour-in 160ms ease-out; }
|
||||
}
|
||||
@keyframes tour-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; } }
|
||||
145
frontend/src/components/CoachMarks.jsx
Normal file
145
frontend/src/components/CoachMarks.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import './CoachMarks.css'
|
||||
|
||||
/**
|
||||
* The short walk somebody gets the first time, and never again.
|
||||
*
|
||||
* A tour is a confession that the product does not explain itself, so this one
|
||||
* is deliberately small: it names the three or four things a learner would
|
||||
* otherwise have to go looking for, and gets out of the way. Anything that
|
||||
* needs six steps to explain wants rebuilding, not narrating.
|
||||
*
|
||||
* Steps are declared against a `data-tour` attribute rather than a CSS class
|
||||
* or a ref, because a tour that reaches into another component's internals
|
||||
* breaks the first time somebody renames a class for a visual reason. A step
|
||||
* whose target is not on the page is skipped rather than shown pointing at
|
||||
* nothing — pages differ by role, and an educator and a learner do not see the
|
||||
* same dashboard.
|
||||
*
|
||||
* Seen-ness is per browser, in localStorage: a tour is about the interface,
|
||||
* not about the account, and somebody arriving on a new machine has not lost
|
||||
* anything by not seeing it again.
|
||||
*/
|
||||
const SEEN_KEY = 'pedshub.tourSeen'
|
||||
|
||||
const seen = () => {
|
||||
try { return localStorage.getItem(SEEN_KEY) === 'yes' } catch { return true }
|
||||
}
|
||||
const remember = () => {
|
||||
try { localStorage.setItem(SEEN_KEY, 'yes') } catch { /* private mode: it will offer once more */ }
|
||||
}
|
||||
|
||||
/** Where the target is now, in page coordinates, or null if it is not there. */
|
||||
function locate(name) {
|
||||
const node = document.querySelector(`[data-tour="${name}"]`)
|
||||
if (!node) return null
|
||||
const box = node.getBoundingClientRect()
|
||||
if (!box.width && !box.height) return null // rendered but hidden
|
||||
return {
|
||||
top: box.top + window.scrollY,
|
||||
left: box.left + window.scrollX,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
}
|
||||
}
|
||||
|
||||
export default function CoachMarks({ steps = [], onDone }) {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [live, setLive] = useState([])
|
||||
const [spot, setSpot] = useState(null)
|
||||
const card = useRef(null)
|
||||
|
||||
// Which steps have something to point at. Computed once, on the page as it
|
||||
// stands: a step that appears later would move the numbering under somebody
|
||||
// mid-tour, and "Step 2 of 6" becoming "Step 2 of 7" is worse than missing a
|
||||
// step nobody was promised.
|
||||
useEffect(() => {
|
||||
const present = steps.filter(step => locate(step.target))
|
||||
setLive(present)
|
||||
if (!present.length) onDone?.()
|
||||
}, [steps, onDone])
|
||||
|
||||
const step = live[index]
|
||||
|
||||
const place = useCallback(() => {
|
||||
if (!step) return
|
||||
const at = locate(step.target)
|
||||
if (!at) { setIndex(i => i + 1); return } // vanished mid-tour
|
||||
setSpot(at)
|
||||
}, [step])
|
||||
|
||||
// Layout, not effect: the hole and the card have to be in place in the same
|
||||
// paint as the overlay, or the first frame shows a black screen.
|
||||
useLayoutEffect(() => {
|
||||
place()
|
||||
window.addEventListener('resize', place)
|
||||
window.addEventListener('scroll', place, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', place)
|
||||
window.removeEventListener('scroll', place, true)
|
||||
}
|
||||
}, [place])
|
||||
|
||||
useEffect(() => { card.current?.focus() }, [index])
|
||||
|
||||
const finish = useCallback(() => { remember(); onDone?.() }, [onDone])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event) => {
|
||||
if (event.key === 'Escape') finish()
|
||||
if (event.key === 'ArrowRight') setIndex(i => i + 1)
|
||||
if (event.key === 'ArrowLeft') setIndex(i => Math.max(0, i - 1))
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [finish])
|
||||
|
||||
useEffect(() => { if (live.length && index >= live.length) finish() }, [index, live.length, finish])
|
||||
|
||||
if (!step || !spot) return null
|
||||
|
||||
// Beneath the target where there is room, above it where there is not.
|
||||
const below = spot.top + spot.height + 12 - window.scrollY + 190 < window.innerHeight
|
||||
const cardStyle = {
|
||||
top: below ? spot.top + spot.height + 12 : undefined,
|
||||
bottom: below ? undefined : `calc(100% - ${spot.top - 12}px)`,
|
||||
left: Math.max(12, Math.min(spot.left, window.innerWidth - 340)),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tour" role="dialog" aria-modal="true" aria-labelledby="tour-heading">
|
||||
{/* Four panels rather than one box-shadow: a shadow that large is a
|
||||
repaint of the whole screen on every scroll, and this follows one. */}
|
||||
<div className="tour-shade" style={{ height: spot.top }} />
|
||||
<div className="tour-shade" style={{ top: spot.top + spot.height, bottom: 0 }} />
|
||||
<div className="tour-shade" style={{ top: spot.top, height: spot.height, width: spot.left }} />
|
||||
<div className="tour-shade" style={{
|
||||
top: spot.top, height: spot.height, left: spot.left + spot.width, right: 0,
|
||||
}} />
|
||||
<div className="tour-ring" style={{
|
||||
top: spot.top - 4, left: spot.left - 4,
|
||||
width: spot.width + 8, height: spot.height + 8,
|
||||
}} />
|
||||
|
||||
<div className="tour-card" style={cardStyle} ref={card} tabIndex={-1}>
|
||||
<p className="tour-step">Step {index + 1} of {live.length}</p>
|
||||
<h2 id="tour-heading">{step.title}</h2>
|
||||
<p className="tour-body">{step.body}</p>
|
||||
<div className="tour-actions">
|
||||
<button type="button" className="tour-skip" onClick={finish}>Skip</button>
|
||||
{index > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => setIndex(i => i - 1)}>Back</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
onClick={() => (index + 1 >= live.length ? finish() : setIndex(i => i + 1))}>
|
||||
{index + 1 >= live.length ? 'Done' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether this browser has been shown the tour. Exported for the caller. */
|
||||
export const tourSeen = seen
|
||||
79
frontend/src/components/CoachMarks.test.jsx
Normal file
79
frontend/src/components/CoachMarks.test.jsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CoachMarks, { tourSeen } from './CoachMarks'
|
||||
|
||||
const STEPS = [
|
||||
{ target: 'here', title: 'Start here', body: 'A session, built for you.' },
|
||||
{ target: 'there', title: 'And there', body: 'What you have not answered yet.' },
|
||||
{ target: 'nowhere', title: 'Missing', body: 'Nothing points at this.' },
|
||||
]
|
||||
|
||||
// jsdom gives every element a zero-sized rect, so a real target has to say it
|
||||
// has a size or the tour would treat it as hidden.
|
||||
const target = (name, size = { width: 200, height: 40 }) => {
|
||||
const node = document.createElement('div')
|
||||
node.setAttribute('data-tour', name)
|
||||
node.getBoundingClientRect = () => ({ top: 100, left: 50, ...size })
|
||||
document.body.appendChild(node)
|
||||
return node
|
||||
}
|
||||
|
||||
describe('the first-run walk', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('counts only the steps that have something to point at', async () => {
|
||||
target('here'); target('there')
|
||||
render(<CoachMarks steps={STEPS} onDone={() => {}} />)
|
||||
// Three steps declared, two on the page. Promising six and showing five is
|
||||
// worse than never mentioning the sixth.
|
||||
expect(await screen.findByText('Step 1 of 2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Start here' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('walks forward and back, and finishes on the last step', async () => {
|
||||
target('here'); target('there')
|
||||
const onDone = vi.fn()
|
||||
render(<CoachMarks steps={STEPS} onDone={onDone} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(screen.getByText('Step 2 of 2')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Back' }))
|
||||
expect(screen.getByText('Step 1 of 2')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(tourSeen()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not come back once it has been skipped', async () => {
|
||||
target('here')
|
||||
const onDone = vi.fn()
|
||||
render(<CoachMarks steps={STEPS} onDone={onDone} />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Skip' }))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
// Skipping is a decision, not a postponement.
|
||||
expect(tourSeen()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows nothing at all when the page has none of its targets', () => {
|
||||
const onDone = vi.fn()
|
||||
render(<CoachMarks steps={STEPS} onDone={onDone} />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
// An educator and a learner do not see the same page; a tour with nothing
|
||||
// to say should not announce itself.
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves on Escape', async () => {
|
||||
target('here')
|
||||
const onDone = vi.fn()
|
||||
render(<CoachMarks steps={STEPS} onDone={onDone} />)
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
60
frontend/src/components/FirstRunTour.jsx
Normal file
60
frontend/src/components/FirstRunTour.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { tourSeen } from './CoachMarks'
|
||||
|
||||
// Only ever loaded for the one visit that shows it.
|
||||
const CoachMarks = lazy(() => import('./CoachMarks'))
|
||||
|
||||
/**
|
||||
* Three things a learner would otherwise go looking for.
|
||||
*
|
||||
* Kept short on purpose. A tour is a confession that the product does not
|
||||
* explain itself, so this one names what is not obvious and stops — anything
|
||||
* needing six steps wants rebuilding rather than narrating.
|
||||
*
|
||||
* Only on the overview, and only once per browser. Following somebody around
|
||||
* the site would make it a thing to escape rather than a thing to read, and
|
||||
* the overview is where all three of these actually are.
|
||||
*/
|
||||
const STEPS = [
|
||||
{
|
||||
target: 'prepared',
|
||||
title: 'A session chosen for you',
|
||||
body: 'It picks what you have not seen, then your weakest topics, weighted by '
|
||||
+ 'how much of the real exam each one carries. You see the plan before it starts.',
|
||||
},
|
||||
{
|
||||
target: 'search',
|
||||
title: 'Search, or ask',
|
||||
body: 'Search finds questions and reading. AI Mode answers from your library and '
|
||||
+ 'shows you what it used. Ctrl+K opens either from anywhere.',
|
||||
},
|
||||
{
|
||||
target: 'glance',
|
||||
title: 'Where you stand',
|
||||
body: 'Your latest answer to each question, not an average of every attempt. '
|
||||
+ 'The full analysis is one click away.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function FirstRunTour() {
|
||||
const location = useLocation()
|
||||
// Read once, on mount, rather than on every render: marking it seen must not
|
||||
// yank the tour off the screen mid-step.
|
||||
const [show, setShow] = useState(() => location.pathname === '/' && !tourSeen())
|
||||
|
||||
// The dashboard mounts its blocks after a request settles, and a tour that
|
||||
// measures too early finds nothing and concludes there is nothing to show.
|
||||
const [ready, setReady] = useState(false)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setReady(true), 600)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
if (!show || !ready) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CoachMarks steps={STEPS} onDone={() => setShow(false)} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import ContinueStudy from '../components/ContinueStudy'
|
||||
import PreparedSession from '../components/PreparedSession'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import LineChart from '../components/LineChart'
|
||||
import ContinueStudy from '../components/ContinueStudy'
|
||||
import PreparedSession from '../components/PreparedSession'
|
||||
import Donut from '../components/Donut'
|
||||
import MyNote from '../components/MyNote'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useSearchPanel } from '../context/SearchPanel'
|
||||
import './DashboardPage.css'
|
||||
|
||||
/**
|
||||
* The line at the top of the dashboard.
|
||||
|
|
@ -35,64 +37,172 @@ export function greeting(name, now = new Date()) {
|
|||
return `${time}${first ? `, ${first}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The way in for somebody who arrived with a question rather than with time to
|
||||
* study.
|
||||
*
|
||||
* Both controls open the one search panel, on the tab they name. It is not a
|
||||
* search box of its own — the panel has the history, the typeahead and the
|
||||
* hand-off into a conversation, and a second field here would be a worse copy
|
||||
* of all three. It earns the space anyway, because the header's box is hidden
|
||||
* below 900px: on a phone this is the only search on the site.
|
||||
*
|
||||
* The two controls are not `role="tab"`: nothing is selected until the panel
|
||||
* is open, and a pair of tabs where neither is on is a lie told to a screen
|
||||
* reader.
|
||||
*/
|
||||
function SearchHero() {
|
||||
const { open } = useSearchPanel()
|
||||
return (
|
||||
<section className="ov-hero" data-tour="search" aria-labelledby="ov-hero-heading">
|
||||
<h2 id="ov-hero-heading" className="sr-only">Search</h2>
|
||||
<div className="ov-hero-modes" role="group" aria-label="Search or ask">
|
||||
<button type="button" onClick={() => open('search')}>Search</button>
|
||||
<button type="button" onClick={() => open('ai')}>AI Mode</button>
|
||||
</div>
|
||||
<button type="button" className="ov-hero-field" onClick={() => open('search')}>
|
||||
<span className="ov-hero-icon" aria-hidden="true">⌕</span>
|
||||
<span className="ov-hero-prompt">Search the library, or ask about anything in it</span>
|
||||
<kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress, at the size a glance deserves.
|
||||
*
|
||||
* One ring and one sentence. Everything else about these answers — the trend,
|
||||
* the peer comparison, the topic-by-topic table — is a click away on the
|
||||
* analysis page and belongs there; repeating a slice of it here would give a
|
||||
* learner two places to read the same figures and a reason to doubt both when
|
||||
* they disagree.
|
||||
*
|
||||
* Latest-attempt basis: the question a glance asks is what is known now, not
|
||||
* how much work has been done. The full page offers both and says which is
|
||||
* which.
|
||||
*/
|
||||
function Glance({ split }) {
|
||||
const now = split.latest
|
||||
return (
|
||||
<section className="ov-glance" aria-labelledby="ov-glance-heading">
|
||||
<div className="ov-glance-head">
|
||||
<h2 id="ov-glance-heading">Where you stand</h2>
|
||||
<Link to="/sessions?view=performance">See full analysis</Link>
|
||||
</div>
|
||||
<div className="an-donut-wrap">
|
||||
<Donut correct={now.correct} hinted={now.correct_with_hints}
|
||||
incorrect={now.incorrect} skipped={now.unanswered} />
|
||||
<ul className="an-legend">
|
||||
<li><i className="is-right" />{now.correct} correct</li>
|
||||
{now.correct_with_hints > 0 && (
|
||||
<li><i className="is-hinted" />{now.correct_with_hints} correct after a tip</li>
|
||||
)}
|
||||
<li><i className="is-wrong" />{now.incorrect} incorrect</li>
|
||||
{now.unanswered > 0 && <li><i className="is-none" />{now.unanswered} unanswered</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="ov-basis">
|
||||
Your most recent answer to each of {split.unique_questions} question
|
||||
{split.unique_questions === 1 ? '' : 's'}, across {split.attempts} session
|
||||
{split.attempts === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What an account with no answers behind it gets instead of a shelf of dashes.
|
||||
*
|
||||
* The prepared session above this one already works on the first visit — it
|
||||
* falls back to an even spread across the blueprint and says so — so the
|
||||
* opening act is a real session, not an onboarding tour. This is the rest of
|
||||
* the building, for somebody who would rather read first or does not want
|
||||
* questions today.
|
||||
*/
|
||||
const FIRST_STEPS = [
|
||||
{ to: '/articles', title: 'Read first', body: 'The reference library, filed by topic and cross-linked.' },
|
||||
{ to: '/study-plans', title: 'Follow a plan', body: 'A structured route through the material, block by block.' },
|
||||
{ to: '/ai', title: 'Ask a question', body: 'Answers drawn from the library, with every source cited.' },
|
||||
]
|
||||
|
||||
function FirstSteps() {
|
||||
return (
|
||||
<section className="ov-first" aria-labelledby="ov-first-heading">
|
||||
<h2 id="ov-first-heading">Other ways to start</h2>
|
||||
<ul className="ov-first-grid">
|
||||
{FIRST_STEPS.map(step => (
|
||||
<li key={step.to}>
|
||||
<Link to={step.to}>
|
||||
<strong>{step.title}</strong>
|
||||
<span>{step.body}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview: what to do now, what was left half-done, and how it is going.
|
||||
*
|
||||
* Each block below has to justify the room it takes, because a page that shows
|
||||
* everything is a page nobody reads any of. Deliberately absent: the trend
|
||||
* chart, the completion tiles and the focus-area table, all of which are one
|
||||
* click away under Sessions and are worth a page rather than a corner.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const [stats, setStats] = useState(null)
|
||||
const [history, setHistory] = useState([])
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deletingAttempt, setDeletingAttempt] = useState(null)
|
||||
|
||||
const [confirmAttempt, setConfirmAttempt] = useState(null)
|
||||
|
||||
const deleteAttempt = async (attemptId) => {
|
||||
if (confirmAttempt !== attemptId) { setConfirmAttempt(attemptId); return }
|
||||
setDeletingAttempt(attemptId)
|
||||
setConfirmAttempt(null)
|
||||
try {
|
||||
await api.delete(`/attempts/${attemptId}`)
|
||||
setHistory(prev => prev.map(q => ({
|
||||
...q,
|
||||
attempts: q.attempts.filter(a => a.attempt_id !== attemptId),
|
||||
})).filter(q => q.attempts.length > 0))
|
||||
} catch { }
|
||||
finally { setDeletingAttempt(null) }
|
||||
}
|
||||
//: One request answers two questions: what the ring should show, and whether
|
||||
//: this account has anything to put in one. Left null on failure, which
|
||||
//: shows neither rather than claiming a fresh account.
|
||||
const [split, setSplit] = useState(null)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const promises = [
|
||||
api.get('/attempts/stats/dashboard'),
|
||||
api.get('/attempts/history'),
|
||||
]
|
||||
Promise.all(promises).then(([statsRes, histRes]) => {
|
||||
setStats(statsRes.data)
|
||||
setHistory(histRes.data)
|
||||
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
let live = true
|
||||
api.get('/study-tools/answer-split')
|
||||
.then(res => { if (live) setSplit(res.data) })
|
||||
.catch(() => { if (live) setSplit(null) })
|
||||
.finally(() => { if (live) setLoaded(true) })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
|
||||
|
||||
const greetingText = greeting(user?.name)
|
||||
|
||||
const selectedQuiz = history.find(q => q.quiz_id === selectedQuizId)
|
||||
const started = !!split && split.latest.total > 0
|
||||
const fresh = loaded && !!split && split.latest.total === 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ov-page">
|
||||
{/* The greeting names the page and nothing else; the first section
|
||||
below is its own heading. Sized as a page title rather than a section
|
||||
one, because below the fold it read as a heading for the wrong
|
||||
section. */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
|
||||
</div>
|
||||
{/* What to do now, before what was left half-done: the session that is
|
||||
waiting is a recovery path, and it is directly underneath. */}
|
||||
<PreparedSession />
|
||||
<ContinueStudy />
|
||||
<MyNote variant="card" />
|
||||
<h1 className="ov-greeting">{greeting(user?.name)}</h1>
|
||||
|
||||
{/* Looking something up is not studying, and somebody who came to do it
|
||||
should not have to scroll past a study plan to get at the box. */}
|
||||
<SearchHero />
|
||||
|
||||
{/* One column on a phone, two where there is room — the stylesheet
|
||||
decides, and the source order is the narrow one. */}
|
||||
<div className="ov-body">
|
||||
{/* What to do now, before what was left half-done: the session that is
|
||||
waiting is a recovery path, and it is directly underneath. */}
|
||||
<div data-tour="prepared"><PreparedSession /></div>
|
||||
<ContinueStudy />
|
||||
|
||||
{/* Mutually exclusive by construction: a ring drawn from nothing is
|
||||
the empty state this page exists to avoid, and three doors offered
|
||||
to somebody already mid-course is clutter. */}
|
||||
{started && <div data-tour="glance"><Glance split={split} /></div>}
|
||||
{fresh && <FirstSteps />}
|
||||
|
||||
{/* Last, and only once there are answers behind it. A notebook with
|
||||
nothing in it is furniture; it turns up when there is something
|
||||
worth writing down. */}
|
||||
{started && <MyNote variant="card" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue