# Journey: Nyx event calendar sync + reminder

Nyx exposes useful calendar, preference, durable reminder, delivery, and
observation capabilities, but today they form several separated seams with two
event registries. The dynamic `/nyx/events` agenda exports a whole astronomical
feed. The fixed `/domains/nyx/events` catalog exports one browser-generated
event. Rich-detail action controls write browser state and a process-local BFF
mirror. A separate assistant/Nyx-adapter path owns durable member reminders;
only that state feeds provider calendars and the optional reminder worker.
Service-worker notification and observation coverage prove further independent
contracts. This journey must preserve those boundaries until one same-event
receipt joins them.

## Personas

- **Agenda watcher** — scans computed astronomical events and subscribes to the
  full calendar feed.
- **Rich-event reader** — opens an authored 2026 detail and downloads a single
  ICS file.
- **Reminder planner** — records a cadence and channel preference, or asks the
  assistant to create a durable reminder.
- **Connected-calendar member** — authorizes a provider in Profile and exports
  the member schedule.
- **Observer** — records an owner-scoped observation associated with an event
  id.

## Pre-conditions

- Use `/nyx/events` for the dynamic Nyx agenda and `/events` for the shared
  cross-domain agenda. These call `/v1/events/upcoming` and use computed ids.
- Use `/domains/nyx/events` for the fixed rich catalog. Its detail routes are
  `/events/<fixed-id>` and `/domains/nyx/events/<fixed-id>`; there is no
  `/nyx/events/<id>` detail route.
- Do not treat matching labels as matching records. `march-equinox` and
  `season-march-equinox-YYYY-MM-DD`, for example, belong to different
  authorities.
- Use an authenticated session for action-state mirroring, durable member
  reminders, provider connections, exports, and BFF observation rows.
- Configure a supported provider before expecting OAuth or export. Profile
  displays Google, Apple, and Outlook; Apple is fail-closed by default, and the
  default writer transport supports Google.
- Enable `OSHUN_REMINDER_WORKER_INTERVAL_MS` with a positive value before
  expecting autonomous in-process reminder delivery; the worker is off by
  default.

## Steps

### 1. Choose the event authority before choosing an action

For an agenda/subscription path, open `/nyx/events`. The page filters the real
upcoming-events response to Nyx records. Lunar phases and seasons are computed
for the current window, while featured eclipse and meteor records are
source-backed templates included only when upcoming. The shared `/events` page
uses the same response without the Nyx-only filter.

For a rich detail, open `/domains/nyx/events` and select a bundled record. These
fixed 2026 records carry description, timing, visibility, guidance, and
recommendations. They are not detail views over the dynamic agenda. Record the
actual id and source in evidence; a human-readable astronomical name is
insufficient.

### 2. Export the appropriate ICS contract

On either agenda page, **Subscribe (.ics)** and every row-level **Add to
calendar (.ics)** link resolve to `/v1/nyx/events.ics`. The endpoint exports the
whole 365-day astronomical feed, capped at 36 records, with `@oshun.app` UIDs.
The row label does not narrow the bytes to that event.

On a rich detail, **Add to calendar** calls `downloadNyxEventICalFile`. It
downloads exactly one fixed record as `<eventId>.ics`, with an
`<eventId>@oshun-nyx` UID. The browser/OS then owns import. Neither file path
provides an external-client acknowledgment, and the two generators must not be
compared as if their ids or UIDs represented the same event record.

### 3. Treat rich-detail action controls as preference metadata

Save, Follow, Remind, cadence, and channel choices write immediately to
`oshun:nyx:event-actions:v1`. Same-tab and cross-tab events refresh the UI. When
an access token exists, the hook also PUTs the selected state to
`/v1/nyx/event-actions/<eventId>` and hydrates the newest timestamp.

The BFF action store is an owner-scoped process-local map with no snapshot sink.
It validates the request and reconstructs route labels, but it has no due-time
worker, verified-recipient join, provider export call, or channel dispatcher.
Selecting push, email, SMS, or external calendar is not evidence that any route
was scheduled or delivered. The browser key also lacks a member partition and is
not removed by normal auth cleanup.

### 4. Create a durable reminder through the separate Nyx member authority

The assistant `nyx.set_reminder` action reaches the Nyx adapter's
`setEventReminder`. That adapter rebuilds the dynamic upcoming-event set,
requires an id in it, and writes title, type, reminder time, cadence, channels,
enabled state, and owner into the durable Nyx member store. The server requires
and wires this store's snapshot persistence; export and erasure include it.

No rich-detail component calls this mutation. Fixed catalog ids generally do not
pass its dynamic lookup. There is no bridge that turns the saved Nyx
event-action record into this durable member reminder. A durable reminder may
therefore be real while the detail control still has no path to create it.

### 5. Connect and export a provider from Profile

At `/profile?path=calendar`, inspect the schedule preview and manage Google,
Apple, or Outlook connections. Google and Outlook may complete OAuth when their
configuration is present. Apple currently returns the explicit unconfigured
`503` boundary. Established OAuth credentials are durable and secret-free in
member views; pending OAuth state is only process memory.

`/v1/calendar/export` builds the member schedule and reports provider outcomes.
Its Nyx items come only from enabled durable member reminders whose channels
permit `external-calendar`, inside the 14-day horizon. The rich event-action
store is never read. The generated Nyx canonical URL currently uses the missing
`/nyx/events/<dynamic-id>` route, so even a successful provider write does not
prove a valid detail deep link.

### 6. Distinguish optional in-app delivery from push navigation

When the worker is enabled, `produceNyxEventReminders` sweeps durable enabled
member reminders and creates stable scheduled entries. The bridge's recipient is
empty and its channel is labelled push; the dependable path is the owner- keyed
in-app inbox, while an external transport lacks a recipient. It also uses the
missing `/nyx/events/<dynamic-id>` URL.

The service-worker E2E independently injects a payload with a valid fixed rich
route. A cold notification tap opens it. With an existing app window, the
service worker posts `OSHUN_NOTIFICATION_TAP` and focuses the window, but the
app bootstrap does not consume that message. The normal Web Push dispatcher does
not pass the optional action data. This test is a notification contract, not
proof that the durable Nyx reminder produced a navigable push.

### 7. Record the observation through the matching surface

Rich detail links to `/domains/nyx/observation-log?eventId=<fixed-id>`.
Submitting creates local `oshun.nyx` state first, then attempts an authenticated
BFF write; success marks the local row synchronized. The server record is owner
scoped and durable. The displayed **Linked event** value is a tag rather than a
return link, and a failed local-first row has no audited replay worker.

Agenda rows instead link to `/nyx/observation?eventId=<dynamic-id>`, a separate
direct-post form. Neither observation surface is reached by the current real
reminder producer. The global `oshun.nyx` browser key is not member partitioned
or cleared on logout, so local evidence and server evidence must be reported
separately.

## Post-conditions

- A feed download can be identified by its dynamic event ids and `@oshun.app`
  UIDs; a rich-event download can be identified by one fixed id and `@oshun-nyx`
  UID.
- A rich-event action preference can be read from the current browser and, for
  an authenticated member, from the current BFF process. It is not a scheduled
  reminder.
- A durable member reminder can survive through the supported snapshot store,
  export, and erasure, but must be reported as created by the separate adapter
  path.
- A provider export can report per-provider success or failure for eligible
  durable schedule items. It does not acknowledge importing a downloaded ICS
  file and is not invoked from rich detail.
- An enabled worker can create an in-app delivery record; a real push deep link
  requires recipient resolution and action data not proven here.
- An observation BFF row can be read back for its owner. A local queued row and
  a non-navigable event tag do not close the calendar/reminder journey.

## Failure modes

- **Wrong event authority** — a fixed detail id is supplied to the dynamic
  reminder adapter, or a dynamic agenda id is expected to resolve in rich
  detail.
- **Mislabelled export expectation** — the row-level agenda link downloads the
  entire feed rather than one VEVENT.
- **Action-store restart** — the BFF preference mirror disappears; the global
  browser record remains and can cross account boundaries.
- **No durable bridge** — changing a rich-detail cadence/channel never creates a
  member reminder.
- **Provider unavailable** — credentials or a writer transport are absent; Apple
  is unconfigured by default, and Outlook has no default export transport.
- **Broken calendar/reminder link** — provider and worker items use a route that
  does not exist and an id the rich catalog may not know.
- **Worker disabled or recipient absent** — no autonomous tick runs, or external
  push cannot resolve a target even though in-app delivery succeeds.
- **Warm notification tap** — the existing tab focuses without navigating
  because the application ignores the posted tap message.
- **Observation sync failure** — local queued data remains without server
  read-back or replay; local state can also survive account change.

These are the twelve separations the result must keep visible: agenda versus
rich record; feed versus single-event ICS; action label versus dispatched
channel; process mirror versus durable reminder; shared label versus shared id;
durable reminder versus detail control; provider export versus detail sync;
injected push versus produced push; focus versus navigation; tag versus return
link; browser storage versus subject partition; distributed cases versus one
receipt.

## E2E coverage

- [`apps/oshun/web/e2e/nyx-event-pages.spec.ts`](../../apps/oshun/web/e2e/nyx-event-pages.spec.ts)
  has 15 route, catalog, detail-action, and observation-link cases across
  related Nyx surfaces.
- [`apps/oshun/web/e2e/nyx-event-ics.spec.ts`](../../apps/oshun/web/e2e/nyx-event-ics.spec.ts)
  has two single-event ICS download cases.
- [`apps/oshun/web/e2e/nyx-event-actions-bff.spec.ts`](../../apps/oshun/web/e2e/nyx-event-actions-bff.spec.ts)
  has four BFF auth/validation/state cases; and
  [`apps/oshun/web/e2e/nyx-event-actions-real-sync.spec.ts`](../../apps/oshun/web/e2e/nyx-event-actions-real-sync.spec.ts)
  has one browser-to-real-BFF synchronization case. Neither claims scheduling.
- [`apps/oshun/web/e2e/profile-calendar-sync.spec.ts`](../../apps/oshun/web/e2e/profile-calendar-sync.spec.ts)
  has four Profile UI cases over mocked calendar routes;
  [`apps/oshun/web/e2e/calendar-connectors-bff.spec.ts`](../../apps/oshun/web/e2e/calendar-connectors-bff.spec.ts)
  has three cases for the separate admin connector runtime.
- [`apps/oshun/web/e2e/nyx-observations-bff.spec.ts`](../../apps/oshun/web/e2e/nyx-observations-bff.spec.ts)
  has four owner-scoped observation API cases.
- [`apps/oshun/web/e2e/nyx-service-worker-notification.spec.ts`](../../apps/oshun/web/e2e/nyx-service-worker-notification.spec.ts)
  has two injected service-worker notification cases.
- The 35 named cases are **partial** coverage. Completion requires one automated
  trace with the same reconciled event identity and member from detail intent
  through durable reminder, provider or delivered notification receipt, event
  navigation, observation write, and read-back.

## Per-view files touched

- [`customer/06-nyx/nyx-events.md`](../customer/06-nyx/nyx-events.md) — dynamic
  agenda and whole-feed subscription.
- [`customer/06-nyx/nyx-event-detail.md`](../customer/06-nyx/nyx-event-detail.md)
  — fixed event, one-event ICS, preference controls, and observation link.
- [`customer/09-account/profile.md`](../customer/09-account/profile.md) —
  provider connections and schedule export.
- [`customer/06-nyx/nyx-observation.md`](../customer/06-nyx/nyx-observation.md)
  — local-first rich observation log and owner-scoped BFF boundary.

## Cross-references

- [`nyx-tonight-observation.md`](./nyx-tonight-observation.md) — dynamic
  astronomy and the separate direct observation form.
- [`messages-quiet-hours-and-channel-binding.md`](./messages-quiet-hours-and-channel-binding.md)
  — recipient verification, delivery routes, worker, and provider receipts.
- [`account-deletion-and-dsar.md`](./account-deletion-and-dsar.md) — durable
  export/erasure versus unmanaged browser-local records.
- Result:
  [`nyx-event-calendar-sync-reminder.md`](../results/nyx-event-calendar-sync-reminder.md)
  — dated source and automation reconciliation.

## Open questions

- Which dynamic/fixed event mapping is authoritative for reminders, calendar
  links, and observations?
- Should the detail action state be migrated into the durable member reminder or
  removed in favor of it?
- Should agenda rows expose separate **Download this event** and **Subscribe to
  all events** controls?
- Which provider exports are production supported, and how will provider event
  ids and receipts be shown?
- Should worker delivery carry a verified push recipient and structured
  `actionPath`, and should `PwaBootstrap` route warm taps?
- How will local Nyx state be subject partitioned, cleared, replayed, and
  reconciled with owner-scoped server rows?
- What single trace and restart test will define completion of this journey?
