---
path: /nyx/events
surface: customer
domain: nyx
auth: signed-in
source: apps/oshun/web/src/app/nyx/events/page.tsx
status: walked + e2e-covered
last_walked:
  '2026-07-03 real-infra Playwright chromium — live `/v1/events/upcoming`
  contract, signed-in customer shell, Nyx-only filter, domain-chip suppression,
  corrected `/v1/nyx/events.ics` BFF-proxied card/footer links, mobile
  standalone sibling route coverage, and shared axe scan. Evidence:
  WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md#246-2026-07-03-events-agenda-calendar-feed-coverage'
---

# Nyx · agenda (upcoming events)

## Purpose

One chronological pane of what the sky (and the school) holds ahead. The page
calls the cross-domain BFF feed `/v1/events/upcoming`, then filters it down to
`domain === 'nyx'` so this view shows only Nyx events (conjunctions, meteor
peaks). A `.ics` subscription link lets a user put them next to their other
appointments. See [`V1/features.md#nyx`](../../../V1/features.md#nyx).

## Entry points

- **Nyx hub (`/nyx`)** — verify a link to the agenda from the hub sub-nav
- **`/nyx/tonight`** — "Upcoming events" ghost button at the foot of the
  timeline links here
- **Direct URL / bookmark** — yes (`alternates.canonical = '/nyx/events'`);
  signed-in
- **Deep link from notification** — Nyx event reminder push (see
  [`journeys/nyx-event-calendar-sync-reminder.md`](../../journeys/nyx-event-calendar-sync-reminder.md))
- **Shell nav** — arrives under the `explore` tab
  (`LCustomerNav active="explore"`)

## Layout regions

`page.tsx` is an async server component: `await getUpcomingEvents()` →
`.filter(event.domain === 'nyx')` → renders inside `LWebShell` with
`LCustomerNav active="explore"`. Centered column, `maxWidth: 880`,
`data-responsive-page`.

- **Top chrome**: `LCustomerNav` (Today / Explore / Activity / Library; Explore
  active) with the "Lilith" wordmark and a "⌕ Search" affordance
- **Masthead** (`LMasthead`): left `Nyx · agenda`, right
  `${events.length} upcoming`, kicker "What the sky holds, ahead", title "The
  next few _nights_."
- **"How this opens" note** (`LEyebrow` + italic serif copy): "Conjunctions and
  meteor peaks for your observation window. Subscribe to the calendar to put
  them next to your other appointments."
- **Main**: `<EventList events={events} showDomainChip={false} />` — a grid of
  event cards (domain chip suppressed since the feed is already Nyx-only)
- **Footer button row**: three `LBtn`s — "← Back to Nyx", "Tonight", "Subscribe
  (.ics)"
- **Automation anchors**: root exposes `data-nyx-events-page`, `data-route`,
  `data-event-count`, and `data-event-domains`; shared `EventList` rows expose
  event id/domain/start/end, date/title/summary, and per-link `data-event-link`
  kinds. `data-show-domain-chip="false"` confirms chip suppression on this
  Nyx-only view.

## States

- [x] **Loading** — `page.tsx` is async (`await getUpcomingEvents()`); no
      adjacent `loading.tsx`, so the route blocks server-side on the BFF fetch
      (5 s timeout in `bffGet`)
- [x] **Populated (BFF)** — `/v1/events/upcoming` returns events; only
      `domain === 'nyx'` rows survive the filter and render as cards
- [ ] **Unavailable (BFF unreachable)** — when the BFF is unreachable,
      `getUpcomingEvents()` returns an empty array `[]` (it never fabricates
      astronomy — see the comment at `nyx-depth.ts` ~205-210, "return an empty
      list rather than fabricating astronomy"); after the `domain === 'nyx'`
      filter the page still shows 0 events, so the masthead reads "0 upcoming"
      and `EventList` renders its empty card
- [ ] **Empty** — if no Nyx events survive the filter, `EventList` renders its
      own empty card: "No upcoming events on the feed yet. The next conjunctions
      and class slots will appear here as the schedule fills."
- [ ] **Error (recoverable)** — `bffGet` swallows non-2xx/network errors and
      returns `null`; `getUpcomingEvents()` then returns an empty array `[]`, so
      the page shows the honest empty state (0 events) rather than fabricated
      data — but a transient outage is indistinguishable from a genuinely empty
      feed; verify whether that should be surfaced as a retryable error
- [ ] **Error (unrecoverable)** — no `error.tsx` boundary adjacent; an
      unexpected throw would bubble to a higher app boundary (verify)
- [ ] **Offline** — view served from SW cache where present; the `.ics`
      subscribe link and back/forward buttons are plain hrefs (no client fetch)
- [ ] **Gated** — middleware enforces signed-in; the BFF resolves the caller by
      forwarded session cookie; an unauthenticated request yields no events, so
      the page shows the honest empty state (0 events), never fabricated data
- [x] **Standalone PWA** — renders cleanly in standalone window; verify
      safe-area insets on the footer button row

## Interactions

### Event card (per `NyxUpcomingEvent`, rendered by `EventList`)

- [x] **Event card** (article; not itself a link)
  - Function: shows the localized date line (`weekday, month day, hh:mm` +
    ` · location` when present), the `title` (serif), and the italic `summary`
  - Screen reader: announces date line, then title, then summary
  - Mobile (≤ 640 px): date line + domain chip row wraps (`flexWrap`)
- [x] **"Plan an observation →"** (link, nyx events only)
  - Function: → `/nyx/observation?eventId=<id>` to pre-anchor a field log
  - Telemetry: verify event fired
- [x] **"Add to calendar (.ics) →"** (link, every card)
  - Function: → `/v1/nyx/events.ics` (BFF-served calendar feed through the
    same-origin Next rewrite)

### Footer buttons

- [x] **"← Back to Nyx"** (`LBtn` ghost) → `/nyx`
- [x] **"Tonight"** (`LBtn` ghost) → `/nyx/tonight`
- [x] **"Subscribe (.ics)"** (`LBtn` primary) → `/v1/nyx/events.ics` — calendar
      subscription; BFF returns a `text/calendar` feed

## Data & contracts

- **Reads**: `getUpcomingEvents()` (`@/lib/server/nyx-depth`) →
  `bffGet<BffEventsResponse>('/v1/events/upcoming')`; returns
  `readonly NyxUpcomingEvent[]` (`id`, `title`, `startIso`, `endIso`,
  `location`, `summary`, `domain`). Cross-domain feed filtered to `nyx` in the
  page.
- **Writes**: none — read-only feed
- **Realtime**: none
- **Caching**: `bffGet` sets `next: { revalidate: 60 }` (60 s ISR by default);
  honest empty array `[]` on `null` (no fixture fallback)
- **Auth/role check**: middleware enforces signed-in; `bffGet` forwards
  `oshun-session` / `__session` cookies so the BFF resolves the caller
- **Calendar**: `/v1/nyx/events.ics` — BFF calendar endpoint, served same-origin
  by the Next `/v1/:path*` rewrite

## Cross-references

- Feature spec: [`V1/features.md#nyx`](../../../V1/features.md#nyx)
- Architecture: [`V1/ARCHITECTURE.md#nyx`](../../../V1/ARCHITECTURE.md#nyx)
- Sibling Nyx routes: [`nyx.md`](./nyx.md),
  [`nyx-tonight.md`](./nyx-tonight.md),
  [`nyx-observation.md`](./nyx-observation.md)
- Journeys:
  [`journeys/nyx-event-calendar-sync-reminder.md`](../../journeys/nyx-event-calendar-sync-reminder.md),
  [`journeys/nyx-to-tara-bridge.md`](../../journeys/nyx-to-tara-bridge.md)
  (cross-domain links in `EventList` hop to `/tara`, `/metis`, `/arete`)
- Component sources:
  - `apps/oshun/web/src/components/nyx/EventList.tsx` (`EventList`, `LinkRow`)
  - `apps/oshun/web/src/lib/server/nyx-depth.ts` (`getUpcomingEvents` — returns
    `[]` on null/unreachable; there is no `upcomingFixture`)

## Open questions / known gaps

- [ ] No telemetry events are wired in `EventList` or the page — confirm whether
      this is intentional for V1
- [ ] On a BFF outage `getUpcomingEvents()` returns an empty array `[]` (the
      honest empty state — never a fabricated agenda), which is
      indistinguishable from a genuinely empty feed; decide whether a transient
      outage should be surfaced as a retryable error instead of "0 upcoming"
- [ ] `EventList`'s cross-domain links (`/tara`, `/metis`, `/arete`) are
      suppressed here only by `showDomainChip={false}` affecting the chip — the
      links themselves still render per domain; verify intended behavior on the
      nyx-only feed
