Skip to content

web: social activity feed (pings + sightings) - #75

Merged
TSVRN9 merged 4 commits into
mainfrom
feat/social-feed
Aug 19, 2026
Merged

TSVRN9 merged 4 commits into
mainfrom
feat/social-feed

Conversation

@TSVRN9

@TSVRN9 TSVRN9 commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Closes #66. Closes #62. Part of epic #63.

What changed

Evolves /notifications into a unified, chronological social activity feed
(pings + favorited-food sightings), matching the epic's design decision
("social is a feed, not a settings page"). "Ping a friend" becomes a
first-class action on the feed instead of being buried in /friends'
accepted-friends list; /friends stays scoped to search/request/accept.

  • web/src/routes/notifications/+page.svelte — rewritten: keeps the
    existing favorited-dish alert toggle, adds a "Ping a friend" composer
    (friend picker + hall + optional message, sourced from accepted
    friendships), and renders pings (as receiver) + food_sightings
    merged into one <ul aria-label="Activity feed">, newest first, each
    item badged "New" when unread. Realtime subscription for new pings
    moved here from /friends.
  • web/src/routes/friends/+page.svelte — ping composer and "Pings
    you've received" inbox removed (moved to the feed); accepted friends
    now render as a plain Friend badge + name, with a pointer to the
    Notifications feed for pinging. Search/request/accept unchanged.
  • web/src/lib/feedLastSeen.ts — new. pings has no read_at column
    and this issue is explicitly no-schema-changes, so ping unread state
    is derived from a device-local "last viewed the feed" timestamp
    (localStorage, same shape as the existing preferences.ts) instead
    of a server flag. Sightings keep their existing server-persisted
    read_at.

Closes #62

Sighting mark-as-read is now a real <button>Mark as read</button>
(keyboard-reachable: focus + Enter), shown whenever a sighting is
unread, alongside the pre-existing onmouseenter for mouse users,
which is unchanged (per #62's "existing mouse behavior unchanged"
acceptance). Mutation-verified: temporarily removed the button, the
red-first spec (friends-notifications.spec.ts — "unread sighting
mark-as-read is reachable by keyboard...") failed as expected; restored,
green again.

Carry-over from PR #69's review (required by issue #66's comment)

home-dashboard.spec.ts's residency guard only intercepted
**/api/**, so a direct cross-origin fetch (e.g. to *.supabase.co)
passed it green even in no-cors mode. Replaced with a
page.on("request") listener flagging any same-origin /api/ path or
disallowed cross-origin request — this fires for no-cors requests too,
since it's request-level, not route-interception-level.

Explicit allow-list for fonts.googleapis.com / fonts.gstatic.com:
app.css (#42) @imports Google Fonts, so the home page legitimately
makes a cross-origin request already — a blanket cross-origin guard
would false-positive on every run without this, not just a mutated one.

Proven red-first via mutation: temporarily added a
fetch(..., { mode: "no-cors" }) call to +page.svelte's onMount,
confirmed the new guard fails (unexpected /api/ or cross-origin request(s): https://your-project-ref.supabase.co/rest/v1/whatever).
Then, with that same mutation still in place, swapped in the old
**/api/**-only guard (checked out from the pre-#66 commit) and
re-ran it — it passed (1 passed), confirming the old guard really
would have missed this leak, not just that the new one catches it.
Reverted both the mutation and the swapped-in old spec afterward;
git diff on +page.svelte is empty — not owned by this PR, only
used to prove the guard red-first.

Nits fixed in friends-notifications.spec.ts (per issue #66)

  • Cookie name (sb-<project-ref>-auth-token) is now derived from
    PUBLIC_SUPABASE_URL (read from web/.env, falling back to
    .env.example) instead of hardcoded "your-project-ref" — matches
    @supabase/supabase-js's own default storage-key derivation.
  • The unawaited-PATCH race (requests.find(...) read synchronously
    right after .click(), before the async handler's request has
    necessarily round-tripped through the mock router) — fixed with
    expect.poll at both the previously-flagged instance (friendships
    Accept) and its unfixed twin (notifications toggle PATCH).

Tests

web/e2e/friends-notifications.spec.ts — 9 tests total (up from 6
before this PR): friends signed-out, friendships accept-flow PATCH,
notifications toggle PATCH, and read-sighting-stays-de-emphasized are
carried over (matched pre-existing behavior in the red run below);
signed-out value-prop with no duplicate sign-in button,
accepted-friends-have-no-ping-controls, feed chronological ordering +
unread badges across both item types, ping-composer POST body
assertion, and keyboard mark-as-read (closes #62) are new.

web/e2e/home-dashboard.spec.ts — residency guard replaced (see
above); the other 3 tests in this file are untouched.

Red evidence

Ran the full new spec against the pre-#66 route files (git stash on
just the two +page.svelte files): 5 failed (all the new/changed
behavior), 4 passed (pre-existing behavior, confirming no regression
in the rewrite of the spec's mechanics). Full log posted on issue #66.

Green evidence

PORT=4307 npx playwright test — 45/45 passed (full suite).
pnpm --filter web check — COMPLETED 399 FILES 0 ERRORS 0 WARNINGS.
pnpm --filter web build — clean (adapter-auto's "could not detect a
production environment" notice is pre-existing/benign).

Data residency

No schema changes. Pings/sightings continue to require a signed-in
session (server-backed by definition, per CLAUDE.md's residency
table). The new feedLastSeen marker is device-local UI state, not
health/food data. Signed-out anonymous path (/, /today, halls,
menus, ranking, filters) is untouched by this PR.

Follow-up commit (self-review)

A second commit tightens three things found on re-check before reporting done:

  • web a11y: notifications markRead is mouse-only (onmouseenter) #62's keyboard test now asserts toBeFocused() before pressing Enter,
    proving the button is actually in the tab order (matches web a11y: notifications markRead is mouse-only (onmouseenter) #62's
    "keyboard-only walkthrough" wording), not just that it activates once
    focused.
  • The alert-toggle badge locator is scoped to its own <section>
    instead of .badge.first(), which would have silently matched the
    wrong badge if the feed's own "New" badges ever preceded it in DOM
    order.
  • refresh() in notifications/+page.svelte now resolves friends
    before assigning pings, so a ping can no longer render with the
    "A friend" fallback for one tick before its sender's name loads.

Full suite (45/45), check, and build all re-verified green after this
commit.

Follow-up commit: two blocking review findings + nits

The original author agent was gone by the time this PR got reviewed; a
new agent (dispatched separately, not the original author) picked up
the two blocking findings and nits below. Note on sourcing: at the
time of this fix, PR #75 itself has zero review comments and zero
issue comments on GitHub (reviews: [], review_comments: 0) — the
findings summarized below were relayed by the dispatching agent's own
text, not lifted verbatim from a GitHub review thread on this PR. Said
so explicitly here so this isn't mistaken for a review artifact that
exists in the repo.

F1 (blocking). notifications/+page.svelte's onMount had:

feedLastSeenAt = loadFeedLastSeen();
refresh().then(() => saveFeedLastSeen(new Date().toISOString()));

refresh() early-returns with no session, but the .then() still ran
unconditionally — a signed-out visit that rendered zero feed items
silently stamped the watermark, marking every older ping as seen
before the user ever signed in. Fixed to only stamp when a session
exists, and to stamp from the newest rendered item's own
created_at
(server/Postgres clock) instead of new Date() (browser
clock) — removes a class of drift where a just-inserted ping could
look already-seen if the two clocks disagreed.

One documented side effect of also taking the optional item below:
because the watermark key is now scoped by user id
(udine-feed-last-seen:<uuid> instead of a single global
udine-feed-last-seen), an already-signed-in user's very first visit
after this ships reads an empty watermark under the new key and sees
every still-unread ping as "New" once, even ones they'd already seen
under the old global key. The old global key is simply never read
again (not actively migrated/cleaned up) — acceptable pre-release,
flagging so it doesn't read as an oversight.

Also intentional: a signed-in user whose feed has zero items gets no
stamp at all (newestCreatedAt guard) — nothing to mark as seen, so
this is a no-op rather than a bug.

F2 (blocking). feedLastSeen.ts had no test that would fail if
isUnread()'s ping branch were hardcoded to return true, or if the
saveFeedLastSeen() call were deleted outright — both mutations stayed
green under the pre-existing suite. Added two tests to
friends-notifications.spec.ts:

  • "a seeded watermark suppresses the New badge on an older ping while
    a newer ping stays badged"
    — seeds the scoped localStorage key via
    page.addInitScript, mocks an older + a newer ping, asserts only the
    newer one is badged "New". Also asserts the app itself advances the
    stored watermark to the newest item's created_at after the visit —
    this second assertion is what catches the "delete the save call"
    mutation (the seed-and-check half alone doesn't, since it never
    depends on the app's own save happening).
  • "a signed-out visit does not stamp the feed-last-seen watermark" —
    F1's direct regression test.

Both were run red-first against the pristine pre-fix commit
(git stash on just feedLastSeen.ts + notifications/+page.svelte,
new spec code kept): both failed as expected. Then, with the real fix
restored, each of the two named mutations was reproduced independently
(isUnread's ping branch hardcoded to return true; the
saveFeedLastSeen() call deleted from onMount) and each was shown to
turn exactly one of the two new assertions red, then reverted. Full
transcript of this red/green/red/green cycle is in the branch history
via local test runs (not committed — the mutations were never
committed, only run locally and reverted).

Nit 1. The feed's sort comparator, (a, b) => (a.createdAt < b.createdAt ? 1 : -1), returned -1 for both orderings of two equal-timestamp
items (cmp(a,b) and cmp(b,a) both said "I go first") — an invalid
comparator. Replaced with (a, b) => b.createdAt.localeCompare(a.createdAt).

Nit 2. sendPing() set pingSent = true unconditionally, without
checking the insert's result — an RLS rejection (no longer friends,
blocked, etc.) still showed "Ping sent". Now checks { error } from
the insert and shows a role="alert" failure message instead; also
made pingSent/pingError mutually exclusive (a fast retry that
succeeds while an earlier failure's 3s badge is still showing no longer
renders both messages at once — caught in self-review before this went
out, not part of the original finding). Added a red-first test
mocking a 403 RLS-style PostgREST error response and asserting no
"Ping sent" text appears.

Optional (taken). feedLastSeen.ts's localStorage key is now
scoped by user id (see the F1 side-effect note above) so a second
account signing in on the same browser doesn't inherit the first
account's watermark and silently mark its own unread pings as seen.

Red/green evidence for this commit

  • friends-notifications.spec.ts new/changed tests run in isolation
    (-g "watermark", -g "RLS rejection") red against pristine pre-fix
    code and against each of the three mutations above (isUnread
    hardcoded, save call deleted, error-check removed) — see per-finding
    notes above.
  • CI=1 PORT=4314 npx playwright test — 53/53 passed (full suite;
    up from 45 because of onboarding/first-run: anonymous-first value prop, web + mobile #74's onboarding suite merged in from main,
    plus the 3 new tests this commit adds — 12 tests now in
    friends-notifications.spec.ts, not the 9 previously reported here).
  • pnpm --filter web check — COMPLETED 400 FILES 0 ERRORS 0 WARNINGS.
  • pnpm --filter web build — clean (same pre-existing adapter-auto
    notice as before).
  • Merged origin/main into this branch first (onboarding/first-run: anonymous-first value prop, web + mobile #74 onboarding + Add README #77
    README landed since this PR opened) — no conflicts, full suite
    re-run after the merge.

Not merging

Per dispatch instructions — leaving for review.

Evolves /notifications into a unified, chronological activity feed
(pings + favorited-food sightings), with "ping a friend" as a
first-class action there instead of buried in /friends' accepted-
friends list. /friends stays scoped to search/request/accept.

- No schema changes: `pings` has no read_at column, so ping unread
  state is derived from a device-local "last viewed the feed" marker
  (web/src/lib/feedLastSeen.ts, localStorage, same shape as
  preferences.ts) rather than a server flag. Sightings keep their
  existing server-persisted read_at.
- Closes #62: sighting mark-as-read is now a real <button> (keyboard-
  reachable via focus+Enter), alongside the pre-existing onmouseenter
  for mouse users, which is unchanged.
- Signed-out: feed shell + sign-in value prop pointing at the header's
  existing "Sign in with Google" control (no duplicate button).
- Carry-over from PR #69's review (issue #66 comment): replaced
  home-dashboard.spec.ts's `**/api/**`-only residency guard with a
  page.on("request") listener flagging any same-origin /api/ path or
  disallowed cross-origin request (fires for no-cors fetches too,
  which the old glob-based route() interception missed). Explicit
  allow-list for the Google Fonts cross-origin request app.css (#42)
  already makes on every page. Proven red-first via mutation
  (temporary no-cors fetch in +page.svelte, reverted -- diff to that
  file is empty).
- Fixed friends-notifications.spec.ts's two known nits: cookie name
  now derived from PUBLIC_SUPABASE_URL instead of hardcoded
  "your-project-ref", and the unawaited-PATCH race (both the
  friendships Accept instance and its unfixed twin on the
  notifications toggle) replaced with expect.poll.

Red-green TDD throughout; see issue #66 for the full red/green log.
TSVRN9 added 3 commits August 19, 2026 13:21
…race

- friends-notifications.spec.ts: #62's keyboard test now asserts
  toBeFocused() before pressing Enter (proves the button is actually
  in the tab order, matching #62's "keyboard-only walkthrough"
  wording, not just that it's clickable-when-focused). The alert
  toggle's badge locator is now scoped to its own section instead of
  ".badge".first(), which would have silently started matching the
  wrong badge if the feed's own "New" badges ever preceded it in DOM
  order.
- notifications/+page.svelte: refresh() now resolves friends before
  assigning pings, so a ping can no longer render with the "A friend"
  fallback for one tick before its sender's name loads.

Full suite (45/45), check, and build all still green after these.
…read coverage (#75 review)

F1 (blocking): refresh().then(() => saveFeedLastSeen(...)) ran unconditionally, even when
refresh() early-returned with no session -- a signed-out visit silently stamped the
device-local unread watermark. Now only stamps when a session exists, and stamps from the
newest rendered item's own created_at (Postgres clock) instead of the browser clock.

F2 (blocking): feedLastSeen.ts had no test that would fail if isUnread()'s ping branch were
hardcoded to `return true`, or if the saveFeedLastSeen() call were deleted outright. Added two
tests to friends-notifications.spec.ts: a seeded watermark correctly splits an older/newer ping
pair into read/unread, and a signed-out visit leaves the watermark key unset. Both were shown
red against pristine pre-fix code, and against each of the two named mutations independently
(verified locally, reverted after).

Nits: fixed the sort comparator (previously returned -1 for both orderings of two equal
timestamps, an invalid comparator); sendPing() now checks the insert's error and shows a
failure state instead of an unconditional "Ping sent" (also made pingSent/pingError mutually
exclusive so a fast retry can't show both at once). Added a red-first test for the failure
state.

Optional: feedLastSeen's localStorage key is now scoped by user id, so a second account on the
same browser doesn't inherit the first account's watermark.
@TSVRN9

TSVRN9 commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

Pushed a follow-up commit fixing the two blocking findings + nits from this PR's review.

Note on process: at the time I picked this up, PR #75 had zero GitHub review comments and zero issue comments (gh api repos/TSVRN9/UDine/pulls/75/reviews → [], .../comments → []). The F1/F2 findings and nits I addressed were relayed to me directly by the dispatching agent's task description, not lifted from a review thread that exists in this repo. Flagging that so it's clear where the findings came from.

F1 (blocking) — fixed. onMount's refresh().then(() => saveFeedLastSeen(new Date().toISOString())) stamped the watermark unconditionally, even when refresh() early-returned with no session — a signed-out visit silently marked every older ping as seen. Now only stamps when a session exists, and stamps from the newest rendered item's own created_at (not the browser clock).

F2 (blocking) — fixed. Added two tests to friends-notifications.spec.ts: a seeded watermark correctly splits an older/newer ping pair into read/unread (and also asserts the app itself advances the stored watermark, which is what catches "delete the saveFeedLastSeen call"); a signed-out visit leaves the watermark key unset (F1's direct regression test). Both shown red against pristine pre-fix code, and each of the two mutations named in the dispatch (isUnread's ping branch hardcoded to return true; the saveFeedLastSeen() call deleted) was reproduced locally and shown to turn exactly one assertion red, then reverted.

Nits — both fixed. The feed sort comparator returned -1 for both orderings of equal timestamps (invalid comparator) — replaced with b.createdAt.localeCompare(a.createdAt). sendPing() set pingSent = true unconditionally without checking the insert's error — now checks { error }, shows a role="alert" failure message, and (caught in self-review) made pingSent/pingError mutually exclusive so a fast retry can't show both at once. Added a red-first test mocking a 403 RLS-style rejection.

Optional — taken. feedLastSeen.ts's localStorage key is now scoped by user id, so a second account on the same browser doesn't inherit the first account's watermark. One disclosed side effect: the old global key is never migrated, so an already-signed-in user's first post-deploy visit re-shows already-seen pings as "New" once — acceptable pre-release, documented in the PR body.

Verification: CI=1 PORT=4314 npx playwright test — 53/53 passed (full suite, up from 45 due to #74's onboarding tests merging in from main plus this commit's 3 new tests). pnpm --filter web check — 0 errors/warnings across 400 files. pnpm --filter web build — clean. Merged origin/main into this branch first (#74, #77 — no conflicts), re-ran the full suite after.

Not merging — leaving for review per dispatch instructions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web: social activity feed (pings + sightings) web a11y: notifications markRead is mouse-only (onmouseenter)

1 participant