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 (
-