From beb88ef632f9287acfc78798ffaf3733372e37e5 Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Thu, 2 Jul 2026 12:53:45 +0200 Subject: [PATCH 1/5] Phase 14: student portal to-do list + progress radar (14.1, 14.2+14.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new sections to the student portal: - "My Work" — a combined essay+test to-do list grouped into Overdue/Planned/Completed, each item showing status (not started/in progress/submitted) and due date. - "My Progress" — a radar chart of the student's own per-criterion scores, combined across graded rubrics or filtered to one. Tests had no persisted assignment record at all (only a share-code URL the teacher had to hand out manually), so there was nothing to list. Adds `test_assignments` (migration 044), mirroring `essay_assignments`' RLS pattern, plus a read-only policy on `student_tests` for submission status and a scoped `tests` read policy so the portal can embed test content into a self-contained "Open" link. Also fixes a real bug found along the way: TestAssignmentModal shared one teacherKey across an entire class batch, so a DB-mode link pointed every student at the same row — now mints one per student. Docs updated per CLAUDE.md (DocsPage, README, LandingPage, all 5 locales). Deliberately left out: fixing StudentTestPage's own disconnected-client auth (a separate, pre-existing bug in the bare share-link flow, scoped out as its own follow-up in the wiki roadmap). Co-Authored-By: Claude Sonnet 5 --- README.md | 2 + src/components/Tests/TestAssignmentModal.tsx | 119 ++++- .../__tests__/TestAssignmentModal.test.tsx | 44 +- src/context/AppContext.tsx | 11 + src/locales/de.json | 38 +- src/locales/en.json | 38 +- src/locales/es.json | 38 +- src/locales/fr.json | 38 +- src/locales/nl.json | 38 +- src/pages/DocsPage.tsx | 19 + src/pages/LandingPage.tsx | 7 + src/pages/StudentPortalPage.tsx | 429 ++++++++++++++++-- .../__tests__/StudentPortalPage.test.tsx | 93 +++- src/services/database/StorageSync.ts | 13 + src/services/database/SupabaseAdapter.ts | 74 +++ src/types/index.ts | 28 ++ supabase/CLAUDE.md | 1 + supabase/migrations/044_test_assignments.sql | 85 ++++ 18 files changed, 1027 insertions(+), 88 deletions(-) create mode 100644 supabase/migrations/044_test_assignments.sql diff --git a/README.md b/README.md index 20db4c3e..58350588 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ A comprehensive, offline-first rubric creation and grading tool built with React - **View feedback**: Students see their grades, teacher comments, and attached files. - **Submit essays**: Anonymous essay submission via submission codes. - **Self-assessment**: Students complete CEFR self-assessments from their portal. +- **My Work**: A combined to-do list of assigned essays and tests, grouped into Overdue/Planned/Completed with per-item status (not started/in progress/submitted). Tests open in one click from the list, backed by a `test_assignments` Supabase table mirroring `essay_assignments`. +- **My Progress**: A radar chart of the student's own per-criterion scores, combined across every graded rubric (criteria with matching titles averaged together) or filtered to a single rubric. ### 7. Customisation & Accessibility diff --git a/src/components/Tests/TestAssignmentModal.tsx b/src/components/Tests/TestAssignmentModal.tsx index f2696e06..430ebf9a 100644 --- a/src/components/Tests/TestAssignmentModal.tsx +++ b/src/components/Tests/TestAssignmentModal.tsx @@ -1,5 +1,5 @@ -import React, { useState, useCallback, useMemo } from 'react'; -import { X, Copy, Check, ClipboardCheck, Database, ExternalLink } from 'lucide-react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { X, Copy, Check, ClipboardCheck, Database, ExternalLink, AlertCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import Modal from '../ui/Modal'; import { useApp } from '../../context/AppContext'; @@ -7,7 +7,7 @@ import { useDbStatus } from '../../hooks/useDbStatus'; import { loadSupabaseConfig } from '../../services/database'; import { encodeTestAssignment } from '../../utils/shareCode'; import { nanoid } from '../../utils/nanoid'; -import type { Test, TestAssignmentPayload } from '../../types'; +import type { Test, TestAssignmentPayload, TestAssignment } from '../../types'; interface Props { test: Test; @@ -16,7 +16,7 @@ interface Props { export default function TestAssignmentModal({ test, onClose }: Props) { const { t } = useTranslation(); - const { students, classes, settings } = useApp(); + const { students, classes, settings, saveTestAssignment } = useApp(); const dbStatus = useDbStatus(); const config = loadSupabaseConfig(); @@ -25,20 +25,33 @@ export default function TestAssignmentModal({ test, onClose }: Props) { const [embedDb, setEmbedDb] = useState(dbStatus.isConnected); const [copiedStudentId, setCopiedStudentId] = useState(null); const [copiedAll, setCopiedAll] = useState(false); - - const teacherKey = useMemo(() => nanoid(), []); + const [savedStudentIds, setSavedStudentIds] = useState>(new Set()); + const [saving, setSaving] = useState(false); + const [saveErrorCount, setSaveErrorCount] = useState(0); const classStudents = useMemo( () => students.filter((s) => (classId ? s.classId === classId : true)), [students, classId] ); + // One teacherKey per student — test_assignments rows are 1:1 with a single teacherKey + // server-side (same constraint essay_assignments has), so a whole-class share batch + // needs a distinct row id per student rather than the single shared key used for the + // offline/legacy link format. + const teacherKeys = useMemo(() => { + const map: Record = {}; + classStudents.forEach((s) => { + map[s.id] = nanoid(); + }); + return map; + }, [classStudents]); + const buildAssignment = useCallback( (studentId: string): TestAssignmentPayload => { const base: TestAssignmentPayload = { testId: test.id, studentId, - teacherKey, + teacherKey: teacherKeys[studentId] ?? nanoid(), requireSEB: test.requireSEB, durationMinutes: test.durationMinutes, createdAt: new Date().toISOString(), @@ -52,7 +65,7 @@ export default function TestAssignmentModal({ test, onClose }: Props) { } return base; }, - [test, teacherKey, expiresAt, embedDb, dbStatus.isConnected, config] + [test, teacherKeys, expiresAt, embedDb, dbStatus.isConnected, config] ); function buildUrl(studentId: string): string { @@ -60,6 +73,45 @@ export default function TestAssignmentModal({ test, onClose }: Props) { return `${window.location.origin}${window.location.pathname}#/test/${code}`; } + const handleSaveAllToDb = useCallback(async () => { + setSaving(true); + setSaveErrorCount(0); + let errors = 0; + const nowSaved = new Set(savedStudentIds); + for (const s of classStudents) { + if (nowSaved.has(s.id)) continue; + const assignment: TestAssignment = { + testId: test.id, + studentId: s.id, + teacherKey: teacherKeys[s.id], + testName: test.name, + requireSEB: test.requireSEB, + durationMinutes: test.durationMinutes, + createdAt: new Date().toISOString(), + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined, + }; + const result = await saveTestAssignment(assignment); + if (result.success) { + nowSaved.add(s.id); + } else { + errors += 1; + } + } + setSavedStudentIds(nowSaved); + setSaveErrorCount(errors); + setSaving(false); + }, [classStudents, teacherKeys, test, expiresAt, saveTestAssignment, savedStudentIds]); + + // Auto-save as soon as a DB-mode batch of links becomes shareable, same rationale as + // EssayAssignmentModal: gating behind a separate button click leaves a window where a + // teacher could hand out a link before its row exists server-side. + useEffect(() => { + if (embedDb && !saving) { + void handleSaveAllToDb(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [embedDb, classId]); + async function handleCopyOne(studentId: string) { try { await navigator.clipboard.writeText(buildUrl(studentId)); @@ -175,9 +227,54 @@ export default function TestAssignmentModal({ test, onClose }: Props) { {t('essay_assignment.db_embed_label')} {embedDb && ( -

- {t('essay_assignment.db_embed_help')} -

+ <> +

+ {t('tests.assignment_db_embed_help')} +

+
+ + {classStudents.length > 0 && ( + + {t('tests.assignment_saved_count', { + saved: savedStudentIds.size, + total: classStudents.length, + })} + + )} + {saveErrorCount > 0 && ( + + + {t('tests.assignment_save_partial_error', { count: saveErrorCount })} + + )} +
+ )} )} diff --git a/src/components/Tests/__tests__/TestAssignmentModal.test.tsx b/src/components/Tests/__tests__/TestAssignmentModal.test.tsx index c6265c39..4a54c3dd 100644 --- a/src/components/Tests/__tests__/TestAssignmentModal.test.tsx +++ b/src/components/Tests/__tests__/TestAssignmentModal.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderWithRouter } from '../../../test-utils/renderWithProviders'; import { DEFAULT_FORMAT } from '../../../types'; import type { AppSettings, Class, Student, Test as RmTest } from '../../../types'; import { decodeTestAssignment } from '../../../utils/shareCode'; +const mockDbStatus = vi.hoisted(() => ({ isConnected: false })); + const mockSettings: AppSettings = { defaultGradeScaleId: 'gs1', theme: 'dark', @@ -31,11 +33,14 @@ const mockTest: RmTest = { createdAt: '2024-01-01T00:00:00Z', }; +const mockSaveTestAssignment = vi.fn().mockResolvedValue({ success: true }); + vi.mock('../../../context/AppContext', () => ({ useApp: () => ({ students: mockStudents, classes: [mockClass], settings: mockSettings, + saveTestAssignment: mockSaveTestAssignment, }), })); @@ -50,18 +55,30 @@ vi.mock('react-i18next', () => ({ })); vi.mock('../../../hooks/useDbStatus', () => ({ - useDbStatus: () => ({ isConnected: false, status: 'idle', lastSyncAt: null, userId: null, currentUser: null }), + useDbStatus: () => ({ + isConnected: mockDbStatus.isConnected, + status: 'idle', + lastSyncAt: null, + userId: null, + currentUser: null, + }), })); vi.mock('../../../services/database', () => ({ - loadSupabaseConfig: vi.fn(() => null), + loadSupabaseConfig: vi.fn(() => ({ supabaseUrl: 'https://x.supabase.co', supabaseAnonKey: 'anon-key' })), })); describe('TestAssignmentModal', () => { - it('generates per-student share links that decode back to a valid TestAssignmentPayload', async () => { + beforeEach(() => { + mockDbStatus.isConnected = false; + mockSaveTestAssignment.mockClear(); + }); + + it('generates per-student share links that decode back to a valid TestAssignmentPayload with distinct teacherKeys', async () => { const { default: TestAssignmentModal } = await import('../TestAssignmentModal'); renderWithRouter(); + const teacherKeys = new Set(); for (const student of mockStudents) { const input = screen.getByLabelText( `tests.assignment_link_for:{"name":"${student.name}"}` @@ -78,6 +95,23 @@ describe('TestAssignmentModal', () => { expect(decoded?.teacherKey).toBeTruthy(); expect(decoded?.requireSEB).toBe(true); expect(decoded?.durationMinutes).toBe(30); + teacherKeys.add(decoded!.teacherKey); } + // Each student's row must have its own id — a shared key would let one DB row + // (test_assignments is 1:1 per teacherKey) silently overwrite another's assignment. + expect(teacherKeys.size).toBe(mockStudents.length); + }); + + it('saves one test_assignments row per student when DB embedding is enabled', async () => { + mockDbStatus.isConnected = true; + const { default: TestAssignmentModal } = await import('../TestAssignmentModal'); + renderWithRouter(); + + await waitFor(() => expect(mockSaveTestAssignment).toHaveBeenCalledTimes(mockStudents.length)); + + const savedStudentIds = mockSaveTestAssignment.mock.calls.map(([a]) => a.studentId).sort(); + expect(savedStudentIds).toEqual(mockStudents.map((s) => s.id).sort()); + const savedKeys = new Set(mockSaveTestAssignment.mock.calls.map(([a]) => a.teacherKey)); + expect(savedKeys.size).toBe(mockStudents.length); }); }); diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 5229531c..ac3bff56 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -32,6 +32,7 @@ import type { GradingTask, Test, StudentTest, + TestAssignment, UserRole, } from '../types'; import { @@ -756,6 +757,10 @@ interface AppContextValue extends StoreData { ) => Promise>>; deleteEssaySubmission: (submissionId: string, storagePath: string) => Promise; getEssaySignedUrl: (storagePath: string) => Promise; + // Test assignments (teacher side) + saveTestAssignment: (a: TestAssignment) => Promise; + fetchMyTestAssignments: () => Promise>>; + fetchAssignedTestContent: (testId: string) => Promise; // Backup / restore importBackup: (json: string) => Promise; // Landing / auth flow @@ -1531,6 +1536,9 @@ export function AppProvider({ children }: { children: ReactNode }) { [] ); const getEssaySignedUrl = useCallback((path: string) => storageSync.getEssaySignedUrl(path), []); + const saveTestAssignment = useCallback((a: TestAssignment) => storageSync.saveTestAssignment(a), []); + const fetchMyTestAssignments = useCallback(() => storageSync.fetchMyTestAssignments(), []); + const fetchAssignedTestContent = useCallback((testId: string) => storageSync.fetchAssignedTestContent(testId), []); // ─── Landing / auth flow ────────────────────────────────────────── const enterLocalMode = useCallback(() => { @@ -1732,6 +1740,9 @@ export function AppProvider({ children }: { children: ReactNode }) { fetchEssayAssignmentByKey, deleteEssaySubmission, getEssaySignedUrl, + saveTestAssignment, + fetchMyTestAssignments, + fetchAssignedTestContent, importBackup, showLanding: landingState === 'show', isCheckingSession: landingState === 'checking', diff --git a/src/locales/de.json b/src/locales/de.json index 71e28843..b91cdc98 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -466,6 +466,10 @@ "assignment_link_for": "Testlink für {{name}}", "copy_all_links": "Alle Links kopieren ({{count}})", "dev_open_as_student": "Als Schüler öffnen (nur Dev)", + "assignment_db_embed_help": "Speichert für jeden Schüler einen Zuweisungsdatensatz, sodass er in dessen Aufgabenliste im Portal erscheint. Der Link selbst funktioniert weiterhin eigenständig, ohne Portalkonto.", + "assignment_save_all_to_db": "Alle in Datenbank speichern", + "assignment_saved_count": "{{saved}}/{{total}} gespeichert", + "assignment_save_partial_error": "{{count}} Zuweisung(en) konnten nicht gespeichert werden — erneut versuchen", "results": { "title": "Testergebnisse", "not_found": "Diese Abgabe wurde nicht gefunden.", @@ -1259,9 +1263,6 @@ "self_assess_save": "Speichern", "self_assess_saved": "Gespeichert!", "self_assessed_on": "Selbst eingeschätzt am {{date}}", - "essays_pending": "Aufsätze zu vervollständigen", - "essays_completed": "Eingereichte Aufsätze", - "essays_empty": "Noch keine Aufsatzaufgaben.", "essay_open": "Aufsatz öffnen", "essay_due": "Fällig am {{date}}", "essay_due_soon": "Bald fällig", @@ -1273,8 +1274,25 @@ "essay_time": "{{n}} Min.", "essay_seb_required": "Safe Exam Browser erforderlich", "essay_submitted_words": "Eingereicht am {{date}} · {{wordCount}} Wörter", - "essays_load_error": "Ihre Aufsatzaufgaben konnten nicht geladen werden. Bitte aktualisieren Sie die Seite.", - "peer_reviews_received": "Erhaltene Peer-Reviews" + "peer_reviews_received": "Erhaltene Peer-Reviews", + "my_work": "Meine Aufgaben", + "work_overdue": "Überfällig", + "work_planned": "Geplant", + "work_completed": "Abgeschlossen", + "work_load_error": "Ihre zugewiesenen Aufgaben konnten nicht geladen werden. Bitte aktualisieren Sie die Seite.", + "my_progress": "Mein Fortschritt", + "progress_view_label": "Ansicht", + "progress_view_combined": "Kombiniert", + "test_open": "Test öffnen", + "test_opening": "Wird geöffnet…", + "test_due": "Fällig am {{date}}", + "test_due_soon": "Bald fällig", + "test_expired": "Abgelaufen", + "test_in_progress": "In Bearbeitung", + "test_submitted": "Eingereicht", + "test_duration": "{{n}} Min.", + "test_seb_required": "Safe Exam Browser erforderlich", + "test_open_error": "Dieser Test konnte nicht geladen werden. Bitten Sie Ihre Lehrkraft, den Link erneut zu senden." }, "selfAssess": { "title": "GER-Selbstbewertung", @@ -1958,7 +1976,7 @@ "route_speaking_session_label": "Sprechsitzung", "route_speaking_session_desc": "Strukturierte Sprechbewertungen mit sechs GER-orientierten Dimensionen.", "route_student_portal_label": "Schülerportal", - "route_student_portal_desc": "Schüler sehen Feedback, reichen Aufsätze ein und führen Selbstbeurteilungen durch. Kein Login erforderlich.", + "route_student_portal_desc": "Schüler sehen ihren Noten-/Fortschrittsverlauf, eine Aufgabenliste zugewiesener Aufsätze und Tests, und führen Selbstbeurteilungen durch. Über einen Freigabelink erreichbar, kein Login erforderlich.", "route_take_test_label": "Test ablegen", "route_take_test_desc": "Schüler öffnen einen geteilten Link, um einen Test zu absolvieren — Fragen beantworten, optionaler Countdown-Timer und Einreichen. Kein Login erforderlich.", "route_attachments_label": "Anhänge", @@ -2199,6 +2217,14 @@ "gr_test_summary_body_suffix": "bei einem Test, um das Panel", "gr_test_summary_export_panel": "Zusammenfassung exportieren", "gr_test_summary_body2": "zu finden. Wähle einen einzelnen Schüler oder die ganze Klasse und exportiere dann eine PDF- oder Word-Zusammenfassung mit Genauigkeit pro Frage und einer Stark/Entwicklung/Schwach-Aufschlüsselung nach verknüpftem Standard oder GER-Deskriptor — nützlich, um zu erkennen, welche Themen ein Schüler oder die gesamte Klasse wiederholen muss.", + "gr_portal_title": "Schülerportal", + "gr_portal_intro_prefix": "Jeder Schüler erhält ein persönliches Dashboard mit Notenverlauf, GER-Fortschritt und Feedback. Teile es über die Schaltfläche", + "gr_portal_intro_button": "Portal anzeigen", + "gr_portal_intro_suffix": "auf der Schülerseite, oder lass den Schüler sich selbst anmelden (E-Mail-OTP oder ein von der Lehrkraft festgelegtes Passwort) — auch direkt erreichbar unter", + "gr_portal_work_title": "Meine Aufgaben", + "gr_portal_work_body": "Eine kombinierte Aufgabenliste aus jedem zugewiesenen Aufsatz und Test, gruppiert nach Überfällig, Geplant und Abgeschlossen. Jeder Eintrag zeigt seinen Status (nicht begonnen, in Bearbeitung, eingereicht) und die Frist, sofern die Lehrkraft eine festgelegt hat. Tests öffnen sich mit einem Klick aus der Liste — kein Suchen nach einem Link mit Testcode mehr nötig.", + "gr_portal_progress_title": "Mein Fortschritt", + "gr_portal_progress_body": "Ein Radardiagramm der eigenen Punktzahlen des Schülers pro Kriterium, entweder kombiniert über alle bewerteten Rubrics (Kriterien mit demselben Namen werden gemittelt) oder gefiltert auf eine einzelne Rubric — sichtbar, sobald mindestens 3 verschiedene bewertete Kriterien vorliegen.", "ce_overview_title": "GER-Übersicht", "ce_overview_intro": "Das GER-Modul (Gemeinsamer Europäischer Referenzrahmen) verfolgt Sprachkompetenz in Lesen, Schreiben, Sprechen und Hören — abgestimmt auf niederländische VO-Zielniveaus (VMBO-BB bis VWO).", "ce_overview_item_whole_class": "Klassenweite Übersicht (/cefr-overview) — Heatmap aller Schüler über GER-Niveaus pro Fertigkeit.", diff --git a/src/locales/en.json b/src/locales/en.json index 20e174ff..29240bdf 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -466,6 +466,10 @@ "assignment_link_for": "Test link for {{name}}", "copy_all_links": "Copy all links ({{count}})", "dev_open_as_student": "Open as student (dev only)", + "assignment_db_embed_help": "Saves an assignment record for each student so it appears in their portal to-do list. The link itself still works standalone, without a portal account.", + "assignment_save_all_to_db": "Save all to database", + "assignment_saved_count": "Saved {{saved}}/{{total}}", + "assignment_save_partial_error": "{{count}} assignment(s) failed to save — try again", "results": { "title": "Test results", "not_found": "This submission could not be found.", @@ -1259,9 +1263,6 @@ "self_assess_save": "Save", "self_assess_saved": "Saved!", "self_assessed_on": "Self-assessed {{date}}", - "essays_pending": "Essays to complete", - "essays_completed": "Submitted essays", - "essays_empty": "No essay assignments yet.", "essay_open": "Open essay", "essay_due": "Due {{date}}", "essay_due_soon": "Due soon", @@ -1273,8 +1274,25 @@ "essay_time": "{{n}} min", "essay_seb_required": "Safe Exam Browser required", "essay_submitted_words": "Submitted {{date}} · {{wordCount}} words", - "essays_load_error": "Could not load your essay assignments. Please try refreshing the page.", - "peer_reviews_received": "Peer Reviews Received" + "peer_reviews_received": "Peer Reviews Received", + "my_work": "My work", + "work_overdue": "Overdue", + "work_planned": "Planned", + "work_completed": "Completed", + "work_load_error": "Could not load your assigned work. Please try refreshing the page.", + "my_progress": "My progress", + "progress_view_label": "View", + "progress_view_combined": "Combined", + "test_open": "Open test", + "test_opening": "Opening…", + "test_due": "Due {{date}}", + "test_due_soon": "Due soon", + "test_expired": "Expired", + "test_in_progress": "In progress", + "test_submitted": "Submitted", + "test_duration": "{{n}} min", + "test_seb_required": "Safe Exam Browser required", + "test_open_error": "Couldn't load this test. Ask your teacher to resend the link." }, "selfAssess": { "title": "CEFR Self-Assessment", @@ -1958,7 +1976,7 @@ "route_speaking_session_label": "Speaking Session", "route_speaking_session_desc": "Structured speaking assessments with six CEFR-aligned dimensions.", "route_student_portal_label": "Student Portal", - "route_student_portal_desc": "Students view feedback, submit essays, and complete self-assessments. No login required.", + "route_student_portal_desc": "Students view grade/progress history, a to-do list of assigned essays and tests, and complete self-assessments. Reachable via a share link, no login required.", "route_take_test_label": "Take a Test", "route_take_test_desc": "Students open a shared link to take a test — answer questions, optional countdown timer, and submit. No login required.", "route_attachments_label": "Attachments", @@ -2197,6 +2215,14 @@ "gr_test_summary_body_suffix": "on a test to find the", "gr_test_summary_export_panel": "Export summary", "gr_test_summary_body2": "panel. Choose a single student or the whole class, then export a PDF or Word summary with per-question accuracy and a strong/developing/weak breakdown by linked standard or CEFR descriptor — useful for spotting which topics a student or the class as a whole needs to revisit.", + "gr_portal_title": "Student Portal", + "gr_portal_intro_prefix": "Each student gets a personal dashboard with their grade history, CEFR progress, and feedback. Share it via the", + "gr_portal_intro_button": "View portal", + "gr_portal_intro_suffix": "button on the Students page, or let the student log in themselves (email OTP or a teacher-set password) — it's also reachable directly at", + "gr_portal_work_title": "My Work", + "gr_portal_work_body": "A combined to-do list of every assigned essay and test, grouped into Overdue, Planned, and Completed. Each item shows its status (not started, in progress, submitted) and due date where the teacher set one. Tests open in one click from the list — no need to hunt down a share-code link.", + "gr_portal_progress_title": "My Progress", + "gr_portal_progress_body": "A radar chart of the student's own per-criterion scores, either combined across every rubric they've been graded on (criteria with the same name are averaged together) or filtered to a single rubric — shown once they have at least 3 distinct graded criteria.", "ce_overview_title": "CEFR Overview", "ce_overview_intro": "The CEFR (Common European Framework of Reference) module tracks language proficiency across Reading, Writing, Speaking, and Listening — aligned to Dutch VO targets (VMBO-BB through VWO).", "ce_overview_item_whole_class": "Whole-class overview (/cefr-overview) — heatmap of all students across CEFR levels per skill.", diff --git a/src/locales/es.json b/src/locales/es.json index 1a639c43..1f25818c 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -466,6 +466,10 @@ "assignment_link_for": "Enlace de la prueba para {{name}}", "copy_all_links": "Copiar todos los enlaces ({{count}})", "dev_open_as_student": "Abrir como estudiante (solo dev)", + "assignment_db_embed_help": "Guarda un registro de asignación para cada estudiante para que aparezca en su lista de tareas del portal. El enlace sigue funcionando de forma independiente, sin cuenta de portal.", + "assignment_save_all_to_db": "Guardar todo en la base de datos", + "assignment_saved_count": "{{saved}}/{{total}} guardado(s)", + "assignment_save_partial_error": "{{count}} asignación(es) no se pudieron guardar — inténtalo de nuevo", "results": { "title": "Resultados de la prueba", "not_found": "No se ha podido encontrar esta entrega.", @@ -1259,9 +1263,6 @@ "self_assess_save": "Guardar", "self_assess_saved": "¡Guardado!", "self_assessed_on": "Autoevaluado el {{date}}", - "essays_pending": "Ensayos por completar", - "essays_completed": "Ensayos enviados", - "essays_empty": "Aún no hay tareas de ensayo.", "essay_open": "Abrir ensayo", "essay_due": "Fecha límite {{date}}", "essay_due_soon": "Fecha límite próxima", @@ -1273,8 +1274,25 @@ "essay_time": "{{n}} min", "essay_seb_required": "Se requiere Safe Exam Browser", "essay_submitted_words": "Enviado el {{date}} · {{wordCount}} palabras", - "essays_load_error": "No se pudieron cargar tus tareas de ensayo. Por favor, intenta actualizar la página.", - "peer_reviews_received": "Revisiones de pares recibidas" + "peer_reviews_received": "Revisiones de pares recibidas", + "my_work": "Mi trabajo", + "work_overdue": "Atrasado", + "work_planned": "Planificado", + "work_completed": "Completado", + "work_load_error": "No se pudo cargar tu trabajo asignado. Por favor, intenta actualizar la página.", + "my_progress": "Mi progreso", + "progress_view_label": "Vista", + "progress_view_combined": "Combinada", + "test_open": "Abrir examen", + "test_opening": "Abriendo…", + "test_due": "Fecha límite {{date}}", + "test_due_soon": "Fecha límite próxima", + "test_expired": "Expirado", + "test_in_progress": "En curso", + "test_submitted": "Enviado", + "test_duration": "{{n}} min", + "test_seb_required": "Se requiere Safe Exam Browser", + "test_open_error": "No se pudo cargar este examen. Pide a tu profesor que reenvíe el enlace." }, "selfAssess": { "title": "Autoevaluación MCER", @@ -1958,7 +1976,7 @@ "route_speaking_session_label": "Sesión oral", "route_speaking_session_desc": "Evaluaciones orales estructuradas con seis dimensiones alineadas con el MCER.", "route_student_portal_label": "Portal del alumno", - "route_student_portal_desc": "Los alumnos ven comentarios, entregan redacciones y completan autoevaluaciones. No se requiere inicio de sesión.", + "route_student_portal_desc": "Los alumnos ven su historial de calificaciones/progreso, una lista de tareas de ensayos y exámenes asignados, y completan autoevaluaciones. Accesible mediante un enlace para compartir, no se requiere inicio de sesión.", "route_take_test_label": "Hacer una prueba", "route_take_test_desc": "Los alumnos abren un enlace compartido para hacer una prueba — responder preguntas, temporizador opcional y enviar. No se requiere inicio de sesión.", "route_attachments_label": "Adjuntos", @@ -2199,6 +2217,14 @@ "gr_test_summary_body_suffix": "en una prueba para encontrar el panel", "gr_test_summary_export_panel": "Exportar resumen", "gr_test_summary_body2": "Elige un alumno individual o toda la clase, luego exporta un resumen en PDF o Word con la precisión por pregunta y un desglose fuerte/en desarrollo/débil por estándar o descriptor MCER vinculado — útil para detectar qué temas necesita repasar un alumno o toda la clase.", + "gr_portal_title": "Portal del estudiante", + "gr_portal_intro_prefix": "Cada estudiante tiene un panel personal con su historial de calificaciones, progreso MCER y comentarios. Compártelo con el botón", + "gr_portal_intro_button": "Ver portal", + "gr_portal_intro_suffix": "de la página de Estudiantes, o deja que el propio estudiante inicie sesión (OTP por correo o una contraseña definida por el profesor) — también accesible directamente en", + "gr_portal_work_title": "Mi trabajo", + "gr_portal_work_body": "Una lista combinada de tareas de cada ensayo y examen asignado, agrupados en Atrasado, Planificado y Completado. Cada elemento muestra su estado (no iniciado, en curso, enviado) y la fecha límite cuando el profesor la haya definido. Los exámenes se abren con un clic desde la lista — sin necesidad de buscar un enlace con código.", + "gr_portal_progress_title": "Mi progreso", + "gr_portal_progress_body": "Un gráfico de radar con las propias puntuaciones del estudiante por criterio, combinado en todas las rúbricas calificadas (los criterios con el mismo nombre se promedian) o filtrado a una sola rúbrica — se muestra en cuanto haya al menos 3 criterios calificados distintos.", "ce_overview_title": "Resumen del MCER", "ce_overview_intro": "El módulo MCER (Marco Común Europeo de Referencia) sigue la competencia lingüística en Lectura, Escritura, Expresión oral y Comprensión oral — alineado con los objetivos neerlandeses VO (de VMBO-BB a VWO).", "ce_overview_item_whole_class": "Resumen de toda la clase (/cefr-overview) — mapa de calor de todos los alumnos según los niveles MCER por destreza.", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9662c68a..11045b4e 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -466,6 +466,10 @@ "assignment_link_for": "Lien du test pour {{name}}", "copy_all_links": "Copier tous les liens ({{count}})", "dev_open_as_student": "Ouvrir en tant qu'élève (dev uniquement)", + "assignment_db_embed_help": "Enregistre une attribution pour chaque élève afin qu'elle apparaisse dans sa liste de travaux du portail. Le lien fonctionne toujours seul, sans compte portail.", + "assignment_save_all_to_db": "Tout enregistrer dans la base de données", + "assignment_saved_count": "{{saved}}/{{total}} enregistré(s)", + "assignment_save_partial_error": "{{count}} attribution(s) n'ont pas pu être enregistrées — réessayez", "results": { "title": "Résultats du test", "not_found": "Cette soumission n'a pas pu être trouvée.", @@ -1259,9 +1263,6 @@ "self_assess_save": "Enregistrer", "self_assess_saved": "Enregistré !", "self_assessed_on": "Auto-évalué le {{date}}", - "essays_pending": "Essais à rédiger", - "essays_completed": "Essais soumis", - "essays_empty": "Aucun devoir d'essai pour l'instant.", "essay_open": "Ouvrir l'essai", "essay_due": "Échéance {{date}}", "essay_due_soon": "Bientôt dû", @@ -1273,8 +1274,25 @@ "essay_time": "{{n}} min", "essay_seb_required": "Safe Exam Browser requis", "essay_submitted_words": "Soumis le {{date}} · {{wordCount}} mots", - "essays_load_error": "Impossible de charger vos devoirs d'essai. Veuillez actualiser la page.", - "peer_reviews_received": "Évaluations par les pairs reçues" + "peer_reviews_received": "Évaluations par les pairs reçues", + "my_work": "Mon travail", + "work_overdue": "En retard", + "work_planned": "Prévu", + "work_completed": "Terminé", + "work_load_error": "Impossible de charger votre travail assigné. Veuillez actualiser la page.", + "my_progress": "Ma progression", + "progress_view_label": "Vue", + "progress_view_combined": "Combinée", + "test_open": "Ouvrir le test", + "test_opening": "Ouverture…", + "test_due": "Échéance {{date}}", + "test_due_soon": "Bientôt dû", + "test_expired": "Expiré", + "test_in_progress": "En cours", + "test_submitted": "Soumis", + "test_duration": "{{n}} min", + "test_seb_required": "Safe Exam Browser requis", + "test_open_error": "Impossible de charger ce test. Demandez à votre enseignant de renvoyer le lien." }, "selfAssess": { "title": "Auto-évaluation CECR", @@ -1958,7 +1976,7 @@ "route_speaking_session_label": "Session orale", "route_speaking_session_desc": "Évaluations orales structurées avec six dimensions alignées sur le CECRL.", "route_student_portal_label": "Portail élève", - "route_student_portal_desc": "Les élèves consultent les retours, soumettent des rédactions et effectuent des autoévaluations. Aucune connexion requise.", + "route_student_portal_desc": "Les élèves consultent leur historique de notes/progression, une liste de tâches des essais et tests attribués, et effectuent des autoévaluations. Accessible via un lien de partage, aucune connexion requise.", "route_take_test_label": "Passer un test", "route_take_test_desc": "Les élèves ouvrent un lien partagé pour passer un test — répondre aux questions, minuteur optionnel et soumission. Aucune connexion requise.", "route_attachments_label": "Pièces jointes", @@ -2199,6 +2217,14 @@ "gr_test_summary_body_suffix": "sur un test pour trouver le panneau", "gr_test_summary_export_panel": "Exporter le résumé", "gr_test_summary_body2": "Choisissez un élève unique ou toute la classe, puis exportez un résumé PDF ou Word avec la précision par question et une répartition fort/en développement/faible par référentiel ou descripteur CECRL lié — utile pour repérer quels sujets un élève ou la classe entière doit revoir.", + "gr_portal_title": "Portail élève", + "gr_portal_intro_prefix": "Chaque élève dispose d'un tableau de bord personnel avec son historique de notes, sa progression CECRL et ses retours. Partagez-le via le bouton", + "gr_portal_intro_button": "Voir le portail", + "gr_portal_intro_suffix": "de la page Élèves, ou laissez l'élève se connecter lui-même (OTP par e-mail ou mot de passe défini par l'enseignant) — également accessible directement à", + "gr_portal_work_title": "Mon travail", + "gr_portal_work_body": "Une liste de tâches combinant chaque essai et test attribués, regroupés en En retard, Prévu et Terminé. Chaque élément affiche son statut (non commencé, en cours, soumis) et sa date limite lorsque l'enseignant en a défini une. Les tests s'ouvrent en un clic depuis la liste — plus besoin de retrouver un lien de code d'accès.", + "gr_portal_progress_title": "Ma progression", + "gr_portal_progress_body": "Un graphique radar des propres scores de l'élève par critère, combiné sur tous les référentiels évalués (les critères de même nom sont moyennés) ou filtré sur un seul référentiel — affiché dès qu'au moins 3 critères distincts ont été évalués.", "ce_overview_title": "Vue d'ensemble CECRL", "ce_overview_intro": "Le module CECRL (Cadre européen commun de référence pour les langues) suit la compétence linguistique en Lecture, Écriture, Expression orale et Compréhension orale — aligné sur les objectifs néerlandais VO (de VMBO-BB à VWO).", "ce_overview_item_whole_class": "Vue d'ensemble de la classe (/cefr-overview) — carte de chaleur de tous les élèves selon les niveaux CECRL par compétence.", diff --git a/src/locales/nl.json b/src/locales/nl.json index 61c6d450..1ffc1d69 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -466,6 +466,10 @@ "assignment_link_for": "Toetslink voor {{name}}", "copy_all_links": "Alle links kopiëren ({{count}})", "dev_open_as_student": "Openen als leerling (alleen dev)", + "assignment_db_embed_help": "Slaat een toewijzingsrecord op voor elke leerling zodat deze in hun portaal-takenlijst verschijnt. De link zelf werkt nog steeds op zichzelf, zonder portaalaccount.", + "assignment_save_all_to_db": "Alles opslaan in database", + "assignment_saved_count": "{{saved}}/{{total}} opgeslagen", + "assignment_save_partial_error": "{{count}} toewijzing(en) konden niet worden opgeslagen — probeer opnieuw", "results": { "title": "Toetsresultaten", "not_found": "Deze inzending kon niet worden gevonden.", @@ -1259,9 +1263,6 @@ "self_assess_save": "Opslaan", "self_assess_saved": "Opgeslagen!", "self_assessed_on": "Zelfbeoordeeld op {{date}}", - "essays_pending": "Nog te maken essays", - "essays_completed": "Ingediende essays", - "essays_empty": "Nog geen essayopdrachten.", "essay_open": "Essay openen", "essay_due": "Inleveren voor {{date}}", "essay_due_soon": "Bijna verlopen", @@ -1273,8 +1274,25 @@ "essay_time": "{{n}} min", "essay_seb_required": "Safe Exam Browser vereist", "essay_submitted_words": "Ingediend op {{date}} · {{wordCount}} woorden", - "essays_load_error": "Essayopdrachten konden niet worden geladen. Probeer de pagina te vernieuwen.", - "peer_reviews_received": "Ontvangen peerreviews" + "peer_reviews_received": "Ontvangen peerreviews", + "my_work": "Mijn werk", + "work_overdue": "Te laat", + "work_planned": "Gepland", + "work_completed": "Voltooid", + "work_load_error": "Je toegewezen werk kon niet worden geladen. Probeer de pagina te vernieuwen.", + "my_progress": "Mijn voortgang", + "progress_view_label": "Weergave", + "progress_view_combined": "Gecombineerd", + "test_open": "Toets openen", + "test_opening": "Openen…", + "test_due": "Inleveren voor {{date}}", + "test_due_soon": "Bijna verlopen", + "test_expired": "Verlopen", + "test_in_progress": "Bezig", + "test_submitted": "Ingediend", + "test_duration": "{{n}} min", + "test_seb_required": "Safe Exam Browser vereist", + "test_open_error": "Deze toets kon niet worden geladen. Vraag je docent de link opnieuw te sturen." }, "selfAssess": { "title": "ERK Zelfbeoordeling", @@ -1958,7 +1976,7 @@ "route_speaking_session_label": "Spreeksessie", "route_speaking_session_desc": "Gestructureerde spreekbeoordelingen met zes ERK-gerelateerde dimensies.", "route_student_portal_label": "Leerlingenportaal", - "route_student_portal_desc": "Leerlingen bekijken feedback, dienen essays in en voltooien zelfbeoordelingen. Geen login vereist.", + "route_student_portal_desc": "Leerlingen bekijken hun cijfer-/voortgangsgeschiedenis, een takenlijst met toegewezen essays en toetsen, en voltooien zelfbeoordelingen. Bereikbaar via een deellink, geen login vereist.", "route_take_test_label": "Toets maken", "route_take_test_desc": "Leerlingen openen een gedeelde link om een toets te maken — vragen beantwoorden, optionele afteltimer en inzenden. Geen login vereist.", "route_attachments_label": "Bijlagen", @@ -2199,6 +2217,14 @@ "gr_test_summary_body_suffix": "bij een toets om het paneel", "gr_test_summary_export_panel": "Overzicht exporteren", "gr_test_summary_body2": "te vinden. Kies een individuele leerling of de hele klas, en exporteer dan een PDF- of Word-overzicht met nauwkeurigheid per vraag en een sterk/ontwikkelend/zwak-overzicht per gekoppelde standaard of ERK-descriptor — handig om te zien welke onderwerpen een leerling of de klas als geheel moet herhalen.", + "gr_portal_title": "Leerlingenportaal", + "gr_portal_intro_prefix": "Elke leerling krijgt een persoonlijk dashboard met zijn cijfergeschiedenis, ERK-voortgang en feedback. Deel het via de knop", + "gr_portal_intro_button": "Portaal bekijken", + "gr_portal_intro_suffix": "op de leerlingenpagina, of laat de leerling zelf inloggen (e-mail-OTP of een door de docent ingesteld wachtwoord) — ook rechtstreeks bereikbaar via", + "gr_portal_work_title": "Mijn werk", + "gr_portal_work_body": "Een gecombineerde takenlijst van elk toegewezen essay en elke toets, gegroepeerd in Te laat, Gepland en Voltooid. Elk item toont de status (nog niet begonnen, bezig, ingediend) en de deadline indien de docent die heeft ingesteld. Toetsen openen met één klik vanuit de lijst — geen link met toetscode meer nodig.", + "gr_portal_progress_title": "Mijn voortgang", + "gr_portal_progress_body": "Een radargrafiek van de eigen scores per criterium van de leerling, gecombineerd over alle beoordeelde rubrics (criteria met dezelfde naam worden gemiddeld) of gefilterd op één rubric — zichtbaar zodra er minstens 3 verschillende beoordeelde criteria zijn.", "ce_overview_title": "ERK-overzicht", "ce_overview_intro": "De ERK-module (Europees Referentiekader) volgt taalvaardigheid op het gebied van Lezen, Schrijven, Spreken en Luisteren — afgestemd op Nederlandse VO-doelen (VMBO-BB tot en met VWO).", "ce_overview_item_whole_class": "Klasbreed overzicht (/cefr-overview) — heatmap van alle leerlingen over ERK-niveaus per vaardigheid.", diff --git a/src/pages/DocsPage.tsx b/src/pages/DocsPage.tsx index 16d8196a..5df6a7fd 100644 --- a/src/pages/DocsPage.tsx +++ b/src/pages/DocsPage.tsx @@ -948,6 +948,25 @@ function GradingTab() { {t('docs.gr_test_summary_body2')}

+ + +

+ {t('docs.gr_portal_intro_prefix')} {t('docs.gr_portal_intro_button')}{' '} + {t('docs.gr_portal_intro_suffix')} /portal/:studentId. +

+

+ {t('docs.gr_portal_work_title')} +

+

+ {t('docs.gr_portal_work_body')} +

+

+ {t('docs.gr_portal_progress_title')} +

+

+ {t('docs.gr_portal_progress_body')} +

+
); } diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 43a227fa..7a9712b3 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -29,6 +29,7 @@ import { Download, Search, KeyRound, + ListChecks, } from 'lucide-react'; import { useApp } from '../context/AppContext'; import { loadSupabaseConfig } from '../services/database'; @@ -152,6 +153,12 @@ const STUDENT_FEATURES = [ desc: 'If your school connects a database, you can sign in with your email and a password your teacher gives you — no confirmation email needed.', color: '#0ea5e9', }, + { + icon: ListChecks, + title: 'My Work & Progress', + desc: 'See every assigned essay and test in one to-do list — overdue, planned, and completed — plus a radar chart of your own scores across rubrics.', + color: '#14b8a6', + }, ]; export default function LandingPage() { diff --git a/src/pages/StudentPortalPage.tsx b/src/pages/StudentPortalPage.tsx index 60a6cec3..38266647 100644 --- a/src/pages/StudentPortalPage.tsx +++ b/src/pages/StudentPortalPage.tsx @@ -10,22 +10,47 @@ import { Star, ClipboardCheck, FileText, + ListChecks, Clock, AlertTriangle, ExternalLink, + Loader2, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Joyride, STATUS } from 'react-joyride'; import type { EventData } from 'react-joyride'; import { useApp } from '../context/AppContext'; -import { calcGradeSummary } from '../utils/gradeCalc'; +import { calcGradeSummary, calcEntryPoints } from '../utils/gradeCalc'; import CefrProgressChart from '../components/Statistics/CefrProgressChart'; +import CriterionRadarChart from '../components/Statistics/CriterionRadarChart'; +import type { CriterionRadarDataPoint } from '../components/Statistics/CriterionRadarChart'; import { CEFR_LEVELS } from '../data/cefrDescriptors'; import { getStudentPortalTutorialSteps } from '../data/StudentPortalTutorialSteps'; import RubricSelfAssessPanel from '../components/Students/RubricSelfAssessPanel'; -import { encodeEssayAssignment } from '../utils/shareCode'; +import { encodeEssayAssignment, encodeTestAssignment } from '../utils/shareCode'; import { loadSupabaseConfig } from '../services/database'; -import type { CefrLevel, CefrSkill, EssayAssignment, StudentEssayAssignmentSummary } from '../types'; +import type { + CefrLevel, + CefrSkill, + EssayAssignment, + StudentEssayAssignmentSummary, + StudentTestAssignmentSummary, + TestAssignmentPayload, + ScoreEntry, + RubricCriterion, +} from '../types'; + +function criterionPct( + h: { sr: { entries: ScoreEntry[] }; rubric: { criteria: RubricCriterion[] } }, + criterionId: string, + levels: { maxPoints: number }[] +): number { + const entry = h.sr.entries.find((e) => e.criterionId === criterionId); + const criterion = h.rubric.criteria.find((c) => c.id === criterionId); + if (!entry || !criterion) return 0; + const max = Math.max(...levels.map((l) => l.maxPoints), 1); + return (calcEntryPoints(entry, criterion) / max) * 100; +} export default function StudentPortalPage() { const { studentId } = useParams<{ studentId: string }>(); @@ -40,6 +65,8 @@ export default function StudentPortalPage() { selfAssessments, saveRubricSelfAssessment, fetchMyEssayAssignments, + fetchMyTestAssignments, + fetchAssignedTestContent, } = useApp(); const { t } = useTranslation(); const [linkCopied, setLinkCopied] = useState(false); @@ -47,11 +74,19 @@ export default function StudentPortalPage() { const [essayRows, setEssayRows] = useState([]); const [essayLoadError, setEssayLoadError] = useState(null); + const [testRows, setTestRows] = useState([]); + const [testLoadError, setTestLoadError] = useState(null); + const [openingTestKey, setOpeningTestKey] = useState(null); + const [testOpenErrorKey, setTestOpenErrorKey] = useState(null); + const [radarRubricId, setRadarRubricId] = useState('combined'); useEffect(() => { fetchMyEssayAssignments() .then(setEssayRows) .catch((err: unknown) => setEssayLoadError(err instanceof Error ? err.message : 'Failed to load essays')); + fetchMyTestAssignments() + .then(setTestRows) + .catch((err: unknown) => setTestLoadError(err instanceof Error ? err.message : 'Failed to load tests')); }, []); // eslint-disable-line react-hooks/exhaustive-deps const tourKey = `rm_portal_tour_seen_${studentId}`; @@ -146,6 +181,44 @@ export default function StudentPortalPage() { .sort((a, b) => CEFR_LEVELS.indexOf(a.level) - CEFR_LEVELS.indexOf(b.level)); }, [student, history]); + // Per-rubric radars a student can pick between, plus one "combined" view averaging + // scores across rubrics wherever a criterion title recurs (e.g. shared skill categories + // across rubric templates) — the two views the roadmap calls "combined and separated". + const rubricRadarOptions = useMemo(() => { + const seen = new Map(); + for (const h of history) { + if (h.rubric.criteria.length >= 3 && !seen.has(h.sr.rubricId)) { + seen.set(h.sr.rubricId, h.rubric.name); + } + } + return Array.from(seen.entries()).map(([id, name]) => ({ id, name })); + }, [history]); + + const combinedRadarData = useMemo((): CriterionRadarDataPoint[] => { + const byTitle = new Map(); + for (const h of history) { + for (const c of h.rubric.criteria) { + const key = c.title.trim().toLowerCase(); + if (!byTitle.has(key)) byTitle.set(key, { display: c.title, scores: [] }); + byTitle.get(key)!.scores.push(criterionPct(h, c.id, c.levels)); + } + } + return Array.from(byTitle.values()).map(({ display, scores }) => ({ + name: display, + avg: parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)), + })); + }, [history]); + + const selectedRadarData = useMemo((): CriterionRadarDataPoint[] => { + if (radarRubricId === 'combined') return combinedRadarData; + const h = history.find((h) => h.sr.rubricId === radarRubricId); + if (!h) return []; + return h.rubric.criteria.map((c) => ({ + name: c.title, + avg: parseFloat(criterionPct(h, c.id, c.levels).toFixed(1)), + })); + }, [radarRubricId, history, combinedRadarData]); + const selfAssess = selfAssessments.filter((sa) => sa.studentId === studentId); if (!student) { @@ -192,14 +265,62 @@ export default function StudentPortalPage() { return `#/essay/${encodeEssayAssignment(assignment)}`; } - const pendingEssays = essayRows.filter( - (r) => !r.submission && (!r.expiresAt || new Date(r.expiresAt) > new Date()) - ); - const completedEssays = essayRows.filter((r) => !!r.submission); - const expiredEssays = essayRows.filter((r) => !r.submission && r.expiresAt && new Date(r.expiresAt) <= new Date()); + async function handleOpenTest(row: StudentTestAssignmentSummary) { + setTestOpenErrorKey(null); + setOpeningTestKey(row.teacherKey); + const content = await fetchAssignedTestContent(row.testId); + setOpeningTestKey(null); + if (!content) { + setTestOpenErrorKey(row.teacherKey); + return; + } + const payload: TestAssignmentPayload = { + testId: row.testId, + studentId: row.studentId, + teacherKey: row.teacherKey, + requireSEB: row.requireSEB, + durationMinutes: row.durationMinutes ?? undefined, + createdAt: row.createdAt, + expiresAt: row.expiresAt ?? undefined, + test: content, + }; + window.location.hash = `/test/${encodeTestAssignment(payload)}`; + } + + function isDone(entry: WorkEntry): boolean { + return entry.kind === 'essay' + ? !!entry.row.submission + : entry.row.submission?.status === 'submitted' || entry.row.submission?.status === 'graded'; + } + function dueAt(entry: WorkEntry): string | null { + return entry.row.expiresAt ?? null; + } + + const allWork: WorkEntry[] = [ + ...essayRows.map((row) => ({ kind: 'essay' as const, key: `essay-${row.teacherKey}`, row })), + ...testRows.map((row) => ({ kind: 'test' as const, key: `test-${row.teacherKey}`, row })), + ]; + + const now = new Date(); + const byDueDateAsc = (a: WorkEntry, b: WorkEntry) => { + const aT = dueAt(a) ? new Date(dueAt(a)!).getTime() : Infinity; + const bT = dueAt(b) ? new Date(dueAt(b)!).getTime() : Infinity; + return aT - bT; + }; + const overdueWork = allWork + .filter((e) => !isDone(e) && dueAt(e) && new Date(dueAt(e)!) <= now) + .sort(byDueDateAsc); + const plannedWork = allWork + .filter((e) => !isDone(e) && (!dueAt(e) || new Date(dueAt(e)!) > now)) + .sort(byDueDateAsc); + const completedWork = allWork.filter(isDone); const hasPeerReviews = peerReviews.some((pr) => pr.studentId === studentId && pr.gradedAt); + const hasWork = allWork.length > 0; + const hasRadar = combinedRadarData.length >= 3 || rubricRadarOptions.length > 0; const navLinks = [ + { id: 'portal-section-work', label: t('studentPortal.my_work'), visible: hasWork }, + { id: 'portal-section-progress', label: t('studentPortal.my_progress'), visible: hasRadar }, { id: 'portal-section-grades', label: t('studentPortal.grade_history'), visible: history.length > 1 }, { id: 'portal-section-cefr', label: t('studentPortal.cefr_progress'), visible: cefrProgress.length > 0 }, { @@ -408,8 +529,8 @@ export default function StudentPortalPage() { )} - {/* ── Essay assignments ────────────────────────────────────── */} - {essayLoadError && dbConfig && ( + {/* ── My Work: combined essay + test to-do list ───────────────────── */} + {(essayLoadError || testLoadError) && dbConfig && (
- {t('studentPortal.essays_load_error')} + {t('studentPortal.work_load_error')}
)} - {essayRows.length > 0 ? ( - <> - {pendingEssays.length > 0 && ( -
-
- {pendingEssays.map((row) => ( - - ))} -
-
- )} - {completedEssays.length > 0 && ( -
-
- {completedEssays.map((row) => ( - - ))} -
-
- )} - {expiredEssays.length > 0 && ( -
-
- {expiredEssays.map((row) => ( - + {hasWork && ( +
+
+ {overdueWork.length > 0 && ( + + )} + {plannedWork.length > 0 && ( + + )} + {completedWork.length > 0 && ( + + )} +
+
+ )} + + {/* My progress: student-scoped radar view(s) */} + {hasRadar && ( +
+ {rubricRadarOptions.length > 0 && ( +
+ + +
)} - - ) : null} + o.id === radarRubricId)?.name ?? '') + } + height={340} + /> +
+ )} {/* Peer reviews received */} {(() => { @@ -669,6 +833,191 @@ export default function StudentPortalPage() { type TFunc = (key: string, opts?: Record) => string; +type WorkEntry = + | { kind: 'essay'; key: string; row: StudentEssayAssignmentSummary } + | { kind: 'test'; key: string; row: StudentTestAssignmentSummary }; + +function WorkGroup({ + title, + entries, + buildEssayUrl, + onOpenTest, + openingTestKey, + testOpenErrorKey, + t, +}: { + title: string; + entries: WorkEntry[]; + buildEssayUrl: (row: StudentEssayAssignmentSummary) => string; + onOpenTest: (row: StudentTestAssignmentSummary) => void; + openingTestKey: string | null; + testOpenErrorKey: string | null; + t: TFunc; +}) { + return ( +
+

+ {title} +

+
+ {entries.map((entry) => + entry.kind === 'essay' ? ( + + ) : ( + + ) + )} +
+
+ ); +} + +function TestCard({ + row, + onOpen, + opening, + openError, + t, +}: { + row: StudentTestAssignmentSummary; + onOpen: (row: StudentTestAssignmentSummary) => void; + opening: boolean; + openError: boolean; + t: TFunc; +}) { + const now = new Date(); + const expired = !!row.expiresAt && new Date(row.expiresAt) <= now; + const dueSoon = + !expired && !!row.expiresAt && new Date(row.expiresAt).getTime() - now.getTime() < 24 * 60 * 60 * 1000; + const done = row.submission?.status === 'submitted' || row.submission?.status === 'graded'; + + const chips: React.ReactNode[] = []; + if (row.durationMinutes) { + chips.push( + + + {t('studentPortal.test_duration', { n: row.durationMinutes })} + + ); + } + if (row.requireSEB) { + chips.push( + + + {t('studentPortal.test_seb_required')} + + ); + } + if (row.submission?.status === 'in_progress') { + chips.push( + + {t('studentPortal.test_in_progress')} + + ); + } + + return ( +
+ +
+
{row.testName}
+
+ {chips} +
+ {done ? ( +
+ {t('studentPortal.test_submitted')} + {row.submission?.submittedAt + ? ` · ${new Date(row.submission.submittedAt).toLocaleDateString()}` + : ''} +
+ ) : ( + row.expiresAt && ( +
+ {expired + ? t('studentPortal.test_expired') + : dueSoon + ? t('studentPortal.test_due_soon') + : t('studentPortal.test_due', { + date: new Date(row.expiresAt).toLocaleDateString(), + })} +
+ ) + )} + {openError && ( +
+ {t('studentPortal.test_open_error')} +
+ )} +
+ {!expired && !done && ( + + )} +
+ ); +} + function EssayCard({ row, href, t }: { row: StudentEssayAssignmentSummary; href: string; t: TFunc }) { const now = new Date(); const expired = !!row.expiresAt && new Date(row.expiresAt) <= now; diff --git a/src/pages/__tests__/StudentPortalPage.test.tsx b/src/pages/__tests__/StudentPortalPage.test.tsx index e5685c68..bcdd7475 100644 --- a/src/pages/__tests__/StudentPortalPage.test.tsx +++ b/src/pages/__tests__/StudentPortalPage.test.tsx @@ -11,6 +11,7 @@ import type { Student, StudentRubric, StudentEssayAssignmentSummary, + StudentTestAssignmentSummary, } from '../../types'; const mockGradeScale: GradeScale = { @@ -38,6 +39,8 @@ const mockGradeScalesArr = [mockGradeScale]; const emptyArr: never[] = []; const mockFetchMyEssayAssignments = vi.fn().mockResolvedValue([]); +const mockFetchMyTestAssignments = vi.fn().mockResolvedValue([]); +const mockFetchAssignedTestContent = vi.fn().mockResolvedValue(null); const mockGradedStudentRubric: StudentRubric = { id: 'sr1', @@ -76,6 +79,20 @@ const mockRubricWithCriteria: Rubric = { { id: 'l2', label: 'Good', minPoints: 70, maxPoints: 89, description: '', subItems: [] }, ], }, + { + id: 'c2', + title: 'Structure', + description: '', + weight: 100, + levels: [{ id: 'l3', label: 'Good', minPoints: 70, maxPoints: 89, description: '', subItems: [] }], + }, + { + id: 'c3', + title: 'Grammar', + description: '', + weight: 100, + levels: [{ id: 'l4', label: 'Good', minPoints: 70, maxPoints: 89, description: '', subItems: [] }], + }, ], gradeScaleId: 'gs1', format: DEFAULT_FORMAT, @@ -112,6 +129,8 @@ const mockAppValue: Record = { selfAssessments: emptyArr, saveRubricSelfAssessment: mockSaveRubricSelfAssessment, fetchMyEssayAssignments: mockFetchMyEssayAssignments, + fetchMyTestAssignments: mockFetchMyTestAssignments, + fetchAssignedTestContent: mockFetchAssignedTestContent, }; vi.mock('../../context/AppContext', () => ({ @@ -139,6 +158,7 @@ vi.mock('../../services/database', () => ({ vi.mock('../../utils/shareCode', () => ({ encodeEssayAssignment: vi.fn(() => 'test-code'), + encodeTestAssignment: vi.fn(() => 'test-code'), })); vi.mock('../../components/Statistics/CefrProgressChart', () => ({ default: () => null })); @@ -167,6 +187,10 @@ function renderAt(studentId: string) { describe('StudentPortalPage', () => { beforeEach(async () => { mockFetchMyEssayAssignments.mockClear(); + mockFetchMyEssayAssignments.mockResolvedValue([]); + mockFetchMyTestAssignments.mockClear(); + mockFetchMyTestAssignments.mockResolvedValue([]); + mockFetchAssignedTestContent.mockClear(); const mod = await import('../StudentPortalPage'); StudentPortalPageComp = mod.default; }); @@ -233,7 +257,7 @@ describe('StudentPortalPage', () => { }; mockFetchMyEssayAssignments.mockResolvedValueOnce([pendingEssay]); renderAt('s1'); - expect(await screen.findByText('studentPortal.essays_pending')).toBeInTheDocument(); + expect(await screen.findByText('studentPortal.work_planned')).toBeInTheDocument(); expect(screen.getByText('My Essay Title')).toBeInTheDocument(); }); @@ -255,7 +279,72 @@ describe('StudentPortalPage', () => { }; mockFetchMyEssayAssignments.mockResolvedValueOnce([completedEssay]); renderAt('s1'); - expect(await screen.findByText('studentPortal.essays_completed')).toBeInTheDocument(); + expect(await screen.findByText('studentPortal.work_completed')).toBeInTheDocument(); expect(screen.getByText('Completed Essay')).toBeInTheDocument(); }); + + it('renders a planned test card when fetchMyTestAssignments returns data', async () => { + const pendingTest: StudentTestAssignmentSummary = { + teacherKey: 'test-1', + testId: 't1', + studentId: 's1', + testName: 'Vocabulary Quiz', + requireSEB: false, + durationMinutes: 20, + createdAt: '2024-01-01T00:00:00Z', + expiresAt: null, + submission: null, + }; + mockFetchMyTestAssignments.mockResolvedValueOnce([pendingTest]); + renderAt('s1'); + expect(await screen.findByText('studentPortal.work_planned')).toBeInTheDocument(); + expect(screen.getByText('Vocabulary Quiz')).toBeInTheDocument(); + expect(screen.getByText('studentPortal.test_open')).toBeInTheDocument(); + }); + + it('groups an overdue essay separately from a completed test', async () => { + const overdueEssay: StudentEssayAssignmentSummary = { + teacherKey: 'essay-3', + rubricId: 'r1', + studentId: 's1', + title: 'Late Essay', + prompt: null, + minWords: null, + maxWords: null, + timeLimitMinutes: null, + requireSEB: false, + readOnlyAfterSubmit: false, + createdAt: '2024-01-01T00:00:00Z', + expiresAt: '2020-01-01T00:00:00Z', + submission: null, + }; + const submittedTest: StudentTestAssignmentSummary = { + teacherKey: 'test-2', + testId: 't2', + studentId: 's1', + testName: 'Grammar Test', + requireSEB: false, + durationMinutes: null, + createdAt: '2024-01-01T00:00:00Z', + expiresAt: null, + submission: { status: 'submitted', submittedAt: '2024-01-10T10:00:00Z' }, + }; + mockFetchMyEssayAssignments.mockResolvedValueOnce([overdueEssay]); + mockFetchMyTestAssignments.mockResolvedValueOnce([submittedTest]); + renderAt('s1'); + expect(await screen.findByText('studentPortal.work_overdue')).toBeInTheDocument(); + expect(screen.getByText('Late Essay')).toBeInTheDocument(); + expect(screen.getByText('studentPortal.work_completed')).toBeInTheDocument(); + expect(screen.getByText('Grammar Test')).toBeInTheDocument(); + }); + + it('renders the My Progress radar section once 3+ criteria have been graded', async () => { + mockAppValue.studentRubrics = mockGradedStudentRubricsArr; + renderAt('s1'); + expect((await screen.findAllByText('studentPortal.my_progress')).length).toBeGreaterThan(0); + // Rubric picker offers the graded rubric alongside the combined view + expect(screen.getByRole('option', { name: 'Essay Rubric' })).toBeInTheDocument(); + expect(screen.getByText('studentPortal.progress_view_combined')).toBeInTheDocument(); + mockAppValue.studentRubrics = emptyArr; + }); }); diff --git a/src/services/database/StorageSync.ts b/src/services/database/StorageSync.ts index bfd351b9..73883476 100644 --- a/src/services/database/StorageSync.ts +++ b/src/services/database/StorageSync.ts @@ -25,6 +25,7 @@ import type { GradingTask, Test, StudentTest, + TestAssignment, } from '../../types'; const CONFIG_KEY = 'rm_supabase_config'; @@ -322,6 +323,18 @@ class StorageSyncService { return this.adapter.fetchMyEssayAssignments(); } + async saveTestAssignment(a: TestAssignment): Promise { + return this.adapter.saveTestAssignment(a); + } + + async fetchMyTestAssignments() { + return this.adapter.fetchMyTestAssignments(); + } + + async fetchAssignedTestContent(testId: string): Promise { + return this.adapter.fetchAssignedTestContent(testId); + } + async fetchEssayAssignmentByKey(teacherKey: string) { return this.adapter.fetchEssayAssignmentByKey(teacherKey); } diff --git a/src/services/database/SupabaseAdapter.ts b/src/services/database/SupabaseAdapter.ts index bf992780..6d18f851 100644 --- a/src/services/database/SupabaseAdapter.ts +++ b/src/services/database/SupabaseAdapter.ts @@ -21,6 +21,8 @@ import type { StudentEssayAssignmentSummary, Test, StudentTest, + TestAssignment, + StudentTestAssignmentSummary, MarketplaceListing, CefrLevel, } from '../../types'; @@ -1092,6 +1094,78 @@ export class SupabaseAdapter { return error ? { success: false, error: error.message } : { success: true }; } + // ── Test assignments (teacher side) ───────────────────────────────────────── + + /** Persist the assignment to the DB so the student portal can list it */ + async saveTestAssignment(a: TestAssignment): Promise { + const { error } = await this.db() + .from('test_assignments') + .upsert( + { + id: a.teacherKey, + owner_id: this.uid(), + test_id: a.testId, + student_id: a.studentId, + test_name: a.testName, + require_seb: a.requireSEB ?? false, + duration_minutes: a.durationMinutes ?? null, + created_at: a.createdAt, + expires_at: a.expiresAt ?? null, + }, + { onConflict: 'id' } + ); + return error ? { success: false, error: error.message } : { success: true }; + } + + /** + * Fetch test assignments belonging to the currently logged-in student. + * Scoped by RLS via get_my_test_assignment_ids() — students only see their own rows. + * Submission status is looked up separately since student_tests stores studentId/testId + * inside its `data` jsonb column rather than as real columns to join on. + */ + async fetchMyTestAssignments(): Promise { + const { data, error } = await this.db() + .from('test_assignments') + .select('id, test_id, student_id, test_name, require_seb, duration_minutes, created_at, expires_at') + .order('created_at', { ascending: false }); + if (error || !data) return []; + + const { data: subRows } = await this.db().from('student_tests').select('data'); + const submissionByKey = new Map(); + for (const row of subRows ?? []) { + const st = row.data as StudentTest; + submissionByKey.set(`${st.testId}__${st.studentId}`, { + status: st.status, + submittedAt: st.submittedAt ?? null, + }); + } + + return data.map((r) => ({ + teacherKey: r.id, + testId: r.test_id, + studentId: r.student_id, + testName: r.test_name, + requireSEB: r.require_seb, + durationMinutes: r.duration_minutes ?? null, + createdAt: r.created_at, + expiresAt: r.expires_at ?? null, + submission: submissionByKey.get(`${r.test_id}__${r.student_id}`) ?? null, + })); + } + + /** + * Fetch the full content of a test the current student has an assignment for + * (RLS: `tests_student_select`, scoped to test_assignments the student owns), so the + * portal can embed it into a self-contained "Open" link — the same offline-content-URL + * shape TestAssignmentModal already produces, sidestepping StudentTestPage's separate + * disconnected client (which cannot read `tests` at all — see migration 044's notes). + */ + async fetchAssignedTestContent(testId: string): Promise { + const { data, error } = await this.db().from('tests').select('data').eq('id', testId).maybeSingle(); + if (error || !data) return null; + return data.data as Test; + } + // ── Analysis Results ────────────────────────────────────────────────────── async fetchAnalysisResults(): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 42bb1454..84db8f95 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -980,6 +980,34 @@ export interface TestAssignmentPayload { test?: Test; } +/** A teacher-created test assignment persisted to `test_assignments` so the student portal can list it, mirrors EssayAssignment */ +export interface TestAssignment { + testId: string; + studentId: string; + /** Opaque teacher identifier written into submissions so the teacher can filter their own rows; also the row id */ + teacherKey: string; + /** Denormalized from Test.name so the portal never needs read access to the `tests` table */ + testName: string; + requireSEB: boolean; + durationMinutes?: number; + createdAt: string; + /** ISO-8601 datetime after which students can no longer submit */ + expiresAt?: string; +} + +/** A test assignment as seen by the student portal, including their own submission status */ +export interface StudentTestAssignmentSummary { + teacherKey: string; + testId: string; + studentId: string; + testName: string; + requireSEB: boolean; + durationMinutes: number | null; + createdAt: string; + expiresAt: string | null; + submission: { status: StudentTest['status']; submittedAt: string | null } | null; +} + /** A student's completed test, encoded into a submission code for the teacher to import */ export interface TestSubmissionPayload { testId: string; diff --git a/supabase/CLAUDE.md b/supabase/CLAUDE.md index 871acff8..2522d724 100644 --- a/supabase/CLAUDE.md +++ b/supabase/CLAUDE.md @@ -101,6 +101,7 @@ Current functions: | `comment_snippets` | Reusable comment bank entries | | `essay_assignments` | Essay prompts created by teachers | | `essay_submissions` | Student essay submissions (anonymous via submission codes) | +| `test_assignments` | Per-student test assignment records (migration 044), mirrors `essay_assignments` so the student portal can list assigned tests | | `peer_reviews` | Peer review entries on essay submissions | | `self_assessments` | Student CEFR self-assessment records | | `speaking_sessions` | Speaking assessment session records | diff --git a/supabase/migrations/044_test_assignments.sql b/supabase/migrations/044_test_assignments.sql new file mode 100644 index 00000000..6870b416 --- /dev/null +++ b/supabase/migrations/044_test_assignments.sql @@ -0,0 +1,85 @@ +-- Migration 044: Test assignment tracking, mirroring essay_assignments (008, tightened in 023/025/026). +-- +-- Lets the student portal show a to-do list of assigned tests, the same way it already +-- does for essays via essay_assignments/get_my_essay_assignment_ids(). test_name is +-- denormalized onto the assignment row (like essay_assignments' title/prompt) so the +-- to-do list can render without reading `tests` at all. +-- +-- Scope note: this migration only grants read access to a portal-authenticated student's +-- OWN main app session (the one already used by fetchMy*Assignments()). It does not touch +-- StudentTestPage's separate disconnected/embedded-credentials client (created with +-- persistSession: false for cold share-code links), which has a pre-existing, unrelated gap: +-- it never authenticates at all (no anonymous sign-in, unlike the essay flow), so its own +-- `tests` content-fetch already fails under `tests_own` regardless of this migration. Fixing +-- that parity gap is a separate, larger change (would need to mirror StudentEssayPage's +-- anonymous-session + short-code resolution) and is out of scope here. + +-- ── 1. Table ──────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.test_assignments ( + id TEXT PRIMARY KEY, -- nanoid (= teacherKey), one row per student + owner_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + test_id TEXT NOT NULL, + student_id TEXT NOT NULL, -- local app student ID + test_name TEXT NOT NULL, -- denormalized so the portal never needs to read `tests` + require_seb BOOLEAN NOT NULL DEFAULT FALSE, + duration_minutes INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS test_assignments_owner_idx ON public.test_assignments(owner_id); +CREATE INDEX IF NOT EXISTS test_assignments_test_student_idx ON public.test_assignments(test_id, student_id); + +ALTER TABLE public.test_assignments ENABLE ROW LEVEL SECURITY; + +-- Teacher owns their assignments +CREATE POLICY "test_assignments_owner_all" + ON public.test_assignments FOR ALL + USING ((SELECT auth.uid()) = owner_id) + WITH CHECK ((SELECT auth.uid()) = owner_id); + +-- Admin can read all assignments +CREATE POLICY "test_assignments_admin_select" + ON public.test_assignments FOR SELECT + USING (get_my_role() = 'admin'); + +-- ── 2. Portal student: may only see their own assignments ───────────────────────── +-- Mirrors get_my_essay_assignment_ids() (migration 023, initplan-wrapped in 026). + +CREATE OR REPLACE FUNCTION public.get_my_test_assignment_ids() +RETURNS SETOF text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT ta.id + FROM public.test_assignments ta + WHERE ta.student_id IN (SELECT get_my_student_ids()) +$$; + +CREATE POLICY "test_assignments_student_select" + ON public.test_assignments FOR SELECT + USING (id IN (SELECT get_my_test_assignment_ids())); + +REVOKE EXECUTE ON FUNCTION public.get_my_test_assignment_ids() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_my_test_assignment_ids() TO authenticated; + +-- ── 3. student_tests: read-only access for a portal student to their own rows ───── +-- student_tests (migration 033) is otherwise owned entirely by the teacher +-- (owner_id = teacher's auth.uid()) — it was designed for the teacher's own connected +-- session to sync locally-graded/imported StudentTest records up, not for direct writes +-- by students. This policy is purely additive and read-only: it lets a portal-authenticated +-- student see the submission status of their own attempts (populated via the teacher's +-- normal submission-code-import flow), without opening any new write path. + +CREATE POLICY "student_tests_student_select" + ON public.student_tests FOR SELECT + USING ((data->>'studentId') IN (SELECT get_my_student_ids())); + +-- ── 4. tests: read-only access to the content of a student's own assigned tests ─── +-- Additive alongside the existing owner-only `tests_own` policy. Scoped strictly to +-- tests the student has a persisted assignment row for — lets the portal embed full +-- test content into a one-click "Open" link (the same self-contained-URL approach +-- TestAssignmentModal already uses when DB embedding is off), rather than pointing +-- students at the still-broken disconnected-client DB-mode fetch described above. + +CREATE POLICY "tests_student_select" + ON public.tests FOR SELECT + USING (id IN (SELECT test_id FROM public.test_assignments WHERE id IN (SELECT get_my_test_assignment_ids()))); From 81c341c7042627afe25c021c02433bddc663c591 Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Thu, 2 Jul 2026 12:57:39 +0200 Subject: [PATCH 2/5] fix: apply prettier formatting to TestAssignmentModal/StudentPortalPage CI's format:check caught line-width violations prettier's own default config didn't flag locally until run explicitly. Whitespace only, no logic change. Co-Authored-By: Claude Sonnet 5 --- src/components/Tests/TestAssignmentModal.tsx | 7 ++++++- src/pages/StudentPortalPage.tsx | 4 +--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/Tests/TestAssignmentModal.tsx b/src/components/Tests/TestAssignmentModal.tsx index 430ebf9a..a76b1383 100644 --- a/src/components/Tests/TestAssignmentModal.tsx +++ b/src/components/Tests/TestAssignmentModal.tsx @@ -229,7 +229,12 @@ export default function TestAssignmentModal({ test, onClose }: Props) { {embedDb && ( <>

{t('tests.assignment_db_embed_help')}

diff --git a/src/pages/StudentPortalPage.tsx b/src/pages/StudentPortalPage.tsx index 38266647..4cfc1263 100644 --- a/src/pages/StudentPortalPage.tsx +++ b/src/pages/StudentPortalPage.tsx @@ -307,9 +307,7 @@ export default function StudentPortalPage() { const bT = dueAt(b) ? new Date(dueAt(b)!).getTime() : Infinity; return aT - bT; }; - const overdueWork = allWork - .filter((e) => !isDone(e) && dueAt(e) && new Date(dueAt(e)!) <= now) - .sort(byDueDateAsc); + const overdueWork = allWork.filter((e) => !isDone(e) && dueAt(e) && new Date(dueAt(e)!) <= now).sort(byDueDateAsc); const plannedWork = allWork .filter((e) => !isDone(e) && (!dueAt(e) || new Date(dueAt(e)!) > now)) .sort(byDueDateAsc); From 2f3aea2aff87555c12efb76646f49cec55225c28 Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Thu, 2 Jul 2026 14:43:27 +0200 Subject: [PATCH 3/5] fix: address CodeRabbit review findings on PR #248 - security (critical): tests_student_select RLS policy didn't verify test_assignments.owner_id matched tests.owner_id, letting an assignment row leak another teacher's test content to a student - critical (found outside diff): TestAssignmentModal regenerated teacherKeys on every class-dropdown switch while savedStudentIds tracked by student id, silently un-syncing displayed links from persisted rows on a class revisit; keyed teacherKeys off the full students list instead so they stay stable across class switches - perf: parallelized per-student saveTestAssignment calls with Promise.all instead of a sequential await loop - correctness: per-rubric radar only used the first graded attempt of a rubric, dropping later re-grades; now averages every matching attempt per criterion, same as the combined view - robustness: handleOpenTest left the card stuck in a loading state if fetchAssignedTestContent rejected; wrapped in try/catch/finally - test quality: radar fixture only graded one of three criteria despite the test asserting "3+ criteria graded"; added real entries for all three - style: removed two comments that only restated their JSX section's heading, and added an explicit button type Added regression tests for the two critical/major bugs (class-switch teacherKey stability, per-rubric radar aggregation). Co-Authored-By: Claude Sonnet 5 --- src/components/Tests/TestAssignmentModal.tsx | 50 ++++++++------- .../__tests__/TestAssignmentModal.test.tsx | 43 ++++++++++--- src/pages/StudentPortalPage.tsx | 61 ++++++++++++------- .../__tests__/StudentPortalPage.test.tsx | 32 +++++++++- supabase/migrations/044_test_assignments.sql | 15 ++++- 5 files changed, 145 insertions(+), 56 deletions(-) diff --git a/src/components/Tests/TestAssignmentModal.tsx b/src/components/Tests/TestAssignmentModal.tsx index a76b1383..34c163f6 100644 --- a/src/components/Tests/TestAssignmentModal.tsx +++ b/src/components/Tests/TestAssignmentModal.tsx @@ -37,14 +37,18 @@ export default function TestAssignmentModal({ test, onClose }: Props) { // One teacherKey per student — test_assignments rows are 1:1 with a single teacherKey // server-side (same constraint essay_assignments has), so a whole-class share batch // needs a distinct row id per student rather than the single shared key used for the - // offline/legacy link format. + // offline/legacy link format. Keyed off the full `students` list (not the class-filtered + // one) so switching the class dropdown back and forth doesn't regenerate keys for + // students already saved under their original key — savedStudentIds tracks student id, + // and a regenerated key for an already-"saved" id would silently un-sync the displayed + // link from what's actually persisted. const teacherKeys = useMemo(() => { const map: Record = {}; - classStudents.forEach((s) => { + students.forEach((s) => { map[s.id] = nanoid(); }); return map; - }, [classStudents]); + }, [students]); const buildAssignment = useCallback( (studentId: string): TestAssignmentPayload => { @@ -76,27 +80,27 @@ export default function TestAssignmentModal({ test, onClose }: Props) { const handleSaveAllToDb = useCallback(async () => { setSaving(true); setSaveErrorCount(0); - let errors = 0; const nowSaved = new Set(savedStudentIds); - for (const s of classStudents) { - if (nowSaved.has(s.id)) continue; - const assignment: TestAssignment = { - testId: test.id, - studentId: s.id, - teacherKey: teacherKeys[s.id], - testName: test.name, - requireSEB: test.requireSEB, - durationMinutes: test.durationMinutes, - createdAt: new Date().toISOString(), - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined, - }; - const result = await saveTestAssignment(assignment); - if (result.success) { - nowSaved.add(s.id); - } else { - errors += 1; - } - } + const pending = classStudents.filter((s) => !nowSaved.has(s.id)); + const results = await Promise.all( + pending.map((s) => + saveTestAssignment({ + testId: test.id, + studentId: s.id, + teacherKey: teacherKeys[s.id], + testName: test.name, + requireSEB: test.requireSEB, + durationMinutes: test.durationMinutes, + createdAt: new Date().toISOString(), + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined, + } satisfies TestAssignment) + ) + ); + let errors = 0; + results.forEach((result, i) => { + if (result.success) nowSaved.add(pending[i].id); + else errors += 1; + }); setSavedStudentIds(nowSaved); setSaveErrorCount(errors); setSaving(false); diff --git a/src/components/Tests/__tests__/TestAssignmentModal.test.tsx b/src/components/Tests/__tests__/TestAssignmentModal.test.tsx index 4a54c3dd..b39871da 100644 --- a/src/components/Tests/__tests__/TestAssignmentModal.test.tsx +++ b/src/components/Tests/__tests__/TestAssignmentModal.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderWithRouter } from '../../../test-utils/renderWithProviders'; import { DEFAULT_FORMAT } from '../../../types'; @@ -18,9 +18,11 @@ const mockSettings: AppSettings = { }; const mockClass: Class = { id: 'c1', name: 'Class A' }; +const mockClass2: Class = { id: 'c2', name: 'Class B' }; const mockStudents: Student[] = [ { id: 's1', name: 'Alice', classId: 'c1' }, { id: 's2', name: 'Bob', classId: 'c1' }, + { id: 's3', name: 'Carla', classId: 'c2' }, ]; const mockTest: RmTest = { @@ -33,12 +35,14 @@ const mockTest: RmTest = { createdAt: '2024-01-01T00:00:00Z', }; +const classAStudents = mockStudents.filter((s) => s.classId === 'c1'); + const mockSaveTestAssignment = vi.fn().mockResolvedValue({ success: true }); vi.mock('../../../context/AppContext', () => ({ useApp: () => ({ students: mockStudents, - classes: [mockClass], + classes: [mockClass, mockClass2], settings: mockSettings, saveTestAssignment: mockSaveTestAssignment, }), @@ -79,7 +83,7 @@ describe('TestAssignmentModal', () => { renderWithRouter(); const teacherKeys = new Set(); - for (const student of mockStudents) { + for (const student of classAStudents) { const input = screen.getByLabelText( `tests.assignment_link_for:{"name":"${student.name}"}` ) as HTMLInputElement; @@ -99,7 +103,7 @@ describe('TestAssignmentModal', () => { } // Each student's row must have its own id — a shared key would let one DB row // (test_assignments is 1:1 per teacherKey) silently overwrite another's assignment. - expect(teacherKeys.size).toBe(mockStudents.length); + expect(teacherKeys.size).toBe(classAStudents.length); }); it('saves one test_assignments row per student when DB embedding is enabled', async () => { @@ -107,11 +111,36 @@ describe('TestAssignmentModal', () => { const { default: TestAssignmentModal } = await import('../TestAssignmentModal'); renderWithRouter(); - await waitFor(() => expect(mockSaveTestAssignment).toHaveBeenCalledTimes(mockStudents.length)); + await waitFor(() => expect(mockSaveTestAssignment).toHaveBeenCalledTimes(classAStudents.length)); const savedStudentIds = mockSaveTestAssignment.mock.calls.map(([a]) => a.studentId).sort(); - expect(savedStudentIds).toEqual(mockStudents.map((s) => s.id).sort()); + expect(savedStudentIds).toEqual(classAStudents.map((s) => s.id).sort()); const savedKeys = new Set(mockSaveTestAssignment.mock.calls.map(([a]) => a.teacherKey)); - expect(savedKeys.size).toBe(mockStudents.length); + expect(savedKeys.size).toBe(classAStudents.length); + }); + + it('keeps a stable teacherKey per student when the class dropdown is switched back and forth', async () => { + mockDbStatus.isConnected = true; + const { default: TestAssignmentModal } = await import('../TestAssignmentModal'); + renderWithRouter(); + + await waitFor(() => expect(mockSaveTestAssignment).toHaveBeenCalledTimes(classAStudents.length)); + const firstVisitKey = mockSaveTestAssignment.mock.calls.find(([a]) => a.studentId === 's1')?.[0]?.teacherKey; + expect(firstVisitKey).toBeTruthy(); + + const classSelect = screen.getByLabelText('tests.assignment_class_label'); + fireEvent.change(classSelect, { target: { value: 'c2' } }); + await waitFor(() => expect(mockSaveTestAssignment).toHaveBeenCalledTimes(mockStudents.length)); + + fireEvent.change(classSelect, { target: { value: 'c1' } }); + await waitFor(() => expect(screen.getByLabelText('tests.assignment_class_label')).toHaveValue('c1')); + + // Revisiting class A must not re-save its students (they're already persisted), + // and the link shown must still point at the exact row that was saved the first + // time — in DB mode the share code IS the bare teacherKey (see shareCode.ts). + expect(mockSaveTestAssignment).toHaveBeenCalledTimes(mockStudents.length); + const input = screen.getByLabelText('tests.assignment_link_for:{"name":"Alice"}') as HTMLInputElement; + const code = input.value.split('#/test/')[1]; + expect(code).toBe(firstVisitKey); }); }); diff --git a/src/pages/StudentPortalPage.tsx b/src/pages/StudentPortalPage.tsx index 4cfc1263..19659b2b 100644 --- a/src/pages/StudentPortalPage.tsx +++ b/src/pages/StudentPortalPage.tsx @@ -211,11 +211,19 @@ export default function StudentPortalPage() { const selectedRadarData = useMemo((): CriterionRadarDataPoint[] => { if (radarRubricId === 'combined') return combinedRadarData; - const h = history.find((h) => h.sr.rubricId === radarRubricId); - if (!h) return []; - return h.rubric.criteria.map((c) => ({ - name: c.title, - avg: parseFloat(criterionPct(h, c.id, c.levels).toFixed(1)), + const rows = history.filter((h) => h.sr.rubricId === radarRubricId); + if (rows.length === 0) return []; + const byTitle = new Map(); + for (const h of rows) { + for (const c of h.rubric.criteria) { + const key = c.title.trim().toLowerCase(); + if (!byTitle.has(key)) byTitle.set(key, { display: c.title, scores: [] }); + byTitle.get(key)!.scores.push(criterionPct(h, c.id, c.levels)); + } + } + return Array.from(byTitle.values()).map(({ display, scores }) => ({ + name: display, + avg: parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)), })); }, [radarRubricId, history, combinedRadarData]); @@ -268,23 +276,33 @@ export default function StudentPortalPage() { async function handleOpenTest(row: StudentTestAssignmentSummary) { setTestOpenErrorKey(null); setOpeningTestKey(row.teacherKey); - const content = await fetchAssignedTestContent(row.testId); - setOpeningTestKey(null); - if (!content) { + try { + const content = await fetchAssignedTestContent(row.testId); + if (!content) { + setTestOpenErrorKey(row.teacherKey); + return; + } + const payload: TestAssignmentPayload = { + testId: row.testId, + studentId: row.studentId, + teacherKey: row.teacherKey, + requireSEB: row.requireSEB, + durationMinutes: row.durationMinutes ?? undefined, + createdAt: row.createdAt, + expiresAt: row.expiresAt ?? undefined, + test: content, + }; + const code = encodeTestAssignment(payload); + if (!code) { + setTestOpenErrorKey(row.teacherKey); + return; + } + window.location.hash = `/test/${code}`; + } catch { setTestOpenErrorKey(row.teacherKey); - return; + } finally { + setOpeningTestKey(null); } - const payload: TestAssignmentPayload = { - testId: row.testId, - studentId: row.studentId, - teacherKey: row.teacherKey, - requireSEB: row.requireSEB, - durationMinutes: row.durationMinutes ?? undefined, - createdAt: row.createdAt, - expiresAt: row.expiresAt ?? undefined, - test: content, - }; - window.location.hash = `/test/${encodeTestAssignment(payload)}`; } function isDone(entry: WorkEntry): boolean { @@ -527,7 +545,6 @@ export default function StudentPortalPage() {
)} - {/* ── My Work: combined essay + test to-do list ───────────────────── */} {(essayLoadError || testLoadError) && dbConfig && (
)} - {/* My progress: student-scoped radar view(s) */} {hasRadar && (
{rubricRadarOptions.length > 0 && ( @@ -985,6 +1001,7 @@ function TestCard({
{!expired && !done && (