# Review app → Volleyboard: features & usability principles to carry forward

> Compiled 2026-07-19 from the review portal session (review.bowden.works,
> `/srv/apps/review`), after ~3 weeks of daily real-world use with Brentwood
> (Erin + team as client reviewers, Rian + Adi as devs). Everything below was
> built, deployed, used by real clients, and iterated on from their feedback.
> This is the "good stuff" list — the mechanisms and principles Volleyboard
> should inherit, whatever shell (volleyball metaphor) it wraps them in.

---

## 1. The core insight: sections are court configurations

The review app organically grew **three sections on one page**, each a
different *shape* of collection but sharing all the same machinery:

| Section | Row identity | Distinct columns | Shared machinery |
|---|---|---|---|
| **Page Reviews** | a page of the site (name + URL slug + tags) | Staging/Live links, Status, Review | notes, comments, mentions, realtime |
| **Features** | a markdown description | Status, Review | same notes/comments table (polymorphic) |
| **General Issues** | a free-floating issue | Action To (assignees), Type, Status | own comments, same composer/mention UX |

**Volleyboard translation:** these are three *court configurations*. A court =
a collection type with its own column layout and ball types, but ALL courts
share: notes/threads, mentions, notifications, presence, realtime, filters,
authorship, resolve mechanics. Design the shared machinery once, make court
configs declarative, and you can add infinite court types (content court,
QA court, launch-checklist court, warm-up/pre-game court…).

The polymorphic notes table is the proof this works: `notes` has nullable
`page_id` XOR `feature_id` (DB check constraint `(page_id IS NOT NULL) <>
(feature_id IS NOT NULL)`) — one thread system, many parent types. In
Volleyboard make the parent reference fully generic from day one
(`parent_type` + `parent_id`, or a `courts`/`balls` model) instead of adding
a nullable FK per court type.

## 2. The workflow automations (these ARE the volleyball mechanics)

The status automations we converged on are literally rally mechanics already:

- **"Ready for Review" → auto-reassign to the issue author**, saving current
  assignees into `prev_action_to`. *This is hitting the ball back over the net.*
- **"Revision Required" → restore `prev_action_to`.** *The receiver returns it
  to whoever had it before — rally memory.*
- **"Resolved" → clear assignees.** *Point over, ball dead.*
- **Category "Informational" → status auto-set "FYI Only".** *Some balls aren't
  meant to be returned — announcements, not serves.*
- Page status ↔ review coupling: setting status auto-adjusts the review state
  (e.g. "drafted" → "ready for review", "revising" → "revision not ready").
  One gesture, two fields — users never micro-manage both.

**Principle:** every automation moves the ball to the person who must act next,
and remembers where it came from. Volleyboard should make this *visible* (ball
travels, sound plays) but the state machine is already proven here.

## 3. Assignment & "whose ball is it" mechanics

- `action_to` is an **array of emails** (multi-assign works, e.g. "either of
  Adi's juniors can take it").
- **"Action to: Me" filter is the default on every new session** — people land
  on *their* balls first. This single default fixed "I don't know what's mine."
- **Session-snapshot filtering**: when I create an issue and assign it away, it
  does NOT vanish from my "Me" view instantly — a `sessionTouchedRef` (React
  ref of ids touched this session) keeps it visible until the next full page
  load. Realtime updates never yank rows out from under the person editing.
  *Principle: filters are snapshots, not live traps. Don't let reactivity
  fight the user's working memory.*
- **Last-used assignee remembered** (localStorage) and pre-filled on the next
  new issue. Same for last-used note category. Small, huge QoL.

## 4. Notifications (hardest-won lessons — copy this design wholesale)

Built server-side in Postgres (security-definer AFTER-INSERT triggers), zero
client code needed to *create* them, RLS so recipients only ever see their own.

**Three kinds, all needed, all distinct:**
1. `mention` — someone wrote `@you@email.com` (with autocomplete, see §5)
2. `thread_reply` — someone replied in a thread you started **or ever
   commented in** (participation = subscription; this fixed "Erin missed the
   question on the Jul 8 issue")
3. `action_to` — someone assigned you (only *newly added* assignees notified
   on update, computed via array diff vs OLD)

**Dedup rule:** thread_reply skips anyone already getting a mention for the
same comment. One event, one notification.

**Every notification carries** (denormalized at insert time):
- `kind` + `category` (the item's type: Question / Bug / Revision / Live
  Changelog / Informational) — rendered as the same colored pill used
  everywhere else
- `context_label` — **the human name of WHERE it happened** (page name,
  feature snippet, "General Issue"), shown bold: "erin replied in a thread
  **on Faculty**". This was the single biggest clarity win; without it people
  couldn't tell which page a question was about.
- `body` — 160-char preview, markdown image tags stripped, whitespace collapsed
- `link` — deep link (see §6)
- `read` (per-user) AND `resolved` (mirrors the source item)

**Resolved propagation:** resolving a note or issue flips `resolved` on all its
notifications via trigger (reopen flips back); realtime pushes the change into
every open bell instantly. The bell hides resolved by default (toggle to show).
*Principle: a notification's lifecycle is tied to the underlying work item, not
just to "did you click it".*

**Bell UI specifics that mattered:**
- Badge = unread count (dark). If zero unread but unresolved items exist, badge
  shows unresolved count in grey — "nothing new, but things still open".
- Filled/ringing icon + one-time bounce animation on load when unresolved > 0.
- No item cap; whole history scrolls (`max-h-[70vh]`).
- Mark-all-read; per-item unread dot + tinted row + bolder text.
- Self-mentions allowed (people use them as reminders).
- **Re-click bug + fix:** clicking the same notification twice did nothing
  because the URL didn't change. Fix: append `_t=<timestamp>` cache-buster to
  the query (inserted BEFORE the `#hash`), and key the handled-guard on it.

## 5. Composer UX (the input people live in)

One shared `Composer` component used for notes, comments, issues, replies:
- **Paste a screenshot with ⌘V/Ctrl+V** — instant data-URL preview, uploaded
  to a **private** storage bucket, served via signed URLs. Clients use this
  constantly; it's the #1 composer feature.
- **@mention autocomplete** — `@` token detected at the caret, dropdown of
  project users opens **upward** (composer sits at sheet bottom — downward
  gets clipped), keyboard nav (arrows/Enter/Tab/Esc), items select on
  `mousedown` not `click` (blur fires first otherwise), 120ms blur grace.
- **Markdown everywhere** (GFM + line breaks), including **interactive task
  checkboxes** — toggling a checkbox rewrites the Nth `[ ]`/`[x]` in the raw
  source and saves. Checklists inside issues get used a lot.
- Category picker inline in the composer (for notes/issues), pre-set to
  last-used category.

## 6. Navigation & deep links: modals, not scrolling

**Hard-learned:** we built deep links that cleared filters and smooth-scrolled
to the target row, and it was *buggy garbage* (fights filters, fights realtime
refresh, fragile timing). Scrapped it. The replacement that works:

- Detail views are **side sheets (modals) over one long page** — never
  separate routes. A deep link (`?focus=page:<id>&note=<id>`) just opens the
  right sheet with the right thread. Works regardless of scroll position or
  active filters, because the sheet reads the full dataset, not the filtered
  view.
- Instead of scrolling you to context, **bring the context into the panel**:
  the thread header shows the parent title big, with Staging/Live link
  buttons right under it. You never need to "find the row".
- Jump-links in the sticky header for section navigation (that's enough).

## 7. Single-page spreadsheet feel (the overall shape)

- One long page per project: all courts stacked, sticky compact header
  (project name · jump links · +Add button · presence avatars · user · bell).
- **Everything is inline-editable in the table**: status pills, review pills,
  assignee chips, category pills — click, pick, done. No edit modes, no save
  buttons for single-field changes.
- **Optimistic updates with snapshot rollback**: apply locally, write to DB,
  on error restore the snapshot + toast. The app *feels* instant.
- **Realtime everything**: postgres_changes subscriptions per project scope →
  `router.refresh()` pulls fresh server data (also re-signs image URLs). Two
  people can work the same board live. Notifications and presence are push.
- **Presence**: Supabase Realtime Presence (no DB), avatar initials of who's
  viewing now, deduped, in the header. Clients love seeing "Rian is here".
- **Header → section communication** via window CustomEvent (the sticky
  "+ Add Issue" button opens the composer living inside the issues section).

## 8. Filtering & overview patterns

- **StatChips**: clickable count cards (Total / Open / Actionable / Ready for
  Review / …) that ARE filters — the overview and the filter are one control,
  with active-state ring.
- **Column-header filters**: enum dropdowns with the same color dots as the
  pills, an expandable search (icon → input), tag multi-select. Filters live
  where the data lives.
- **"Show resolved" off by default, everywhere** (notes, issues,
  notifications). Done work disappears but is one toggle away. Synthetic
  filter options like "Actionable" (not Informational/FYI/Resolved) and
  "Incomplete" encode the *useful* questions.
- Consistent **color language**: every enum value has a pill style + dot color
  defined once (`status-styles.ts`) and used identically in cells, dropdowns,
  filters, stat chips, and notifications. People learn the colors fast.

## 9. Authorship, trust & data rules

- Every row gets `author_id` + `author_email` via BEFORE-INSERT trigger
  (server-set, not client-claimed).
- **Author-gated edit/delete** — you can only edit/delete your own notes,
  comments, issues. Everyone can change status/assignment (that's the game).
- Editable timestamps on notes (author only) — real workflows need backdating
  (e.g. logging a change made yesterday).
- RLS on every table; cross-user writes (notifications) happen only through
  security-definer triggers.
- **Deterministic UTC timestamp formatting** (shared formatter) — avoids React
  hydration mismatches, consistent everywhere.
- Real `<a target="_blank">` links for external URLs (staging/live) — NOT
  synthetic window.open/modifier-click tricks (browsers ignore scripted
  modifier clicks; middle-click/Ctrl-click on a real link backgrounds
  correctly).
- Long content in table cells: collapsed at ~96px with gradient fade +
  "Show more" toggle.

## 10. Stack & architecture (proven, reuse)

- **Next.js App Router (standalone output, dockerized) + Supabase** (Postgres,
  Auth, Realtime, Storage). Deploy via `srv-gw deploy --project X --build`.
- Numbered SQL migrations + `supabase/migrate.sh` (Session-pooler URI in
  chmod-600 `.env.migrate`; direct URI is IPv6-only and unreachable from
  mosiah).
- Server components fetch initial data in parallel (`Promise.all`); client
  components own optimistic state + realtime re-sync via
  `useEffect(() => setX(initialX), [initialX])`.
- Shadcn UI (Base-UI based — uses `render` prop, not `asChild`), Tailwind v4,
  lucide icons, sonner toasts.
- **For Volleyboard, per the parked decision: BW Auth (Pattern B) instead of
  Supabase Auth**, which also brings the standard View As drop-in
  (`/srv/system/id-auth/app-auth/VIEW-AS.md`). Everything else above ports.

## 11. Volleyball metaphor mapping (quick reference)

| Review app mechanism | Volleyboard concept |
|---|---|
| action_to (array) | who has the ball (a side can have multiple hands up) |
| Ready for Review → auto-return to author | spike over the net |
| prev_action_to restore | rally memory / return to setter |
| Resolved (clears assignees) | point won, ball dead |
| Informational → FYI Only | announcement, not a serve — no return expected |
| New issue (defaults assigned) | serve |
| Thread replies | touches within a rally |
| thread_reply notifications | "ball's coming to you" awareness |
| context_label on notification | which court the ball is on |
| Presence avatars | who's on the court |
| "Action to: Me" default filter | my side of the net |
| Categories (Question/Bug/…) | ball types |
| StatChips | scoreboard tiles |
| Page/Feature/Issue sections | court configurations (make declarative → infinite) |

---

*Source of truth for implementation details: `/srv/apps/review` (components:
`review-table`, `issues-table`, `features-table`, `notes-sheet`,
`notifications-bell`, `presence-viewers`, `column-filters`, `markdown`,
`use-image-paste`; migrations `0001`–`0017`, esp. `0014`–`0017` for the
notification system).*
