webapp/docs/nostr-patterns/replaceable-events.md
Padreug 5541d2bc7a refactor(events): rename activities module to events + wire VITE_APP_NAME for per-deployment branding (#94)
## Why

The module was named `activities` originally to avoid colliding with Nostr's `Event` type. In practice that defense added friction without preventing confusion — the backend extension is named `events`, NIP-52 calls them "Calendar Events", and the UI already displayed "Events". The collision with `nostr-tools` `Event` is handled cleanly by the existing `import { Event as NostrEvent }` alias pattern (already in 5 files inside the module). Renaming collapses the 4-way mismatch into one consistent term.

Separately, deployments needed per-instance app names. `VITE_APP_NAME` was already plumbed per-standalone via NixOS `services.webapp-standalones.<app>.displayName` (e.g. cfaun shipped `"Sortir"`, now rebranded to `"Bouge"`), but nothing in the standalone consumed it — PWA manifest, HTML title, and runtime branding were all hardcoded. This PR wires the env through every app-name display point so any deploy can flip `displayName = "Bouge"` (or anything) and pick up the brand end-to-end.

## What

Nine commits on the branch:

1. **`refactor(events): rename activities module to events`** — 70-file rename. `src/modules/activities/` → `src/modules/events/`, `src/activities-app/` → `src/events-app/`, types/services/composables/views/components/store renamed (`Activity`→`Event`, `Activities`→`Events`). Routes `/activities/*` → `/events/*`; the legacy `/events` (ticketing management) moves to `/my-events` so `/events` belongs to the canonical feed. DI tokens `ACTIVITIES_*` → `EVENTS_*`. i18n namespace renamed; English domain strings updated, French/Spanish title key realigned. npm scripts `:activities` → `:events`. Build output `dist-activities/` → `dist-events/`.

2. **`feat(events): wire VITE_APP_NAME through PWA manifest, HTML, runtime`** — PWA manifest `name`/`short_name` template from `process.env.VITE_APP_NAME` with fallback `'Events'`. `events.html` uses Vite's `%VITE_APP_NAME%` substitution. `src/events-app/app.ts` + `main.ts` runtime brand string drives console logs, offline notification, and `acceptTokenFromUrl()`. `events.title` route meta sources from VITE_APP_NAME. `.env.example` updated with per-standalone scoping notes.

3. **`docs(events): update activities→events references`** — `docs/nostr-patterns/*.md` "Canonical: src/modules/activities/composables/useRSVP.ts" anchors point at renamed paths. CLAUDE.md Payment Rails Pattern section updated.

4. **`fix(events): drop lowercase from PWA description brand name`** — minor casing fix caught during verification.

5. **`fix(events): use domain noun in description, not brand name`** — manifest + HTML description switched from `"Discover ${BRAND} near you"` to static `"Discover events near you"`. Description is about *what* the app does, not the brand.

6. **`chore(events): scrub leftover sortir/activities references`** — nginx.conf.example, package.json (concurrently process label + `build:demo` BASE_PATH), router-helpers comment, useMarket comment, and vite.events.config.ts doc-comment.

7. **`refactor(events): conditional brand in console label, tighten docs`** — adds `APP_LABEL` next to `APP_NAME` in `events-app/app.ts`. Reads as `Events` on unbranded builds and `Events (Bouge)` (etc.) when `VITE_APP_NAME` is set to anything other than "Events" (case-insensitive). Used by all four console messages; `acceptTokenFromUrl` keeps the raw `APP_NAME` (it's a token-namespace identifier, not display copy). Also collapses the redundant "cfaun sets X; future deployments can override (e.g. X)" doc-comment.

8. **`i18n(events): finish activité/actividad → événement/evento sweep`** — completes the i18n rename in fr.ts and es.ts (search placeholders, favorites prompts, settings prompt) and fixes five gender-agreement errors that came along with the masculine `événement`/`evento` switch (French: `Aucune ... trouvée`→`Aucun ... trouvé`, `préférées`→`préférés`, `d'une ... la sauvegarder`→`d'un ... le sauvegarder`; Spanish: `favoritas`→`favoritos`, `guardarla`→`guardarlo`).

9. **`chore(events): finish sortir → bouge sweep in .env.example`** — four remaining doc-comment refs in `.env.example` (cfaun branding, ticket-scanner comment, section header, subdomain-mode URL example).

## Cross-repo coordination

This PR has matching changes already pushed to two other repos. They have to land in a coordinated bump because the names are tightly coupled.

- **`aiolabs/webapp-module` main** — commit `9d82016`. Renames `hubActivitiesUrl` → `hubEventsUrl` and `VITE_HUB_ACTIVITIES_URL` → `VITE_HUB_EVENTS_URL`. No backwards-compat shim.
- **`aiolabs/server-deploy` main** — commits `f15e1eb`, `bf4698b`, `d46a520`:
  - `standalones.nix` `apps.events` uses `build:events` / `dist-events` / `events.html`; `hubUrlOption` maps `events → "hubEventsUrl"`; comment + cfaun-as-example doc switched from sortir/Sortir to bouge/Bouge.
  - `hosts/cfaun/default.nix` rebranded: `subdomain = "bouge"` + `displayName = "Bouge"`. (Native-French feedback retired the "Sortir" branding as awkward.)
  - `hosts/atio/default.nix` stale "activities app" comment dropped (atio's `displayName = "Eventos"` config is unchanged).

server-deploy's `flake.lock` still pins the OLD webapp + OLD webapp-module, so nix builds from server-deploy main will fail until the bumps. Suggested order after this PR merges to `dev`: one server-deploy commit that does `nix flake lock --update-input webapp-module` + `--update-input webapp-demo` together. DNS for `bouge.ariege.io` needs to point at the cfaun host before the deploy lands.

## Verification

- `pnpm typecheck` — clean ✓
- `pnpm build:events` (default) — `dist-events/manifest.webmanifest` has `name: "Events"`, description "Discover events near you" ✓
- `VITE_APP_NAME=Bouge pnpm build:events` — `name: "Bouge"`, HTML title "Bouge", console label resolves to `Events (Bouge)` ✓
- Cross-repo grep sweep: no `Sortir`/`sortir`/`activities`/`Activities` references in events-module scope. (Domain false positives — "Market Activity" in market dashboard, "time-based activities" in nostr-feed content filters, "Activity Lifecycle Kills" in CLAUDE.md mobile-browser docs — are unrelated and intentionally left alone.)

Not verified in-browser (no GUI in this session) — needs a manual smoke at `app.ariege.io/bouge/` once the coordinated flake bump lands on cfaun. Watch for: feed loads, `/events/calendar`, `/events/map`, `/events/favorites`, `/events/:id` routes work; "My Events" appears in the user dropdown and `/my-events` shows the management page; PWA install prompt shows "Bouge".

## Out of scope (deferred)

- Backend extension rename — `aiolabs/events` is already named correctly.
- Nix standalone attribute name — `services.webapp-standalones.events` is already correct.
- Overriding domain-noun strings ("Your event was created", RSVP labels) — those stay locale-driven; `VITE_APP_NAME` covers app-name only.
- Backwards-compat redirects from `/activities/*` to `/events/*` — pre-launch, public URL was `/sortir/` (path-mode) or `sortir.ariege.io` (subdomain-mode), never `/activities/`. No external bookmarks to preserve. (`sortir.ariege.io` → `bouge.ariege.io` is a separate decision; up to the deploy operator whether to keep a 301 for word-of-mouth referrers.)

Reviewed-on: #94
2026-06-09 18:18:26 +00:00

89 lines
3.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Replaceable events (NIP-01 §13)
Replaceable kinds (1000019999) are keyed by `(kind, pubkey)`; addressable
replaceable kinds (3000039999) by `(kind, pubkey, d-tag)`. The relay keeps
only the event with the highest `created_at` for that key. Almost everything
in this file follows from that single fact.
## Strictly-monotonic `created_at` per coord
**Canonical:** `src/modules/events/composables/useRSVP.ts`
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
```ts
const lastPublishAt = new Map<string, number>()
const now = Math.floor(Date.now() / 1000)
const previous = lastPublishAt.get(coord) ?? 0
const createdAt = Math.max(now, previous + 1)
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
```
**Why:** NIP-01 `created_at` is integer seconds. Two button clicks in the
same wall-clock second produce the same timestamp; relays treat the second
event as a non-newer replacement and silently drop it. The publish appears
to succeed (websocket ACK), but the relay never updates the canonical
state. Bumping past `previous + 1` guarantees each click is strictly newer
than the last click on the same coord.
**Pair with** the [pending-coord debounce](./publishing.md#pending-coord-debounce-disable-the-button-during-in-flight-publish) — together they make rapid-click sequences both well-ordered and rate-limited.
## Per-pubkey latest-wins state for derived counts
**Canonical:** `src/modules/events/composables/useRSVP.ts`
`rsvpStates: ref<Map<coord, Map<pubkey, RSVPEntry>>>` + `upsertRSVPState` +
`getRSVPCount` (count entries where status === 'accepted').
**Why a flat counter is wrong:** every replaceable event your subscription
sees may be a *replacement* of a previous one from the same pubkey. A
naive `count++` per accepted event:
- double-counts when the relay echoes the same RSVP again (reconnect, dup
delivery)
- never decrements when a pubkey flips from accepted → tentative
- never decrements when a pubkey deletes their RSVP via NIP-09
Per-pubkey latest-wins gives a correct count derived from current state.
Same shape works for: RSVPs, reaction tallies (`ReactionService`
`recalculateEventReactions` from `latestReactionsByUser`), poll responses,
any "who's currently in state X" question.
## Replaceable list, full-rewrite on toggle
**Canonical:** `src/modules/events/composables/useBookmarks.ts`
NIP-51 kind 10003 bookmark list.
For replaceable lists (10003 bookmarks, 10000 mute list, 10006 communities,
etc.), don't try to compute a delta and merge — fetch latest with `limit: 1`,
mutate the in-memory array, then publish the *whole list* as one event.
Simpler than diff-merge, and there's nothing to diff against because the
event is a complete replacement.
Confirm `result.success > 0` before updating the local set — a failed
publish leaves the in-memory list ahead of the relay's view, which silently
diverges on next refresh.
## Vue 3 reactivity for nested `ref<Map>`
**Canonical:** `src/modules/events/composables/useRSVP.ts`
`upsertRSVPState` (the `rsvpStates.value.set(coord, inner)` after mutating
`inner`).
```ts
function upsertRSVPState(coord, pubkey, entry) {
let inner = rsvpStates.value.get(coord) ?? new Map()
inner.set(pubkey, entry)
rsvpStates.value.set(coord, inner) // ← required to notify dependents
}
```
**Why:** Vue 3's reactive proxy on `ref<Map>` notifies on mutations of the
*outer* Map (`set`/`delete`). It does NOT proxy through to nested values.
`computed(() => outer.get(k).size)` will read once, cache, and never re-run
when the inner Map mutates. Re-`set`ting on the outer is the cheap fix.
Same caveat applies to nested `ref<Set>`, `ref<Map<_, Array>>`, etc. If you
have one level of nesting, this trick suffices; if you have two levels,
you'll need to re-set up the chain or use `reactive()` instead of `ref()`
for the whole structure.