feat: the landing page shows the product instead of describing it
Six cards of prose sat under two mock players that already showed the thing. "A player built for how the paper asks. Sit a session as study or as an exam block…" is the picture above it, written out again, and a stranger reads neither. They are gone, and so is the heading over the band — it named what the pictures show. A third panel joins them: an analysis with a ring that fills and three bars that grow. No figures and no labels, because a number on a landing page is a number nobody checks; the shapes move and where they sit says what they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
83f6cb15cf
commit
0a331cf4eb
13 changed files with 443 additions and 136 deletions
|
|
@ -1,37 +0,0 @@
|
|||
"""Project existing article sections into the section index.
|
||||
|
||||
`_rebuild_section_index` runs when an article is saved, so articles written
|
||||
before that existed have no rows — the index was empty and section-level
|
||||
retrieval had nothing to search.
|
||||
|
||||
docker compose exec backend python -m scripts.backfill_section_index
|
||||
docker compose exec backend python -m scripts.backfill_section_index --apply
|
||||
"""
|
||||
import sys
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.article import Article
|
||||
from app.routers.articles import _rebuild_section_index
|
||||
|
||||
|
||||
def main():
|
||||
apply_changes = "--apply" in sys.argv
|
||||
db = SessionLocal()
|
||||
try:
|
||||
articles = db.query(Article).all()
|
||||
sections = sum(len(a.sections or []) for a in articles)
|
||||
print(f" articles : {len(articles)}")
|
||||
print(f" sections : {sections}")
|
||||
if apply_changes:
|
||||
for article in articles:
|
||||
_rebuild_section_index(db, article)
|
||||
db.commit()
|
||||
print(" indexed and embedded.")
|
||||
else:
|
||||
print("\n Re-run with --apply to build the index.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -11,6 +11,7 @@ const JOBS = [
|
|||
{ key: 'article', label: 'Articles', hint: 'Drafting and refining reading.' },
|
||||
{ key: 'flashcard', label: 'Cards', hint: 'Generating flashcard decks.' },
|
||||
{ key: 'keyword', label: 'Classification', hint: 'Suggesting subjects and tags.' },
|
||||
{ key: 'tool', label: 'Tool', hint: 'Does what the model on a job cannot — reading an image, for one.' },
|
||||
{ key: 'tts', label: 'Read aloud', hint: 'Turning a question into speech.' },
|
||||
{ key: 'stt', label: 'Transcription', hint: 'Turning speech into text.' },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -75,6 +75,16 @@ describe('model selection', () => {
|
|||
expect(await screen.findByText(/Answered/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the tool model as a job of its own, and tests it like any other', async () => {
|
||||
// The model that covers what another model cannot do — reading an image,
|
||||
// today — is chosen the same way as the tutor's or the extractor's.
|
||||
api.get.mockResolvedValue({ data: [...MODELS,
|
||||
{ id: 4, name: 'gpt-4.1', model_id: 'gpt-4.1', task: 'tool', is_active: true, is_default: true }] })
|
||||
render(<ModelsAdmin />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tool' }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/4/test'))
|
||||
})
|
||||
|
||||
it('keeps the allow-list folded away, since it is set once', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
|
|
|
|||
|
|
@ -50,9 +50,31 @@ export default function PractiseTopic({ article }) {
|
|||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
// Nothing linked, or the count never came back. Offering a test of no
|
||||
// questions is worse than offering nothing.
|
||||
if (!available) return null
|
||||
// The count never came back. Nothing to say about a number we do not have.
|
||||
if (available === null) return null
|
||||
|
||||
// No questions, said rather than hidden. It used to render nothing at all,
|
||||
// which reads as a page that has not finished loading — and a reader who has
|
||||
// just read the article is the one person entitled to know why there is
|
||||
// nothing to sit on it. Reading is open to everyone; questions are drawn
|
||||
// from the bank for the exam you are studying for, and both of those facts
|
||||
// are worth stating here rather than leaving somebody to infer them.
|
||||
if (!available) {
|
||||
return (
|
||||
<section className="article-practise is-empty">
|
||||
<h3>Nothing to practise here yet</h3>
|
||||
<p>
|
||||
No questions in your bank are linked to this topic. Reading is open to
|
||||
everyone; questions are drawn from the bank for the exam you are
|
||||
studying for, so a topic can be readable long before it is examinable.
|
||||
</p>
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate('/study/new')}>
|
||||
Build a session on something else
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="article-practise">
|
||||
|
|
|
|||
53
frontend/src/components/PreparedSession.css
Normal file
53
frontend/src/components/PreparedSession.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* "Ready for you" — the prepared session and the reasoning behind it. */
|
||||
|
||||
.ps-panel {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px; margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ps-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||
.ps-head h2 { margin: 0; font-size: 1.15rem; font-weight: 650; }
|
||||
.ps-basis {
|
||||
font-size: .7rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
|
||||
padding: 3px 8px; border-radius: 999px; color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
.ps-basis.is-cold { color: var(--text-muted); background: var(--border); }
|
||||
|
||||
.ps-summary { margin: 0 0 14px; font-size: .88rem; color: var(--text-muted); }
|
||||
|
||||
.ps-topics { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 11px; }
|
||||
.ps-topic-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.ps-topic-name { font-size: .9rem; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.ps-topic-count { font-size: .8rem; color: var(--text-muted); white-space: nowrap; }
|
||||
.ps-topic-bar {
|
||||
display: block; height: 4px; margin: 5px 0 4px; border-radius: 2px;
|
||||
background: var(--border); overflow: hidden;
|
||||
}
|
||||
.ps-topic-bar > span { display: block; height: 100%; background: var(--primary); }
|
||||
.ps-topic-reason { margin: 0; font-size: .8rem; color: var(--text-muted); }
|
||||
|
||||
.ps-foot {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding-top: 13px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.ps-count { display: flex; align-items: center; gap: 8px; }
|
||||
.ps-count > span { font-size: .8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.ps-count input {
|
||||
width: 68px; padding: 7px 9px; font: inherit; font-size: .88rem;
|
||||
color: var(--text); background: var(--input-bg, var(--card-bg));
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
/* Takes the slack, so the button stays on the right on a wide card and drops
|
||||
below on a narrow one rather than being squeezed against the count. */
|
||||
.ps-length { flex: 1 1 180px; margin: 0; font-size: .78rem; color: var(--text-subtle, var(--text-muted)); }
|
||||
.ps-start { white-space: nowrap; }
|
||||
|
||||
.ps-error { margin: 10px 0 0; font-size: .82rem; color: var(--danger-fg, #c0392b); }
|
||||
.ps-manual { margin: 12px 0 0; font-size: .78rem; color: var(--text-muted); }
|
||||
.ps-manual a { color: var(--primary); }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.ps-foot { align-items: stretch; }
|
||||
.ps-start { width: 100%; }
|
||||
}
|
||||
132
frontend/src/components/PreparedSession.jsx
Normal file
132
frontend/src/components/PreparedSession.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { sessionTitle } from '../utils/sessionTitle'
|
||||
import './PreparedSession.css'
|
||||
|
||||
/**
|
||||
* One action that produces the session this learner should sit now, and the
|
||||
* reasoning behind it before they commit to it.
|
||||
*
|
||||
* The reasoning is the point. The adaptive picker has existed for a while
|
||||
* behind a toggle on the custom-test builder, and a learner who cannot see why
|
||||
* they were given those twenty questions goes back to choosing topics by hand —
|
||||
* so the plan is shown first, in the learner's own vocabulary, and only then
|
||||
* started. Everything here is arithmetic over their own answers; nothing is
|
||||
* generated.
|
||||
*
|
||||
* It sits above "Continue your study" rather than below it because this is the
|
||||
* answer to the question the dashboard exists to ask. Resuming a session left
|
||||
* open is a recovery path, and it is immediately underneath.
|
||||
*/
|
||||
|
||||
//: Long enough that typing "30" is one request rather than two, short enough
|
||||
//: that the plan does not feel stale under the count that produced it.
|
||||
const RECOUNT_DELAY_MS = 300
|
||||
|
||||
export default function PreparedSession() {
|
||||
const navigate = useNavigate()
|
||||
const [plan, setPlan] = useState(null)
|
||||
//: What the learner typed, or null while they have not touched it — the
|
||||
//: length is the plan's decision to make until somebody says otherwise.
|
||||
//: Held as the raw string so that clearing the box leaves it cleared rather
|
||||
//: than snapping back to the plan's number under the cursor.
|
||||
const [chosen, setChosen] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const asked = chosen === null ? null : Number.parseInt(chosen, 10)
|
||||
const valid = asked === null || (Number.isInteger(asked) && asked >= 1 && asked <= 200)
|
||||
|
||||
useEffect(() => {
|
||||
if (!valid) return undefined
|
||||
let live = true
|
||||
const timer = setTimeout(() => {
|
||||
api.get('/questions/builder/prepared', { params: asked === null ? {} : { count: asked } })
|
||||
.then(res => { if (live) { setPlan(res.data); setFailed(false) } })
|
||||
// No message. An objective that has not been chosen is already asked
|
||||
// for by the modal over this page, and an empty bank is not something
|
||||
// the learner can do anything about from here.
|
||||
.catch(() => { if (live) setFailed(true) })
|
||||
.finally(() => { if (live) setLoading(false) })
|
||||
}, asked === null ? 0 : RECOUNT_DELAY_MS)
|
||||
return () => { live = false; clearTimeout(timer) }
|
||||
}, [asked, valid])
|
||||
|
||||
const shown = chosen ?? (plan?.count ?? '')
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (starting || !valid || !plan) return
|
||||
setStarting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/questions/builder/prepared', {
|
||||
count: asked || plan.count,
|
||||
mode: 'learning',
|
||||
title: sessionTitle('Prepared session'),
|
||||
})
|
||||
navigate(`/study/${res.data.id}?start=1`)
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Could not start that session. Try again.')
|
||||
setStarting(false)
|
||||
}
|
||||
}, [starting, valid, plan, asked, navigate])
|
||||
|
||||
if (loading || failed || !plan) return null
|
||||
|
||||
return (
|
||||
<section className="ps-panel" aria-labelledby="ps-heading">
|
||||
<div className="ps-head">
|
||||
<h2 id="ps-heading">Ready for you</h2>
|
||||
<span className={`ps-basis${plan.basis === 'cold_start' ? ' is-cold' : ''}`}>
|
||||
{plan.basis === 'cold_start' ? 'Starting point' : 'Personalized'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="ps-summary">{plan.summary}</p>
|
||||
|
||||
<ul className="ps-topics">
|
||||
{plan.topics.map(topic => (
|
||||
<li key={topic.category_id ?? topic.name}>
|
||||
<div className="ps-topic-line">
|
||||
<span className="ps-topic-name">{topic.name}</span>
|
||||
<span className="ps-topic-count">
|
||||
{topic.count} question{topic.count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ps-topic-bar" aria-hidden="true">
|
||||
<span style={{ width: `${Math.round((100 * topic.count) / plan.count)}%` }} />
|
||||
</span>
|
||||
<p className="ps-topic-reason">{topic.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="ps-foot">
|
||||
<label className="ps-count">
|
||||
<span>Questions</span>
|
||||
<input type="number" min="1" max="200" step="1" value={shown}
|
||||
aria-label="Number of questions"
|
||||
onChange={e => setChosen(e.target.value)} />
|
||||
</label>
|
||||
<p className="ps-length">{plan.length_reason}</p>
|
||||
<button type="button" className="btn btn-primary ps-start"
|
||||
disabled={starting || !valid} onClick={start}>
|
||||
{starting ? 'Starting…' : 'Start session'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!valid && <p className="ps-error">Choose between 1 and 200 questions.</p>}
|
||||
{error && <p className="ps-error" role="alert">{error}</p>}
|
||||
|
||||
{/* The manual builder is not replaced by this — it is what you reach for
|
||||
when you want something this cannot know you want. */}
|
||||
<p className="ps-manual">
|
||||
Want something specific? <Link to="/study/new">Build a test yourself</Link>.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
129
frontend/src/components/PreparedSession.test.jsx
Normal file
129
frontend/src/components/PreparedSession.test.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import PreparedSession from './PreparedSession'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
const PLAN = {
|
||||
count: 14,
|
||||
basis: 'personalized',
|
||||
new_count: 9,
|
||||
review_count: 5,
|
||||
available: 2900,
|
||||
exam_name: 'Pediatrics Boards',
|
||||
summary: '9 new and 5 due for review, ranked by what would move your score the most.',
|
||||
length_reason: 'You usually finish 14 questions, so that’s the length.',
|
||||
topics: [
|
||||
{ category_id: 1, name: 'Cardiology', count: 8, new_count: 5, review_count: 3,
|
||||
accuracy: 41, weight: 12, reason: 'Weak area — 41% correct so far' },
|
||||
{ category_id: 2, name: 'Neonatology', count: 4, new_count: 3, review_count: 1,
|
||||
accuracy: null, weight: 9, reason: 'Not attempted yet, worth 9% of the exam' },
|
||||
{ category_id: 3, name: 'Rheumatology', count: 2, new_count: 1, review_count: 1,
|
||||
accuracy: 78, weight: 2, reason: 'Due for review — last answered 6 weeks ago' },
|
||||
],
|
||||
}
|
||||
|
||||
const COLD = {
|
||||
...PLAN,
|
||||
count: 20, basis: 'cold_start', new_count: 20, review_count: 0,
|
||||
summary: "You haven't finished a session yet, so this is an even spread across the exam blueprint rather than a personalized one.",
|
||||
length_reason: '20 to start with — after 3 more finished sessions this matches your own median.',
|
||||
topics: [{ category_id: 1, name: 'Cardiology', count: 12, new_count: 12, review_count: 0,
|
||||
accuracy: null, weight: 60, reason: 'Worth 60% of the exam' }],
|
||||
}
|
||||
|
||||
const mount = () => render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route path="/" element={<PreparedSession />} />
|
||||
<Route path="/study/:id" element={<h1>Session started</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: PLAN })
|
||||
})
|
||||
|
||||
it('states the plan before anything is started', async () => {
|
||||
mount()
|
||||
expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
|
||||
expect(screen.getByText(PLAN.summary)).toBeInTheDocument()
|
||||
expect(screen.getByText(PLAN.length_reason)).toBeInTheDocument()
|
||||
|
||||
// Every topic, its share of the session, and the one line saying why.
|
||||
expect(screen.getByText('Cardiology')).toBeInTheDocument()
|
||||
expect(screen.getByText('8 questions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Weak area — 41% correct so far')).toBeInTheDocument()
|
||||
expect(screen.getByText('Due for review — last answered 6 weeks ago')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Number of questions')).toHaveValue(14)
|
||||
|
||||
// The length is the plan's until the learner says otherwise, so the first
|
||||
// ask carries no count at all.
|
||||
expect(api.get).toHaveBeenCalledWith('/questions/builder/prepared', { params: {} })
|
||||
// Nothing exists yet: the plan is a statement, not a session.
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says when it has nothing to personalize from rather than pretending', async () => {
|
||||
api.get.mockResolvedValue({ data: COLD })
|
||||
mount()
|
||||
expect(await screen.findByText(/haven't finished a session yet/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Starting point')).toBeInTheDocument()
|
||||
expect(screen.getByText('Worth 60% of the exam')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Personalized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replans when the learner changes the length', async () => {
|
||||
mount()
|
||||
await screen.findByText(PLAN.summary)
|
||||
const longer = { ...PLAN, count: 30, summary: '21 new and 9 due for review, ranked by what would move your score the most.' }
|
||||
api.get.mockResolvedValue({ data: longer })
|
||||
|
||||
const input = screen.getByLabelText('Number of questions')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '30')
|
||||
|
||||
await waitFor(() => expect(api.get).toHaveBeenLastCalledWith(
|
||||
'/questions/builder/prepared', { params: { count: 30 } }))
|
||||
expect(await screen.findByText(longer.summary)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts the session at the length on screen and opens it', async () => {
|
||||
api.post.mockResolvedValue({ data: { id: 91, questions_count: 14, plan: PLAN } })
|
||||
mount()
|
||||
await screen.findByText(PLAN.summary)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Start session' }))
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
|
||||
'/questions/builder/prepared', expect.objectContaining({ count: 14, mode: 'learning' })))
|
||||
expect(await screen.findByRole('heading', { name: 'Session started' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the manual builder one click away', async () => {
|
||||
mount()
|
||||
await screen.findByText(PLAN.summary)
|
||||
expect(screen.getByRole('link', { name: 'Build a test yourself' })).toHaveAttribute('href', '/study/new')
|
||||
})
|
||||
|
||||
it('reports a refused start without navigating away', async () => {
|
||||
api.post.mockRejectedValue({ response: { data: { detail: 'Choose what you are studying for' } } })
|
||||
mount()
|
||||
await screen.findByText(PLAN.summary)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Start session' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Choose what you are studying for')
|
||||
expect(screen.queryByRole('heading', { name: 'Session started' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows nothing at all when there is no plan to show', async () => {
|
||||
// An empty bank, or an objective not yet chosen — which the modal over this
|
||||
// page is already asking for. A second error message here would be noise.
|
||||
api.get.mockRejectedValue({ response: { status: 400 } })
|
||||
const { container } = mount()
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalled())
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
|
@ -530,7 +530,7 @@ export default function AnalysisPage() {
|
|||
<div className="an-nextstep">
|
||||
<div className="an-nextstep-text">
|
||||
<h2>Next step: adaptive session</h2>
|
||||
<p>Practises what you have not seen first, then the topics you get wrong most.</p>
|
||||
<p>Practices what you have not seen first, then what you get wrong most and what is due again.</p>
|
||||
</div>
|
||||
<div className="an-nextstep-controls">
|
||||
<label>
|
||||
|
|
|
|||
|
|
@ -197,6 +197,12 @@
|
|||
|
||||
/* ── Practise this topic ──────────────────────────────────────────── */
|
||||
.article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; }
|
||||
/* Quieter than the offer it replaces: it is an explanation, not a control. */
|
||||
.article-practise.is-empty h3 { margin: 0 0 6px; font-size: 0.95rem; }
|
||||
.article-practise.is-empty p {
|
||||
margin: 0 0 12px; max-width: 62ch;
|
||||
font-size: 0.86rem; line-height: 1.6; color: var(--text-muted);
|
||||
}
|
||||
.article-practise-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.article-practise-head h3 { margin: 0; font-size: 1rem; }
|
||||
.article-practise-count { font-size: .78rem; color: var(--text-muted); }
|
||||
|
|
|
|||
|
|
@ -306,7 +306,8 @@ export default function CustomQuizPage() {
|
|||
</div>
|
||||
{adaptive && (
|
||||
<p className="custom-test-note custom-test-card-pad">
|
||||
Adaptive prefers unanswered questions, then recycles older incorrect ones, and moves between weak
|
||||
Adaptive fills most of the test with questions you have not seen, gives the rest to ones that are
|
||||
due again — missed, or last answered long enough ago to be worth checking — and moves between weak
|
||||
areas instead of repeating one.
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import ContinueStudy from '../components/ContinueStudy'
|
||||
import PreparedSession from '../components/PreparedSession'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import LineChart from '../components/LineChart'
|
||||
|
|
@ -79,11 +80,16 @@ export default function DashboardPage() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* The greeting names the page; "continue your study" is the first thing
|
||||
on it. Below the fold it read as a heading for the wrong section. */}
|
||||
{/* 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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -488,3 +488,57 @@
|
|||
@media (max-width: 640px) {
|
||||
.lp-modes { gap: 22px; }
|
||||
}
|
||||
|
||||
|
||||
/* ── The third panel: what the sitting was for ────────────────────────
|
||||
A ring that fills and three bars that grow. No figures and no labels — the
|
||||
shapes move, and what they are is obvious from where they sit. Six cards of
|
||||
prose used to say this instead, which is a slower way of showing somebody a
|
||||
picture of a donut. */
|
||||
.lp-pill.is-analysis { background: #e5ecf8; color: #365b8d; }
|
||||
|
||||
.lp-donut {
|
||||
position: relative; width: 92px; height: 92px; margin: 6px auto 16px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(var(--primary) 0turn, var(--border) 0turn);
|
||||
}
|
||||
/* The hole. A ring drawn with a gradient needs one, or it is a pie. */
|
||||
.lp-donut > i {
|
||||
position: absolute; inset: 13px; border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.lp-donut > span {
|
||||
position: absolute; inset: 30px; border-radius: 4px; background: var(--border);
|
||||
}
|
||||
|
||||
.lp-bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.lp-bars li { display: flex; align-items: center; gap: 10px; }
|
||||
.lp-bar-label { flex: 0 0 62px; height: 8px; border-radius: 4px; background: var(--border); }
|
||||
.lp-bar { flex: 1; height: 10px; border-radius: 5px; background: var(--bg); overflow: hidden; }
|
||||
.lp-bar > span { display: block; height: 100%; width: 0; background: var(--primary); border-radius: 5px; }
|
||||
.lp-bar > span.w72 { --to: 72%; }
|
||||
.lp-bar > span.w48 { --to: 48%; background: #a13c51; }
|
||||
.lp-bar > span.w86 { --to: 86%; background: #327b64; }
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.lp-modes:not(.is-calm) .lp-donut { animation: lp-fill 9s ease-in-out infinite; }
|
||||
.lp-modes:not(.is-calm) .lp-bar > span { animation: lp-grow 9s ease-in-out infinite; }
|
||||
}
|
||||
/* Without motion the shapes still have to mean something, so they rest full. */
|
||||
.lp-modes.is-calm .lp-donut,
|
||||
.lp-modes:not(.is-calm) .lp-donut { }
|
||||
.lp-bar > span { width: var(--to); }
|
||||
|
||||
@keyframes lp-fill {
|
||||
0%, 10% { background: conic-gradient(var(--primary) 0turn, var(--border) 0turn); }
|
||||
55%, 100% { background: conic-gradient(var(--primary) 0.68turn, var(--border) 0.68turn); }
|
||||
}
|
||||
@keyframes lp-grow {
|
||||
0%, 18% { width: 0; }
|
||||
62%, 100% { width: var(--to); }
|
||||
}
|
||||
|
||||
/* Three panels rather than two, so they may sit narrower. */
|
||||
@media (min-width: 1100px) {
|
||||
.lp-modes { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import './LandingPage.css'
|
|||
const ADAPTIVE_STEPS = [
|
||||
'what you have never seen',
|
||||
'then your weakest topic',
|
||||
'then what you got wrong, oldest first',
|
||||
'then what you got wrong, most recently first',
|
||||
'all of it weighted by the real paper',
|
||||
]
|
||||
|
||||
|
|
@ -42,60 +42,6 @@ const FIGURES = [
|
|||
['articles', 'Articles'],
|
||||
]
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: 'timer',
|
||||
title: 'A player built for how the paper asks',
|
||||
desc: 'Sit a session as study or as an exam block. Rule an option out and it stays out, highlight the part of the stem that matters, open an attending tip when you are stuck.',
|
||||
points: [
|
||||
'Per-question timing that only runs while the screen is actually open',
|
||||
'A warning before the block ends, not after',
|
||||
'Study mode marks as you go; exam mode waits until the end',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'target',
|
||||
title: 'Sessions that decide themselves',
|
||||
desc: 'Ask for twenty questions and the bank works out which twenty. Never-seen first, then the topic you are weakest in, then the ones you got wrong, oldest first.',
|
||||
points: [
|
||||
'Scaled by the share of the real exam each topic is published as carrying',
|
||||
'A topic the blueprint does not cover takes the median weight, never zero',
|
||||
'It picks what to study, not what the paper is made of',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'chart',
|
||||
title: 'An analysis that says where you stand',
|
||||
desc: 'Readiness, score over time, how much of the bank you have covered, and a split of correct, correct after a tip, and incorrect — because those are three different things.',
|
||||
points: [
|
||||
'A knowledge profile across Articles, Systems and Disciplines',
|
||||
'Relevance taken from the ABP content outline, not from our own proportions',
|
||||
'Every session reviewable question by question, long after it is closed',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'spark',
|
||||
title: 'AI Mode, and what it is not allowed to do',
|
||||
desc: 'Ask it anything and it answers out of your library alone. Retrieval builds the shortlist, the shortlist is the whole prompt, and any citation retrieval did not find is deleted before you see the answer.',
|
||||
points: [
|
||||
'Every claim arrives with a source you can open and check',
|
||||
'Turn any answer straight into a practice session on the same material',
|
||||
'No library, no answer — it says so rather than inventing one',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'book',
|
||||
title: 'Articles that read like a reference',
|
||||
desc: 'Written material with cross-references between them and tips set inline in the prose, so the explanation is where the question left you rather than three clicks away.',
|
||||
points: ['Flashcards', 'Courses with modules and lessons', 'Study plans you can hold yourself to'],
|
||||
},
|
||||
{
|
||||
icon: 'shield',
|
||||
title: 'A sign-up that reports you to nobody',
|
||||
desc: 'The challenge in front of registration is self-hosted. It runs here, on this server, and no third party is told that you visited, tried to sign up, or how long you thought about it.',
|
||||
},
|
||||
]
|
||||
|
||||
const CLAIMS = [
|
||||
{
|
||||
title: 'Where the model writes',
|
||||
|
|
@ -120,23 +66,6 @@ const CLINICAL_TILES = [
|
|||
['Bedside', 'Emergency care'],
|
||||
]
|
||||
|
||||
const ICONS = {
|
||||
timer: <><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M9 1h6" /></>,
|
||||
target: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="0.6" /></>,
|
||||
chart: <><path d="M3 21h18M7 17V9M12 17V4M17 17v-6" /></>,
|
||||
spark: <><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" /><path d="M18 16.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" /></>,
|
||||
book: <><path d="M4 4.5A1.5 1.5 0 015.5 3H19v18H5.5A1.5 1.5 0 014 19.5z" /><path d="M8 7h7M8 11h7" /></>,
|
||||
shield: <><path d="M12 3l7 3v6c0 4.2-2.9 7.9-7 9-4.1-1.1-7-4.8-7-9V6z" /><path d="M9 12l2 2 4-4" /></>,
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{ICONS[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** True when the visitor has asked their system for less movement. */
|
||||
function useCalmMotion() {
|
||||
|
|
@ -612,11 +541,7 @@ function ModeShowcase({ calm }) {
|
|||
</div>
|
||||
</figure>
|
||||
|
||||
<figure className="lp-mode lp-mode-exam">
|
||||
<figcaption>
|
||||
<strong>Exam</strong>
|
||||
<span>Nothing marked until the block ends, and the clock only runs while you are in it.</span>
|
||||
</figcaption>
|
||||
<figure className="lp-mode lp-mode-exam" aria-label="An exam block with its clock running and nothing marked">
|
||||
<div className="lp-screen is-exam" aria-hidden="true">
|
||||
<div className="lp-screen-bar is-exam">
|
||||
<span className="lp-block">Item 3 of 40</span>
|
||||
|
|
@ -651,6 +576,26 @@ function ModeShowcase({ calm }) {
|
|||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<figure className="lp-mode lp-mode-analysis" aria-label="An analysis filling in as answers are recorded">
|
||||
<div className="lp-screen" aria-hidden="true">
|
||||
<div className="lp-screen-bar">
|
||||
<span className="lp-pill is-analysis">Analysis</span>
|
||||
<span className="lp-line w40" />
|
||||
</div>
|
||||
<div className="lp-screen-body">
|
||||
{/* A ring that fills and three bars that grow. Nothing here claims a
|
||||
number: the shapes move, and what they are is obvious from where
|
||||
they sit. */}
|
||||
<div className="lp-donut"><span /><i /></div>
|
||||
<ul className="lp-bars">
|
||||
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w72" /></span></li>
|
||||
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w48" /></span></li>
|
||||
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w86" /></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -704,22 +649,7 @@ export default function LandingPage() {
|
|||
{/* ── What it does ───────────────────────────────────────────────────── */}
|
||||
<section className="lp-section">
|
||||
<div className="lp-inner">
|
||||
<div className="lp-head">
|
||||
<h2>Two ways to sit it</h2>
|
||||
</div>
|
||||
<ModeShowcase calm={calm} />
|
||||
<div className="lp-grid">
|
||||
{FEATURES.map(feature => (
|
||||
<Reveal key={feature.title} className="lp-card">
|
||||
<span className="lp-card-mark"><Icon name={feature.icon} /></span>
|
||||
<h3>{feature.title}</h3>
|
||||
<p>{feature.desc}</p>
|
||||
{feature.points && (
|
||||
<ul>{feature.points.map(point => <li key={point}>{point}</li>)}</ul>
|
||||
)}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue