Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions v2/e2e/mentor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
170 changes: 170 additions & 0 deletions v2/src/models/classroom-insights.ts
Original file line number Diff line number Diff line change
@@ -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<string, ClassroomAlert>();
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");
}
97 changes: 97 additions & 0 deletions v2/src/pages/MentorPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
Activity,
AlertTriangle,
ArrowRight,
BarChart3,
BookCopy,
CalendarPlus,
Check,
Expand All @@ -11,6 +13,7 @@ import {
Clock3,
Code2,
Copy,
Download,
Eye,
FilePlus2,
Gift,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
}: {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -279,6 +307,75 @@ function MentorOverview({
</div>
</details>

<div className="insights-grid">
<section className="mentor-section insight-panel" aria-labelledby="alerts-title">
<div className="section-header">
<div>
<p className="eyebrow">PARA MIRAR</p>
<h2 id="alerts-title">Necesitan atención</h2>
</div>
<span className="section-note">El motivo siempre está visible</span>
</div>
{classroomAlerts.length > 0 ? (
<div className="classroom-alerts">
{classroomAlerts.map((alert) => (
<Link
className={`classroom-alert is-${alert.priority}`}
key={alert.id}
to={`/admin/students/${alert.studentId}?assignment=${alert.assignmentId}`}
>
<AlertTriangle aria-hidden="true" />
<span>
<strong>{alert.studentName}</strong>
<small>{alert.assignmentTitle} · {alert.reason}</small>
</span>
<ArrowRight aria-hidden="true" />
</Link>
))}
</div>
) : (
<div className="insight-empty">
<CheckCircle2 aria-hidden="true" />
<span><strong>Sin alertas por ahora</strong><small>No hay atrasos ni señales que requieran una revisión rápida.</small></span>
</div>
)}
</section>

<section className="mentor-section insight-panel" aria-labelledby="trends-title">
<div className="section-header">
<div>
<p className="eyebrow">ÚLTIMAS 6 SEMANAS</p>
<h2 id="trends-title">Movimiento del curso</h2>
</div>
<button
className="button secondary compact-button"
type="button"
onClick={() => downloadCsv(buildClassroomCsv(snapshot))}
>
<Download aria-hidden="true" /> Exportar CSV
</button>
</div>
<div className="trend-legend" aria-hidden="true">
<span><i className="activity" />Ejecuciones</span>
<span><i className="submissions" />Entregas</span>
<span><i className="approvals" />Aprobaciones</span>
</div>
<div className="weekly-trends" role="img" aria-label="Ejecuciones, entregas y aprobaciones de las últimas seis semanas">
{weeklyTrends.map((week) => (
<div className="trend-week" key={week.key}>
<div className="trend-bars">
<i className="activity" style={{ height: `${Math.max(3, (week.activity / trendMax) * 100)}%` }} title={`${week.activity} ejecuciones`} />
<i className="submissions" style={{ height: `${Math.max(3, (week.submissions / trendMax) * 100)}%` }} title={`${week.submissions} entregas`} />
<i className="approvals" style={{ height: `${Math.max(3, (week.approvals / trendMax) * 100)}%` }} title={`${week.approvals} aprobaciones`} />
</div>
<span>{week.label}</span>
</div>
))}
</div>
<p className="trend-note"><BarChart3 aria-hidden="true" />Los datos describen actividad real; no califican a los estudiantes.</p>
</section>
</div>

<section className="mentor-section" aria-labelledby="matrix-title">
<div className="section-header">
<div>
Expand Down
Loading