From db4c9b8bf3ed93c6bf6c1451803b269528d0b886 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 17 Jun 2026 19:06:52 +0200 Subject: [PATCH 1/7] feat(events): calendar popup respects the selected category filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/modules/events/views/EventsPage.vue | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/modules/events/views/EventsPage.vue b/src/modules/events/views/EventsPage.vue index 121941e..7ffc44d 100644 --- a/src/modules/events/views/EventsPage.vue +++ b/src/modules/events/views/EventsPage.vue @@ -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,7 +267,7 @@ onBeforeRouteLeave(() => { day filters the feed to it and closes. --> Date: Thu, 18 Jun 2026 13:25:34 +0200 Subject: [PATCH 2/7] chore(test): add vitest runner + smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 3 + pnpm-lock.yaml | 237 +++++++++++++++++++++++++++++++++++++++++ src/test/smoke.spec.ts | 10 ++ vitest.config.ts | 23 ++++ 4 files changed, 273 insertions(+) create mode 100644 src/test/smoke.spec.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 439cb84..d40f262 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c242e32..f531eb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/test/smoke.spec.ts b/src/test/smoke.spec.ts new file mode 100644 index 0000000..becd2bc --- /dev/null +++ b/src/test/smoke.spec.ts @@ -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) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fca1d7e --- /dev/null +++ b/vitest.config.ts @@ -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)) }, + ], + }, +}) From 4b3b905225a442ca4b7b052fda127a9e972be46b Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 13:28:18 +0200 Subject: [PATCH 3/7] fix(events): key the events store by addressable coordinate (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/modules/events/stores/events.spec.ts | 120 +++++++++++++++++++++++ src/modules/events/stores/events.ts | 72 ++++++++++++-- 2 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 src/modules/events/stores/events.spec.ts diff --git a/src/modules/events/stores/events.spec.ts b/src/modules/events/stores/events.spec.ts new file mode 100644 index 0000000..e6e90e7 --- /dev/null +++ b/src/modules/events/stores/events.spec.ts @@ -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 { + 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) + }) +}) diff --git a/src/modules/events/stores/events.ts b/src/modules/events/stores/events.ts index 97ab88b..7a4d437 100644 --- a/src/modules/events/stores/events.ts +++ b/src/modules/events/stores/events.ts @@ -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): 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, +): 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>(new Map()) const isLoading = ref(false) const lastUpdated = ref(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, } }) From c6f626df0813016a75967979527f8a8745e0dbd2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 13:30:29 +0200 Subject: [PATCH 4/7] fix(events): publish bookmarks with monotonic created_at (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/nostr/timestamp.spec.ts | 36 +++++++++++++++++++ src/lib/nostr/timestamp.ts | 30 ++++++++++++++++ .../events/composables/useBookmarks.ts | 30 +++++++++++----- 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/lib/nostr/timestamp.spec.ts create mode 100644 src/lib/nostr/timestamp.ts diff --git a/src/lib/nostr/timestamp.spec.ts b/src/lib/nostr/timestamp.spec.ts new file mode 100644 index 0000000..3e5c099 --- /dev/null +++ b/src/lib/nostr/timestamp.spec.ts @@ -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]) + } + }) +}) diff --git a/src/lib/nostr/timestamp.ts b/src/lib/nostr/timestamp.ts new file mode 100644 index 0000000..62bc899 --- /dev/null +++ b/src/lib/nostr/timestamp.ts @@ -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) +} diff --git a/src/modules/events/composables/useBookmarks.ts b/src/modules/events/composables/useBookmarks.ts index 57281a9..be72cae 100644 --- a/src/modules/events/composables/useBookmarks.ts +++ b/src/modules/events/composables/useBookmarks.ts @@ -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 /** 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({ 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() 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 } From 2febf0926da2162d255af21cd8febc5974ed5b08 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 13:33:15 +0200 Subject: [PATCH 5/7] docs(nostr-patterns): point monotonic created_at at the shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/nostr-patterns/replaceable-events.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/nostr-patterns/replaceable-events.md b/docs/nostr-patterns/replaceable-events.md index 0cad379..f02158b 100644 --- a/docs/nostr-patterns/replaceable-events.md +++ b/docs/nostr-patterns/replaceable-events.md @@ -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` 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() -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 ``` From 3514d934511c3de05ed7333434a2f56524869221 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 14:31:39 +0200 Subject: [PATCH 6/7] feat(events): show selected categories as deselectable chips in calendar popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/i18n/locales/en.ts | 2 + src/i18n/locales/es.ts | 2 + src/i18n/locales/fr.ts | 2 + src/i18n/types.ts | 2 + .../events/components/EventCalendarPopup.vue | 55 +++++++++++++++++-- src/modules/events/views/EventsPage.vue | 2 + 6 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 34f4bc6..cacfb8c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -71,6 +71,8 @@ const messages: LocaleMessages = { past: 'Past', filters: 'Filters', clearAll: 'Clear all', + filteringBy: 'Filtering by:', + removeCategory: 'Remove {category} filter', }, categories: { concert: 'Concert', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 048c71a..898352b 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 57b3797..9638e69 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index f4177ba..ecdf42f 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -72,6 +72,8 @@ export interface LocaleMessages { past: string filters: string clearAll: string + filteringBy: string + removeCategory: string } categories: Record detail: { diff --git a/src/modules/events/components/EventCalendarPopup.vue b/src/modules/events/components/EventCalendarPopup.vue index 955d805..546c16f 100644 --- a/src/modules/events/components/EventCalendarPopup.vue +++ b/src/modules/events/components/EventCalendarPopup.vue @@ -1,5 +1,6 @@