Compare commits
8 commits
dce55f0c0c
...
8ade942c32
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ade942c32 | |||
| 83ea3e609c | |||
| 3514d93451 | |||
| 2febf0926d | |||
| c6f626df08 | |||
| 4b3b905225 | |||
| 327092c022 | |||
| db4c9b8bf3 |
16 changed files with 624 additions and 30 deletions
|
|
@ -7,15 +7,19 @@ in this file follows from that single fact.
|
||||||
|
|
||||||
## Strictly-monotonic `created_at` per coord
|
## Strictly-monotonic `created_at` per coord
|
||||||
|
|
||||||
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
**Canonical helper:** `src/lib/nostr/timestamp.ts` —
|
||||||
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
|
`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
|
```ts
|
||||||
|
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
||||||
|
|
||||||
const lastPublishAt = new Map<string, number>()
|
const lastPublishAt = new Map<string, number>()
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const createdAt = monotonicCreatedAt(lastPublishAt.get(coord))
|
||||||
const previous = lastPublishAt.get(coord) ?? 0
|
|
||||||
const createdAt = Math.max(now, previous + 1)
|
|
||||||
…
|
…
|
||||||
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
|
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview --host",
|
"preview": "vite preview --host",
|
||||||
"analyze": "vite build --mode analyze",
|
"analyze": "vite build --mode analyze",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"dev:events": "vite --host --config vite.events.config.ts",
|
"dev:events": "vite --host --config vite.events.config.ts",
|
||||||
"build:events": "vue-tsc -b && vite build --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",
|
"preview:events": "vite preview --host --config vite.events.config.ts",
|
||||||
|
|
@ -107,6 +109,7 @@
|
||||||
"vite-plugin-image-optimizer": "^1.1.7",
|
"vite-plugin-image-optimizer": "^1.1.7",
|
||||||
"vite-plugin-inspect": "^0.8.3",
|
"vite-plugin-inspect": "^0.8.3",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
|
"vitest": "^4.1.9",
|
||||||
"vue-tsc": "^2.2.0",
|
"vue-tsc": "^2.2.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
|
|
||||||
237
pnpm-lock.yaml
generated
237
pnpm-lock.yaml
generated
|
|
@ -192,6 +192,9 @@ importers:
|
||||||
vite-plugin-pwa:
|
vite-plugin-pwa:
|
||||||
specifier: ^0.21.1
|
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)
|
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:
|
vue-tsc:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.12(typescript@5.6.3)
|
version: 2.2.12(typescript@5.6.3)
|
||||||
|
|
@ -1495,6 +1498,9 @@ packages:
|
||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0':
|
||||||
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.21':
|
'@swc/helpers@0.5.21':
|
||||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||||
|
|
||||||
|
|
@ -1635,6 +1641,12 @@ packages:
|
||||||
'@types/cacheable-request@6.0.3':
|
'@types/cacheable-request@6.0.3':
|
||||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
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':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
|
@ -1718,6 +1730,35 @@ packages:
|
||||||
vite: ^5.0.0 || ^6.0.0
|
vite: ^5.0.0 || ^6.0.0
|
||||||
vue: ^3.2.25
|
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':
|
'@volar/language-core@2.4.15':
|
||||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||||
|
|
||||||
|
|
@ -2029,6 +2070,10 @@ packages:
|
||||||
asn1.js@5.4.1:
|
asn1.js@5.4.1:
|
||||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
async-function@1.0.0:
|
async-function@1.0.0:
|
||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2170,6 +2215,10 @@ packages:
|
||||||
caniuse-lite@1.0.30001793:
|
caniuse-lite@1.0.30001793:
|
||||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||||
|
|
||||||
|
chai@6.2.2:
|
||||||
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2613,6 +2662,9 @@ packages:
|
||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
esutils@2.0.3:
|
esutils@2.0.3:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2636,6 +2688,10 @@ packages:
|
||||||
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
exponential-backoff@3.1.3:
|
||||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||||
|
|
||||||
|
|
@ -3596,6 +3652,10 @@ packages:
|
||||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
obug@2.1.3:
|
||||||
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
ohash@2.0.11:
|
ohash@2.0.11:
|
||||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
|
|
@ -3726,6 +3786,9 @@ packages:
|
||||||
pathe@1.1.2:
|
pathe@1.1.2:
|
||||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
pe-library@1.0.1:
|
pe-library@1.0.1:
|
||||||
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
|
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
|
||||||
engines: {node: '>=14', npm: '>=7'}
|
engines: {node: '>=14', npm: '>=7'}
|
||||||
|
|
@ -4099,6 +4162,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
signal-exit@3.0.7:
|
signal-exit@3.0.7:
|
||||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||||
|
|
||||||
|
|
@ -4179,6 +4245,12 @@ packages:
|
||||||
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
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:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -4346,10 +4418,21 @@ packages:
|
||||||
tiny-each-async@2.0.3:
|
tiny-each-async@2.0.3:
|
||||||
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
|
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:
|
tinyglobby@0.2.16:
|
||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
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:
|
tmp-promise@3.0.3:
|
||||||
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||||
|
|
||||||
|
|
@ -4584,6 +4667,47 @@ packages:
|
||||||
yaml:
|
yaml:
|
||||||
optional: true
|
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:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
|
@ -4704,6 +4828,11 @@ packages:
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -6423,6 +6552,8 @@ snapshots:
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
'@sindresorhus/is@4.6.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.21':
|
'@swc/helpers@0.5.21':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -6539,6 +6670,13 @@ snapshots:
|
||||||
'@types/node': 22.19.19
|
'@types/node': 22.19.19
|
||||||
'@types/responselike': 1.0.3
|
'@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.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@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)
|
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)
|
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':
|
'@volar/language-core@2.4.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 2.4.15
|
'@volar/source-map': 2.4.15
|
||||||
|
|
@ -6988,6 +7167,8 @@ snapshots:
|
||||||
minimalistic-assert: 1.0.1
|
minimalistic-assert: 1.0.1
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
@ -7151,6 +7332,8 @@ snapshots:
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793: {}
|
caniuse-lite@1.0.30001793: {}
|
||||||
|
|
||||||
|
chai@6.2.2: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|
@ -7687,6 +7870,10 @@ snapshots:
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
eta@3.5.0: {}
|
eta@3.5.0: {}
|
||||||
|
|
@ -7707,6 +7894,8 @@ snapshots:
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
strip-eof: 1.0.0
|
strip-eof: 1.0.0
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
exponential-backoff@3.1.3: {}
|
exponential-backoff@3.1.3: {}
|
||||||
|
|
||||||
external-editor@3.1.0:
|
external-editor@3.1.0:
|
||||||
|
|
@ -8648,6 +8837,8 @@ snapshots:
|
||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
|
|
||||||
|
obug@2.1.3: {}
|
||||||
|
|
||||||
ohash@2.0.11: {}
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
|
|
@ -8766,6 +8957,8 @@ snapshots:
|
||||||
|
|
||||||
pathe@1.1.2: {}
|
pathe@1.1.2: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
pe-library@1.0.1: {}
|
pe-library@1.0.1: {}
|
||||||
|
|
||||||
pend@1.2.0: {}
|
pend@1.2.0: {}
|
||||||
|
|
@ -9214,6 +9407,8 @@ snapshots:
|
||||||
side-channel-map: 1.0.1
|
side-channel-map: 1.0.1
|
||||||
side-channel-weakmap: 1.0.2
|
side-channel-weakmap: 1.0.2
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
signal-exit@3.0.7: {}
|
signal-exit@3.0.7: {}
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
@ -9290,6 +9485,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 3.3.6
|
minipass: 3.3.6
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@4.1.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
@ -9458,11 +9657,17 @@ snapshots:
|
||||||
tiny-each-async@2.0.3:
|
tiny-each-async@2.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@1.2.4: {}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0: {}
|
||||||
|
|
||||||
tmp-promise@3.0.3:
|
tmp-promise@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
tmp: 0.2.5
|
tmp: 0.2.5
|
||||||
|
|
@ -9674,6 +9879,33 @@ snapshots:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
terser: 5.48.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: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
|
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
|
||||||
|
|
@ -9836,6 +10068,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
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:
|
word-wrap@1.2.5:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ const messages: LocaleMessages = {
|
||||||
past: 'Past',
|
past: 'Past',
|
||||||
filters: 'Filters',
|
filters: 'Filters',
|
||||||
clearAll: 'Clear all',
|
clearAll: 'Clear all',
|
||||||
|
filteringBy: 'Filtering by:',
|
||||||
|
removeCategory: 'Remove {category} filter',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ const messages: LocaleMessages = {
|
||||||
past: 'Pasado',
|
past: 'Pasado',
|
||||||
filters: 'Filtros',
|
filters: 'Filtros',
|
||||||
clearAll: 'Limpiar todo',
|
clearAll: 'Limpiar todo',
|
||||||
|
filteringBy: 'Filtrando por:',
|
||||||
|
removeCategory: 'Quitar el filtro {category}',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concierto',
|
concert: 'Concierto',
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ const messages: LocaleMessages = {
|
||||||
past: 'Passé',
|
past: 'Passé',
|
||||||
filters: 'Filtres',
|
filters: 'Filtres',
|
||||||
clearAll: 'Tout effacer',
|
clearAll: 'Tout effacer',
|
||||||
|
filteringBy: 'Filtré par :',
|
||||||
|
removeCategory: 'Retirer le filtre {category}',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ export interface LocaleMessages {
|
||||||
past: string
|
past: string
|
||||||
filters: string
|
filters: string
|
||||||
clearAll: string
|
clearAll: string
|
||||||
|
filteringBy: string
|
||||||
|
removeCategory: string
|
||||||
}
|
}
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
|
|
|
||||||
36
src/lib/nostr/timestamp.spec.ts
Normal file
36
src/lib/nostr/timestamp.spec.ts
Normal 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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
30
src/lib/nostr/timestamp.ts
Normal file
30
src/lib/nostr/timestamp.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
DialogRoot,
|
DialogRoot,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
|
|
@ -10,8 +11,10 @@ import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import EventCalendarView from './EventCalendarView.vue'
|
import EventCalendarView from './EventCalendarView.vue'
|
||||||
import type { Event } from '../types/event'
|
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
|
// 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
|
// 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
|
// DialogContent) so it can use a light, blurred overlay instead of the
|
||||||
// usual opaque dark dim — the feed stays visible, softly blurred, behind
|
// usual opaque dark dim — the feed stays visible, softly blurred, behind
|
||||||
// the frosted-glass panel.
|
// the frosted-glass panel.
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
open: boolean
|
defineProps<{
|
||||||
events: Event[]
|
open: boolean
|
||||||
title: string
|
events: Event[]
|
||||||
description: string
|
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<{
|
const emit = defineEmits<{
|
||||||
'update:open': [value: boolean]
|
'update:open': [value: boolean]
|
||||||
selectDate: [date: Date]
|
selectDate: [date: Date]
|
||||||
|
'toggle-category': [category: EventCategory]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
get: () => props.open,
|
get: () => props.open,
|
||||||
set: (v) => emit('update:open', v),
|
set: (v) => emit('update:open', v),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function categoryLabel(cat: EventCategory): string {
|
||||||
|
return t(`events.categories.${cat}`, cat)
|
||||||
|
}
|
||||||
|
|
||||||
function onSelectDate(date: Date) {
|
function onSelectDate(date: Date) {
|
||||||
emit('selectDate', date)
|
emit('selectDate', date)
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
|
|
@ -62,6 +82,29 @@ function onSelectDate(date: Date) {
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</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" />
|
<EventCalendarView :events="events" picker-mode @select-date="onSelectDate" />
|
||||||
<DialogClose
|
<DialogClose
|
||||||
class="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
|
class="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
|
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
||||||
|
|
@ -21,12 +22,16 @@ interface BookmarkState {
|
||||||
bookmarkedCoords: Set<string>
|
bookmarkedCoords: Set<string>
|
||||||
/** The latest bookmark event we've seen */
|
/** The latest bookmark event we've seen */
|
||||||
lastEventId: string | null
|
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
|
// Shared state across all component instances
|
||||||
const state = ref<BookmarkState>({
|
const state = ref<BookmarkState>({
|
||||||
bookmarkedCoords: new Set(),
|
bookmarkedCoords: new Set(),
|
||||||
lastEventId: null,
|
lastEventId: null,
|
||||||
|
lastCreatedAt: null,
|
||||||
})
|
})
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
|
|
@ -65,7 +70,7 @@ export function useBookmarks() {
|
||||||
}],
|
}],
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
// Only process if newer than what we have
|
// 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>()
|
const coords = new Set<string>()
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
|
|
@ -76,8 +81,8 @@ export function useBookmarks() {
|
||||||
state.value = {
|
state.value = {
|
||||||
bookmarkedCoords: coords,
|
bookmarkedCoords: coords,
|
||||||
lastEventId: event.id,
|
lastEventId: event.id,
|
||||||
|
lastCreatedAt: event.created_at,
|
||||||
}
|
}
|
||||||
;(state.value as any).lastCreatedAt = event.created_at
|
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
|
|
@ -116,19 +121,25 @@ export function useBookmarks() {
|
||||||
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
|
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
|
||||||
// the real event is confirmed.
|
// the real event is confirmed.
|
||||||
const prevState = state.value
|
const prevState = state.value
|
||||||
state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId }
|
state.value = {
|
||||||
;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt
|
bookmarkedCoords: newCoords,
|
||||||
|
lastEventId: prevState.lastEventId,
|
||||||
|
lastCreatedAt: prevState.lastCreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
function rollback() {
|
function rollback() {
|
||||||
state.value = prevState
|
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 tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
||||||
|
|
||||||
const template: EventTemplate = {
|
const template: EventTemplate = {
|
||||||
kind: BOOKMARK_KIND,
|
kind: BOOKMARK_KIND,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: monotonicCreatedAt(prevState.lastCreatedAt),
|
||||||
content: '',
|
content: '',
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
|
|
@ -150,8 +161,11 @@ export function useBookmarks() {
|
||||||
|
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
if (result.success > 0) {
|
if (result.success > 0) {
|
||||||
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
|
state.value = {
|
||||||
;(state.value as any).lastCreatedAt = template.created_at
|
bookmarkedCoords: newCoords,
|
||||||
|
lastEventId: signedEvent.id,
|
||||||
|
lastCreatedAt: template.created_at,
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
src/modules/events/stores/events.spec.ts
Normal file
120
src/modules/events/stores/events.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,12 +3,37 @@ import { ref, computed } from 'vue'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
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.
|
* 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', () => {
|
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 eventsMap = ref<Map<string, Event>>(new Map())
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const lastUpdated = ref<Date | null>(null)
|
const lastUpdated = ref<Date | null>(null)
|
||||||
|
|
@ -43,14 +68,19 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an event in the store.
|
* 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) {
|
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) {
|
if (!existing || event.createdAt >= existing.createdAt) {
|
||||||
eventsMap.value.set(event.id, event)
|
eventsMap.value.set(key, event)
|
||||||
lastUpdated.value = new Date()
|
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) {
|
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 {
|
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 {
|
return {
|
||||||
|
|
@ -104,6 +155,7 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
upsertEvents,
|
upsertEvents,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
clearAll,
|
clearAll,
|
||||||
|
getByCoordinate,
|
||||||
getEventById,
|
getEventById,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,18 @@ const {
|
||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
const calendarOpen = 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.
|
// Human label for the active day filter, shown as a removable chip.
|
||||||
const selectedDateLabel = computed(() =>
|
const selectedDateLabel = computed(() =>
|
||||||
selectedDate.value
|
selectedDate.value
|
||||||
|
|
@ -255,10 +267,12 @@ onBeforeRouteLeave(() => {
|
||||||
day filters the feed to it and closes. -->
|
day filters the feed to it and closes. -->
|
||||||
<EventCalendarPopup
|
<EventCalendarPopup
|
||||||
v-model:open="calendarOpen"
|
v-model:open="calendarOpen"
|
||||||
:events="allEvents"
|
:events="calendarEvents"
|
||||||
|
:selected-categories="selectedCategories"
|
||||||
:title="t('events.nav.calendar', 'Calendar')"
|
:title="t('events.nav.calendar', 'Calendar')"
|
||||||
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
|
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
|
||||||
@select-date="onSelectDate"
|
@select-date="onSelectDate"
|
||||||
|
@toggle-category="toggleCategory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
10
src/test/smoke.spec.ts
Normal file
10
src/test/smoke.spec.ts
Normal 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
23
vitest.config.ts
Normal 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)) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue