From 7078a011008e5d31d95dcb688e6ab3901f0c72c4 Mon Sep 17 00:00:00 2001 From: eeminionn <109454414+eeminionn@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:25:40 -0400 Subject: [PATCH] feat: add student avatars and contextual feedback --- package.json | 2 + pnpm-lock.yaml | 29 ++ ...202607300005_student_profiles_feedback.sql | 52 ++ supabase/tests/rls.sql | 44 +- v2/e2e/student.spec.ts | 48 ++ v2/src/App.tsx | 2 + v2/src/components/AppShell.tsx | 47 +- v2/src/components/ProfileAvatar.tsx | 84 ++++ v2/src/lib/avatar.ts | 228 +++++++++ v2/src/pages/DashboardPage.tsx | 5 +- v2/src/pages/FeedbackPage.tsx | 69 ++- v2/src/pages/ProfilePage.tsx | 385 +++++++++++++++ v2/src/pages/RankingPage.tsx | 9 +- v2/src/state/classroom-context.tsx | 97 +++- v2/src/styles.css | 451 +++++++++++++++++- v2/src/types.ts | 18 + v2/tests/avatar-config.test.ts | 34 ++ 17 files changed, 1558 insertions(+), 46 deletions(-) create mode 100644 supabase/migrations/202607300005_student_profiles_feedback.sql create mode 100644 v2/src/components/ProfileAvatar.tsx create mode 100644 v2/src/lib/avatar.ts create mode 100644 v2/src/pages/ProfilePage.tsx create mode 100644 v2/tests/avatar-config.test.ts diff --git a/package.json b/package.json index 0bc56e1..d595be6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "license": "MIT", "packageManager": "pnpm@11.9.0", "dependencies": { + "@dicebear/avataaars": "9.4.2", + "@dicebear/core": "^9.4.2", "@monaco-editor/react": "^4.7.0", "@supabase/supabase-js": "^2.110.8", "@tanstack/react-query": "^5.101.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2adb4e3..7353f34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@dicebear/avataaars': + specifier: 9.4.2 + version: 9.4.2(@dicebear/core@9.4.2) + '@dicebear/core': + specifier: ^9.4.2 + version: 9.4.2 '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -210,6 +216,16 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dicebear/avataaars@9.4.2': + resolution: {integrity: sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@dicebear/core': ^9.0.0 + + '@dicebear/core@9.4.2': + resolution: {integrity: sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==} + engines: {node: '>=18.0.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -786,6 +802,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -1545,6 +1564,14 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dicebear/avataaars@9.4.2(@dicebear/core@9.4.2)': + dependencies: + '@dicebear/core': 9.4.2 + + '@dicebear/core@9.4.2': + dependencies: + '@types/json-schema': 7.0.15 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1919,6 +1946,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 diff --git a/supabase/migrations/202607300005_student_profiles_feedback.sql b/supabase/migrations/202607300005_student_profiles_feedback.sql new file mode 100644 index 0000000..dc1fc07 --- /dev/null +++ b/supabase/migrations/202607300005_student_profiles_feedback.sql @@ -0,0 +1,52 @@ +alter table public.profiles + add column if not exists avatar_config jsonb; + +alter table public.profiles + drop constraint if exists profiles_avatar_config_object_check, + add constraint profiles_avatar_config_object_check + check ( + avatar_config is null + or ( + jsonb_typeof(avatar_config) = 'object' + and octet_length(avatar_config::text) <= 4096 + ) + ); + +alter table public.notifications + add column if not exists dismissed_at timestamptz; + +create or replace function public.protect_profile_role() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + if auth.uid() is not null and ( + new.role is distinct from old.role + or new.email is distinct from old.email + or new.github_login is distinct from old.github_login + or new.github_id is distinct from old.github_id + or new.avatar_url is distinct from old.avatar_url + or new.created_at is distinct from old.created_at + ) then + raise exception 'Profile identity fields cannot be changed by the user.'; + end if; + return new; +end; +$$; + +revoke update on public.notifications from authenticated; +grant update (read_at, dismissed_at) on public.notifications to authenticated; + +do $$ +begin + if exists ( + select 1 from pg_publication where pubname = 'supabase_realtime' + ) then + alter publication supabase_realtime add table public.profiles; + end if; +exception when duplicate_object then + null; +end; +$$; diff --git a/supabase/tests/rls.sql b/supabase/tests/rls.sql index 85dac45..ddda93a 100644 --- a/supabase/tests/rls.sql +++ b/supabase/tests/rls.sql @@ -1,7 +1,7 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(18); +select plan(22); insert into auth.users ( id, instance_id, aud, role, email, encrypted_password, @@ -284,6 +284,48 @@ select is( 'students only read their own notifications' ); +select lives_ok( + $$ + update public.profiles + set + display_name = 'Student Alias', + avatar_config = '{"top":"shortFlat"}'::jsonb + where id = '00000000-0000-0000-0000-000000000102' + $$, + 'students can update their own display name and avatar configuration' +); + +select throws_ok( + $$ + update public.profiles + set github_login = 'forged-login' + where id = '00000000-0000-0000-0000-000000000102' + $$, + 'P0001', + 'Profile identity fields cannot be changed by the user.', + 'students cannot change their GitHub identity' +); + +select lives_ok( + $$ + update public.notifications + set dismissed_at = now() + where title = 'Own feedback' + $$, + 'students can dismiss their own feedback notification' +); + +select throws_ok( + $$ + update public.notifications + set title = 'Rewritten feedback' + where title = 'Own feedback' + $$, + '42501', + 'permission denied for table notifications', + 'students cannot rewrite mentor feedback' +); + select lives_ok( $$ select public.record_student_activity( diff --git a/v2/e2e/student.spec.ts b/v2/e2e/student.spec.ts index 6c44403..80212ac 100644 --- a/v2/e2e/student.spec.ts +++ b/v2/e2e/student.spec.ts @@ -100,6 +100,54 @@ test("keeps independent code when changing language", async ({ page }) => { .toBe(pythonDraft); }); +test("student edits their avatar and visible name", async ({ page }) => { + await page.locator(".profile-menu-trigger").click(); + await page.getByRole("menuitem", { name: "Editar perfil" }).click(); + await expect(page.getByRole("heading", { name: "Tu perfil" })).toBeVisible(); + + await page.getByLabel("Nombre visible").fill("Cami Rojas"); + await page.getByRole("tab", { name: "Accesorios" }).click(); + await page.getByRole("button", { name: "Redondos" }).click(); + await page.getByRole("button", { name: "Argolla" }).click(); + await page.getByRole("button", { name: "Guardar perfil" }).click(); + await expect(page.getByText("Perfil actualizado.")).toBeVisible(); + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations).toEqual([]); + + await page.getByRole("link", { name: "Ranking" }).click(); + await expect( + page.locator(".ranking-row").filter({ hasText: "Cami Rojas" }), + ).toBeVisible(); +}); + +test("feedback identifies its mission and can be dismissed", async ({ page }) => { + await page.getByRole("link", { name: "Feedback" }).click(); + await expect(page.getByText("P1-01").first()).toBeVisible(); + await expect(page.getByText("La once de Tomatin").first()).toBeVisible(); + const before = await page.locator(".feedback-item").count(); + + await page + .getByRole("button", { name: /Eliminar feedback Comentario del mentor/ }) + .click(); + await expect(page.locator(".feedback-item")).toHaveCount(before - 1); +}); + +test("ranking places the winner above the other podium positions", async ({ + page, +}) => { + await page.getByRole("link", { name: "Ranking" }).click(); + const first = await page.locator(".podium-entry.place-1").boundingBox(); + const second = await page.locator(".podium-entry.place-2").boundingBox(); + const third = await page.locator(".podium-entry.place-3").boundingBox(); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(third).not.toBeNull(); + expect(first!.y).toBeLessThan(second!.y); + expect(first!.y).toBeLessThan(third!.y); + expect(first!.x).toBeGreaterThan(second!.x); + expect(first!.x).toBeLessThan(third!.x); +}); + test("dashboard has no serious automated accessibility violations", async ({ page, }) => { diff --git a/v2/src/App.tsx b/v2/src/App.tsx index 508dcd5..8fa67b0 100644 --- a/v2/src/App.tsx +++ b/v2/src/App.tsx @@ -13,6 +13,7 @@ const MissionsPage = lazyPage(() => import("@/pages/MissionsPage")); const MissionWorkspace = lazyPage(() => import("@/pages/MissionWorkspace")); const RankingPage = lazyPage(() => import("@/pages/RankingPage")); const FeedbackPage = lazyPage(() => import("@/pages/FeedbackPage")); +const ProfilePage = lazyPage(() => import("@/pages/ProfilePage")); const MentorPage = lazyPage(() => import("@/pages/MentorPage")); const AboutPage = lazyPage(() => import("@/pages/AboutPage")); @@ -48,6 +49,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/v2/src/components/AppShell.tsx b/v2/src/components/AppShell.tsx index 86a55c6..f01e979 100644 --- a/v2/src/components/AppShell.tsx +++ b/v2/src/components/AppShell.tsx @@ -15,6 +15,7 @@ import { MessageSquareText, TriangleAlert, Trophy, + UserRound, Users, X, } from "lucide-react"; @@ -24,7 +25,7 @@ import { useLocation, useNavigate, } from "react-router-dom"; -import { initials } from "@/lib/format"; +import { ProfileAvatar } from "@/components/ProfileAvatar"; import { useClassroom } from "@/state/classroom-context"; const studentNavigation = [ @@ -64,7 +65,10 @@ export function AppShell() { const navigate = useNavigate(); const unread = snapshot?.notifications.filter( - (entry) => entry.userId === viewProfile?.id && !entry.readAt, + (entry) => + entry.userId === viewProfile?.id && + !entry.readAt && + !entry.dismissedAt, ).length ?? 0; const isActorStaff = profile?.role === "mentor" || profile?.role === "owner"; @@ -285,7 +289,9 @@ export function AppShell() { aria-haspopup="menu" onClick={() => setProfileOpen((current) => !current)} > - {initials(profile?.displayName ?? "?")} + {profile ? ( + + ) : null} {profile?.displayName} @@ -299,18 +305,29 @@ export function AppShell() {