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:
Daniel 2026-09-12 01:57:46 +02:00
parent 767bb60099
commit 68d65ac782
7 changed files with 286 additions and 57 deletions

View file

@ -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",

View file

@ -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)

View 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); }

View file

@ -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>
)
}

View file

@ -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; }

View file

@ -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 />

View file

@ -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()
})