Compare commits

...

8 commits

Author SHA1 Message Date
8ade942c32 fix(events): keep event detail's ticket counts live (subscribe even when cached)
useEventDetail.load() early-returned when the event was already in the
store, so arriving from the feed (cached) set up no live subscription.
NIP-52 calendar events are replaceable and the events extension
republishes them when a ticket sells (updating tickets_sold/available),
but with no subscription the detail page never received the update —
counts went stale until a manual reload.

Always open the dTag-scoped subscription (only the one-shot query +
loading state are skipped on a cache hit), and unsubscribe a prior sub
before re-subscribing so reload() can't leak one. The reactive `event`
computed then reflects republished counts without a reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:43:38 +00:00
83ea3e609c Merge pull request 'feat(events): calendar popup respects the selected category filter' (#115) from feat/calendar-respect-categories into dev
Reviewed-on: #115
2026-06-18 12:41:17 +00:00
3514d93451 feat(events): show selected categories as deselectable chips in calendar popup
The calendar popup already narrows its day-dots to the active category
filter; surface those categories inside the popup so the user can see —
and loosen — what's narrowing it without closing. Renders only the
selected categories as removable chips; clicking one emits toggle-category
to the parent, which reactively re-widens the dots in place.

- EventCalendarPopup: optional selectedCategories prop (defaults to none
  for callers like My Tickets) + toggle-category emit; chip row between
  the header and the month grid.
- EventsPage: wire selectedCategories + toggleCategory through.
- i18n: events.filters.filteringBy + removeCategory (en/fr/es + schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:31:39 +02:00
2febf0926d docs(nostr-patterns): point monotonic created_at at the shared helper
The "strictly-monotonic created_at per coord" section named useRSVP.ts as
canonical, but that file no longer exists. monotonicCreatedAt() in
src/lib/nostr/timestamp.ts is now the single implementation — make the
doc reference it and show both the per-coord-Map and single-field
tracking shapes. Keeps doc and code aligned per the docs discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:03:58 +00:00
c6f626df08 fix(events): publish bookmarks with monotonic created_at (#122)
Relays only push a replaceable-event update to OPEN subscriptions when
its created_at is strictly newer than the held version. created_at is
second-resolution, so useBookmarks' `Math.floor(Date.now()/1000)` lets
two rapid toggles collide in the same second — the second is treated as
not-newer and never reaches live subscribers (only a reload shows it).
This is the same root cause found while debugging the live ticket count.

- Add `monotonicCreatedAt(lastCreatedAt, now?)` = max(now, last+1), a
  reusable helper for any replaceable-event publisher.
- Use it in `toggleBookmark`; track `lastCreatedAt` as a typed field on
  BookmarkState (drops the `(state as any)` casts).

Unit tests cover no-prior, same-second bump, wall-clock tracking,
future-dated prior, and a strictly-increasing same-second burst.

The aiolabs/events extension's nostr_publisher uses int(time.time()) the
same way — flagged in #122 for a follow-up on the backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:03:58 +00:00
4b3b905225 fix(events): key the events store by addressable coordinate (#121)
NIP-52 calendar events (kinds 31922/31923) are addressable: their d-tag
is author-scoped, so the replacement key is kind:pubkey:d-tag, not the
bare d-tag. The store keyed `eventsMap` by `event.id` (d-tag) and
replaced on newer `created_at` ignoring pubkey, so a different author
republishing the same d-tag could overwrite a legit event in the store
(cross-author hijack). NDK (`event.coordinate()`) and welshman
(`eventsByAddress`) both key addressable events by the full coordinate.

- Key `eventsMap` by `eventCoordinate()` = `${kind}:${pubkey}:${dtag}`;
  same-coordinate-newer-wins replacement, different authors stored apart.
- Keep the d-tag as the route identifier: `getEventById(dtag)` scans and
  returns the newest match (single-publisher in practice). Add
  `getByCoordinate()` for precise, author-known lookups.
- `removeEvent(dtag)` deletes every coordinate sharing that d-tag.

Client-side only — the store is rebuilt from relays each session, so no
demo-DB surgery. Covered by vitest unit tests including the cross-author
no-overwrite case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:28:18 +02:00
327092c022 chore(test): add vitest runner + smoke test
No test runner existed in the repo. Add vitest (node env, *.spec.ts
discovery) with a minimal config mirroring only the `@`→src alias, plus
`test`/`test:watch` scripts and a smoke test as a known-good baseline.

Precursor for the nostr-patterns review fixes (events store coordinate
keying #121, monotonic created_at #122), which ship with unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:25:34 +02:00
db4c9b8bf3 feat(events): calendar popup respects the selected category filter
The date-picker popup showed dots for all events regardless of the
active category filter. Feed it a category-filtered set so its per-day
dots reflect what the user is browsing (temporal/day filters still don't
apply — the calendar is for picking any date). No categories selected
behaves as before (all events).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:06:52 +02:00
17 changed files with 654 additions and 50 deletions

View file

@ -7,15 +7,19 @@ 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.
**Canonical helper:** `src/lib/nostr/timestamp.ts`
`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`.
Use it for **every** replaceable-event publish; track the last
`created_at` per coord (a `Map<coord, number>` when one composable
publishes many coords like `useRSVP.ts`, or a single field when there's
one coord per user like `useBookmarks.ts`' kind-10003 list).
```ts
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
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)
const createdAt = monotonicCreatedAt(lastPublishAt.get(coord))
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
```

View file

@ -10,6 +10,8 @@
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host",
"analyze": "vite build --mode analyze",
"test": "vitest run",
"test:watch": "vitest",
"dev:events": "vite --host --config vite.events.config.ts",
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
"preview:events": "vite preview --host --config vite.events.config.ts",
@ -107,6 +109,7 @@
"vite-plugin-image-optimizer": "^1.1.7",
"vite-plugin-inspect": "^0.8.3",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^4.1.9",
"vue-tsc": "^2.2.0",
"web-push": "^3.6.7",
"workbox-window": "^7.3.0"

237
pnpm-lock.yaml generated
View file

@ -192,6 +192,9 @@ importers:
vite-plugin-pwa:
specifier: ^0.21.1
version: 0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
vue-tsc:
specifier: ^2.2.0
version: 2.2.12(typescript@5.6.3)
@ -1495,6 +1498,9 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
@ -1635,6 +1641,12 @@ packages:
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -1718,6 +1730,35 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
@ -2029,6 +2070,10 @@ packages:
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@ -2170,6 +2215,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -2613,6 +2662,9 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@ -2636,6 +2688,10 @@ packages:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
@ -3596,6 +3652,10 @@ packages:
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
engines: {node: '>= 0.4'}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@ -3726,6 +3786,9 @@ packages:
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pe-library@1.0.1:
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
engines: {node: '>=14', npm: '>=7'}
@ -4099,6 +4162,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -4179,6 +4245,12 @@ packages:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@ -4346,10 +4418,21 @@ packages:
tiny-each-async@2.0.3:
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
@ -4584,6 +4667,47 @@ packages:
yaml:
optional: true
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.9
'@vitest/browser-preview': 4.1.9
'@vitest/browser-webdriverio': 4.1.9
'@vitest/coverage-istanbul': 4.1.9
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@ -4704,6 +4828,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@ -6423,6 +6552,8 @@ snapshots:
'@sindresorhus/is@4.6.0': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
@ -6539,6 +6670,13 @@ snapshots:
'@types/node': 22.19.19
'@types/responselike': 1.0.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/estree@1.0.9': {}
@ -6627,6 +6765,47 @@ snapshots:
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
vue: 3.5.34(typescript@5.6.3)
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.9': {}
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
@ -6988,6 +7167,8 @@ snapshots:
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
async-function@1.0.0: {}
async@3.2.6: {}
@ -7151,6 +7332,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -7687,6 +7870,10 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {}
eta@3.5.0: {}
@ -7707,6 +7894,8 @@ snapshots:
signal-exit: 3.0.7
strip-eof: 1.0.0
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
external-editor@3.1.0:
@ -8648,6 +8837,8 @@ snapshots:
has-symbols: 1.1.0
object-keys: 1.1.1
obug@2.1.3: {}
ohash@2.0.11: {}
once@1.4.0:
@ -8766,6 +8957,8 @@ snapshots:
pathe@1.1.2: {}
pathe@2.0.3: {}
pe-library@1.0.1: {}
pend@1.2.0: {}
@ -9214,6 +9407,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@ -9290,6 +9485,10 @@ snapshots:
dependencies:
minipass: 3.3.6
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@ -9458,11 +9657,17 @@ snapshots:
tiny-each-async@2.0.3:
optional: true
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.5
@ -9674,6 +9879,33 @@ snapshots:
lightningcss: 1.32.0
terser: 5.48.0
vitest@4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.19
transitivePeerDependencies:
- msw
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
@ -9836,6 +10068,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5:
optional: true

View file

@ -71,6 +71,8 @@ const messages: LocaleMessages = {
past: 'Past',
filters: 'Filters',
clearAll: 'Clear all',
filteringBy: 'Filtering by:',
removeCategory: 'Remove {category} filter',
},
categories: {
concert: 'Concert',

View file

@ -71,6 +71,8 @@ const messages: LocaleMessages = {
past: 'Pasado',
filters: 'Filtros',
clearAll: 'Limpiar todo',
filteringBy: 'Filtrando por:',
removeCategory: 'Quitar el filtro {category}',
},
categories: {
concert: 'Concierto',

View file

@ -71,6 +71,8 @@ const messages: LocaleMessages = {
past: 'Passé',
filters: 'Filtres',
clearAll: 'Tout effacer',
filteringBy: 'Filtré par :',
removeCategory: 'Retirer le filtre {category}',
},
categories: {
concert: 'Concert',

View file

@ -72,6 +72,8 @@ export interface LocaleMessages {
past: string
filters: string
clearAll: string
filteringBy: string
removeCategory: string
}
categories: Record<string, string>
detail: {

View file

@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { monotonicCreatedAt } from './timestamp'
describe('monotonicCreatedAt', () => {
it('uses now when there is no prior version', () => {
expect(monotonicCreatedAt(null, 1000)).toBe(1000)
expect(monotonicCreatedAt(undefined, 1000)).toBe(1000)
})
it('bumps to prior+1 when republished in the same second', () => {
// now == last: a naive floor(Date.now()/1000) would tie and the relay
// would drop the update; we must produce a strictly newer stamp.
expect(monotonicCreatedAt(1000, 1000)).toBe(1001)
})
it('tracks wall-clock once enough real seconds have elapsed', () => {
expect(monotonicCreatedAt(1000, 1005)).toBe(1005)
})
it('steps past a future-dated prior (clock skew / rapid bursts)', () => {
expect(monotonicCreatedAt(2000, 1000)).toBe(2001)
})
it('is strictly increasing across a same-second burst', () => {
let last: number | null = null
const stamps: number[] = []
for (let i = 0; i < 5; i++) {
last = monotonicCreatedAt(last, 1000) // clock frozen at 1000
stamps.push(last)
}
expect(stamps).toEqual([1000, 1001, 1002, 1003, 1004])
for (let i = 1; i < stamps.length; i++) {
expect(stamps[i]).toBeGreaterThan(stamps[i - 1])
}
})
})

View file

@ -0,0 +1,30 @@
/**
* Monotonic `created_at` for replaceable / addressable Nostr events.
*
* Relays only push a replaceable update to OPEN subscriptions when its
* `created_at` is **strictly newer** than the version they already hold
* (verified against our relay). `created_at` is second-resolution, so a
* publisher that stamps `Math.floor(Date.now() / 1000)` can emit two
* versions within the same wall-clock second the relay treats the
* second as not-newer and never propagates it to live subscribers (it
* only surfaces on a reload / fresh REQ). This is exactly the failure
* seen with rapid bookmark toggles.
*
* Returning `max(now, lastCreatedAt + 1)` guarantees a strictly
* increasing timestamp across successive publishes of the same
* replaceable event, so each version reaches open subscriptions. When
* enough real seconds have elapsed it tracks wall-clock; only same-second
* (or clock-skewed) republishes get nudged forward.
*
* @param lastCreatedAt `created_at` of the previously published version
* (seconds), or null/undefined if none has been published yet.
* @param now Current time in **seconds** injectable for tests; defaults
* to `Math.floor(Date.now() / 1000)`.
*/
export function monotonicCreatedAt(
lastCreatedAt?: number | null,
now: number = Math.floor(Date.now() / 1000),
): number {
if (lastCreatedAt == null) return now
return Math.max(now, lastCreatedAt + 1)
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
DialogRoot,
DialogPortal,
@ -10,8 +11,10 @@ import {
DialogClose,
} from 'reka-ui'
import { X } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import EventCalendarView from './EventCalendarView.vue'
import type { Event } from '../types/event'
import type { EventCategory } from '../types/category'
// A date-picker popup: the month grid (with per-day event dots) in a
// dialog. Picking a day emits selectDate and closes. Reused by the feed
@ -21,23 +24,40 @@ import type { Event } from '../types/event'
// DialogContent) so it can use a light, blurred overlay instead of the
// usual opaque dark dim the feed stays visible, softly blurred, behind
// the frosted-glass panel.
const props = defineProps<{
open: boolean
events: Event[]
title: string
description: string
}>()
const props = withDefaults(
defineProps<{
open: boolean
events: Event[]
title: string
description: string
// Active category filter mirrored from the feed. Rendered as
// deselectable chips so the user can see and loosen what's
// narrowing the calendar without closing it. Defaults to none for
// callers that don't filter by category (e.g. My Tickets).
selectedCategories?: EventCategory[]
}>(),
{
selectedCategories: () => [],
},
)
const emit = defineEmits<{
'update:open': [value: boolean]
selectDate: [date: Date]
'toggle-category': [category: EventCategory]
}>()
const { t } = useI18n()
const isOpen = computed({
get: () => props.open,
set: (v) => emit('update:open', v),
})
function categoryLabel(cat: EventCategory): string {
return t(`events.categories.${cat}`, cat)
}
function onSelectDate(date: Date) {
emit('selectDate', date)
isOpen.value = false
@ -62,6 +82,29 @@ function onSelectDate(date: Date) {
{{ description }}
</DialogDescription>
</div>
<!-- Active category filter only the selected categories, each
removable. Clicking deselects via the parent's toggle, which
reactively re-narrows the calendar dots without closing. -->
<div
v-if="selectedCategories.length"
class="flex flex-wrap items-center gap-1.5"
>
<span class="text-xs text-muted-foreground">
{{ t('events.filters.filteringBy') }}
</span>
<Badge
v-for="cat in selectedCategories"
:key="cat"
variant="secondary"
class="cursor-pointer gap-1 text-xs select-none hover:opacity-80 transition-opacity"
:aria-label="t('events.filters.removeCategory', { category: categoryLabel(cat) })"
@click="emit('toggle-category', cat)"
>
{{ categoryLabel(cat) }}
<X class="w-3 h-3" />
</Badge>
</div>
<EventCalendarView :events="events" picker-mode @select-date="onSelectDate" />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none"

View file

@ -3,6 +3,7 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
/**
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
@ -21,12 +22,16 @@ interface BookmarkState {
bookmarkedCoords: Set<string>
/** The latest bookmark event we've seen */
lastEventId: string | null
/** `created_at` of the latest bookmark event used to publish a
* strictly-newer timestamp so relays push the update to open subs. */
lastCreatedAt: number | null
}
// Shared state across all component instances
const state = ref<BookmarkState>({
bookmarkedCoords: new Set(),
lastEventId: null,
lastCreatedAt: null,
})
const isLoaded = ref(false)
@ -65,7 +70,7 @@ export function useBookmarks() {
}],
onEvent: (event: NostrEvent) => {
// Only process if newer than what we have
if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return
if (state.value.lastCreatedAt != null && event.created_at <= state.value.lastCreatedAt) return
const coords = new Set<string>()
for (const tag of event.tags) {
@ -76,8 +81,8 @@ export function useBookmarks() {
state.value = {
bookmarkedCoords: coords,
lastEventId: event.id,
lastCreatedAt: event.created_at,
}
;(state.value as any).lastCreatedAt = event.created_at
isLoaded.value = true
},
onEose: () => {
@ -116,19 +121,25 @@ export function useBookmarks() {
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
// the real event is confirmed.
const prevState = state.value
state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId }
;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt
state.value = {
bookmarkedCoords: newCoords,
lastEventId: prevState.lastEventId,
lastCreatedAt: prevState.lastCreatedAt,
}
function rollback() {
state.value = prevState
}
// Build and publish updated bookmark list
// Build and publish updated bookmark list. Use a strictly-monotonic
// created_at so a same-second re-toggle still outranks the prior
// version and relays push it to open subscriptions (a bare
// floor(Date.now()/1000) can tie and be silently dropped).
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
const template: EventTemplate = {
kind: BOOKMARK_KIND,
created_at: Math.floor(Date.now() / 1000),
created_at: monotonicCreatedAt(prevState.lastCreatedAt),
content: '',
tags,
}
@ -150,8 +161,11 @@ export function useBookmarks() {
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
;(state.value as any).lastCreatedAt = template.created_at
state.value = {
bookmarkedCoords: newCoords,
lastEventId: signedEvent.id,
lastCreatedAt: template.created_at,
}
return true
}

View file

@ -19,36 +19,46 @@ export function useEventDetail(eventId: string) {
)
async function load() {
// Already in cache
if (event.value) return
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (!nostrService) {
error.value = 'Events service not available'
return
}
// Scope both the subscription and the one-shot query to this
// event's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the event is reachable.
const detailFilters = { dTags: [eventId] }
// Subscribe for LIVE updates regardless of cache state. NIP-52
// calendar events are replaceable, so when the events extension
// republishes after a ticket sells (updating tickets_sold /
// tickets_available — see events services.py), the new version
// arrives here and the reactive `event` (and its ticket counts)
// updates without a reload. Subscribing only on a cache miss meant
// arriving from the feed (event already cached) left the detail
// page with no live subscription, so counts went stale until reload.
if (unsubscribe) unsubscribe() // avoid leaking a sub if load() re-runs
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertEvent(incoming)
if (incoming.id === eventId) {
isLoading.value = false
}
},
detailFilters
)
// Already cached — the subscription above keeps it fresh; skip the
// one-shot query + loading state.
if (event.value) return
try {
isLoading.value = true
error.value = null
// Scope both the subscription and the one-shot query to this
// event's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the event is reachable.
const detailFilters = { dTags: [eventId] }
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertEvent(incoming)
if (incoming.id === eventId) {
isLoading.value = false
}
},
detailFilters
)
const results = await nostrService.queryCalendarEvents(detailFilters)
store.upsertEvents(results)

View file

@ -0,0 +1,120 @@
import { beforeEach, describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useEventsStore, eventCoordinate, eventKind } from './events'
import type { Event } from '../types/event'
// Minimal Event factory — only the fields the store touches matter; the
// rest are filled with inert defaults and cast to the full type.
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 'd-tag-1',
nostrEventId: 'nostr-id',
type: 'time',
organizer: { pubkey: 'pubkey-alice' },
title: 'Test Event',
description: '',
startDate: new Date('2026-07-01T18:00:00Z'),
tags: [],
isPrivate: false,
createdAt: new Date('2026-06-01T00:00:00Z'),
...overrides,
} as Event
}
describe('eventKind / eventCoordinate', () => {
it('maps date events to 31922 and time events to 31923', () => {
expect(eventKind(makeEvent({ type: 'date' }))).toBe(31922)
expect(eventKind(makeEvent({ type: 'time' }))).toBe(31923)
})
it('builds kind:pubkey:d-tag coordinates', () => {
const e = makeEvent({ type: 'time', id: 'abc', organizer: { pubkey: 'pk' } })
expect(eventCoordinate(e)).toBe('31923:pk:abc')
})
it('distinguishes same d-tag across authors', () => {
const a = makeEvent({ id: 'same', organizer: { pubkey: 'alice' } })
const b = makeEvent({ id: 'same', organizer: { pubkey: 'mallory' } })
expect(eventCoordinate(a)).not.toBe(eventCoordinate(b))
})
})
describe('useEventsStore.upsertEvent', () => {
beforeEach(() => setActivePinia(createPinia()))
it('keeps the newer version of the same coordinate', () => {
const store = useEventsStore()
const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' })
const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' })
store.upsertEvent(older)
store.upsertEvent(newer)
expect(store.events).toHaveLength(1)
expect(store.getEventById('d-tag-1')?.title).toBe('new')
})
it('ignores an older version of the same coordinate', () => {
const store = useEventsStore()
const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' })
const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' })
store.upsertEvent(newer)
store.upsertEvent(older)
expect(store.events).toHaveLength(1)
expect(store.getEventById('d-tag-1')?.title).toBe('new')
})
it('does NOT let a different author overwrite a same-d-tag event (cross-author hijack)', () => {
const store = useEventsStore()
const legit = makeEvent({
id: 'concert',
organizer: { pubkey: 'alice' },
title: 'Alice concert',
createdAt: new Date('2026-06-01T00:00:00Z'),
})
// Mallory republishes the same d-tag with a newer created_at — must
// NOT clobber Alice's event; both are kept under their own coordinate.
const impostor = makeEvent({
id: 'concert',
organizer: { pubkey: 'mallory' },
title: 'Mallory hijack',
createdAt: new Date('2026-06-10T00:00:00Z'),
})
store.upsertEvent(legit)
store.upsertEvent(impostor)
expect(store.events).toHaveLength(2)
expect(store.getByCoordinate('31923:alice:concert')?.title).toBe('Alice concert')
expect(store.getByCoordinate('31923:mallory:concert')?.title).toBe('Mallory hijack')
})
})
describe('useEventsStore lookups & removal', () => {
beforeEach(() => setActivePinia(createPinia()))
it('getEventById resolves by d-tag (route identifier)', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'party', organizer: { pubkey: 'alice' } }))
expect(store.getEventById('party')?.id).toBe('party')
expect(store.getEventById('missing')).toBeUndefined()
})
it('getEventById returns the newest when a d-tag is shared across authors', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' }, title: 'older', createdAt: new Date('2026-06-01') }))
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' }, title: 'newer', createdAt: new Date('2026-06-05') }))
expect(store.getEventById('x')?.title).toBe('newer')
})
it('removeEvent deletes every coordinate sharing the d-tag', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' } }))
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' } }))
expect(store.events).toHaveLength(2)
store.removeEvent('x')
expect(store.events).toHaveLength(0)
})
})

View file

@ -3,12 +3,37 @@ import { ref, computed } from 'vue'
import type { Event } from '../types/event'
import type { TicketedEvent } from '../types/ticket'
/** NIP-52 calendar event kinds. Date-based = 31922, time-based = 31923. */
export const EVENT_KIND_DATE = 31922
export const EVENT_KIND_TIME = 31923
/** The NIP-52 kind for an event, derived from its date/time type. */
export function eventKind(event: Pick<Event, 'type'>): number {
return event.type === 'date' ? EVENT_KIND_DATE : EVENT_KIND_TIME
}
/**
* Addressable-event coordinate `kind:pubkey:d-tag` (NIP-01 `a` tag form).
*
* NIP-52 calendar events are *addressable* (parameterized-replaceable):
* their d-tag is scoped to the **author**, so the replacement key MUST
* include the pubkey. Keying by the bare d-tag alone lets a different
* author publishing the same d-tag overwrite a legit event in the store.
* This mirrors NDK's `event.coordinate()` and welshman's `eventsByAddress`.
*/
export function eventCoordinate(
event: Pick<Event, 'type' | 'organizer' | 'id'>,
): string {
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`
}
/**
* Pinia store for cached events from Nostr relays.
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
* Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag).
*/
export const useEventsStore = defineStore('events', () => {
// State
// State — keyed by addressable coordinate, NOT bare d-tag, so two
// authors using the same d-tag are stored independently.
const eventsMap = ref<Map<string, Event>>(new Map())
const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null)
@ -43,14 +68,19 @@ export const useEventsStore = defineStore('events', () => {
/**
* Add or update an event in the store.
* Deduplicates by id (d-tag). Newer events replace older ones.
*
* Deduplicates by addressable coordinate (kind:pubkey:d-tag). A newer
* version (by `created_at`) replaces an older one *for the same
* coordinate only* a same-d-tag event from a different author lands
* under its own coordinate and never clobbers another author's event.
*/
function upsertEvent(event: Event) {
const existing = eventsMap.value.get(event.id)
const key = eventCoordinate(event)
const existing = eventsMap.value.get(key)
// Only update if this is a newer version
// Only update if this is a newer version of the same coordinate.
if (!existing || event.createdAt >= existing.createdAt) {
eventsMap.value.set(event.id, event)
eventsMap.value.set(key, event)
lastUpdated.value = new Date()
}
}
@ -65,10 +95,13 @@ export const useEventsStore = defineStore('events', () => {
}
/**
* Remove an event from the store.
* Remove an event by its d-tag. Deletes every stored coordinate whose
* d-tag matches (normally one our calendar events are single-publisher).
*/
function removeEvent(id: string) {
eventsMap.value.delete(id)
for (const [key, event] of eventsMap.value) {
if (event.id === id) eventsMap.value.delete(key)
}
}
/**
@ -80,10 +113,28 @@ export const useEventsStore = defineStore('events', () => {
}
/**
* Get a single event by its id (d-tag).
* Get a single event by its full addressable coordinate (kind:pubkey:d-tag).
* The precise, unambiguous lookup.
*/
function getByCoordinate(coordinate: string): Event | undefined {
return eventsMap.value.get(coordinate)
}
/**
* Get a single event by its d-tag (the route identifier).
*
* Calendar events in this app are single-publisher, so a d-tag resolves
* to one event in practice. If multiple authors ever share a d-tag, the
* newest (by `created_at`) wins deterministic rather than first-seen.
* Use {@link getByCoordinate} when the author is known.
*/
function getEventById(id: string): Event | undefined {
return eventsMap.value.get(id)
let match: Event | undefined
for (const event of eventsMap.value.values()) {
if (event.id !== id) continue
if (!match || event.createdAt >= match.createdAt) match = event
}
return match
}
return {
@ -104,6 +155,7 @@ export const useEventsStore = defineStore('events', () => {
upsertEvents,
removeEvent,
clearAll,
getByCoordinate,
getEventById,
}
})

View file

@ -61,6 +61,18 @@ const {
const filtersOpen = ref(false)
const calendarOpen = ref(false)
// Events feeding the calendar popup's per-day dots. Respects the active
// category filter (so the calendar reflects what the user is browsing),
// but not the temporal/day filters the calendar is for picking any
// date. No categories selected all events.
const calendarEvents = computed(() =>
selectedCategories.value.length
? allEvents.value.filter(
(e) => e.category && selectedCategories.value.includes(e.category),
)
: allEvents.value,
)
// Human label for the active day filter, shown as a removable chip.
const selectedDateLabel = computed(() =>
selectedDate.value
@ -255,10 +267,12 @@ onBeforeRouteLeave(() => {
day filters the feed to it and closes. -->
<EventCalendarPopup
v-model:open="calendarOpen"
:events="allEvents"
:events="calendarEvents"
:selected-categories="selectedCategories"
:title="t('events.nav.calendar', 'Calendar')"
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
@select-date="onSelectDate"
@toggle-category="toggleCategory"
/>
</div>
</template>

10
src/test/smoke.spec.ts Normal file
View file

@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
// Smoke test — proves the runner, TS transform and `@` alias resolve so
// the suite has a known-good baseline. Real coverage lives beside the
// code it tests as `*.spec.ts`.
describe('vitest smoke', () => {
it('runs', () => {
expect(1 + 1).toBe(2)
})
})

23
vitest.config.ts Normal file
View file

@ -0,0 +1,23 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
// Minimal test runner config. Unit tests live next to the code they
// cover as `*.spec.ts`. The default `node` environment is enough for
// the pure logic + Pinia/Vue-reactivity tests we run today (no DOM);
// switch a given file to jsdom via a per-file `// @vitest-environment`
// pragma if a component test ever needs it.
//
// Only the bare `@` → src alias is mirrored from vite.config.ts. The
// brand-kit aliases (@brand-*) are build-time asset shims that unit
// tests don't touch, so they're deliberately omitted to keep this lean.
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
},
resolve: {
alias: [
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
},
})