From 306a546bc638c74d82ba000dd1eb6f1e9ce33702 Mon Sep 17 00:00:00 2001 From: TSVRN9 <66744142+TSVRN9@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:02:04 -0400 Subject: [PATCH] web UI: style friends, pings, and notifications screens (#39) Restyles friends/+page.svelte and notifications/+page.svelte with #35's design tokens: .page-title/.label-rule headers, .card panels, .badge for friend-request state (Pending vs Wants to be friends vs Friend) and notification alerts state (Alerts on/off, New sightings), .btn-primary/.btn-secondary per app.css's own component-layer guidance, .empty-state for the signed-out prompts, .field-label + .input on every form control. Replaces the old inline `style="opacity:0.6"` read-sighting styling with the opacity-60 utility plus a New badge. Adds web/e2e/friends-notifications.spec.ts: real (unmocked) signed-out coverage for both screens, plus signed-in coverage that mocks the Supabase session via a seeded auth cookie (read by @supabase/ssr's browser client post-hydration, never seen by the SSR request) and mocks all REST/realtime traffic via page.route/routeWebSocket -- nothing here touches the live project. --- web/e2e/friends-notifications.spec.ts | 223 ++++++++++++++++++++++ web/src/routes/friends/+page.svelte | 161 ++++++++++------ web/src/routes/notifications/+page.svelte | 44 +++-- 3 files changed, 359 insertions(+), 69 deletions(-) create mode 100644 web/e2e/friends-notifications.spec.ts diff --git a/web/e2e/friends-notifications.spec.ts b/web/e2e/friends-notifications.spec.ts new file mode 100644 index 00000000..96583463 --- /dev/null +++ b/web/e2e/friends-notifications.spec.ts @@ -0,0 +1,223 @@ +import { test, expect, type Page, type Route } from "@playwright/test"; + +// Coverage for #39: styling on the friends/pings and notifications screens (src/routes/friends, +// src/routes/notifications). Both are gated on a signed-in session (see CLAUDE.md — friends, pings +// and profile are the only legitimately server-backed features), so this spec has two halves per +// page: a real, unmocked signed-out check (no session cookie at all — proves the sign-in prompt +// renders instead of crashing), and a signed-in check that mocks the Supabase session and REST +// responses so nothing here ever touches the live project. +// +// Session mocking: @supabase/ssr's browser client stores its session as a cookie +// (`sb--auth-token`, base64url-JSON, see node_modules/@supabase/ssr/dist/main/cookies.js), +// not localStorage. Seeding that cookie via page.addInitScript() (so it exists for the client's own +// document.cookie read, but *not* for the initial SSR request, which fires before addInitScript's +// script runs) means: +// - SSR (+layout.server.ts's safeGetSession) sees no cookie -> session stays null -> zero network. +// - Hydration re-runs +layout.ts in the browser, which reads the cookie via getSession(). A future +// expires_at means this is satisfied from the cookie alone, no refresh-token network call. +// page.data.session flips non-null once that client-side load resolves -- waiting for session-only +// content to become visible IS the hydration proof here (it cannot render before the client load +// that produces it has run), same idea as shell.spec.ts's proveHydrated but tied to this page's own +// state transition instead of a synthetic click. +const USER_ID = "11111111-1111-4111-8111-111111111111"; +const FRIEND_ID = "22222222-2222-4222-8222-222222222222"; +const REQUESTER_ID = "33333333-3333-4333-8333-333333333333"; + +function fakeSessionCookie(): [string, string] { + const now = Math.floor(Date.now() / 1000); + const exp = now + 3600; // well past auth-js's refresh margin -- no refresh-token network call + const b64url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + const accessToken = [b64url({ alg: "HS256", typ: "JWT" }), b64url({ sub: USER_ID, role: "authenticated", exp }), "fakesig"].join("."); + const session = { + access_token: accessToken, + token_type: "bearer", + expires_in: 3600, + expires_at: exp, + refresh_token: "fake-refresh-token", + user: { + id: USER_ID, + aud: "authenticated", + role: "authenticated", + email: "friend@umass.edu", + app_metadata: {}, + user_metadata: {}, + created_at: new Date().toISOString(), + }, + }; + return ["sb-your-project-ref-auth-token", "base64-" + Buffer.from(JSON.stringify(session)).toString("base64url")]; +} + +type CapturedRequest = { table: string; method: string; url: URL }; + +/** Seeds the fake session cookie and wires up a REST mock router + a realtime no-op, so a + * signed-in test never reaches the live project. `handlers` maps a `rest/v1/` path segment + * to a fulfiller; anything not listed gets an empty-array 200, which is enough for tables a given + * test doesn't care about (e.g. the profiles search-by-name query on the notifications page). */ +async function signInAndMockSupabase(page: Page, handlers: Record Promise | void>): Promise { + const [name, value] = fakeSessionCookie(); + await page.addInitScript(([n, v]) => { + document.cookie = `${n}=${v}; path=/`; + }, [name, value]); + + const requests: CapturedRequest[] = []; + await page.route("**/rest/v1/**", async (route) => { + const url = new URL(route.request().url()); + const table = url.pathname.replace("/rest/v1/", ""); + requests.push({ table, method: route.request().method(), url }); + const handler = handlers[table]; + if (handler) { + await handler(route, url); + } else { + await route.fulfill({ json: [] }); + } + }); + // Belt-and-braces: nothing should hit real auth (getSession() is satisfied from the cookie), but + // if something did call it, this keeps it from reaching the live project. + await page.route("**/auth/v1/**", (route) => route.fulfill({ json: {} })); + // friends/+page.svelte opens a realtime channel for the pings inbox -- page.route doesn't cover + // WebSockets, so this closes the connection immediately instead of letting it hit the live + // project's realtime endpoint. + await page.routeWebSocket(/realtime/, (ws) => ws.close()); + + return requests; +} + +test.describe("Friends — signed out", () => { + test("shows a styled sign-in prompt instead of the friends UI", async ({ page }) => { + await page.goto("/friends"); + + await expect(page.getByRole("heading", { name: "Friends", level: 1 })).toHaveClass(/page-title/); + const prompt = page.locator(".empty-state"); + await expect(prompt).toBeVisible(); + await expect(prompt).toContainText("Sign in to add friends and send pings."); + // No crash: none of the signed-in-only sections render. + await expect(page.getByRole("heading", { name: "Find friends" })).toHaveCount(0); + }); +}); + +test.describe("Notifications — signed out", () => { + test("shows a styled sign-in prompt instead of the notifications UI", async ({ page }) => { + await page.goto("/notifications"); + + await expect(page.getByRole("heading", { name: "Notifications", level: 1 })).toHaveClass(/page-title/); + const prompt = page.locator(".empty-state"); + await expect(prompt).toBeVisible(); + await expect(prompt).toContainText("Sign in to enable favorited-food alerts."); + await expect(page.getByRole("heading", { name: "Sightings" })).toHaveCount(0); + }); +}); + +test.describe("Friends — signed in", () => { + test("pending (incoming/outgoing) and accepted friend states are visually distinct, and Accept sends the right update", async ({ page }) => { + const friendships = [ + // Incoming: FRIEND_ID requested me. + { user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "pending", requested_by: FRIEND_ID }, + // Outgoing: I requested REQUESTER_ID. + { user_a: USER_ID < REQUESTER_ID ? USER_ID : REQUESTER_ID, user_b: USER_ID < REQUESTER_ID ? REQUESTER_ID : USER_ID, status: "pending", requested_by: USER_ID }, + ]; + const requests = await signInAndMockSupabase(page, { + friendships: async (route) => { + if (route.request().method() === "PATCH") { + return route.fulfill({ json: [] }); + } + return route.fulfill({ json: friendships }); + }, + profiles: (route) => + route.fulfill({ + json: [ + { user_id: FRIEND_ID, display_name: "Casey Friend" }, + { user_id: REQUESTER_ID, display_name: "Riley Requested" }, + ], + }), + pings: (route) => route.fulfill({ json: [] }), + }); + + await page.goto("/friends"); + + // Hydration proof: this section only exists once page.data.session has flipped non-null. + await expect(page.getByRole("heading", { name: "Find friends" })).toBeVisible({ timeout: 15_000 }); + + const incomingRow = page.locator("li", { hasText: "Casey Friend" }); + await expect(incomingRow.locator(".badge")).toHaveText("Wants to be friends"); + const acceptButton = incomingRow.getByRole("button", { name: "Accept" }); + await expect(acceptButton).toHaveClass(/btn-primary/); + + const outgoingRow = page.locator("li", { hasText: "Riley Requested" }); + await expect(outgoingRow.locator(".badge")).toHaveText("Pending"); + await expect(outgoingRow.getByRole("button", { name: "Accept" })).toHaveCount(0); + + await acceptButton.click(); + const patch = requests.find((r) => r.table === "friendships" && r.method === "PATCH"); + expect(patch, "Accept should PATCH the friendships row").toBeTruthy(); + expect(patch!.url.searchParams.get("user_a")).toBe(`eq.${friendships[0].user_a}`); + expect(patch!.url.searchParams.get("user_b")).toBe(`eq.${friendships[0].user_b}`); + }); + + test("accepted friends get ping controls in a card, badged distinctly from requests", async ({ page }) => { + const friendship = { user_a: USER_ID < FRIEND_ID ? USER_ID : FRIEND_ID, user_b: USER_ID < FRIEND_ID ? FRIEND_ID : USER_ID, status: "accepted", requested_by: FRIEND_ID }; + await signInAndMockSupabase(page, { + friendships: (route) => route.fulfill({ json: [friendship] }), + profiles: (route) => route.fulfill({ json: [{ user_id: FRIEND_ID, display_name: "Casey Friend" }] }), + pings: (route) => route.fulfill({ json: [{ id: "ping-1", sender_id: FRIEND_ID, receiver_id: USER_ID, hall_tid: 3, message: "meet up?", created_at: new Date().toISOString() }] }), + }); + + await page.goto("/friends"); + await expect(page.getByRole("heading", { name: "Find friends" })).toBeVisible({ timeout: 15_000 }); + + const friendRow = page.locator("li.card", { hasText: "Casey Friend" }); + await expect(friendRow.locator(".badge", { hasText: "Friend" })).toBeVisible(); + const pingButton = friendRow.getByRole("button", { name: /Ping/ }); + await expect(pingButton).toHaveClass(/btn-secondary/); + + const inboxEntry = page.locator("li", { hasText: "meet up?" }); + await expect(inboxEntry).toBeVisible(); + await expect(inboxEntry.locator(".badge")).toHaveText("Hampshire"); + }); +}); + +test.describe("Notifications — signed in", () => { + test("the alert toggle is visually clear and flips state with the right PATCH", async ({ page }) => { + const requests = await signInAndMockSupabase(page, { + profiles: (route) => route.fulfill({ json: { notifications_enabled: false } }), + food_sightings: (route) => route.fulfill({ json: [] }), + favorited_foods: (route) => route.fulfill({ json: [] }), + }); + + await page.goto("/notifications"); + + const badge = page.locator(".badge"); + await expect(badge).toHaveText("Alerts off", { timeout: 15_000 }); // hydration proof: reflects the mocked profile fetch + + const toggle = page.getByRole("checkbox"); + await toggle.click(); + await expect(badge).toHaveText("Alerts on"); + + const patch = requests.find((r) => r.table === "profiles" && r.method === "PATCH"); + expect(patch, "toggling should PATCH the profile row").toBeTruthy(); + expect(patch!.url.searchParams.get("user_id")).toBe(`eq.${USER_ID}`); + }); + + test("unread sightings are badged New, hall names are badged, both use the design tokens", async ({ page }) => { + await signInAndMockSupabase(page, { + profiles: (route) => route.fulfill({ json: { notifications_enabled: true } }), + food_sightings: (route) => + route.fulfill({ + json: [ + { id: "s1", dish_name: "French Toast", hall_tid: 3, sighted_date: "2026-08-19", read_at: null, created_at: new Date().toISOString() }, + { id: "s2", dish_name: "Fried Plantain", hall_tid: 1, sighted_date: "2026-08-18", read_at: new Date().toISOString(), created_at: new Date().toISOString() }, + ], + }), + }); + + await page.goto("/notifications"); + await expect(page.getByRole("heading", { name: "Sightings" })).toBeVisible({ timeout: 15_000 }); + + const unreadRow = page.locator("li", { hasText: "French Toast" }); + await expect(unreadRow.locator(".badge", { hasText: "New" })).toBeVisible(); + await expect(unreadRow.locator(".badge", { hasText: "Hampshire" })).toBeVisible(); + + const readRow = page.locator("li", { hasText: "Fried Plantain" }); + await expect(readRow.locator(".badge", { hasText: "New" })).toHaveCount(0); + await expect(readRow.locator(".badge", { hasText: "Worcester" })).toBeVisible(); + }); +}); diff --git a/web/src/routes/friends/+page.svelte b/web/src/routes/friends/+page.svelte index 73d04d69..4a35348c 100644 --- a/web/src/routes/friends/+page.svelte +++ b/web/src/routes/friends/+page.svelte @@ -14,6 +14,8 @@ let inbox: Ping[] = $state([]); let pingHallTid: Record = $state({}); let pingMessage: Record = $state({}); + let pending = $derived(friendships.filter((f) => f.status === "pending")); + let accepted = $derived(friendships.filter((f) => f.status === "accepted")); function hallName(hallTid: number | null): string { return DINING_HALLS.find((h) => h.tid === hallTid)?.name ?? "somewhere"; @@ -90,64 +92,111 @@ } -

Friends

+

Friends

+
{#if !page.data.session} -

Sign in to add friends and send pings.

+
+

Sign in to add friends and send pings.

+
{:else} -

Find friends

- - {#if searchResults.length > 0} -
    - {#each searchResults as p (p.user_id)} -
  • {p.display_name}
  • - {/each} -
- {/if} - -

Friend requests

- {#each friendships.filter((f) => f.status === "pending") as f (f.user_a + f.user_b)} - {@const myId = page.data.session.user.id} - {@const other = profilesById.get(otherUserId(f, myId))} -

- {other?.display_name ?? "..."} - {#if f.requested_by === myId} - (pending) - {:else} - wants to be friends - {/if} -

- {/each} - -

Your friends

- {#each friendships.filter((f) => f.status === "accepted") as f (f.user_a + f.user_b)} - {@const myId = page.data.session.user.id} - {@const otherId = otherUserId(f, myId)} - {@const other = profilesById.get(otherId)} -
- {other?.display_name ?? "..."} - + {#if searchResults.length > 0} +
    + {#each searchResults as p (p.user_id)} +
  • + {p.display_name} + +
  • {/each} - - - -
- {/each} - -

Pings you've received

- {#if inbox.length === 0} -

No pings yet.

- {:else} -
    - {#each inbox as p (p.id)} -
  • - {profilesById.get(p.sender_id)?.display_name ?? "Someone"} wants to eat {p.hall_tid ? `at ${hallName(p.hall_tid)}` : ""} - {#if p.message}— "{p.message}"{/if} -
  • - {/each} -
- {/if} + + {/if} + + + +
+

Friend requests

+ {#if pending.length === 0} +
No pending requests.
+ {:else} +
    + {#each pending as f (f.user_a + f.user_b)} + {@const myId = page.data.session.user.id} + {@const other = profilesById.get(otherUserId(f, myId))} +
  • + {other?.display_name ?? "…"} + {#if f.requested_by === myId} + Pending + {:else} + + Wants to be friends + + + {/if} +
  • + {/each} +
+ {/if} +
+ +
+

Your friends

+ {#if accepted.length === 0} +
No friends yet — search above to add some.
+ {:else} +
    + {#each accepted as f (f.user_a + f.user_b)} + {@const myId = page.data.session.user.id} + {@const otherId = otherUserId(f, myId)} + {@const other = profilesById.get(otherId)} +
  • +
    + Friend + {other?.display_name ?? "…"} +
    + +
    +
    + + +
    +
    + + +
    + +
    +
  • + {/each} +
+ {/if} +
+ +
+

Pings you've received

+ {#if inbox.length === 0} +
No pings yet.
+ {:else} +
    + {#each inbox as p (p.id)} +
  • + {profilesById.get(p.sender_id)?.display_name ?? "Someone"} wants to eat + {#if p.hall_tid}{hallName(p.hall_tid)}{/if} + {#if p.message}— "{p.message}"{/if} +
  • + {/each} +
+ {/if} +
{/if} diff --git a/web/src/routes/notifications/+page.svelte b/web/src/routes/notifications/+page.svelte index c2b34637..7ac547e9 100644 --- a/web/src/routes/notifications/+page.svelte +++ b/web/src/routes/notifications/+page.svelte @@ -119,27 +119,45 @@ } -

Notifications

+

Notifications

+
{#if !page.data.session} -

Sign in to enable favorited-food alerts.

+
+

Sign in to enable favorited-food alerts.

+
{:else} -

- + +

+
+ +

Notify me when a favorited dish shows up on the menu.

+
+
+ {notificationsEnabled ? "Alerts on" : "Alerts off"} + +
+
+

+ Turning this on will ask your browser for notification permission and register a push subscription. This page is always the notification feed either way.

-

Turning this on will ask your browser for notification permission and register a push subscription. This page is always the notification feed either way.

-

Sightings

+

Sightings

{#if sightings.length === 0} -

No favorited-food sightings yet.

+
No favorited-food sightings yet.
{:else} -
    +
      {#each sightings as s (s.id)} -
    • markRead(s)} style={s.read_at ? "opacity: 0.6" : ""}> - {s.dish_name} at {hallName(s.hall_tid)} on {s.sighted_date} + +
    • markRead(s)}> + {#if !s.read_at}New{/if} + {s.dish_name} + {hallName(s.hall_tid)} + on {s.sighted_date}
    • {/each}