diff --git a/v2/e2e/mentor.spec.ts b/v2/e2e/mentor.spec.ts index 872e7b4..696ba53 100644 --- a/v2/e2e/mentor.spec.ts +++ b/v2/e2e/mentor.spec.ts @@ -77,6 +77,21 @@ test("mentor overview prioritizes actions without duplicate navigation", async ( await expect(page.getByText("Ver más indicadores")).toBeVisible(); }); +test("mentor opens an explained alert and exports the course summary", async ({ + page, +}) => { + await loginAsMentor(page); + await expect(page.getByRole("heading", { name: "Necesitan atención" })).toBeVisible(); + const firstAlert = page.locator(".classroom-alert").first(); + await expect(firstAlert).toContainText(/intentos|cambios solicitados|vence/i); + await expect(firstAlert).toHaveAttribute("href", /\/admin\/students\//); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Exportar CSV" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^tomatin-curso-\d{4}-\d{2}-\d{2}\.csv$/); +}); + test("mentor can reopen the classroom preparation summary", async ({ page }) => { await loginAsMentor(page); await page.getByRole("button", { name: "Abrir guía rápida" }).click(); diff --git a/v2/src/models/classroom-insights.ts b/v2/src/models/classroom-insights.ts new file mode 100644 index 0000000..384d2c3 --- /dev/null +++ b/v2/src/models/classroom-insights.ts @@ -0,0 +1,170 @@ +import type { ClassroomSnapshot, StudentProgress } from "@/types"; + +const DAY = 86_400_000; + +export interface ClassroomAlert { + id: string; + studentId: string; + assignmentId: string; + studentName: string; + assignmentTitle: string; + reason: string; + priority: "high" | "medium"; +} + +export interface WeeklyTrend { + key: string; + label: string; + activity: number; + submissions: number; + approvals: number; +} + +function priorityValue(priority: ClassroomAlert["priority"]) { + return priority === "high" ? 2 : 1; +} + +export function buildClassroomAlerts( + snapshot: ClassroomSnapshot, + now = new Date(), +): ClassroomAlert[] { + const alerts = new Map(); + const nowTime = now.getTime(); + + function add(progress: StudentProgress, reason: string, priority: ClassroomAlert["priority"]) { + const student = snapshot.profiles.find((entry) => entry.id === progress.userId); + const assignment = snapshot.assignments.find( + (entry) => entry.id === progress.assignmentId, + ); + if (!student || !assignment || assignment.status !== "published") return; + const alert: ClassroomAlert = { + id: `${progress.userId}-${progress.assignmentId}`, + studentId: progress.userId, + assignmentId: progress.assignmentId, + studentName: student.displayName, + assignmentTitle: assignment.title, + reason, + priority, + }; + const current = alerts.get(alert.id); + if (!current || priorityValue(priority) > priorityValue(current.priority)) { + alerts.set(alert.id, alert); + } + } + + for (const progress of snapshot.progress) { + if (progress.status === "approved" || progress.status === "awaiting_review") continue; + const assignment = snapshot.assignments.find( + (entry) => entry.id === progress.assignmentId, + ); + if (!assignment) continue; + const dueTime = new Date(assignment.dueAt).getTime(); + + if (dueTime < nowTime) { + add(progress, "La fecha de entrega ya pasó.", "high"); + continue; + } + if (progress.status === "changes_requested") { + add(progress, "Tiene cambios solicitados y aún no vuelve a entregar.", "high"); + continue; + } + if (progress.attempts >= 3) { + add(progress, `Lleva ${progress.attempts} intentos sin aprobar.`, "medium"); + continue; + } + if ( + progress.lastActivityAt && + nowTime - new Date(progress.lastActivityAt).getTime() >= 7 * DAY + ) { + add(progress, "No registra actividad hace 7 días o más.", "medium"); + continue; + } + if (progress.status === "not_started" && dueTime - nowTime <= 3 * DAY) { + add(progress, "Aún no comienza y vence dentro de 3 días.", "medium"); + } + } + + return [...alerts.values()].sort((left, right) => { + const priority = priorityValue(right.priority) - priorityValue(left.priority); + return priority || left.studentName.localeCompare(right.studentName, "es"); + }); +} + +function mondayStart(value: Date) { + const date = new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); + const weekday = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() - weekday + 1); + return date; +} + +export function buildWeeklyTrends( + snapshot: ClassroomSnapshot, + now = new Date(), + weekCount = 6, +): WeeklyTrend[] { + const currentWeek = mondayStart(now); + return Array.from({ length: weekCount }, (_, index) => { + const offset = weekCount - index - 1; + const start = new Date(currentWeek.getTime() - offset * 7 * DAY); + const end = new Date(start.getTime() + 7 * DAY); + const attempts = snapshot.attempts.filter((entry) => { + const time = new Date(entry.createdAt).getTime(); + return time >= start.getTime() && time < end.getTime(); + }); + const approvals = snapshot.progress.filter((entry) => { + if (!entry.approvedAt) return false; + const time = new Date(entry.approvedAt).getTime(); + return time >= start.getTime() && time < end.getTime(); + }).length; + return { + key: start.toISOString().slice(0, 10), + label: start.toLocaleDateString("es-CL", { + day: "numeric", + month: "short", + timeZone: "UTC", + }), + activity: attempts.length, + submissions: attempts.filter((entry) => entry.kind === "submit").length, + approvals, + }; + }); +} + +function csvCell(value: string | number) { + let text = String(value); + if (/^[=+\-@]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; +} + +export function buildClassroomCsv(snapshot: ClassroomSnapshot) { + const header = [ + "Estudiante", + "GitHub", + "Tarea", + "Fecha de entrega", + "Estado", + "Intentos", + "Pistas", + "Última actividad", + ]; + const rows = snapshot.progress.flatMap((progress) => { + const student = snapshot.profiles.find((entry) => entry.id === progress.userId); + const assignment = snapshot.assignments.find( + (entry) => entry.id === progress.assignmentId, + ); + if (!student || !assignment) return []; + return [[ + student.displayName, + student.githubLogin ? `@${student.githubLogin}` : "", + assignment.title, + assignment.dueAt, + progress.status, + progress.attempts, + progress.hintsUsed, + progress.lastActivityAt ?? "", + ]]; + }); + return [header, ...rows] + .map((row) => row.map((value) => csvCell(value)).join(",")) + .join("\n"); +} diff --git a/v2/src/pages/MentorPage.tsx b/v2/src/pages/MentorPage.tsx index 5f2da78..ad72077 100644 --- a/v2/src/pages/MentorPage.tsx +++ b/v2/src/pages/MentorPage.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { Activity, + AlertTriangle, ArrowRight, + BarChart3, BookCopy, CalendarPlus, Check, @@ -11,6 +13,7 @@ import { Clock3, Code2, Copy, + Download, Eye, FilePlus2, Gift, @@ -38,6 +41,11 @@ import { useLocation, useNavigate, } from "react-router-dom"; +import { + buildClassroomAlerts, + buildClassroomCsv, + buildWeeklyTrends, +} from "@/models/classroom-insights"; import { StatusBadge } from "@/components/StatusBadge"; import { RankingBoard } from "@/components/RankingBoard"; import { RewardsManager } from "@/components/RewardsManager"; @@ -105,6 +113,16 @@ function toDateTimeLocal(value: string) { return new Date(date.getTime() - offset).toISOString().slice(0, 16); } +function downloadCsv(contents: string) { + const blob = new Blob(["\uFEFF", contents], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `tomatin-curso-${new Date().toISOString().slice(0, 10)}.csv`; + anchor.click(); + URL.revokeObjectURL(url); +} + function MentorOverview({ onOpenReviews, }: { @@ -204,6 +222,16 @@ function MentorOverview({ new Date(a.lastActivityAt!).getTime(), ) .slice(0, 8); + const classroomAlerts = buildClassroomAlerts(snapshot).slice(0, 8); + const weeklyTrends = buildWeeklyTrends(snapshot); + const trendMax = Math.max( + 1, + ...weeklyTrends.flatMap((entry) => [ + entry.activity, + entry.submissions, + entry.approvals, + ]), + ); const eventLabels = { opened: "abrió", editing: "está editando", @@ -279,6 +307,75 @@ function MentorOverview({ +
+
+
+
+

PARA MIRAR

+

Necesitan atención

+
+ El motivo siempre está visible +
+ {classroomAlerts.length > 0 ? ( +
+ {classroomAlerts.map((alert) => ( + +
+ ) : ( +
+
+ )} +
+ +
+
+
+

ÚLTIMAS 6 SEMANAS

+ +
+ +
+ +
+ {weeklyTrends.map((week) => ( +
+
+ + + +
+ {week.label} +
+ ))} +
+

+
+
+
diff --git a/v2/src/styles.css b/v2/src/styles.css index 39b769f..30eda02 100644 --- a/v2/src/styles.css +++ b/v2/src/styles.css @@ -4367,6 +4367,180 @@ fieldset legend { margin-top: 10px; } +.insights-grid { + display: grid; + gap: 14px; + margin-bottom: 24px; + grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.85fr); +} + +.insight-panel { + min-width: 0; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); +} + +.insight-panel .section-header { + margin-bottom: 12px; +} + +.classroom-alerts { + display: grid; + gap: 6px; +} + +.classroom-alert { + display: grid; + min-height: 54px; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 4px; + grid-template-columns: 18px minmax(0, 1fr) 15px; +} + +.classroom-alert:hover { + border-color: var(--line-strong); + background: rgba(255, 255, 255, 0.025); +} + +.classroom-alert > svg:first-child { + width: 16px; + color: var(--yellow); +} + +.classroom-alert.is-high > svg:first-child { + color: var(--red); +} + +.classroom-alert > svg:last-child { + width: 14px; + color: var(--muted); +} + +.classroom-alert span, +.insight-empty span { + display: grid; + min-width: 0; +} + +.classroom-alert strong, +.insight-empty strong { + font-size: 10px; +} + +.classroom-alert small, +.insight-empty small { + color: var(--muted); + font-size: 8px; + line-height: 1.45; +} + +.insight-empty { + display: flex; + min-height: 110px; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--green); +} + +.insight-empty svg { + width: 20px; +} + +.trend-legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + color: var(--muted); + font-size: 8px; +} + +.trend-legend span { + display: flex; + align-items: center; + gap: 5px; +} + +.trend-legend i { + width: 7px; + height: 7px; + border-radius: 1px; +} + +.weekly-trends { + display: grid; + height: 160px; + align-items: end; + gap: 8px; + margin-top: 10px; + padding: 12px 8px 0; + border-bottom: 1px solid var(--line); + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.trend-week { + display: grid; + height: 100%; + min-width: 0; + grid-template-rows: minmax(0, 1fr) 22px; +} + +.trend-bars { + display: flex; + min-height: 0; + align-items: end; + justify-content: center; + gap: 3px; +} + +.trend-bars i { + width: min(9px, 28%); + min-height: 3px; + border-radius: 2px 2px 0 0; +} + +.trend-week > span { + overflow: hidden; + color: var(--muted); + font-size: 7px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trend-legend .activity, +.trend-bars .activity { + background: var(--cyan); +} + +.trend-legend .submissions, +.trend-bars .submissions { + background: var(--yellow); +} + +.trend-legend .approvals, +.trend-bars .approvals { + background: var(--green); +} + +.trend-note { + display: flex; + align-items: center; + gap: 6px; + margin: 10px 0 0; + color: var(--muted); + font-size: 8px; +} + +.trend-note svg { + width: 14px; +} + .mentor-filters { display: flex; align-items: center; @@ -6797,6 +6971,14 @@ fieldset legend { flex-direction: column; } + .insights-grid { + grid-template-columns: 1fr; + } + + .insight-panel .section-header { + align-items: flex-start; + } + .mentor-filters select { width: 100%; } diff --git a/v2/tests/classroom-insights.test.ts b/v2/tests/classroom-insights.test.ts new file mode 100644 index 0000000..e07bbab --- /dev/null +++ b/v2/tests/classroom-insights.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { createDemoSnapshot } from "@/data/demo-classroom"; +import { + buildClassroomAlerts, + buildClassroomCsv, + buildWeeklyTrends, +} from "@/models/classroom-insights"; + +describe("classroom insights", () => { + it("explains why a student may need attention", () => { + const snapshot = createDemoSnapshot(); + const now = new Date("2030-01-01T12:00:00.000Z"); + const alerts = buildClassroomAlerts(snapshot, now); + + expect(alerts.length).toBeGreaterThan(0); + expect(alerts.every((entry) => entry.reason.endsWith("."))).toBe(true); + expect(alerts.every((entry) => entry.studentId && entry.assignmentId)).toBe(true); + }); + + it("returns six ordered weekly buckets", () => { + const trends = buildWeeklyTrends( + createDemoSnapshot(), + new Date("2026-08-12T12:00:00.000Z"), + ); + + expect(trends).toHaveLength(6); + expect(trends.map((entry) => entry.key)).toEqual( + [...trends].map((entry) => entry.key).sort(), + ); + }); + + it("exports progress without drafts or source code", () => { + const snapshot = createDemoSnapshot(); + const csv = buildClassroomCsv(snapshot); + + expect(csv).toContain('"Estudiante","GitHub","Tarea"'); + expect(csv).toContain('"Camila Rojas"'); + expect(csv).not.toContain("function totalOnce"); + expect(csv.split("\n")).toHaveLength(snapshot.progress.length + 1); + }); +});