feat: performance over time, locked until it means something
`GET /study-tools/performance-over-time` returns a point per completed session with two figures: that session's percentage, and the running score across everything answered up to that day. The chart draws the running line and marks the sessions along it — a single session of twelve questions swings too far to say anything about whether a learner is improving. It stays shut below 40 answers or 3 sessions and says which of the two it is waiting for, rather than drawing a line through two points and letting the shape suggest a trend that is not there. LineChart was in the tree unused, with a hardcoded slate palette that vanishes on a dark page. Rewritten against the theme tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
767bb60099
commit
68d65ac782
7 changed files with 286 additions and 57 deletions
|
|
@ -5,7 +5,7 @@ from typing import Literal
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
from sqlalchemy import func, inspect, or_
|
||||
from sqlalchemy import case, func, inspect, or_
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -356,6 +356,72 @@ def answer_split(
|
|||
}
|
||||
|
||||
|
||||
#: Two points make a line but not a trend; the chart stays shut until there is
|
||||
#: something to see in it.
|
||||
TREND_MIN_SESSIONS = 3
|
||||
|
||||
|
||||
@router.get("/performance-over-time")
|
||||
def performance_over_time(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The headline score by date.
|
||||
|
||||
Two lines are meant here, and only one of them is the score. A session's
|
||||
own percentage swings with whatever twelve questions it happened to hold;
|
||||
the running figure — everything answered up to that day — is the one that
|
||||
says whether the learner is getting better. The chart draws the running
|
||||
line and marks the sessions along it.
|
||||
|
||||
It stays locked until there are enough answers to mean anything, for the
|
||||
same reason readiness does: a line through two points is a decoration.
|
||||
"""
|
||||
rows = db.query(
|
||||
QuizAttempt.id, QuizAttempt.completed_at, Quiz.title,
|
||||
func.count(AttemptAnswer.id).label("answered"),
|
||||
func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)).label("correct"),
|
||||
).join(AttemptAnswer, AttemptAnswer.attempt_id == QuizAttempt.id
|
||||
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
||||
).filter(
|
||||
QuizAttempt.user_id == user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
||||
Quiz.course_id.is_(None),
|
||||
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
||||
AttemptAnswer.user_answer != "",
|
||||
).group_by(QuizAttempt.id, QuizAttempt.completed_at, Quiz.title
|
||||
).order_by(QuizAttempt.completed_at.asc()).all()
|
||||
|
||||
points = []
|
||||
seen = correct_so_far = 0
|
||||
for row in rows:
|
||||
answered = int(row.answered or 0)
|
||||
if not answered:
|
||||
continue
|
||||
correct = int(row.correct or 0)
|
||||
seen += answered
|
||||
correct_so_far += correct
|
||||
points.append({
|
||||
"attempt_id": row.id,
|
||||
"date": row.completed_at.date().isoformat() if row.completed_at else None,
|
||||
"title": row.title,
|
||||
"answered": answered,
|
||||
"percent": round(100 * correct / answered, 1),
|
||||
# Everything answered up to and including this session.
|
||||
"running": round(100 * correct_so_far / seen, 1),
|
||||
})
|
||||
|
||||
total = seen
|
||||
return {
|
||||
"points": points,
|
||||
"total_answered": total,
|
||||
"unlocked": total >= READINESS_UNLOCK_ANSWERS and len(points) >= TREND_MIN_SESSIONS,
|
||||
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total),
|
||||
"sessions_needed": max(0, TREND_MIN_SESSIONS - len(points)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
def study_recommendations(
|
||||
group: Literal["articles", "disciplines", "systems"] = "disciplines",
|
||||
|
|
|
|||
|
|
@ -404,3 +404,54 @@ class AnswerSplitTests(CompletionTests):
|
|||
# It was right, so it counts as right: the percentage is not docked.
|
||||
self.assertEqual(data['percent_correct'], 100.0)
|
||||
self.assertEqual(data['answered'], 2)
|
||||
|
||||
|
||||
class PerformanceOverTimeTests(CompletionTests):
|
||||
"""The headline score by date, and the honesty of an empty chart."""
|
||||
|
||||
def trend(self):
|
||||
response = self.client.get('/study-tools/performance-over-time')
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
def test_the_running_figure_is_every_answer_so_far_not_this_session(self):
|
||||
# Two right, then one wrong: the session drops to 0%, the running
|
||||
# figure to 67% — which is the honest account of what is known.
|
||||
self.sat(1, True, ago_days=3, seconds=30)
|
||||
self.sat(2, True, ago_days=2, seconds=30)
|
||||
self.sat(3, False, ago_days=1, seconds=30)
|
||||
points = self.trend()['points']
|
||||
self.assertEqual([p['percent'] for p in points], [100.0, 100.0, 0.0])
|
||||
self.assertEqual([p['running'] for p in points], [100.0, 100.0, 66.7])
|
||||
|
||||
def test_a_chart_is_locked_until_there_is_something_in_it(self):
|
||||
self.sat(1, True, ago_days=1, seconds=30)
|
||||
data = self.trend()
|
||||
self.assertFalse(data['unlocked'])
|
||||
self.assertEqual(data['sessions_needed'], 2)
|
||||
self.assertEqual(data['answers_needed'], 39)
|
||||
# The points are still returned: the page says how far off it is.
|
||||
self.assertEqual(len(data['points']), 1)
|
||||
|
||||
def test_blank_answers_and_repetitions_are_not_points_on_the_line(self):
|
||||
from app.models.course import Course
|
||||
from app.models.quiz import Quiz
|
||||
course = Course(title='Neonatology', user_id=1)
|
||||
self.bank.db.add(course)
|
||||
self.bank.db.flush()
|
||||
again = Quiz(title='Repeat of test', user_id=1, is_repetition=1)
|
||||
self.bank.db.add(again)
|
||||
self.bank.db.commit()
|
||||
self.sat(1, True, ago_days=2, seconds=30)
|
||||
self.sat(2, True, ago_days=2, seconds=30, quiz_id=again.id)
|
||||
# A session where nothing was answered is not a session at 0%.
|
||||
self.sat(3, False, ago_days=1, seconds=0, answered=False)
|
||||
points = self.trend()['points']
|
||||
self.assertEqual(len(points), 1)
|
||||
self.assertEqual(points[0]['answered'], 1)
|
||||
|
||||
def test_nothing_sat_is_an_empty_chart_not_a_flat_line(self):
|
||||
data = self.trend()
|
||||
self.assertEqual(data['points'], [])
|
||||
self.assertFalse(data['unlocked'])
|
||||
self.assertEqual(data['total_answered'], 0)
|
||||
|
|
|
|||
10
frontend/src/components/LineChart.css
Normal file
10
frontend/src/components/LineChart.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* Themed rather than painted: the chart is read on a dark page as often as a
|
||||
light one, and a hardcoded slate grid disappears on one of them. */
|
||||
.lc { width: 100%; height: auto; overflow: visible; }
|
||||
.lc-grid { stroke: var(--border); stroke-width: 1; }
|
||||
.lc-grid.is-target { stroke: var(--primary); stroke-width: 1.5; stroke-dasharray: 4 3; opacity: 0.55; }
|
||||
.lc-axis { font-size: 9px; fill: var(--text-subtle); }
|
||||
.lc-area { fill: var(--primary); opacity: 0.1; }
|
||||
.lc-line { fill: none; stroke: var(--primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.lc-dot { fill: var(--card-bg); stroke: var(--primary); stroke-width: 2; }
|
||||
.lc-dot.is-good { stroke: var(--correct-fg); }
|
||||
|
|
@ -1,71 +1,71 @@
|
|||
export default function LineChart({ data, width = 500, height = 180 }) {
|
||||
if (!data || data.length < 2) {
|
||||
return (
|
||||
<div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: '0.85rem' }}>
|
||||
Need at least 2 attempts to show a graph
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import './LineChart.css'
|
||||
|
||||
const pad = { top: 16, right: 16, bottom: 32, left: 40 }
|
||||
const W = width - pad.left - pad.right
|
||||
const H = height - pad.top - pad.bottom
|
||||
/**
|
||||
* A score by date.
|
||||
*
|
||||
* Two figures are plotted and only one of them is a line: the running score —
|
||||
* everything answered up to that day — because a single session's percentage
|
||||
* swings with whatever twelve questions it happened to hold, and a line that
|
||||
* jumps between 40 and 90 says nothing about whether anyone is improving. The
|
||||
* sessions themselves are marked along it, each one hoverable for its own
|
||||
* figure.
|
||||
*
|
||||
* Nothing is drawn through fewer than two points. A chart with one dot in it
|
||||
* is a decoration, and this page is supposed to be evidence.
|
||||
*/
|
||||
|
||||
const minY = 0, maxY = 100
|
||||
const xStep = W / (data.length - 1)
|
||||
const PAD = { top: 16, right: 18, bottom: 30, left: 36 }
|
||||
const W = 520
|
||||
const H = 190
|
||||
const PLOT_W = W - PAD.left - PAD.right
|
||||
const PLOT_H = H - PAD.top - PAD.bottom
|
||||
|
||||
const toX = (i) => pad.left + i * xStep
|
||||
const toY = (v) => pad.top + H - ((v - minY) / (maxY - minY)) * H
|
||||
const GRID = [0, 25, 50, 75, 100]
|
||||
|
||||
// Line path
|
||||
const points = data.map((d, i) => `${toX(i)},${toY(d.percentage)}`)
|
||||
const linePath = `M ${points.join(' L ')}`
|
||||
const day = (iso) => (iso
|
||||
? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
: '')
|
||||
|
||||
// Fill path
|
||||
const fillPath = `M ${toX(0)},${toY(0)} L ${points.join(' L ')} L ${toX(data.length - 1)},${toY(0)} Z`
|
||||
export default function LineChart({ points = [], target = 75, label = 'Score over time' }) {
|
||||
if (points.length < 2) return null
|
||||
|
||||
// Y gridlines
|
||||
const gridLines = [0, 25, 50, 75, 100]
|
||||
const toX = (i) => PAD.left + (points.length === 1 ? PLOT_W / 2 : (i / (points.length - 1)) * PLOT_W)
|
||||
const toY = (value) => PAD.top + PLOT_H - (Math.max(0, Math.min(100, value)) / 100) * PLOT_H
|
||||
|
||||
const line = points.map((point, i) => `${toX(i)},${toY(point.running)}`).join(' L ')
|
||||
const area = `M ${toX(0)},${toY(0)} L ${line} L ${toX(points.length - 1)},${toY(0)} Z`
|
||||
|
||||
// Only the ends and the middle are labelled: a dozen dates along the foot of
|
||||
// a chart this wide overlap into a smear.
|
||||
const ticks = new Set([0, Math.floor((points.length - 1) / 2), points.length - 1])
|
||||
|
||||
return (
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>
|
||||
{/* Grid lines */}
|
||||
{gridLines.map(v => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={pad.left} y1={toY(v)} x2={pad.left + W} y2={toY(v)}
|
||||
stroke={v === 75 ? '#fde68a' : '#f1f5f9'} strokeWidth={v === 75 ? 1.5 : 1}
|
||||
strokeDasharray={v === 75 ? '4 3' : 'none'}
|
||||
/>
|
||||
<text x={pad.left - 6} y={toY(v)} textAnchor="end" dominantBaseline="middle"
|
||||
fontSize="10" fill="#94a3b8">{v}%</text>
|
||||
<svg className="lc" viewBox={`0 0 ${W} ${H}`} role="img"
|
||||
aria-label={`${label}: ${points[0].running}% at the start, ${points[points.length - 1].running}% now, over ${points.length} sessions`}>
|
||||
{GRID.map(value => (
|
||||
<g key={value}>
|
||||
<line className={value === target ? 'lc-grid is-target' : 'lc-grid'}
|
||||
x1={PAD.left} y1={toY(value)} x2={PAD.left + PLOT_W} y2={toY(value)} />
|
||||
<text className="lc-axis" x={PAD.left - 6} y={toY(value)}
|
||||
textAnchor="end" dominantBaseline="middle">{value}%</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 75% label */}
|
||||
<text x={pad.left + W + 4} y={toY(75)} dominantBaseline="middle" fontSize="9" fill="#d97706">75%</text>
|
||||
<path className="lc-area" d={area} />
|
||||
<path className="lc-line" d={`M ${line}`} />
|
||||
|
||||
{/* Fill */}
|
||||
<path d={fillPath} fill="#e0e7ff" opacity="0.4" />
|
||||
|
||||
{/* Line */}
|
||||
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
|
||||
{/* Dots + tooltips */}
|
||||
{data.map((d, i) => {
|
||||
const x = toX(i), y = toY(d.percentage)
|
||||
const color = d.percentage >= 75 ? '#22c55e' : '#ef4444'
|
||||
const label = new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle cx={x} cy={y} r="4" fill={color} stroke="white" strokeWidth="2" />
|
||||
{/* X axis label */}
|
||||
<text x={x} y={pad.top + H + 16} textAnchor="middle" fontSize="9" fill="#94a3b8">{label}</text>
|
||||
{/* Hover tooltip via title */}
|
||||
<title>{label}: {d.percentage}% ({d.score}/{d.total})</title>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{points.map((point, i) => (
|
||||
<g key={point.attempt_id ?? i}>
|
||||
<circle className={point.percent >= target ? 'lc-dot is-good' : 'lc-dot'}
|
||||
cx={toX(i)} cy={toY(point.running)} r="3.5" />
|
||||
{ticks.has(i) && (
|
||||
<text className="lc-axis" x={toX(i)} y={PAD.top + PLOT_H + 15} textAnchor="middle">
|
||||
{day(point.date)}
|
||||
</text>
|
||||
)}
|
||||
<title>{day(point.date)} — this session {point.percent}% of {point.answered}; overall {point.running}%</title>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,3 +165,13 @@
|
|||
.an-perf-top { grid-template-columns: minmax(0, 1fr); }
|
||||
.an-split-card { margin-top: 0; }
|
||||
}
|
||||
|
||||
/* ── Performance over time ────────────────────────────────────────────
|
||||
Locked until it would mean something, and saying which of the two things
|
||||
it is waiting for. */
|
||||
.an-trend {
|
||||
margin-top: 24px; padding: 16px 18px 18px;
|
||||
border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg);
|
||||
}
|
||||
.an-trend-now { font-size: 1.1rem; font-weight: 700; }
|
||||
.an-trend .an-basis { margin-top: 10px; }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||
import api from '../api/client'
|
||||
import CategoryPerformance from '../components/CategoryPerformance'
|
||||
import Donut from '../components/Donut'
|
||||
import LineChart from '../components/LineChart'
|
||||
import AnalysisShell from '../components/AnalysisShell'
|
||||
import './AnalysisPage.css'
|
||||
|
||||
|
|
@ -93,6 +94,56 @@ function Completion() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the score is going anywhere.
|
||||
*
|
||||
* Locked until there are enough answers and enough sessions behind it, and it
|
||||
* says which is missing rather than drawing a line through two points and
|
||||
* letting the shape of it suggest a trend that is not there.
|
||||
*/
|
||||
function OverTime() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
api.get('/study-tools/performance-over-time')
|
||||
.then(res => { if (live) setData(res.data) })
|
||||
.catch(() => { if (live) setError('Could not load your score over time') })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
|
||||
const short = data && !data.unlocked && (
|
||||
data.answers_needed > 0
|
||||
? `Answer ${data.answers_needed} more question${data.answers_needed === 1 ? '' : 's'} to unlock this chart.`
|
||||
: `Complete ${data.sessions_needed} more session${data.sessions_needed === 1 ? '' : 's'} to unlock this chart.`)
|
||||
|
||||
const last = data?.points?.[data.points.length - 1]
|
||||
|
||||
return (
|
||||
<section className="an-card an-trend">
|
||||
<div className="an-completion-head">
|
||||
<h2>Performance over time</h2>
|
||||
{data?.unlocked && last && <span className="an-trend-now">{last.running}% overall</span>}
|
||||
</div>
|
||||
{error ? <p className="an-empty" role="alert">{error}</p>
|
||||
: !data ? <p className="an-empty">Loading…</p>
|
||||
: !data.unlocked ? <p className="an-empty">{short}</p>
|
||||
: (
|
||||
<>
|
||||
<LineChart points={data.points} />
|
||||
<p className="an-basis">
|
||||
The line is your score across everything answered up to that day,
|
||||
not the session on its own — one session of twelve questions
|
||||
swings too far to say anything. Each mark is a session; hover it
|
||||
for its own figure. The dashed rule is 75%.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const BASES = [
|
||||
{ key: 'all', label: 'All attempts' },
|
||||
{ key: 'latest', label: 'Latest attempt' },
|
||||
|
|
@ -384,6 +435,7 @@ export default function AnalysisPage() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<OverTime />
|
||||
<div className="an-perf-top">
|
||||
<Completion />
|
||||
<AnswerSplit />
|
||||
|
|
|
|||
|
|
@ -152,3 +152,43 @@ it('says plainly when nothing has been sat', async () => {
|
|||
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||
expect(await screen.findByText(/Nothing sat yet/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const trend = (over = {}) => ({
|
||||
points: [
|
||||
{ attempt_id: 1, date: '2026-08-01', title: 'One', answered: 20, percent: 50.0, running: 50.0 },
|
||||
{ attempt_id: 2, date: '2026-08-15', title: 'Two', answered: 20, percent: 70.0, running: 60.0 },
|
||||
{ attempt_id: 3, date: '2026-09-01', title: 'Three', answered: 20, percent: 90.0, running: 70.0 },
|
||||
],
|
||||
total_answered: 60, unlocked: true, answers_needed: 0, sessions_needed: 0, ...over,
|
||||
})
|
||||
|
||||
const withTrend = (data) => {
|
||||
api.get.mockImplementation(url =>
|
||||
Promise.resolve({ data: url === '/study-tools/performance-over-time' ? data : payload() }))
|
||||
}
|
||||
|
||||
it('draws the running score and says what the line is', async () => {
|
||||
withTrend(trend())
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||
|
||||
const panel = (await screen.findByText('Performance over time')).closest('.an-trend')
|
||||
expect(within(panel).getByText('70% overall')).toBeInTheDocument()
|
||||
// The running figure, not the last session's 90%.
|
||||
expect(within(panel).getByRole('img', { name: /50% at the start, 70% now, over 3 sessions/ }))
|
||||
.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('will not draw a trend it does not have, and says which half is missing', async () => {
|
||||
withTrend(trend({ unlocked: false, answers_needed: 25, sessions_needed: 0, total_answered: 15 }))
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Performance' }))
|
||||
expect(await screen.findByText('Answer 25 more questions to unlock this chart.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img', { name: /at the start/ })).not.toBeInTheDocument()
|
||||
|
||||
withTrend(trend({ unlocked: false, answers_needed: 0, sessions_needed: 1 }))
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
const tabs = await screen.findAllByRole('tab', { name: 'Performance' })
|
||||
await userEvent.click(tabs[tabs.length - 1])
|
||||
expect(await screen.findByText('Complete 1 more session to unlock this chart.')).toBeInTheDocument()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue