From 68d65ac782bf2993028d325c162b149875da3d9d Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 01:57:46 +0200 Subject: [PATCH] feat: performance over time, locked until it means something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/study_tools.py | 68 +++++++++++++- backend/tests/test_study_tools.py | 51 +++++++++++ frontend/src/components/LineChart.css | 10 ++ frontend/src/components/LineChart.jsx | 112 +++++++++++------------ frontend/src/pages/AnalysisPage.css | 10 ++ frontend/src/pages/AnalysisPage.jsx | 52 +++++++++++ frontend/src/pages/AnalysisPage.test.jsx | 40 ++++++++ 7 files changed, 286 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/LineChart.css diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 2b3b916..7a23d13 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -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", diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index 7af7078..67bfc7d 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -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) diff --git a/frontend/src/components/LineChart.css b/frontend/src/components/LineChart.css new file mode 100644 index 0000000..e6e3b59 --- /dev/null +++ b/frontend/src/components/LineChart.css @@ -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); } diff --git a/frontend/src/components/LineChart.jsx b/frontend/src/components/LineChart.jsx index d2fe6df..58aee11 100644 --- a/frontend/src/components/LineChart.jsx +++ b/frontend/src/components/LineChart.jsx @@ -1,71 +1,71 @@ -export default function LineChart({ data, width = 500, height = 180 }) { - if (!data || data.length < 2) { - return ( -
- Need at least 2 attempts to show a graph -
- ) - } +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 ( - - {/* Grid lines */} - {gridLines.map(v => ( - - - {v}% + + {GRID.map(value => ( + + + {value}% ))} - {/* 75% label */} - 75% + + - {/* Fill */} - - - {/* Line */} - - - {/* 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 ( - - - {/* X axis label */} - {label} - {/* Hover tooltip via title */} - {label}: {d.percentage}% ({d.score}/{d.total}) - - ) - })} + {points.map((point, i) => ( + + = target ? 'lc-dot is-good' : 'lc-dot'} + cx={toX(i)} cy={toY(point.running)} r="3.5" /> + {ticks.has(i) && ( + + {day(point.date)} + + )} + {day(point.date)} — this session {point.percent}% of {point.answered}; overall {point.running}% + + ))} ) } diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index d126607..5990238 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -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; } diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 86c146d..6958c46 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -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 ( +
+
+

Performance over time

+ {data?.unlocked && last && {last.running}% overall} +
+ {error ?

{error}

+ : !data ?

Loading…

+ : !data.unlocked ?

{short}

+ : ( + <> + +

+ 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%. +

+ + )} + + ) +} + const BASES = [ { key: 'all', label: 'All attempts' }, { key: 'latest', label: 'Latest attempt' }, @@ -384,6 +435,7 @@ export default function AnalysisPage() { ) : ( <> +
diff --git a/frontend/src/pages/AnalysisPage.test.jsx b/frontend/src/pages/AnalysisPage.test.jsx index a2159f8..0b86b11 100644 --- a/frontend/src/pages/AnalysisPage.test.jsx +++ b/frontend/src/pages/AnalysisPage.test.jsx @@ -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() + 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() + 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() + 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() +})