# Tasks: redesign-events-admin-grid

Design decisions are numbered D1–D11 in `design.md`; cite them from code comments the way the
rest of the repo does (`design D4`).

## 1. Modal groundwork

- [x] 1.1 Add an optional `footer?: React.ReactNode` to `Modal`, rendered as a sibling *below*
      the `max-h-[75vh] overflow-y-auto` content div, not inside it, with a top border matching
      the header's (design D3). Verify the footer stays visible while long content scrolls.
- [x] 1.2 Replace `wide?: boolean` with `size?: "default" | "wide" | "full"` mapping to
      `max-w-lg` / `max-w-3xl` / `max-w-5xl`, defaulting to `default` (design D3).
- [x] 1.3 Migrate the one existing `wide` call site, `GuestDrawer.tsx:54`, to `size="wide"`.
      `npm run typecheck` in `web/` must be clean — it is what catches a missed call site.

## 2. Event tile and grid

- [x] 2.1 Add an `EventTile` component: a pure function of `{ event: AdminEvent,
      hasPublishedCard: boolean, canEdit: boolean, onEdit: () => void }` with no form state
      (design D2).
- [x] 2.2 Render every settings field as read-only text: name, ceremony type, `starts_at` via
      the existing `formatDateTime` in `Asia/Dhaka`, venue, capacity, and the `/e/{slug}`
      address in the existing mono style. Capacity of `0` or `null` reads "No limit", never "0".
- [x] 2.3 Add published / draft and card-design badges, visually distinct from each other, so a
      draft event and a card-less event are separable at a glance.
- [x] 2.4 Add the tile's `Edit` and `Guests` controls. `Edit` is hidden without `edit_content`,
      exactly as the current delete control is; `Guests` is shown regardless, since viewing
      guests is not an editing permission. No delete control on the tile (design D8).
- [x] 2.5 Convert `EventsView`'s `space-y-4` list into `grid gap-4 sm:grid-cols-2 lg:grid-cols-3`
      (design D1). Keep the existing empty state and `Create event` button placement.
- [x] 2.6 Move the card-design lookup from per-event to the container: one
      `Promise.allSettled` over `api.cardDesigns(id)` after `api.events()` resolves, feeding
      `hasPublishedCard` per tile (design D5). A rejected lookup degrades that one badge.

## 3. Event editor modal

- [x] 3.1 Add `EventEditorModal`, taking `{ event, canEdit, onClose, onSaved }`, owning the
      settings state currently held by `EventCard` — `startsAt`, `venue`, `capacity`,
      `published`, `result`, `error`, `saving`, `broadcast` — seeded from `event` on mount so
      it dies with the modal (design D2).
- [x] 3.2 Move the settings form in unchanged: the `datetime-local` round trip through
      `toLocalInput`/`fromLocalInput`, the `starts_at`-only-when-changed patch, the published
      checkbox, and the `Currently <date>` line.
- [x] 3.3 Move the three consequence surfaces in unchanged: the pre-save date-change warning,
      the post-save `reminders_rescheduled / cancelled / created` report, the separate
      `broadcast_recommended` announce action, and the `hasCard && (dateChanged ||
      venueChanged)` stale-card warning.
- [x] 3.4 Render `CardDesignPanel` as a second section below settings, under a heading, in a
      `size="full"` modal. Check its `lg:grid-cols-2` layout and 375px preview frame fit at
      `max-w-5xl` (design D3).
- [x] 3.5 Build the modal footer: `Guests` and `Delete this event` on the left, `Close` and
      `Save` on the right. `Save` submits the settings form only and is `disabled` while
      saving; card-design actions keep acting immediately (design D3).
- [x] 3.6 Add the dirty guard: compare the four settings fields against the seeding `event`
      and, when they differ, confirm before closing. Hang it off `onClose`, which `Modal`
      already funnels ✕, backdrop click and Escape through — do not add per-path handling
      (design D4). Card-design actions are never "unsaved".
- [x] 3.7 Open `DeleteEventDialog` from inside the editor modal, unchanged (design D8). On
      confirmed deletion close the editor too, bypassing the D4 guard — the event is gone and
      challenging the close would be nonsense.
- [x] 3.8 Delete `EventGuests` and the tab strip from `EventsView`; keep `CreateEventForm` and
      `DeleteEventDialog` as they are.

## 4. Guests navigation and addressable filters

- [x] 4.1 Point the tile's and the modal's `Guests` controls at
      `/admin/guests?event_id=<id>` with `next/link` (design D6).
- [x] 4.2 Read `searchParams` in `guests/page.tsx` (already `async` and `force-dynamic`) and
      pass `initialEventId` into `GuestsView`; seed its `event` state from it (design D6).
- [x] 4.3 Sync filter changes to the address with `window.history.replaceState`, not
      `router.replace` — the page is `force-dynamic`, so a router navigation would re-render
      the server component on every debounced keystroke (design D6). Include event, status,
      side, tag and search; omit empty values rather than writing `?tag=`.
- [x] 4.4 Reconcile an unknown `event_id` once the events list loads: clear it, show a note,
      fall back to the unfiltered list (design D7). Cover both a malformed identifier and a
      well-formed one naming a deleted event.
- [ ] 4.5 Confirm the arrival path works end to end: `Add guest` is enabled on arrival because
      an event is selected, and the total reads "guest" rather than "guest record" while an
      event filter is active.

## 5. Per-event invitation messages — API

- [x] 5.1 Add `invitation_messages` to the `Event` model: `JSONB`, `nullable=False`,
      `default=dict`, `server_default="{}"` — the same shape as the column on `Wedding`
      (design D9). Comment why it is keyed by locale when only English is exposed.
- [x] 5.2 Alembic revision with `down_revision = "b7d2f8e19c34"` adding that column. No
      backfill: an empty map already means "inherits". `downgrade` drops it.
- [x] 5.3 Change `greeting.resolve` to take message maps variadically in priority order,
      most-specific first (design D10). The existing one-map call form must keep working —
      `test_greeting.py` is the check. Preserve both invariants: never empty, never falls
      back across locales.
- [x] 5.4 Resolve through the event in `invitations.py`'s `_greeting`: pass the event's map
      ahead of the wedding's. Update its callers to hand it the event.
- [x] 5.5 Expose the field on `AdminEventRead`, `EventCreate` and `EventUpdate` in
      `admin_events.py`, validating writes through the existing `greeting.validate_messages`
      so the 100-character cap, the markup stripping and the unknown-key dropping are the
      same rules the wedding-wide endpoint enforces. Match `admin_wedding.py`'s handling of
      `MessageTooLongError`. Include the field in the audit record's before/after.
- [x] 5.6 Add API tests: event override wins over wedding; empty event map inherits; an
      English event override does not reach a Bangla reader; over-limit is refused; markup is
      stripped and measured after cleaning.
- [x] 5.7 Run `make client` and commit the regenerated `web/lib/api/schema.d.ts` — CI fails
      on a stale client.

## 6. Per-event invitation messages — web

- [x] 6.1 Fetch the wedding once in `EventsView` and pass the two English fallbacks (its
      message, or the built-in default) into both modals, so neither fetches it itself
      (design D11).
- [x] 6.2 Add a shared messages section: two labelled textareas, single and family, prefilled
      with the inherited value, live character counter against the 100 cap, counter turning
      red past it, no `maxLength`. Mirror `InvitationMessages.tsx`'s copy about *why* the
      limit exists.
- [x] 6.3 Render it at the bottom of the event editor modal, below Card design, saved by the
      footer's Save along with the settings fields.
- [x] 6.4 Render it at the bottom of the Create event form, saved with the create.
- [x] 6.5 Store on difference (design D11): on save, compare each trimmed box against the
      value it inherited and send only what differs; an emptied box clears the override.
      Include the message fields in the editor's dirty check so closing with an edited
      message prompts.
- [x] 6.6 Block Save while either box is over the limit, the way the wedding-wide panel does.

## 7. Verification

There is no test runner in `web/` — CI gates this with `eslint`, `tsc --noEmit` and
`next build`, and `make walkthrough` never renders a page (design, Risks). So these are
written manual checks against `make dev`.

> **7.1–7.6 are pending by owner decision (15 Aug 2026).** They need an authenticated admin
> session, and the owner declined a dev-bypass sign-in, so nothing below the toolchain checks
> has been exercised in a browser. 7.6 additionally needs a viewer-role `admin_user`, which
> the dev database does not have — both seeded admins are super admins.
>
> Note when running `next build` by hand: the dev overlay bind-mounts `./web:/app`, so the
> container's `next dev` owns `web/.next`. A host-side `next build` fights it and leaves the
> dev server serving MODULE_NOT_FOUND. Build in an isolated container instead —
> `docker compose ... run --rm --no-deps -e NODE_ENV=production -v /app/.next web npm run build`
> (the anonymous volume masks the shared `.next`; `NODE_ENV=production` avoids the spurious
> `<Html> should not be imported outside of pages/_document` prerender failure).

- [ ] 7.1 **Nested dialogs, Chromium and Firefox both.** Open the editor, open the delete
      confirmation from it, then in each browser: press Escape, click the backdrop, and press
      the ✕ — the inner dialog must close without closing or breaking the outer one, and the
      editor must reopen afterwards. This is the specific interaction `Modal.tsx`'s header
      comment warns about. If it misbehaves, fall back to an inline confirmation inside the
      editor (design D8).
- [ ] 7.2 Grid reflow at desktop, tablet and phone widths: three, two and one column, no tile
      clipped, no horizontal page scroll.
- [ ] 7.3 Dirty guard on all four close paths — Close button, ✕, Escape, backdrop click —
      with an edit pending; and no prompt on any of them with nothing changed.
- [ ] 7.4 Date change end to end: warning before save, reminder counts after, announce action
      offered only when `broadcast_recommended`, stale-card warning on an event with a
      published card.
- [ ] 7.5 Back button returns to the events grid in one press after following a Guests link
      and changing several filters — the check that D6's `replaceState` choice is right.
- [ ] 7.6 Sign in as a Viewer: no Edit control, no delete, `Guests` still navigates. Then call
      `PATCH /admin/events/{id}` directly as that Viewer and confirm the API refuses on its own
      authority — the screen hiding a button is never the enforcement.
- [x] 7.7 `make lint` clean (all four: ruff, mypy, tsc, eslint — `api/` changes now too) and
      `next build` succeeds. Re-run after group 5; the pass recorded before the messages work
      no longer covers it.
- [x] 7.8 `make test` green, including the new greeting and event-message tests.
- [x] 7.9 Confirm `web/lib/api/schema.d.ts` **does** now differ, and that the difference is
      exactly the added `invitation_messages` fields. This change adds API fields, so an
      unchanged client means `make client` was never run and CI will fail on staleness.
- [ ] 7.10 Messages end to end: an event override reaches its own guests and no others; an
      untouched event still follows a later edit to the wedding-wide panel; a Bangla reader
      never sees an English event override.
