fix(calendar): refuse to change an event's kind on update (#65) #66

Merged
laoc merged 1 commit from fix/issue-65-block-allday-toggle into dev 2026-07-30 14:09:12 +00:00
Owner

Fixes #65 — the half of #62 that was not a cache bug.

The bug, as measured

TestOER reproduced it in a browser at b82bfe9c, twice. Create a timed event, toggle All Day, save:

17002:  kind=31922  created_at=1785418289  "Issue65 Timed … - NowAllDay"
        kind=31923  created_at=1785418284  "Issue65 Timed …"
17003:  identical pair

Two live events at one d-tag, on both relays, the original byte-identical. The page stays on the original naddr and renders the pre-edit title, with no error. /calendar lists both.

updateEvent took kind: eventData.kind || existingEvent.kind, and convertFormDataToEvent sets 31922 for date-based and 31923 for time-based. A replaceable event is addressed by (kind, pubkey, d-tag), so the new kind is a new coordinate — nothing is replaced.

That is the same user-visible symptom as #62 ("my edit did nothing"), which is why it needed splitting out: no cache fix can reach it, because the edit genuinely went somewhere else.

Why refusing, and not something cleverer

Keeping the kind is not legal. NIP-52 requires start to be YYYY-MM-DD on 31922 and a unix timestamp on 31923, plus a required D day-granularity tag on 31923. There is no way to express an all-day event as a 31923, so the kind change is forced by the spec — this cannot be fixed by making updateEvent keep the existing kind.

Delete-and-recreate cannot be done correctly by this client. NIP-52 calendars (kind 31924) reference their events by a = <kind>:<pubkey>:<d>, and that list lives on the calendar event, signed by its owner — who need not be the person editing. A correct migration would have to rewrite a tags in events the editing user cannot sign. RSVPs (31925) are at least authored by their own users, but the NIP states it "is intentionally not defining what happens if a calendar event changes after an RSVP is submitted", so there is no spec-blessed migration to follow either.

Refusing costs nothing that works. The original event survives byte-identical today, so the toggle has never produced a working edit. Blocking it does not remove a working feature; it stops the app manufacturing a second event.

What changed

updateEvent refuses a kind change — before signing, before publishing, and before calendarStore.setEvents. That ordering is deliberate: updateEvent optimistically rewrites the store first, so a guard placed later would still show the user a phantom edit that no relay has.

The type selector is disabled in edit mode, with the reason as both a title attribute and visible helper text (en + de). The UI lock alone would not be enough — the action layer is where the fork happens.

Tests

Five cases in src/lib/__tests__/calendar-update-event-kind-lock.test.js: both directions refused, nothing published, calendarStore untouched, and an ordinary same-kind edit still working.

Negative control, actually run: with the guard reverted, 4 of 5 fail and the fifth — the same-kind edit — passes. That fifth is the control that makes the other four evidence about the guard rather than about a broken updateEvent.

Verification at 2df1c8bd

  • 470 files, 5193/5193 passed (+5, exactly the new cases), .env exported and node_modules/.bin on PATH.
  • pnpm check 0 errors (4 warnings, all pre-existing in unrelated test fixtures).
  • pnpm lint clean, exit 0.
  • The suite ran against the working tree now committed as 2df1c8bd; git status --porcelain is empty, so the bytes tested are the bytes committed.

I did not run the browser. The acceptance check is that the All Day button is disabled in edit mode and that a query at the d-tag after an attempted toggle returns exactly one event.

Note for anyone testing: the publish is not visible on the e2e relays for ~4-8s, and seeded calendar events live on 17002 only — a 17003 query looks empty on a perfectly healthy relay.

🤖 Generated with Claude Code

Fixes #65 — the half of #62 that was not a cache bug. ## The bug, as measured TestOER reproduced it in a browser at `b82bfe9c`, twice. Create a timed event, toggle All Day, save: ``` 17002: kind=31922 created_at=1785418289 "Issue65 Timed … - NowAllDay" kind=31923 created_at=1785418284 "Issue65 Timed …" 17003: identical pair ``` **Two live events at one d-tag, on both relays, the original byte-identical.** The page stays on the original naddr and renders the pre-edit title, with no error. `/calendar` lists both. `updateEvent` took `kind: eventData.kind || existingEvent.kind`, and `convertFormDataToEvent` sets 31922 for date-based and 31923 for time-based. A replaceable event is addressed by `(kind, pubkey, d-tag)`, so the new kind is a **new coordinate** — nothing is replaced. That is the same user-visible symptom as #62 ("my edit did nothing"), which is why it needed splitting out: no cache fix can reach it, because the edit genuinely went somewhere else. ## Why refusing, and not something cleverer **Keeping the kind is not legal.** NIP-52 requires `start` to be `YYYY-MM-DD` on 31922 and a unix timestamp on 31923, plus a required `D` day-granularity tag on 31923. There is no way to express an all-day event as a 31923, so the kind change is forced by the spec — this cannot be fixed by making `updateEvent` keep the existing kind. **Delete-and-recreate cannot be done correctly by this client.** NIP-52 calendars (kind 31924) reference their events by `a` = `<kind>:<pubkey>:<d>`, and that list lives on the calendar event, signed by *its* owner — who need not be the person editing. A correct migration would have to rewrite `a` tags in events the editing user cannot sign. RSVPs (31925) are at least authored by their own users, but the NIP states it "is intentionally not defining what happens if a calendar event changes after an RSVP is submitted", so there is no spec-blessed migration to follow either. **Refusing costs nothing that works.** The original event survives byte-identical today, so the toggle has never produced a working edit. Blocking it does not remove a working feature; it stops the app manufacturing a second event. ## What changed **`updateEvent` refuses a kind change** — before signing, before publishing, and before `calendarStore.setEvents`. That ordering is deliberate: `updateEvent` optimistically rewrites the store first, so a guard placed later would still show the user a phantom edit that no relay has. **The type selector is disabled in edit mode**, with the reason as both a `title` attribute and visible helper text (en + de). The UI lock alone would not be enough — the action layer is where the fork happens. ## Tests Five cases in `src/lib/__tests__/calendar-update-event-kind-lock.test.js`: both directions refused, nothing published, `calendarStore` untouched, and an ordinary same-kind edit still working. **Negative control, actually run:** with the guard reverted, **4 of 5 fail** and the fifth — the same-kind edit — passes. That fifth is the control that makes the other four evidence about the guard rather than about a broken `updateEvent`. ## Verification at `2df1c8bd` - **470 files, 5193/5193 passed** (+5, exactly the new cases), `.env` exported and `node_modules/.bin` on PATH. - `pnpm check` **0 errors** (4 warnings, all pre-existing in unrelated test fixtures). - `pnpm lint` clean, exit 0. - The suite ran against the working tree now committed as `2df1c8bd`; `git status --porcelain` is empty, so the bytes tested are the bytes committed. **I did not run the browser.** The acceptance check is that the All Day button is disabled in edit mode and that a query at the d-tag after an attempted toggle returns exactly one event. Note for anyone testing: the publish is not visible on the e2e relays for ~4-8s, and seeded calendar events live on 17002 only — a 17003 query looks empty on a perfectly healthy relay. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Toggling all-day on an existing calendar event changed the NIP-52 kind
(31922 date-based <-> 31923 time-based) while keeping the d-tag. A
replaceable event is addressed by (kind, pubkey, d-tag), so the new kind
is a NEW COORDINATE: the original was never replaced.

Reproduced in a browser by TestOER at b82bfe9c, twice — two live events at
one d-tag on both relays, the original byte-identical, and the page still
rendering the pre-edit title with no error shown. That is the same
user-visible symptom as #62 ("my edit did nothing"), but no cache fix can
reach it because the edit genuinely went somewhere else.

The kind change is forced by the spec, so keeping the existing kind and
clearing the time fields is not an option: NIP-52 requires `start` to be
YYYY-MM-DD on 31922 and a unix timestamp (plus a `D` tag) on 31923, so
there is no legal way to express an all-day event as a 31923.

Delete-and-recreate was rejected as the remedy. NIP-52 calendars (31924)
reference their events by `a` = <kind>:<pubkey>:<d>, and that list is held
by the calendar owner, who need not be the person editing — this client
cannot re-point references it cannot sign. The NIP also states it "is
intentionally not defining what happens if a calendar event changes after
an RSVP is submitted", so there is no spec-blessed migration to follow.

Refusing is the only option that cannot corrupt anything, and it takes
nothing away that works today: the toggle never produced a working edit.

- updateEvent throws before signing, publishing or touching calendarStore,
  so no second event can be created and no phantom edit is shown.
- The type selector is disabled in edit mode with an explanation, so the
  user does not reach a control that cannot work.

Five unit cases; with the guard reverted, 4 of the 5 fail and the fifth is
the negative control that an ordinary same-kind edit still publishes.

Refs #65
laoc merged commit aa4a129aa4 into dev 2026-07-30 14:09:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app!66
No description provided.