Skip to content

Commit a0452bb

Browse files
fix(analytics): translate the hotspot detail lines on /es
The analytics page shipped its per-hotspot "why" line as a prebuilt English sentence built inside lib/analytics/confusion-hotspots.ts, so on /es every heading, badge and footer translated while the line carrying the actual finding stayed in English: 4 of 5 student(s) still failing on their latest attempt · average 52% 3 of 5 student(s) missed it on their latest submission · 60% miss rate Caught by rendering the page under /es for the QA screenshots, not by a test — the strings were correct, just not translatable. Hotspot now carries numbers only (`totalAttempts` replaces `evidence`) and the page formats them through next-intl. Each signal counts something different, so each scope gets its own message rather than one generic sentence that would read wrong for three scopes out of four. The MCP tool keeps its English `evidence` string on purpose: its reader is a model, not a localised UI. Noted in the twin-implementation header. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqQTE7LDYPx7d15BcWLGfC
1 parent 07645de commit a0452bb

5 files changed

Lines changed: 62 additions & 8 deletions

File tree

app/[locale]/dashboard/teacher/courses/[courseId]/analytics/page.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,38 @@ function hotspotHref(courseId: string, h: Hotspot): string | null {
4747
return null
4848
}
4949

50+
/**
51+
* The one-line "why this is a hotspot" under each item.
52+
*
53+
* Built here rather than in the analytics module so it can be translated: each
54+
* signal counts something different, so each gets its own message instead of a
55+
* generic one that would read wrong for three scopes out of four.
56+
*/
57+
function hotspotEvidence(h: Hotspot, t: Awaited<ReturnType<typeof getTranslations>>): string {
58+
if (h.scope === 'practice') {
59+
return t('hotspots.evidencePractice', {
60+
attempts: h.totalAttempts ?? 0,
61+
students: h.studentsAttempted,
62+
avg: h.avgScore ?? 0,
63+
below: h.studentsAffected,
64+
})
65+
}
66+
if (h.scope === 'exam_question') {
67+
return t('hotspots.evidenceExam', {
68+
missers: h.studentsAffected,
69+
students: h.studentsAttempted,
70+
rate: Math.round((h.studentsAffected / Math.max(h.studentsAttempted, 1)) * 100),
71+
})
72+
}
73+
// Exercises and checkpoints share a shape: latest attempt decides "stuck".
74+
const base = t('hotspots.evidenceStuck', {
75+
stuck: h.studentsAffected,
76+
students: h.studentsAttempted,
77+
attempts: (h.avgAttempts ?? 0).toFixed(1),
78+
})
79+
return h.avgScore == null ? base : `${base} · ${t('hotspots.evidenceAvg', { avg: h.avgScore })}`
80+
}
81+
5082
export default async function CourseAnalyticsPage({ params, searchParams }: PageProps) {
5183
const { courseId } = await params
5284
const { days: daysParam } = await searchParams
@@ -265,7 +297,9 @@ export default async function CourseAnalyticsPage({ params, searchParams }: Page
265297
<span className="text-sm font-medium">{h.label}</span>
266298
)}
267299
</div>
268-
<p className="mt-1 text-xs text-muted-foreground">{h.evidence}</p>
300+
<p className="mt-1 text-xs text-muted-foreground">
301+
{hotspotEvidence(h, t)}
302+
</p>
269303
</div>
270304
<SeverityBar value={h.severity} label={t('hotspots.severity')} />
271305
</div>

lib/analytics/confusion-hotspots.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
* query; the tool was left alone to keep a shipped payload stable. So the
2828
* page can surface a hotspot the tool will not — never the reverse.
2929
*
30+
* The second difference is presentational: this module returns only numbers
31+
* per hotspot, never a prebuilt sentence, because the page renders in `en`
32+
* and `es`. (It first returned an `evidence` string, which left every hotspot
33+
* detail line stuck in English on `/es` while the rest of the page
34+
* translated.) The MCP tool keeps its English `evidence` string — its reader
35+
* is a model, not a localised UI.
36+
*
3037
* RLS
3138
* Every read below runs as the signed-in teacher. Each source table has a
3239
* teacher-scoped SELECT policy (`teachers_view_tenant_evaluations`,
@@ -61,7 +68,8 @@ export interface Hotspot {
6168
avgAttempts: number | null
6269
/** Course lesson this item belongs to, when known — lets the page deep-link. */
6370
lessonId: number | null
64-
evidence: string
71+
/** Total attempts behind this row, where the signal counts them (practice only). */
72+
totalAttempts: number | null
6573
}
6674

6775
export type DifficultyLabel = 'easy' | 'medium' | 'hard'
@@ -344,7 +352,7 @@ async function loadPractice(
344352
avgScore: Math.round(avg),
345353
avgAttempts: null,
346354
lessonId: b.lessonId,
347-
evidence: `${b.scores.length} attempt(s) by ${b.users.size} student(s) · average ${Math.round(avg)}% · ${b.struggling.size} below ${LOW_SCORE}%`,
355+
totalAttempts: b.scores.length,
348356
})
349357
}
350358
return { hotspots, rowCount: rows.length }
@@ -538,7 +546,7 @@ function rollUpAttempts<K extends string | number>(
538546
avgScore: avgScore == null ? null : Math.round(avgScore),
539547
avgAttempts: Math.round(avgAttempts * 10) / 10,
540548
lessonId: d.lessonId,
541-
evidence: `${b.stuck.size} of ${b.attempted.size} student(s) still failing on their latest attempt · ${avgAttempts.toFixed(1)} attempt(s) each on average${avgScore == null ? '' : ` · average ${Math.round(avgScore)}%`}`,
549+
totalAttempts: null,
542550
})
543551
}
544552
return out
@@ -644,7 +652,7 @@ async function loadExamQuestionMisses(
644652
avgScore: null,
645653
avgAttempts: null,
646654
lessonId: null,
647-
evidence: `${b.missers.size} of ${b.attempts} student(s) missed it on their latest submission · ${Math.round(missRate * 100)}% miss rate`,
655+
totalAttempts: null,
648656
})
649657
}
650658
return { hotspots, submissionCount: subById.size }

messages/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1649,7 +1649,11 @@
16491649
"severity": "Severity",
16501650
"allClear": "No hotspots in this window — nobody is stuck on an exercise, checkpoint, drill or exam question.",
16511651
"noData": "No student activity in this window yet. Once students attempt exercises, checkpoints, practice drills or exams, their results appear here.",
1652-
"sources": "Based on {practice} practice attempts, {exercises} exercise attempts, {checkpoints} checkpoint attempts and {exams} exam submissions in the last {days} days."
1652+
"sources": "Based on {practice} practice attempts, {exercises} exercise attempts, {checkpoints} checkpoint attempts and {exams} exam submissions in the last {days} days.",
1653+
"evidencePractice": "{attempts} attempt(s) by {students} student(s) · average {avg}% · {below} below 70%",
1654+
"evidenceStuck": "{stuck} of {students} student(s) still failing on their latest attempt · {attempts} attempt(s) each on average",
1655+
"evidenceAvg": "average {avg}%",
1656+
"evidenceExam": "{missers} of {students} student(s) missed it on their latest submission · {rate}% miss rate"
16531657
}
16541658
}
16551659
},

messages/es.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1649,7 +1649,11 @@
16491649
"severity": "Gravedad",
16501650
"allClear": "Sin puntos de confusión en este periodo: nadie está atascado en un ejercicio, checkpoint, práctica o pregunta de examen.",
16511651
"noData": "Aún no hay actividad de estudiantes en este periodo. Cuando intenten ejercicios, checkpoints, prácticas o exámenes, sus resultados aparecerán aquí.",
1652-
"sources": "Basado en {practice} intentos de práctica, {exercises} intentos de ejercicio, {checkpoints} intentos de checkpoint y {exams} entregas de examen en los últimos {days} días."
1652+
"sources": "Basado en {practice} intentos de práctica, {exercises} intentos de ejercicio, {checkpoints} intentos de checkpoint y {exams} entregas de examen en los últimos {days} días.",
1653+
"evidencePractice": "{attempts} intento(s) de {students} estudiante(s) · promedio {avg}% · {below} por debajo del 70%",
1654+
"evidenceStuck": "{stuck} de {students} estudiante(s) siguen fallando en su último intento · {attempts} intento(s) cada uno en promedio",
1655+
"evidenceAvg": "promedio {avg}%",
1656+
"evidenceExam": "{missers} de {students} estudiante(s) la fallaron en su última entrega · {rate}% de fallo"
16531657
}
16541658
}
16551659
},

tests/unit/confusion-hotspots.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ describe('exercise hotspots', () => {
116116
const hot = bySc(await run(db), 'exercise')[0]
117117
// u1 peaked at attempt 6, u2 at attempt 1 → mean 3.5
118118
expect(hot.avgAttempts).toBe(3.5)
119-
expect(hot.evidence).toContain('3.5 attempt(s)')
119+
// Attempt totals are a practice-only counter; exercises report the mean.
120+
expect(hot.totalAttempts).toBeNull()
120121
})
121122

122123
it('reports no hotspot when everyone eventually passes', async () => {
@@ -245,6 +246,9 @@ describe('practice-topic hotspots', () => {
245246
expect(hot.avgScore).toBe(60)
246247
expect(hot.studentsAffected).toBe(2)
247248
expect(hot.lessonId).toBe(4)
249+
// The page renders this count, so it must be attempts and not students.
250+
expect(hot.totalAttempts).toBe(3)
251+
expect(hot.studentsAttempted).toBe(3)
248252
})
249253

250254
it('excludes attempts older than the look-back window', async () => {

0 commit comments

Comments
 (0)