# Design: redesign-events-admin-grid

## Context

See `proposal.md` — Why. This is a `web/`-only change; `api/` is untouched, so `make client`
output is unchanged and the CI client-freshness check stays green.

What already exists and shapes the approach:

- `EventsView.tsx` holds three components. `EventsView` fetches `api.events()` and maps them;
  `EventCard` carries the tab strip and ten `useState` hooks per event (tab, deleting, hasCard,
  startsAt, venue, capacity, published, result, error, saving, broadcast) plus a
  `api.cardDesigns(event.id)` fetch; `EventGuests` fetches up to 200 guests per event.
- `Modal.tsx` is a native `<dialog>` with a hard-won invariant: **every close path — the ✕
  button, a backdrop click, and Escape via `onCancel` — is routed through the parent's
  `onClose`**, deliberately, so React stays the single owner of whether the modal exists. Its
  header comment records what happened when it was not.
- `CardDesignPanel.tsx` (251 lines) lays out `lg:grid-cols-2` and renders a fixed 375px phone
  frame for its preview. It posts on action: upload, publish and delete each hit the API when
  pressed.
- `GuestsView.tsx` already sends `event_id` to `GET /admin/guests` and already gates its
  Add guest button on an event being selected. Its filters are plain `useState`, so no URL can
  address a filtered view.
- `AdminEventRead` carries every field a tile needs — `title_en`, `type`, `starts_at`, `slug`,
  `venue_name`, `capacity`, `is_published` — and nothing about card designs or guest counts.

## Goals / Non-Goals

**Goals**

- Fit an event into a one-third-width tile without losing any information the settings form shows.
- Keep the editor's consequential-edit behaviour (reminder re-planning, the announce offer, the
  stale-card warning) bit-for-bit, since it is the part most expensive to get wrong.
- Make the guest list addressable by event, which is what turns a Guests button into a link
  rather than a second guest table.

**Non-Goals**

- No new or changed API endpoint. If a tile wants something the API does not already return,
  the tile does without it.
- No redesign of `CardDesignPanel`'s internals. It moves from a tab into a modal section; its
  behaviour does not change.
- No change to the Create event flow, which is already a modal and already correct.

## Decisions

### D1 — CSS grid, not flex

Tiles are laid out with `grid gap-4 sm:grid-cols-2 lg:grid-cols-3`.

*Why not flex:* flex needs explicit `basis` arithmetic to hit thirds and leaves a ragged last
row that has to be padded with invisible items. Grid gives equal-height tiles in a row for
free, which matters because tile height varies with venue-name length and badge count.

Breakpoints follow the existing admin layout (`max-w-6xl` container): one column below `sm`,
two at `sm`, three at `lg`.

### D2 — Split by lifetime: tile is presentational, modal owns form state

`EventsView` keeps the fetch and the grid. A new `EventTile` is a pure function of an
`AdminEvent` plus a `hasPublishedCard` flag and two callbacks. A new `EventEditorModal` owns
every piece of form state that `EventCard` owns today.

*Why:* the current design pays for editing on every event whether or not anyone is editing —
ten hooks and a card-designs fetch per event, times the whole grid. More usefully, moving form
state into the modal means it is **born when the modal opens and dies when it closes**. A
half-typed venue cannot survive a close and reappear on the next open, which is exactly the
bug a long-lived-state version would have, and it is invisible until someone hits it.

*Alternative considered:* keep one component and branch on a `mode` prop. Rejected — it keeps
the state lifetime problem while making both paths harder to read.

### D3 — `Modal` gains a footer slot and a `size` prop

`Modal` currently renders `{children}` inside `max-h-[75vh] overflow-y-auto` and takes a
boolean `wide` (`max-w-3xl`). It gains an optional `footer` rendered **outside** that scroll
container, and `wide?: boolean` becomes `size?: "default" | "wide" | "full"` with `full` at
`max-w-5xl`.

*Why the footer must be outside the scroll area:* with the card design section in the modal,
the content is long. A Save button at the end of the children is reachable only by scrolling
past the entire card panel — so the primary action on the screen would be hidden by default.

*Why a wider size:* `CardDesignPanel` lays out `lg:grid-cols-2` and contains a fixed 375px
preview frame. Inside `max-w-3xl` (768px) the two-column layout has roughly 350px per column
and the preview frame overflows its own column.

*Alternative considered:* leave `Modal` alone and pin a footer inside the children with
`sticky bottom-0`. Rejected — it fights the dialog's own scroll container and every consumer
would have to re-implement the same sticky styling.

`wide` has exactly one call site to migrate (`GuestDrawer`); a boolean that already needs a
third value is better replaced than extended.

### D4 — The unsaved-changes guard hangs off `Modal`'s existing single close path

`EventEditorModal` compares its live form values against the `AdminEvent` they were seeded
from and, when they differ, intercepts `onClose` with a confirmation.

*Why this is cheap:* `Modal` already funnels the ✕ button, the backdrop click and Escape
through the parent's `onClose` — that is the invariant its header comment exists to protect.
One guard on `onClose` therefore covers all three paths, with no per-path handling and no way
for a fourth path to sneak past.

Dirty-checking compares the four settings fields only. Card-design actions have already been
committed by the time they could be discarded, so they are not part of "unsaved".

### D5 — Card-design state stays N parallel requests

Each tile needs to know whether the event has a published card. `AdminEventRead` does not say,
so `EventsView` fetches `api.cardDesigns(id)` for each event in a `Promise.allSettled` and
passes a boolean down.

*Why not add `has_published_card` to the API:* it is the better answer at scale, but it means
a Pydantic change, a regenerated client, and a CI client-freshness commit — for a screen that
realistically holds three to eight events.

*Why this is not a regression:* the current `EventCard` already fires exactly one
`cardDesigns` request per event, for the same reason (the stale-card warning). The request
count is unchanged; it moves from the card to the container. `allSettled`, not `all`, so one
failing lookup degrades one badge instead of blanking the grid.

**Revisit if** the events list ever paginates or routinely exceeds ~20 events.

### D6 — Guests is a `<Link>` to the existing screen, and filters become addressable

The Guests control is `next/link` to `/admin/guests?event_id=<id>`, from both the tile and the
modal footer.

Making that work needs `GuestsView`'s filters to be readable from the URL, in two halves:

- **Initial value comes from the server page as a prop.** `guests/page.tsx` is already
  `async` and `force-dynamic`; it reads `searchParams` and passes `initialEventId` down.
- **Subsequent filter changes are written with `window.history.replaceState`.**

*Why `replaceState` rather than `router.replace`:* the guests page is `force-dynamic`, so
`router.replace` triggers a server round-trip and re-render on every filter change — including
each debounced keystroke. `replaceState` updates the address bar with no navigation and no
re-render at all, which is precisely what "reflect the state I already have" wants.

*Why not `useSearchParams` for the initial value:* it would work here (the page is dynamic),
but taking the value as a prop keeps `GuestsView` a plain component with no Suspense boundary
to remember, and there is already a server component in the right place to read it.

*Why `replace` and not `push` for filter changes:* the arrival from the Events grid is a
`push` from the `<Link>`, so Back returns to Events. If each filter change pushed as well,
Back would walk through every intermediate filter state and every debounced search — and the
"press Back to return to Events" scenario would take a dozen presses.

### D7 — An unknown `event_id` is reconciled against the loaded event list

`GuestsView` already loads all events for its dropdown. Once they arrive, a seeded `event_id`
that matches none of them is cleared and a note is shown.

*Why not validate up front:* a malformed UUID and a well-formed one naming a deleted event are
different failures at the API (422 vs. an empty page) but the same failure to the admin. One
reconciliation against a list that is being fetched anyway covers both, and it also covers the
case where the event is deleted between the grid rendering and the link being followed.

### D8 — Delete stays a nested dialog, opened from the editor modal

`DeleteEventDialog` is unchanged and is opened from the editor modal rather than from the
event panel — so a second native `<dialog>` opens on top of the first.

Native dialogs stack in the top layer and `Modal`'s backdrop-click guard is per-instance
(`e.target === ref.current`), so this should work. But `Modal` has a documented history of
cross-browser dialog surprises, so this specific interaction is called out for manual
verification in Chromium **and** Firefox rather than assumed — see the risk below.

On confirmed deletion the editor modal closes too; the guard from D4 must not challenge that
close, since the event no longer exists.

### D9 — Per-event messages reuse the wedding column's shape, not a new one

`event` gains `invitation_messages JSONB NOT NULL DEFAULT '{}'`, keyed `{locale}.{type}` —
byte-identical in shape to the column already on `wedding`.

*Why the same shape when only English is exposed:* the UI offers two English boxes, so the
stored map will only ever hold `{"en": {...}}` for now. Keying by locale anyway means adding
Bangla later is a UI change and not a migration, and — more importantly — one `resolve` can
walk an event map and a wedding map with the same code because they are the same structure.

*Why not two `text` columns (`message_single_en`, `message_family_en`):* it is flatter and
typed, but it forks the storage format between the two levels, so every read site would need
to know which shape it is looking at. The existing column's own comment already argues this
case for the wedding.

`DEFAULT '{}'` and `NOT NULL` mean existing events need no backfill: an empty map is exactly
"inherits", which is the correct state for every event that exists today.

### D10 — `resolve` takes layers in priority order

`greeting.resolve` changes from taking one message map to taking any number, positionally,
most-specific first:

```python
resolve(event.invitation_messages, wedding.invitation_messages, locale=…, invitation_type=…)
```

*Why variadic rather than a new `resolve_layered`:* the existing single-argument call
`resolve(messages, locale=…, invitation_type=…)` stays valid unchanged, so `test_greeting.py`
and any other caller keep working while the new behaviour is additive.

The two properties the current implementation guarantees are preserved deliberately: the
result is never empty (the built-in default is the last resort, below every layer), and it
never falls back across locales — an English override on an event must not reach a Bangla
reader, which is the same argument the existing docstring makes about the wedding level.

### D11 — Prefilled boxes and inheritance are reconciled by storing on difference

The requirement is that the boxes show real, editable default text (not a grey placeholder),
*and* that the wedding-wide panel keeps working as the default. Those pull against each
other: if a prefilled box is saved verbatim, every event silently overrides the panel the
moment it is saved once, and the panel becomes decorative.

The resolution: **the form compares each box against the value it inherited, and stores only
what differs.** Unchanged box, no key written, event keeps inheriting. Changed box, override
stored on that event.

*Why not a placeholder (today's wedding-panel behaviour):* the owner asked for the wording to
be in the box and replaceable, and a placeholder is neither.

*Why not store the prefilled value always:* it makes "inherits" unreachable after the first
save, which contradicts the owner's other decision that the panel stays the default.

The modal therefore needs to know the inherited value, which is the wedding message or the
built-in default. `EventsView` fetches the wedding once and passes the two English fallbacks
into both modals, rather than each modal fetching it.

**The honest cost:** an admin who deliberately wants an event to say exactly what the wedding
panel says gets no override, so changing the panel later changes that event too. That is the
correct reading of "they didn't change it", but it is not the only possible one.

## Risks / Trade-offs

- **Nested `<dialog>` misbehaves in one browser** → The failure mode is the one already
  documented in `Modal.tsx`: the inner dialog closes the outer one, or the outer one becomes
  unopenable until reload. Task 5.1 verifies open, Escape, backdrop click and confirm in both
  Chromium and Firefox. Fallback if it misbehaves: render the delete confirmation inline
  within the editor modal instead of as a second dialog.
- **`Modal`'s `wide` prop is load-bearing elsewhere** → It has one call site (`GuestDrawer`),
  migrated to `size` in the same commit; `tsc --noEmit` in `make lint` catches a miss.
- **Losing the inline guest list is a real reduction on the events screen** → Mitigated by the
  destination being strictly better: search, filters, pagination, the guest drawer, export and
  QR. The one genuine loss is seeing an event's guests without leaving the events screen.
- **`replaceState` writes the address bar behind Next's back** → It is officially supported in
  Next 15 for exactly this, but it means the URL and the router's internal state can disagree
  until the next real navigation. Nothing on this screen reads the router's copy, and the
  server-prop seeding means a real navigation always re-reads the truth.
- **Nothing automated covers this screen** → `web/` has no test runner; its CI gates are
  `eslint`, `tsc --noEmit` and `next build`. `make walkthrough` does not help here — it drives
  the API in-process through `httpx`'s `ASGITransport` and never renders a page, so it will
  keep passing whether or not the grid works. Verification is therefore a written manual pass
  (task 5), not a suite.
- **The change is no longer frontend-only** → Per-event messages pull in a migration, event
  schema fields, a `resolve` signature change and a regenerated client. The rest of the change
  still touches no API. If the messages work needs to be dropped, groups 5 and 6 come out
  cleanly and groups 1–4 stand on their own.
- **`resolve` is called from the guest-facing hot path** → Changing its signature risks the
  invitation page. Mitigated by making the change additive (D10), so the existing call form
  and its tests keep passing, and by `test_greeting.py` already covering the fallback order.
- **A stale generated client silently breaks the modal** → Adding fields to `AdminEventRead`
  without running `make client` leaves the TypeScript unaware of `invitation_messages`. CI's
  freshness check catches it; task 6.6 runs it before that.
- **Tiles show no guest count** → An admin cannot tell an empty event from a full one at a
  glance. Accepted deliberately (proposal Non-goals): the fix is an API change and belongs with
  the dashboard.

## Migration Plan

One additive migration, no data change.

1. Ship as one commit: the migration, the API fields, the web components, the guests page
   prop, and the regenerated `schema.d.ts`.
2. `make migrate` before the app deploy. The column is `NOT NULL DEFAULT '{}'` and nothing
   backfills — an empty map already means "inherits", which is the right state for every
   existing event.
3. Deploy rebuilds both images (`make prod`), because `api/` changed this time.
4. Rollback: the migration's `down` drops the column, but reverting the code alone is enough
   and safer — the old API simply never reads the column, and every event falls back to the
   wedding-wide message, which is what it did before. Drop the column only if the revert is
   permanent.

Ordering matters in one direction only: the new column must exist before the new API reads
it. The old API tolerates the new column, so migrating early is safe.

Admin-visible behaviour changes on deploy with no announcement mechanism; the audience is a
handful of allowlisted accounts, and the change is self-evident on the screen.
