mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips: - Dexie IndexedDB schema (trips, places, packing, todo, budget, reservations, files, mutationQueue, syncMeta, blobCache) - Repo layer for all domains: offline reads from Dexie, writes optimistically to Dexie and enqueue mutations for later replay - Mutation queue with UUID idempotency keys (X-Idempotency-Key), FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx - Trip sync manager: caches all trips with end_date >= today or null, auto-evicts 7d after end_date, fetches bundle endpoint in one request - Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap, warms SW cache via fetch - Sync triggers: network online → flush + syncAll; WS reconnect → flush only (rate-limiter safe); visibilitychange/30s → flush only - WS remoteEventHandler writes through to Dexie on every event - Server idempotency middleware + idempotency_keys table (migration 100, 24h TTL nightly cleanup) - GET /api/trips/:id/bundle endpoint for efficient single-request sync - OfflineBanner component: amber (offline) / blue (syncing) / hidden - OfflineTab in Settings: cached trip list, re-sync and clear actions - usePendingMutations hook for per-item pending indicators Closes #505 #541
This commit is contained in:
Generated
+168
-74
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"marked": "^18.0.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
@@ -100,9 +102,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz",
|
||||
"integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==",
|
||||
"version": "5.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz",
|
||||
"integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1737,9 +1739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1761,9 +1763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
|
||||
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1778,7 +1780,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.1.1"
|
||||
"@csstools/css-calc": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -1812,9 +1814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz",
|
||||
"integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2364,6 +2366,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2381,6 +2386,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2398,6 +2406,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2415,6 +2426,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2432,6 +2446,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2449,6 +2466,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2466,6 +2486,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2489,6 +2512,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2512,6 +2538,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2535,6 +2564,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2558,6 +2590,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2581,6 +2616,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2794,9 +2832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3020,16 +3058,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.5.0.tgz",
|
||||
"integrity": "sha512-XlGLzcZFdEYQHK6b9z9uPOVywbjv6pQ92D4RvqRmYNjpf7lQLnhdHr63tbiF7fB5k8Lg9/lGBXkHbzkeQW5geQ==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.5.1.tgz",
|
||||
"integrity": "sha512-1V8ssgg9FHVsmvuCKmp7TWoUiPGgxAR2cgyvdcao8UQm7emWB7rP1o4CieHH56kgZyXXbwWqQAmmtgvcju+xfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/image": "^3.0.4",
|
||||
"@react-pdf/primitives": "^4.2.0",
|
||||
"@react-pdf/stylesheet": "^6.1.4",
|
||||
"@react-pdf/textkit": "^6.1.1",
|
||||
"@react-pdf/textkit": "^6.2.0",
|
||||
"@react-pdf/types": "^2.10.0",
|
||||
"emoji-regex-xs": "^1.0.0",
|
||||
"queue": "^6.0.1",
|
||||
@@ -3083,15 +3121,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.4.0.tgz",
|
||||
"integrity": "sha512-+gGa9ymGosN6Ld3hFFSIVCV03Vva5S+asW7vmKetZY4LnhX5ea2gYNy6wL3e9VsLHqFDAefIXGtGDLH6XxQHag==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.4.1.tgz",
|
||||
"integrity": "sha512-TBaEw6F+IBI4oVHUF7LL2OJX87unRrk6r7mkEmgjehN9BV5LF53I8CzVtdAchuO1+YhvE4MoMzkNelA+X2luRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/primitives": "^4.2.0",
|
||||
"@react-pdf/textkit": "^6.1.1",
|
||||
"@react-pdf/textkit": "^6.2.0",
|
||||
"@react-pdf/types": "^2.10.0",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^1.9.1",
|
||||
@@ -3101,19 +3139,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.4.0.tgz",
|
||||
"integrity": "sha512-TtCcz1vyYD6fddhvBagwr9Aj3gRFLCqvduERQyNhTzHJi3zAKayHvJFr2PxcP4sRyIDRhibDW6ApqNhTqIVPoQ==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.4.1.tgz",
|
||||
"integrity": "sha512-mK7xyCdDUagO1kg8jraad3aUzdVAGBru08qyjjp8FMhGsh4BcuPGa0SycQ8Pv8EDEdyEOfmiE+XI1sBybSLwaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
"@react-pdf/font": "^4.0.6",
|
||||
"@react-pdf/layout": "^4.5.0",
|
||||
"@react-pdf/layout": "^4.5.1",
|
||||
"@react-pdf/pdfkit": "^5.0.0",
|
||||
"@react-pdf/primitives": "^4.2.0",
|
||||
"@react-pdf/reconciler": "^2.0.0",
|
||||
"@react-pdf/render": "^4.4.0",
|
||||
"@react-pdf/render": "^4.4.1",
|
||||
"@react-pdf/types": "^2.10.0",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
@@ -3139,9 +3177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.1.tgz",
|
||||
"integrity": "sha512-HAHoa407q0UHLzwe/oL6VwgJj2cGKs5vORSVY+cRG/GC0kt7nxUV9N+2hA6VcqJA37gSRg7BTLsVr8Tt+4l5ow==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.2.0.tgz",
|
||||
"integrity": "sha512-0B22Kue/ALHiEcYNbrx2BdkpHPTq2j3u2xmAyCnf3XJbTyANjljJjtWRohkVLQKqOlieD88BvmQt7OeWLj+ZYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.3",
|
||||
@@ -3360,6 +3398,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3374,6 +3415,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3388,6 +3432,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3402,6 +3449,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3416,6 +3466,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3430,6 +3483,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3444,6 +3500,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3458,6 +3517,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3472,6 +3534,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3486,6 +3551,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3500,6 +3568,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3514,6 +3585,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3528,6 +3602,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4324,9 +4401,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4344,8 +4421,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001774",
|
||||
"browserslist": "^4.28.2",
|
||||
"caniuse-lite": "^1.0.30001787",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -4470,9 +4547,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.17",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
|
||||
"integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==",
|
||||
"version": "2.10.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
|
||||
"integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -4659,9 +4736,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001787",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
||||
"integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
|
||||
"version": "1.0.30001788",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
|
||||
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5284,6 +5361,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dexie": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
|
||||
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
@@ -5350,9 +5433,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.334",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
|
||||
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
|
||||
"version": "1.5.336",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz",
|
||||
"integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5637,6 +5720,16 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -5737,9 +5830,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5773,9 +5866,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6078,9 +6171,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7094,9 +7187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
|
||||
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -8333,9 +8426,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msw": {
|
||||
"version": "2.13.2",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.13.2.tgz",
|
||||
"integrity": "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==",
|
||||
"version": "2.13.3",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz",
|
||||
"integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -8351,7 +8444,7 @@
|
||||
"outvariant": "^1.4.3",
|
||||
"path-to-regexp": "^6.3.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"rettime": "^0.10.1",
|
||||
"rettime": "^0.11.7",
|
||||
"statuses": "^2.0.2",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"tough-cookie": "^6.0.0",
|
||||
@@ -9388,12 +9481,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
@@ -9415,9 +9509,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rettime": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
|
||||
"integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==",
|
||||
"version": "0.11.7",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz",
|
||||
"integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -10832,9 +10926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11692,9 +11786,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/lru-cache": {
|
||||
"version": "11.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
|
||||
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"marked": "^18.0.0",
|
||||
@@ -44,6 +45,7 @@
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
|
||||
@@ -22,6 +22,8 @@ import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -146,6 +148,11 @@ export default function App() {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
@@ -178,6 +185,7 @@ export default function App() {
|
||||
return (
|
||||
<TranslationProvider>
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -38,13 +38,25 @@ export const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add socket ID
|
||||
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
@@ -161,6 +173,7 @@ export const tripsApi = {
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
|
||||
@@ -13,6 +13,8 @@ let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
|
||||
let preReconnectHook: (() => Promise<void>) | null = null
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook that runs (and is awaited) before the refetch callback
|
||||
* fires on WS reconnect. Use this to flush the mutation queue so queued
|
||||
* local writes reach the server before the app reads back canonical state.
|
||||
* Pass null to clear.
|
||||
*/
|
||||
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
|
||||
preReconnectHook = fn
|
||||
}
|
||||
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
})
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
const doRefetch = () => {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Flush queued mutations first so local writes land before server read-back.
|
||||
// If the hook fails, still refetch to keep the UI correct.
|
||||
if (preReconnectHook) {
|
||||
preReconnectHook().catch(console.error).then(doRefetch)
|
||||
} else {
|
||||
doRefetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
||||
*
|
||||
* States:
|
||||
* offline + N queued → amber bar "Offline — N changes queued"
|
||||
* offline + 0 queued → amber bar "Offline"
|
||||
* online + N pending → blue bar "Syncing N changes…"
|
||||
* online + 0 pending → hidden
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const n = await mutationQueue.pendingCount()
|
||||
if (!cancelled) setPendingCount(n)
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
const bg = offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={14} />
|
||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import type { SyncMeta } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
|
||||
return (
|
||||
<Section title="Offline Cache" icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label="Cached trips" value={rows.length} />
|
||||
<Stat label="Pending changes" value={pendingCount} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing || !navigator.onLine}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
cursor: syncing || !navigator.onLine ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: !navigator.onLine ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? 'Syncing…' : 'Re-sync now'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: '#ef4444',
|
||||
cursor: clearing || rows.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: rows.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Clear cache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
No trips cached yet. Connect to internet to sync.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{trip.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', minWidth: 100,
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>{value}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types';
|
||||
|
||||
// ── Queue + sync types ────────────────────────────────────────────────────────
|
||||
|
||||
export type MutationStatus = 'pending' | 'syncing' | 'failed';
|
||||
|
||||
export interface QueuedMutation {
|
||||
/** UUID — also used as X-Idempotency-Key sent to the server */
|
||||
id: string;
|
||||
tripId: number;
|
||||
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
url: string;
|
||||
body: unknown;
|
||||
createdAt: number;
|
||||
status: MutationStatus;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
/** Dexie table name to write the server response into after flush (e.g. 'places') */
|
||||
resource?: string;
|
||||
/** For CREATE mutations enqueued offline: the temporary negative id written to Dexie */
|
||||
tempId?: number;
|
||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||
entityId?: number;
|
||||
}
|
||||
|
||||
export interface SyncMeta {
|
||||
tripId: number;
|
||||
lastSyncedAt: number | null;
|
||||
status: 'idle' | 'syncing' | 'error';
|
||||
/** Bounding box [minLng, minLat, maxLng, maxLat] of pre-downloaded map tiles */
|
||||
tilesBbox: [number, number, number, number] | null;
|
||||
filesCachedCount: number;
|
||||
}
|
||||
|
||||
export interface BlobCacheEntry {
|
||||
/** Relative URL, e.g. "/api/files/42/download" */
|
||||
url: string;
|
||||
blob: Blob;
|
||||
mime: string;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TrekOfflineDb extends Dexie {
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
places!: Table<Place, number>;
|
||||
packingItems!: Table<PackingItem, number>;
|
||||
todoItems!: Table<TodoItem, number>;
|
||||
budgetItems!: Table<BudgetItem, number>;
|
||||
reservations!: Table<Reservation, number>;
|
||||
tripFiles!: Table<TripFile, number>;
|
||||
mutationQueue!: Table<QueuedMutation, string>;
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
|
||||
constructor() {
|
||||
super('trek-offline');
|
||||
|
||||
this.version(1).stores({
|
||||
trips: 'id',
|
||||
days: 'id, trip_id',
|
||||
places: 'id, trip_id',
|
||||
packingItems: 'id, trip_id',
|
||||
todoItems: 'id, trip_id',
|
||||
budgetItems: 'id, trip_id',
|
||||
reservations: 'id, trip_id',
|
||||
tripFiles: 'id, trip_id',
|
||||
mutationQueue:'id, tripId, status, createdAt',
|
||||
syncMeta: 'tripId',
|
||||
blobCache: 'url, cachedAt',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const offlineDb = new TrekOfflineDb();
|
||||
|
||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertTrip(trip: Trip): Promise<void> {
|
||||
await offlineDb.trips.put(trip);
|
||||
}
|
||||
|
||||
export async function upsertDays(days: Day[]): Promise<void> {
|
||||
await offlineDb.days.bulkPut(days);
|
||||
}
|
||||
|
||||
export async function upsertPlaces(places: Place[]): Promise<void> {
|
||||
await offlineDb.places.bulkPut(places);
|
||||
}
|
||||
|
||||
export async function upsertPackingItems(items: PackingItem[]): Promise<void> {
|
||||
await offlineDb.packingItems.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertTodoItems(items: TodoItem[]): Promise<void> {
|
||||
await offlineDb.todoItems.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertBudgetItems(items: BudgetItem[]): Promise<void> {
|
||||
await offlineDb.budgetItems.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertReservations(items: Reservation[]): Promise<void> {
|
||||
await offlineDb.reservations.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertTripFiles(files: TripFile[]): Promise<void> {
|
||||
await offlineDb.tripFiles.bulkPut(files);
|
||||
}
|
||||
|
||||
export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
||||
await offlineDb.syncMeta.put(meta);
|
||||
}
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.transaction(
|
||||
'rw',
|
||||
[
|
||||
offlineDb.days,
|
||||
offlineDb.places,
|
||||
offlineDb.packingItems,
|
||||
offlineDb.todoItems,
|
||||
offlineDb.budgetItems,
|
||||
offlineDb.reservations,
|
||||
offlineDb.tripFiles,
|
||||
offlineDb.mutationQueue,
|
||||
offlineDb.syncMeta,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.places.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.todoItems.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.budgetItems.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.reservations.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||
},
|
||||
);
|
||||
// Remove the trip row itself outside the transaction since it's a separate table
|
||||
await offlineDb.trips.delete(tripId);
|
||||
}
|
||||
|
||||
/** Wipe the entire offline database (called on logout). */
|
||||
export async function clearAll(): Promise<void> {
|
||||
await offlineDb.delete();
|
||||
// Re-open so subsequent operations don't fail
|
||||
await offlineDb.open();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* usePendingMutations — returns the set of entity IDs that have a pending
|
||||
* or syncing mutation for a given trip.
|
||||
*
|
||||
* Components use this to render a clock/pending indicator on list rows.
|
||||
* Polls Dexie every 2 s so the indicator clears automatically once synced.
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 2_000
|
||||
|
||||
export function usePendingMutations(tripId: number): Set<number> {
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
const pending = await mutationQueue.pending(tripId)
|
||||
if (cancelled) return
|
||||
|
||||
const ids = new Set<number>()
|
||||
for (const m of pending) {
|
||||
// Extract entity id from the mutation URL (last numeric segment)
|
||||
const match = m.url.match(/\/(\d+)$/)
|
||||
if (match) ids.add(Number(match[1]))
|
||||
// Also include tempId for offline-created items
|
||||
if (m.tempId !== undefined) ids.add(m.tempId)
|
||||
}
|
||||
setPendingIds(ids)
|
||||
}
|
||||
|
||||
refresh()
|
||||
const timer = setInterval(refresh, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
return pendingIds
|
||||
}
|
||||
@@ -17,6 +17,7 @@ vi.mock('../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,7 @@ import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
import IntegrationsTab from '../components/Settings/IntegrationsTab'
|
||||
import AccountTab from '../components/Settings/AccountTab'
|
||||
import AboutTab from '../components/Settings/AboutTab'
|
||||
import OfflineTab from '../components/Settings/OfflineTab'
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -41,6 +42,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
{ id: 'map', label: t('settings.tabs.map') },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline', 'Offline') },
|
||||
{ id: 'account', label: t('settings.tabs.account') },
|
||||
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
|
||||
]
|
||||
@@ -84,6 +86,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { budgetApi } from '../api/client'
|
||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||
import type { BudgetItem } from '../types'
|
||||
|
||||
export const budgetRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.budgetItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await budgetApi.list(tripId)
|
||||
upsertBudgetItems(result.items)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { daysApi } from '../api/client'
|
||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||
import type { Day } from '../types'
|
||||
|
||||
export const dayRepo = {
|
||||
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.days
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.sortBy('day_number' as keyof Day)
|
||||
return { days: cached as Day[] }
|
||||
}
|
||||
const result = await daysApi.list(tripId)
|
||||
upsertDays(result.days)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { filesApi } from '../api/client'
|
||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||
import type { TripFile } from '../types'
|
||||
|
||||
export const fileRepo = {
|
||||
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.tripFiles
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { files: cached }
|
||||
}
|
||||
const result = await filesApi.list(tripId)
|
||||
upsertTripFiles(result.files)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { packingApi } from '../api/client'
|
||||
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { PackingItem } from '../types'
|
||||
|
||||
export const packingRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.packingItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await packingApi.list(tripId)
|
||||
upsertPackingItems(result.items)
|
||||
return result
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: PackingItem = {
|
||||
...(data as Partial<PackingItem>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New item',
|
||||
checked: 0,
|
||||
} as PackingItem
|
||||
await offlineDb.packingItems.put(tempItem)
|
||||
const id = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id,
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/packing`,
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
tempId,
|
||||
})
|
||||
return { item: tempItem }
|
||||
}
|
||||
const result = await packingApi.create(tripId, data)
|
||||
offlineDb.packingItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.packingItems.get(id)
|
||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||
await offlineDb.packingItems.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
})
|
||||
return { item: optimistic }
|
||||
}
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
offlineDb.packingItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.packingItems.delete(id)
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
body: undefined,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await packingApi.delete(tripId, id)
|
||||
offlineDb.packingItems.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { placesApi } from '../api/client'
|
||||
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import type { Place } from '../types'
|
||||
|
||||
export const placeRepo = {
|
||||
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.places
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { places: cached }
|
||||
}
|
||||
const result = await placesApi.list(tripId, params)
|
||||
upsertPlaces(result.places)
|
||||
return result
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New place',
|
||||
} as Place
|
||||
await offlineDb.places.put(tempPlace)
|
||||
const id = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id,
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/places`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
tempId,
|
||||
})
|
||||
return { place: tempPlace }
|
||||
}
|
||||
const result = await placesApi.create(tripId, data)
|
||||
offlineDb.places.put(result.place)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.places.get(Number(id))
|
||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||
await offlineDb.places.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
})
|
||||
return { place: optimistic }
|
||||
}
|
||||
const result = await placesApi.update(tripId, id, data)
|
||||
offlineDb.places.put(result.place)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number | string): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.places.delete(Number(id))
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await placesApi.delete(tripId, id)
|
||||
offlineDb.places.delete(Number(id))
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { reservationsApi } from '../api/client'
|
||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
export const reservationRepo = {
|
||||
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.reservations
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { reservations: cached }
|
||||
}
|
||||
const result = await reservationsApi.list(tripId)
|
||||
upsertReservations(result.reservations)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { todoApi } from '../api/client'
|
||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||
import type { TodoItem } from '../types'
|
||||
|
||||
export const todoRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.todoItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await todoApi.list(tripId)
|
||||
upsertTodoItems(result.items)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { tripsApi } from '../api/client'
|
||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||
import type { Trip } from '../types'
|
||||
|
||||
export const tripRepo = {
|
||||
async get(tripId: number | string): Promise<{ trip: Trip }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.trips.get(Number(tripId))
|
||||
if (cached) return { trip: cached }
|
||||
throw new Error('No cached trip data available offline')
|
||||
}
|
||||
const result = await tripsApi.get(tripId)
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { authApi } from '../api/client'
|
||||
import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { clearAll } from '../db/offlineDb'
|
||||
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
@@ -88,6 +90,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
@@ -108,6 +111,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Verification failed')
|
||||
@@ -128,6 +132,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
@@ -145,6 +150,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { packingApi } from '../../api/client'
|
||||
import { packingRepo } from '../../repo/packingRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface PackingSlice {
|
||||
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
const result = await packingRepo.create(tripId, data as Record<string, unknown>)
|
||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -27,7 +27,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
|
||||
updatePackingItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
const result = await packingRepo.update(tripId, id, data as Record<string, unknown>)
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -41,7 +41,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
const prev = get().packingItems
|
||||
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await packingApi.delete(tripId, id)
|
||||
await packingRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ packingItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting item'))
|
||||
@@ -55,7 +55,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await packingApi.update(tripId, id, { checked })
|
||||
await packingRepo.update(tripId, id, { checked })
|
||||
} catch {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { placesApi } from '../../api/client'
|
||||
import { placeRepo } from '../../repo/placeRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Place, Assignment } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface PlacesSlice {
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
const data = await placeRepo.list(tripId)
|
||||
set({ places: data.places })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to refresh places:', err)
|
||||
@@ -26,7 +26,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
const data = await placeRepo.create(tripId, placeData as Record<string, unknown>)
|
||||
set(state => ({ places: [data.place, ...state.places] }))
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
@@ -36,7 +36,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
const data = await placeRepo.update(tripId, placeId, placeData as Record<string, unknown>)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
@@ -61,7 +61,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
await placeRepo.delete(tripId, placeId)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
|
||||
@@ -1,14 +1,167 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
// ── Dexie write-through ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist remote event to IndexedDB so the data is available offline.
|
||||
* Fire-and-forget: errors are swallowed to never block the Zustand update.
|
||||
* Called AFTER set() so `state` already reflects the update.
|
||||
*/
|
||||
function writeToDexie(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
state: TripStoreState,
|
||||
): void {
|
||||
;(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
// ── Places ──────────────────────────────────────────────────────────
|
||||
case 'place:created':
|
||||
case 'place:updated':
|
||||
await offlineDb.places.put(payload.place as Place)
|
||||
break
|
||||
case 'place:deleted':
|
||||
await offlineDb.places.delete(payload.placeId as number)
|
||||
break
|
||||
|
||||
// ── Assignments (embedded in Day rows) ──────────────────────────────
|
||||
// Read the already-updated Day from the Zustand state and persist it.
|
||||
case 'assignment:created':
|
||||
case 'assignment:updated': {
|
||||
const assignment = payload.assignment as Assignment
|
||||
await _writeDayToDb(assignment.day_id, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
await Promise.all([
|
||||
_writeDayToDb(payload.oldDayId as number, state),
|
||||
_writeDayToDb(movedAssignment.day_id, state),
|
||||
])
|
||||
break
|
||||
}
|
||||
case 'assignment:reordered':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Days ─────────────────────────────────────────────────────────────
|
||||
case 'day:created':
|
||||
case 'day:updated': {
|
||||
const day = payload.day as Day
|
||||
await _writeDayToDb(day.id, state)
|
||||
break
|
||||
}
|
||||
case 'day:deleted':
|
||||
await offlineDb.days.delete(payload.dayId as number)
|
||||
break
|
||||
|
||||
// ── Day notes (embedded in Day rows) ─────────────────────────────────
|
||||
case 'dayNote:created':
|
||||
case 'dayNote:updated':
|
||||
case 'dayNote:deleted':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────
|
||||
case 'packing:created':
|
||||
case 'packing:updated':
|
||||
await offlineDb.packingItems.put(payload.item as PackingItem)
|
||||
break
|
||||
case 'packing:deleted':
|
||||
await offlineDb.packingItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Todo ─────────────────────────────────────────────────────────────
|
||||
case 'todo:created':
|
||||
case 'todo:updated':
|
||||
await offlineDb.todoItems.put(payload.item as TodoItem)
|
||||
break
|
||||
case 'todo:deleted':
|
||||
await offlineDb.todoItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────
|
||||
case 'budget:created':
|
||||
case 'budget:updated':
|
||||
await offlineDb.budgetItems.put(payload.item as BudgetItem)
|
||||
break
|
||||
case 'budget:deleted':
|
||||
await offlineDb.budgetItems.delete(payload.itemId as number)
|
||||
break
|
||||
case 'budget:members-updated':
|
||||
case 'budget:member-paid-updated':
|
||||
case 'budget:reordered': {
|
||||
// Partial update — read canonical item(s) from updated Zustand state
|
||||
if (type === 'budget:reordered') {
|
||||
await offlineDb.budgetItems.bulkPut(state.budgetItems)
|
||||
} else {
|
||||
const item = state.budgetItems.find(i => i.id === (payload.itemId as number))
|
||||
if (item) await offlineDb.budgetItems.put(item)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
case 'reservation:created':
|
||||
case 'reservation:updated':
|
||||
await offlineDb.reservations.put(payload.reservation as Reservation)
|
||||
break
|
||||
case 'reservation:deleted':
|
||||
await offlineDb.reservations.delete(payload.reservationId as number)
|
||||
break
|
||||
|
||||
// ── Trip ─────────────────────────────────────────────────────────────
|
||||
case 'trip:updated':
|
||||
await offlineDb.trips.put(payload.trip as Trip)
|
||||
break
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────
|
||||
case 'file:created':
|
||||
case 'file:updated':
|
||||
await offlineDb.tripFiles.put(payload.file as TripFile)
|
||||
break
|
||||
case 'file:deleted':
|
||||
await offlineDb.tripFiles.delete(payload.fileId as number)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Dexie write failures are non-fatal — online state is source of truth
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** Write a Day (with its current assignments + notes from Zustand) to Dexie. */
|
||||
async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void> {
|
||||
const day = state.days.find(d => d.id === dayId)
|
||||
if (!day) return
|
||||
await offlineDb.days.put({
|
||||
...day,
|
||||
assignments: state.assignments[String(dayId)] ?? [],
|
||||
notes_items: state.dayNotes[String(dayId)] ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Zustand event reducer ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
|
||||
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
|
||||
* After the Zustand update, the change is also written through to IndexedDB for offline access.
|
||||
*/
|
||||
export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocketEvent): void {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
@@ -285,4 +438,7 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// Write the change through to IndexedDB using the post-update state
|
||||
writeToDexie(type, payload as Record<string, unknown>, get())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import { tripsApi, daysApi, placesApi, packingApi, todoApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tripRepo } from '../repo/tripRepo'
|
||||
import { dayRepo } from '../repo/dayRepo'
|
||||
import { placeRepo } from '../repo/placeRepo'
|
||||
import { packingRepo } from '../repo/packingRepo'
|
||||
import { todoRepo } from '../repo/todoRepo'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDayNotesSlice } from './slices/dayNotesSlice'
|
||||
@@ -78,19 +83,19 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event),
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||
|
||||
loadTrip: async (tripId: number | string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
packingApi.list(tripId),
|
||||
todoApi.list(tripId),
|
||||
tagsApi.list(),
|
||||
categoriesApi.list(),
|
||||
tripRepo.get(tripId),
|
||||
dayRepo.list(tripId),
|
||||
placeRepo.list(tripId),
|
||||
packingRepo.list(tripId),
|
||||
todoRepo.list(tripId),
|
||||
tagsApi.list().catch(() => ({ tags: [] })),
|
||||
categoriesApi.list().catch(() => ({ categories: [] })),
|
||||
])
|
||||
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
@@ -121,7 +126,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
refreshDays: async (tripId: number | string) => {
|
||||
try {
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
@@ -138,7 +143,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Mutation queue — offline write queue backed by IndexedDB (Dexie).
|
||||
*
|
||||
* Flow:
|
||||
* offline create/update/delete → enqueue() → optimistic Dexie write (in repo)
|
||||
* online trigger → flush() → replay REST with X-Idempotency-Key header → update Dexie
|
||||
*/
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
||||
function getTable(resource: string): Table | undefined {
|
||||
const map: Record<string, Table> = {
|
||||
places: offlineDb.places,
|
||||
packingItems: offlineDb.packingItems,
|
||||
todoItems: offlineDb.todoItems,
|
||||
budgetItems: offlineDb.budgetItems,
|
||||
reservations: offlineDb.reservations,
|
||||
tripFiles: offlineDb.tripFiles,
|
||||
}
|
||||
return map[resource]
|
||||
}
|
||||
|
||||
/** Generate a v4-style UUID using the platform crypto API. */
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// Fallback for environments without crypto.randomUUID (e.g. old Node)
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
let _flushing = false
|
||||
|
||||
export const mutationQueue = {
|
||||
/**
|
||||
* Add a mutation to the queue.
|
||||
* Returns the UUID (= idempotency key).
|
||||
*/
|
||||
async enqueue(
|
||||
mutation: Omit<QueuedMutation, 'status' | 'attempts' | 'createdAt' | 'lastError'>,
|
||||
): Promise<string> {
|
||||
const item: QueuedMutation = {
|
||||
...mutation,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
createdAt: Date.now(),
|
||||
lastError: null,
|
||||
}
|
||||
await offlineDb.mutationQueue.put(item)
|
||||
return item.id
|
||||
},
|
||||
|
||||
/**
|
||||
* Drain the queue: replay each pending mutation against the server in FIFO order.
|
||||
* Stops on first network error (will retry on next trigger).
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine) return
|
||||
_flushing = true
|
||||
try {
|
||||
const pending = await offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.equals('pending')
|
||||
.sortBy('createdAt')
|
||||
|
||||
for (const mutation of pending) {
|
||||
// Mark as syncing so UI can show progress
|
||||
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
||||
|
||||
try {
|
||||
const response = await apiClient.request({
|
||||
method: mutation.method,
|
||||
url: mutation.url,
|
||||
data: mutation.body,
|
||||
headers: { 'X-Idempotency-Key': mutation.id },
|
||||
})
|
||||
|
||||
// Apply canonical server response to Dexie
|
||||
if (mutation.method !== 'DELETE' && mutation.resource) {
|
||||
const table = getTable(mutation.resource)
|
||||
if (table && response.data && typeof response.data === 'object') {
|
||||
// Server returns { place: {...} } or { item: {...} } — grab first value
|
||||
const values = Object.values(response.data as Record<string, unknown>)
|
||||
const entity = values[0]
|
||||
if (entity && typeof entity === 'object' && 'id' in entity) {
|
||||
// Remove temp optimistic entry if id changed (CREATE case)
|
||||
if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
|
||||
await table.delete(mutation.tempId)
|
||||
}
|
||||
await table.put(entity)
|
||||
}
|
||||
}
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
|
||||
// DELETE was already applied optimistically; ensure it's gone
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.delete(mutation.entityId)
|
||||
}
|
||||
|
||||
await offlineDb.mutationQueue.delete(mutation.id)
|
||||
} catch (err: unknown) {
|
||||
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
||||
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
|
||||
// Permanent client error — mark failed, continue with next
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'failed',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
} else {
|
||||
// Network error — reset to pending, abort flush (retry on next trigger)
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'pending',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_flushing = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return all pending/syncing mutations, optionally filtered by tripId.
|
||||
* Used by the UI to show per-item pending indicators.
|
||||
*/
|
||||
async pending(tripId?: number): Promise<QueuedMutation[]> {
|
||||
if (tripId !== undefined) {
|
||||
return offlineDb.mutationQueue
|
||||
.where('tripId')
|
||||
.equals(tripId)
|
||||
.filter(m => m.status === 'pending' || m.status === 'syncing')
|
||||
.toArray()
|
||||
}
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.anyOf(['pending', 'syncing'])
|
||||
.toArray()
|
||||
},
|
||||
|
||||
/** Count pending mutations (for banner badge). */
|
||||
async pendingCount(): Promise<number> {
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.anyOf(['pending', 'syncing'])
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag — useful in tests. */
|
||||
_resetFlushing(): void {
|
||||
_flushing = false
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Sync triggers — register event listeners that flush the mutation queue
|
||||
* and/or run a full trip sync based on the connectivity trigger source.
|
||||
*
|
||||
* Trigger matrix:
|
||||
* window 'online' → flush mutations + full syncAll (network truly back)
|
||||
* visibilitychange visible → flush mutations only (avoid hammering server on tab switch)
|
||||
* periodic 30s → flush mutations only
|
||||
* WS reconnect → flush mutations only (no syncAll — avoids rate-limiter
|
||||
* on server restart / socket timeout while already online)
|
||||
*
|
||||
* Call `registerSyncTriggers()` once on app mount.
|
||||
* Call `unregisterSyncTriggers()` on unmount / logout.
|
||||
*/
|
||||
import { mutationQueue } from './mutationQueue'
|
||||
import { tripSyncManager } from './tripSyncManager'
|
||||
import { setPreReconnectHook } from '../api/websocket'
|
||||
|
||||
const PERIODIC_MS = 30_000
|
||||
|
||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let _registered = false
|
||||
|
||||
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
|
||||
function onOnline() {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
}
|
||||
|
||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||
function onVisibility() {
|
||||
if (!document.hidden && navigator.onLine) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic heartbeat — drain any lingering pending mutations. */
|
||||
function onPeriodic() {
|
||||
if (navigator.onLine) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSyncTriggers(): void {
|
||||
if (_registered) return
|
||||
_registered = true
|
||||
|
||||
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
||||
// limiters when the socket drops and reconnects while the device is online.
|
||||
setPreReconnectHook(() => mutationQueue.flush())
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
_intervalId = setInterval(onPeriodic, PERIODIC_MS)
|
||||
}
|
||||
|
||||
export function unregisterSyncTriggers(): void {
|
||||
if (!_registered) return
|
||||
_registered = false
|
||||
|
||||
setPreReconnectHook(null)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (_intervalId !== null) {
|
||||
clearInterval(_intervalId)
|
||||
_intervalId = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Map tile prefetcher — warms the Workbox 'map-tiles' cache for a trip's
|
||||
* bounding box so maps render offline.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Compute bbox from trip's place coordinates + padding.
|
||||
* 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox.
|
||||
* 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB).
|
||||
* 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it.
|
||||
*
|
||||
* Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional
|
||||
* {s} (subdomain) and {r} (retina suffix).
|
||||
*/
|
||||
|
||||
import type { Place } from '../types'
|
||||
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Estimated average tile size in KB (road/transit tiles ~15 KB). */
|
||||
const AVG_TILE_KB = 15
|
||||
|
||||
/** Hard cap: ~50 MB worth of tiles. */
|
||||
export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413
|
||||
|
||||
const DEFAULT_TILE_URL =
|
||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
|
||||
const SUBDOMAINS = ['a', 'b', 'c', 'd']
|
||||
let _subIdx = 0
|
||||
function nextSubdomain(): string {
|
||||
return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length]
|
||||
}
|
||||
|
||||
// ── Tile math ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Longitude → tile X at given zoom. */
|
||||
export function lngToTileX(lng: number, zoom: number): number {
|
||||
return Math.floor(((lng + 180) / 360) * Math.pow(2, zoom))
|
||||
}
|
||||
|
||||
/** Latitude → tile Y at given zoom (Web Mercator, y increases southward). */
|
||||
export function latToTileY(lat: number, zoom: number): number {
|
||||
const n = Math.pow(2, zoom)
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
return Math.floor(
|
||||
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n,
|
||||
)
|
||||
}
|
||||
|
||||
/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */
|
||||
function ensureMinSpan(min: number, max: number, minSpan = 0.1): [number, number] {
|
||||
if (max - min < minSpan) {
|
||||
const mid = (min + max) / 2
|
||||
return [mid - minSpan / 2, mid + minSpan / 2]
|
||||
}
|
||||
return [min, max]
|
||||
}
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TileBbox {
|
||||
minLat: number
|
||||
maxLat: number
|
||||
minLng: number
|
||||
maxLng: number
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the bounding box for a list of places with optional padding.
|
||||
* Returns null if no places have coordinates.
|
||||
*/
|
||||
export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox | null {
|
||||
const valid = places.filter(p => p.lat !== null && p.lng !== null)
|
||||
if (valid.length === 0) return null
|
||||
|
||||
const lats = valid.map(p => p.lat as number)
|
||||
const lngs = valid.map(p => p.lng as number)
|
||||
|
||||
const [rawMinLat, rawMaxLat] = ensureMinSpan(Math.min(...lats), Math.max(...lats))
|
||||
const [rawMinLng, rawMaxLng] = ensureMinSpan(Math.min(...lngs), Math.max(...lngs))
|
||||
|
||||
const latPad = (rawMaxLat - rawMinLat) * paddingFraction
|
||||
const lngPad = (rawMaxLng - rawMinLng) * paddingFraction
|
||||
|
||||
return {
|
||||
minLat: Math.max(-85.0511, rawMinLat - latPad),
|
||||
maxLat: Math.min(85.0511, rawMaxLat + latPad),
|
||||
minLng: Math.max(-180, rawMinLng - lngPad),
|
||||
maxLng: Math.min(180, rawMaxLng + lngPad),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tiles that would be fetched across the zoom range for a bbox.
|
||||
* Used to enforce the size guard without actually fetching.
|
||||
*/
|
||||
export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number {
|
||||
let total = 0
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const minX = lngToTileX(bbox.minLng, z)
|
||||
const maxX = lngToTileX(bbox.maxLng, z)
|
||||
const minY = latToTileY(bbox.maxLat, z) // northern edge → smaller y
|
||||
const maxY = latToTileY(bbox.minLat, z) // southern edge → larger y
|
||||
total += (maxX - minX + 1) * (maxY - minY + 1)
|
||||
if (total > MAX_TILES) return total
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the concrete tile URL for given z/x/y from a Leaflet template.
|
||||
* Rotates through subdomains (a–d).
|
||||
*/
|
||||
export function buildTileUrl(template: string, z: number, x: number, y: number): string {
|
||||
return template
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
.replace('{s}', nextSubdomain())
|
||||
.replace('{r}', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch tiles for a bbox into the Service Worker cache.
|
||||
* Stops at the zoom level where the size cap would be exceeded.
|
||||
* No-ops when:
|
||||
* - offline
|
||||
* - no active Service Worker (tiles won't be cached anyway)
|
||||
* - total tile count exceeds MAX_TILES before even starting zoom 10
|
||||
*/
|
||||
export async function prefetchTiles(
|
||||
bbox: TileBbox,
|
||||
tileUrlTemplate: string,
|
||||
minZoom = 10,
|
||||
maxZoom = 16,
|
||||
): Promise<number> {
|
||||
if (!navigator.onLine) return 0
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
|
||||
|
||||
let fetched = 0
|
||||
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const minX = lngToTileX(bbox.minLng, z)
|
||||
const maxX = lngToTileX(bbox.maxLng, z)
|
||||
const minY = latToTileY(bbox.maxLat, z)
|
||||
const maxY = latToTileY(bbox.minLat, z)
|
||||
const count = (maxX - minX + 1) * (maxY - minY + 1)
|
||||
|
||||
if (fetched + count > MAX_TILES) break
|
||||
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
const url = buildTileUrl(tileUrlTemplate, z, x, y)
|
||||
// Fire-and-forget: SW CacheFirst handler stores the response
|
||||
fetch(url, { mode: 'no-cors' }).catch(() => {})
|
||||
fetched++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fetched
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline: compute bbox → guard → prefetch → update syncMeta.
|
||||
* Designed to be called fire-and-forget from tripSyncManager.
|
||||
*/
|
||||
export async function prefetchTilesForTrip(
|
||||
tripId: number,
|
||||
places: Place[],
|
||||
tileUrlTemplate?: string,
|
||||
): Promise<void> {
|
||||
const template = tileUrlTemplate || DEFAULT_TILE_URL
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return
|
||||
|
||||
// Size guard: if total tile count across all zooms exceeds cap, skip
|
||||
const estimated = countTiles(bbox, 10, 16)
|
||||
if (estimated > MAX_TILES) {
|
||||
console.warn(
|
||||
`[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fetched = await prefetchTiles(bbox, template)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
if (meta) {
|
||||
await upsertSyncMeta({
|
||||
...meta,
|
||||
tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat],
|
||||
})
|
||||
}
|
||||
|
||||
if (fetched > 0) {
|
||||
console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Trip sync manager — seeds Dexie with trip data for offline use.
|
||||
*
|
||||
* Cache scope: trips where end_date >= today OR end_date is null/empty.
|
||||
* Eviction: trips where end_date < today - 7 days.
|
||||
* File blobs: all non-photo files (MIME type != image/*) for cached trips.
|
||||
*
|
||||
* Call syncAll() on:
|
||||
* - login success
|
||||
* - trip list refresh (DashboardPage)
|
||||
* - WS reconnect (phase 7)
|
||||
*/
|
||||
import { tripsApi } from '../api/client'
|
||||
import {
|
||||
offlineDb,
|
||||
upsertTrip,
|
||||
upsertDays,
|
||||
upsertPlaces,
|
||||
upsertPackingItems,
|
||||
upsertTodoItems,
|
||||
upsertBudgetItems,
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
clearTripData,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripBundle {
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
packingItems: PackingItem[]
|
||||
todoItems: TodoItem[]
|
||||
budgetItems: BudgetItem[]
|
||||
reservations: Reservation[]
|
||||
files: TripFile[]
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
if (!trip.end_date) return true // no end date → cache forever
|
||||
return trip.end_date >= todayStr() // ongoing or future
|
||||
}
|
||||
|
||||
function isStale(trip: Trip): boolean {
|
||||
if (!trip.end_date) return false
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 7)
|
||||
return trip.end_date < cutoff.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function isPhoto(file: TripFile): boolean {
|
||||
return file.mime_type.startsWith('image/')
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch bundle + write all entities for one trip into Dexie. */
|
||||
async function syncTrip(tripId: number): Promise<void> {
|
||||
const bundle = await tripsApi.bundle(tripId) as TripBundle
|
||||
|
||||
await upsertTrip(bundle.trip)
|
||||
await upsertDays(bundle.days)
|
||||
await upsertPlaces(bundle.places)
|
||||
await upsertPackingItems(bundle.packingItems)
|
||||
await upsertTodoItems(bundle.todoItems)
|
||||
await upsertBudgetItems(bundle.budgetItems)
|
||||
await upsertReservations(bundle.reservations)
|
||||
await upsertTripFiles(bundle.files)
|
||||
await upsertSyncMeta({
|
||||
tripId,
|
||||
lastSyncedAt: Date.now(),
|
||||
status: 'idle',
|
||||
tilesBbox: null,
|
||||
filesCachedCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
|
||||
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const nonPhotos = files.filter(f => f.url && !isPhoto(f))
|
||||
let cached = 0
|
||||
|
||||
for (const file of nonPhotos) {
|
||||
// Skip if already cached
|
||||
const existing = await offlineDb.blobCache.get(file.url!)
|
||||
if (existing) { cached++; continue }
|
||||
|
||||
try {
|
||||
const resp = await fetch(file.url!, { credentials: 'include' })
|
||||
if (!resp.ok) continue
|
||||
const blob = await resp.blob()
|
||||
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
|
||||
cached++
|
||||
} catch {
|
||||
// Network failure — skip this file, will retry next sync
|
||||
}
|
||||
}
|
||||
|
||||
// Update filesCachedCount in syncMeta
|
||||
const tripId = files[0]?.trip_id
|
||||
if (tripId) {
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
if (meta) await upsertSyncMeta({ ...meta, filesCachedCount: cached })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _syncing = false
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale trips. Caches file blobs in the background.
|
||||
* No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
// Evict stale trips first
|
||||
const stale = trips.filter(isStale)
|
||||
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
|
||||
|
||||
// Sync eligible trips
|
||||
const toSync = trips.filter(shouldCache)
|
||||
for (const trip of toSync) {
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache file blobs + map tiles in background (don't block syncAll)
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
for (const trip of toSync) {
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(files).catch(console.error)
|
||||
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
}
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset syncing flag — useful in tests. */
|
||||
_resetSyncing(): void {
|
||||
_syncing = false
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildTrip, buildDay, buildUser } from '../../factories';
|
||||
import { buildTrip, buildDay, buildUser, buildPlace, buildPackingItem, buildTodoItem, buildBudgetItem, buildReservation, buildTripFile } from '../../factories';
|
||||
|
||||
export const tripsHandlers = [
|
||||
// List all trips (active or archived)
|
||||
@@ -47,6 +47,22 @@ export const tripsHandlers = [
|
||||
return HttpResponse.json({ accommodations: [] });
|
||||
}),
|
||||
|
||||
http.get('/api/trips/:id/bundle', ({ params }) => {
|
||||
const tripId = Number(params.id);
|
||||
const trip = buildTrip({ id: tripId });
|
||||
const day = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
|
||||
return HttpResponse.json({
|
||||
trip,
|
||||
days: [day],
|
||||
places: [buildPlace({ trip_id: tripId })],
|
||||
packingItems: [buildPackingItem({ trip_id: tripId })],
|
||||
todoItems: [buildTodoItem({ trip_id: tripId })],
|
||||
budgetItems: [buildBudgetItem({ trip_id: tripId })],
|
||||
reservations: [buildReservation({ trip_id: tripId })],
|
||||
files: [buildTripFile({ trip_id: tripId })],
|
||||
});
|
||||
}),
|
||||
|
||||
http.delete('/api/trips/:id', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => 'mock-socket-id'),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn((fn) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked module AFTER vi.mock
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
|
||||
import { server } from './helpers/msw/server';
|
||||
@@ -9,6 +10,7 @@ vi.mock('../src/api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
// MSW lifecycle
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* offlineDb unit tests.
|
||||
*
|
||||
* Uses fake-indexeddb so no real browser IDB is needed.
|
||||
* Each test gets a fresh database by using `use-fake-indexeddb` with Dexie.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import Dexie from 'dexie';
|
||||
|
||||
// Re-import after fake-indexeddb is set up so Dexie picks up the shim.
|
||||
// We re-open a clean db in each test to isolate state.
|
||||
import {
|
||||
offlineDb,
|
||||
clearTripData,
|
||||
clearAll,
|
||||
upsertTrip,
|
||||
upsertDays,
|
||||
upsertPlaces,
|
||||
upsertPackingItems,
|
||||
upsertTodoItems,
|
||||
upsertBudgetItems,
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
type QueuedMutation,
|
||||
type SyncMeta,
|
||||
type BlobCacheEntry,
|
||||
} from '../../../src/db/offlineDb';
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../../../src/types';
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTrip = (id = 1): Trip => ({
|
||||
id,
|
||||
name: `Trip ${id}`,
|
||||
description: null,
|
||||
start_date: '2026-07-01',
|
||||
end_date: '2026-07-05',
|
||||
cover_url: null,
|
||||
is_archived: false,
|
||||
reminder_days: 3,
|
||||
owner_id: 42,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const makeDay = (id: number, tripId = 1): Day => ({
|
||||
id,
|
||||
trip_id: tripId,
|
||||
date: '2026-07-01',
|
||||
title: null,
|
||||
notes: null,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
});
|
||||
|
||||
const makePlace = (id: number, tripId = 1): Place => ({
|
||||
id,
|
||||
trip_id: tripId,
|
||||
name: `Place ${id}`,
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ensure DB is open (fake-indexeddb resets between test files but not between tests).
|
||||
if (!offlineDb.isOpen()) await offlineDb.open();
|
||||
// Clear all tables before each test.
|
||||
await clearAll();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (!offlineDb.isOpen()) await offlineDb.open();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('offlineDb — trips', () => {
|
||||
it('stores and retrieves a trip via upsertTrip', async () => {
|
||||
const trip = makeTrip(10);
|
||||
await upsertTrip(trip);
|
||||
const stored = await offlineDb.trips.get(10);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.name).toBe('Trip 10');
|
||||
});
|
||||
|
||||
it('upsertTrip overwrites an existing trip (put semantics)', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertTrip({ ...makeTrip(1), name: 'Updated' });
|
||||
const stored = await offlineDb.trips.get(1);
|
||||
expect(stored!.name).toBe('Updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — days', () => {
|
||||
it('stores days and retrieves by trip_id index', async () => {
|
||||
await upsertDays([makeDay(1, 5), makeDay(2, 5), makeDay(3, 9)]);
|
||||
const trip5Days = await offlineDb.days.where('trip_id').equals(5).toArray();
|
||||
expect(trip5Days).toHaveLength(2);
|
||||
expect(trip5Days.map(d => d.id)).toContain(1);
|
||||
expect(trip5Days.map(d => d.id)).toContain(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — places', () => {
|
||||
it('stores places and retrieves by trip_id', async () => {
|
||||
await upsertPlaces([makePlace(10, 1), makePlace(11, 1), makePlace(12, 2)]);
|
||||
const places = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||
expect(places).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — packing / todo / budget / reservations / files', () => {
|
||||
it('upserts packing items', async () => {
|
||||
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
expect(await offlineDb.packingItems.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts todo items', async () => {
|
||||
const item: TodoItem = {
|
||||
id: 1, trip_id: 1, name: 'Book hotel', category: null, checked: 0,
|
||||
sort_order: 0, due_date: null, description: null, assigned_user_id: null, priority: 0,
|
||||
};
|
||||
await upsertTodoItems([item]);
|
||||
expect(await offlineDb.todoItems.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts budget items', async () => {
|
||||
const item: BudgetItem = {
|
||||
id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR',
|
||||
category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null,
|
||||
};
|
||||
await upsertBudgetItems([item]);
|
||||
expect(await offlineDb.budgetItems.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts reservations', async () => {
|
||||
const item: Reservation = {
|
||||
id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed',
|
||||
date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
await upsertReservations([item]);
|
||||
expect(await offlineDb.reservations.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts trip files', async () => {
|
||||
const file: TripFile = {
|
||||
id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf',
|
||||
mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
await upsertTripFiles([file]);
|
||||
expect(await offlineDb.tripFiles.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — syncMeta', () => {
|
||||
it('stores and retrieves syncMeta by tripId', async () => {
|
||||
const meta: SyncMeta = {
|
||||
tripId: 7,
|
||||
lastSyncedAt: Date.now(),
|
||||
status: 'idle',
|
||||
tilesBbox: null,
|
||||
filesCachedCount: 0,
|
||||
};
|
||||
await upsertSyncMeta(meta);
|
||||
const stored = await offlineDb.syncMeta.get(7);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — mutationQueue', () => {
|
||||
it('stores queued mutations queryable by status', async () => {
|
||||
const pending: QueuedMutation = {
|
||||
id: 'uuid-1', tripId: 1, method: 'POST', url: '/api/trips/1/places',
|
||||
body: { name: 'Eiffel Tower' }, createdAt: Date.now(),
|
||||
status: 'pending', attempts: 0, lastError: null,
|
||||
};
|
||||
const failed: QueuedMutation = {
|
||||
id: 'uuid-2', tripId: 1, method: 'PUT', url: '/api/trips/1/places/5',
|
||||
body: { name: 'Updated' }, createdAt: Date.now(),
|
||||
status: 'failed', attempts: 3, lastError: 'Network error',
|
||||
};
|
||||
await offlineDb.mutationQueue.bulkPut([pending, failed]);
|
||||
|
||||
const pendingRows = await offlineDb.mutationQueue.where('status').equals('pending').toArray();
|
||||
expect(pendingRows).toHaveLength(1);
|
||||
expect(pendingRows[0].id).toBe('uuid-1');
|
||||
|
||||
const failedRows = await offlineDb.mutationQueue.where('status').equals('failed').toArray();
|
||||
expect(failedRows).toHaveLength(1);
|
||||
expect(failedRows[0].lastError).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — blobCache', () => {
|
||||
it('stores and retrieves a Blob entry', async () => {
|
||||
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
|
||||
const entry: BlobCacheEntry = {
|
||||
url: '/api/files/99/download',
|
||||
blob,
|
||||
mime: 'application/pdf',
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
await offlineDb.blobCache.put(entry);
|
||||
|
||||
const stored = await offlineDb.blobCache.get('/api/files/99/download');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.mime).toBe('application/pdf');
|
||||
expect(stored!.blob).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearTripData', () => {
|
||||
it('removes all data for the given trip across all tables', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
|
||||
// Also add data for a different trip — should NOT be removed
|
||||
await upsertTrip(makeTrip(2));
|
||||
await upsertDays([makeDay(99, 2)]);
|
||||
|
||||
await clearTripData(1);
|
||||
|
||||
expect(await offlineDb.trips.get(1)).toBeUndefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
|
||||
|
||||
// Trip 2 intact
|
||||
expect(await offlineDb.trips.get(2)).toBeDefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearAll', () => {
|
||||
it('empties all tables', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
|
||||
await clearAll();
|
||||
|
||||
expect(await offlineDb.trips.count()).toBe(0);
|
||||
expect(await offlineDb.days.count()).toBe(0);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* packingRepo unit tests.
|
||||
*
|
||||
* Online path: calls REST via MSW, writes result to Dexie.
|
||||
* Offline path: returns Dexie cache, skips REST.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { packingRepo } from '../../../src/repo/packingRepo';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('packingRepo.list', () => {
|
||||
it('online — fetches from REST and caches in Dexie', async () => {
|
||||
const item = buildPackingItem({ trip_id: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [item] })),
|
||||
);
|
||||
|
||||
const result = await packingRepo.list(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBe(item.id);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
|
||||
expect(cached).toHaveLength(1);
|
||||
expect(cached[0].id).toBe(item.id);
|
||||
});
|
||||
|
||||
it('offline — returns Dexie cache without REST call', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
const item = buildPackingItem({ trip_id: 1 });
|
||||
await offlineDb.packingItems.put(item);
|
||||
|
||||
let restCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/packing', () => {
|
||||
restCalled = true;
|
||||
return HttpResponse.json({ items: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await packingRepo.list(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBe(item.id);
|
||||
expect(restCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('offline — returns empty array when nothing cached', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const result = await packingRepo.list(99);
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingRepo.create', () => {
|
||||
it('calls REST and caches created item in Dexie', async () => {
|
||||
const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' });
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', () => HttpResponse.json({ item })),
|
||||
);
|
||||
|
||||
const result = await packingRepo.create(1, { name: 'Sunscreen' });
|
||||
expect(result.item.name).toBe('Sunscreen');
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.get(item.id);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.name).toBe('Sunscreen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingRepo.update', () => {
|
||||
it('calls REST and updates Dexie cache', async () => {
|
||||
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
|
||||
await offlineDb.packingItems.put(original);
|
||||
|
||||
const updated = { ...original, checked: 1 };
|
||||
server.use(
|
||||
http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })),
|
||||
);
|
||||
|
||||
const result = await packingRepo.update(1, original.id, { checked: true });
|
||||
expect(result.item.checked).toBe(1);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.get(original.id);
|
||||
expect(cached!.checked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingRepo.delete', () => {
|
||||
it('calls REST and removes from Dexie', async () => {
|
||||
const item = buildPackingItem({ trip_id: 1 });
|
||||
await offlineDb.packingItems.put(item);
|
||||
|
||||
server.use(
|
||||
http.delete(`/api/trips/1/packing/${item.id}`, () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await packingRepo.delete(1, item.id);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.get(item.id);
|
||||
expect(cached).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* placeRepo unit tests.
|
||||
*
|
||||
* Online path: calls REST via MSW, writes result to Dexie.
|
||||
* Offline path: returns Dexie cache, skips REST.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { placeRepo } from '../../../src/repo/placeRepo';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('placeRepo.list', () => {
|
||||
it('online — fetches from REST and caches in Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [place] })),
|
||||
);
|
||||
|
||||
const result = await placeRepo.list(1);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].id).toBe(place.id);
|
||||
|
||||
// Give fire-and-forget a tick to flush
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||
expect(cached).toHaveLength(1);
|
||||
expect(cached[0].id).toBe(place.id);
|
||||
});
|
||||
|
||||
it('offline — returns Dexie cache without REST call', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
let restCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => {
|
||||
restCalled = true;
|
||||
return HttpResponse.json({ places: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await placeRepo.list(1);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].id).toBe(place.id);
|
||||
expect(restCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('offline — returns empty array when nothing cached', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const result = await placeRepo.list(99);
|
||||
expect(result.places).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.create', () => {
|
||||
it('calls REST and caches created place in Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' });
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||
);
|
||||
|
||||
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
|
||||
expect(result.place.name).toBe('Eiffel Tower');
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.get(place.id);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.name).toBe('Eiffel Tower');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.update', () => {
|
||||
it('calls REST and updates Dexie cache', async () => {
|
||||
const original = buildPlace({ trip_id: 1, name: 'Old Name' });
|
||||
await offlineDb.places.put(original);
|
||||
|
||||
const updated = { ...original, name: 'New Name' };
|
||||
server.use(
|
||||
http.put(`/api/trips/1/places/${original.id}`, () => HttpResponse.json({ place: updated })),
|
||||
);
|
||||
|
||||
const result = await placeRepo.update(1, original.id, { name: 'New Name' });
|
||||
expect(result.place.name).toBe('New Name');
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.get(original.id);
|
||||
expect(cached!.name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.delete', () => {
|
||||
it('calls REST and removes from Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
server.use(
|
||||
http.delete(`/api/trips/1/places/${place.id}`, () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await placeRepo.delete(1, place.id);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.get(place.id);
|
||||
expect(cached).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* mutationQueue unit tests.
|
||||
*
|
||||
* Covers: enqueue, flush (2xx success, 4xx fail, network error), idempotency header,
|
||||
* pending count, create temp-id reconciliation, delete Dexie cleanup.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
mutationQueue._resetFlushing();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMutation(overrides: Partial<Parameters<typeof mutationQueue.enqueue>[0]> = {}) {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
tripId: 1,
|
||||
method: 'POST' as const,
|
||||
url: '/trips/1/places',
|
||||
body: { name: 'Eiffel Tower' },
|
||||
resource: 'places',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── enqueue ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.enqueue', () => {
|
||||
it('stores mutation with pending status', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
const stored = await offlineDb.mutationQueue.get(id);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.status).toBe('pending');
|
||||
expect(stored!.attempts).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the mutation id', async () => {
|
||||
const id = generateUUID();
|
||||
const returned = await mutationQueue.enqueue(makeMutation({ id }));
|
||||
expect(returned).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush — success path ──────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — 2xx success', () => {
|
||||
it('removes mutation from queue and writes canonical entity to Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1, id: 42 });
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const queued = await offlineDb.mutationQueue.get(id);
|
||||
expect(queued).toBeUndefined();
|
||||
|
||||
const cached = await offlineDb.places.get(42);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.name).toBe(place.name);
|
||||
});
|
||||
|
||||
it('attaches X-Idempotency-Key header matching the mutation id', async () => {
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let capturedKey: string | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', ({ request }) => {
|
||||
capturedKey = request.headers.get('X-Idempotency-Key');
|
||||
return HttpResponse.json({ place });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(capturedKey).toBe(id);
|
||||
});
|
||||
|
||||
it('removes temp entry and adds canonical entry on CREATE flush', async () => {
|
||||
const tempId = -12345;
|
||||
const place = buildPlace({ trip_id: 1, id: 99 });
|
||||
const id = generateUUID();
|
||||
|
||||
// Optimistic temp entry in Dexie
|
||||
await offlineDb.places.put({ ...place, id: tempId });
|
||||
|
||||
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
||||
expect(await offlineDb.places.get(99)).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles DELETE: removes entity from Dexie after flush', async () => {
|
||||
const place = buildPlace({ trip_id: 1, id: 55 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue({
|
||||
id,
|
||||
tripId: 1,
|
||||
method: 'DELETE',
|
||||
url: '/trips/1/places/55',
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: 55,
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/places/55', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
|
||||
expect(await offlineDb.places.get(55)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush — error paths ───────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — 4xx client error', () => {
|
||||
it('marks mutation as failed and continues to next mutation', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
|
||||
// Enqueue in order
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json({ error: 'Bad request' }, { status: 400 });
|
||||
}
|
||||
return HttpResponse.json({ place });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m1 = await offlineDb.mutationQueue.get(id1);
|
||||
expect(m1).toBeDefined();
|
||||
expect(m1!.status).toBe('failed');
|
||||
|
||||
// Second mutation succeeded and was removed
|
||||
expect(await offlineDb.mutationQueue.get(id2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutationQueue.flush — network error', () => {
|
||||
it('resets to pending and stops flush without marking failed', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.error()),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m).toBeDefined();
|
||||
expect(m!.status).toBe('pending');
|
||||
expect(m!.attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush — offline guard ─────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — offline guard', () => {
|
||||
it('does nothing when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(called).toBe(false);
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pending / pendingCount ────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.pending', () => {
|
||||
it('returns pending mutations for a trip', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1, tripId: 1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2, tripId: 2 }));
|
||||
|
||||
const trip1 = await mutationQueue.pending(1);
|
||||
expect(trip1).toHaveLength(1);
|
||||
expect(trip1[0].id).toBe(id1);
|
||||
});
|
||||
|
||||
it('returns all pending when no tripId given', async () => {
|
||||
await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 2 }));
|
||||
|
||||
const all = await mutationQueue.pending();
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('excludes failed mutations', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
await offlineDb.mutationQueue.update(id, { status: 'failed' });
|
||||
|
||||
const pending = await mutationQueue.pending(1);
|
||||
expect(pending).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutationQueue.pendingCount', () => {
|
||||
it('returns zero for empty queue', async () => {
|
||||
expect(await mutationQueue.pendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('counts pending and syncing, excludes failed', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
const id3 = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id3 }));
|
||||
await offlineDb.mutationQueue.update(id3, { status: 'failed' });
|
||||
|
||||
expect(await mutationQueue.pendingCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* tilePrefetcher unit tests.
|
||||
*
|
||||
* Covers: bbox computation, tile math, URL building, size guard,
|
||||
* offline/no-SW guard, syncMeta update.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import {
|
||||
computeBbox,
|
||||
lngToTileX,
|
||||
latToTileY,
|
||||
buildTileUrl,
|
||||
countTiles,
|
||||
prefetchTiles,
|
||||
prefetchTilesForTrip,
|
||||
MAX_TILES,
|
||||
type TileBbox,
|
||||
} from '../../../src/sync/tilePrefetcher';
|
||||
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch + serviceWorker so prefetch path is exercised
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
value: { controller: {} },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── bbox computation ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeBbox', () => {
|
||||
it('returns null when no places have coordinates', () => {
|
||||
const places = [buildPlace({ lat: null, lng: null })];
|
||||
expect(computeBbox(places)).toBeNull();
|
||||
});
|
||||
|
||||
it('expands single-point bbox to at least 0.1° span', () => {
|
||||
const place = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||
const bbox = computeBbox([place])!;
|
||||
expect(bbox.maxLat - bbox.minLat).toBeGreaterThan(0.09);
|
||||
expect(bbox.maxLng - bbox.minLng).toBeGreaterThan(0.09);
|
||||
});
|
||||
|
||||
it('computes multi-point bbox with padding', () => {
|
||||
const places = [
|
||||
buildPlace({ lat: 48.8566, lng: 2.3522 }), // Paris
|
||||
buildPlace({ lat: 51.5074, lng: -0.1278 }), // London
|
||||
];
|
||||
const bbox = computeBbox(places, 0.1)!;
|
||||
// Padded bbox should extend beyond raw points
|
||||
expect(bbox.minLat).toBeLessThan(48.8566);
|
||||
expect(bbox.maxLat).toBeGreaterThan(51.5074);
|
||||
expect(bbox.minLng).toBeLessThan(-0.1278);
|
||||
expect(bbox.maxLng).toBeGreaterThan(2.3522);
|
||||
});
|
||||
|
||||
it('clamps to valid Mercator lat bounds', () => {
|
||||
const places = [buildPlace({ lat: 85.0, lng: 0 })];
|
||||
const bbox = computeBbox(places, 0.5)!;
|
||||
expect(bbox.maxLat).toBeLessThanOrEqual(85.0511);
|
||||
});
|
||||
});
|
||||
|
||||
// ── tile math ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('lngToTileX', () => {
|
||||
it('returns 0 for lng=-180 at any zoom', () => {
|
||||
expect(lngToTileX(-180, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns max tile for lng=180 at zoom 1', () => {
|
||||
// At zoom 1: 2^1 = 2 tiles, lng=180 → x = floor(360/360 * 2) = floor(2) = 2
|
||||
// But tile range is 0..1, so this is the "overflow" edge — that's fine
|
||||
expect(lngToTileX(180, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it('increases with more easterly longitude', () => {
|
||||
const x1 = lngToTileX(0, 10);
|
||||
const x2 = lngToTileX(10, 10);
|
||||
expect(x2).toBeGreaterThan(x1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latToTileY', () => {
|
||||
it('returns smaller y for higher latitude (north = top)', () => {
|
||||
const yNorth = latToTileY(60, 10);
|
||||
const ySouth = latToTileY(10, 10);
|
||||
expect(yNorth).toBeLessThan(ySouth);
|
||||
});
|
||||
|
||||
it('equator is roughly half the tile grid', () => {
|
||||
const yEq = latToTileY(0, 1);
|
||||
// zoom 1 → 2 rows, equator ≈ row 1
|
||||
expect(yEq).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── URL building ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildTileUrl', () => {
|
||||
it('replaces {z}, {x}, {y}, {r} correctly', () => {
|
||||
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
|
||||
const url = buildTileUrl(tmpl, 10, 500, 300);
|
||||
expect(url).toBe('https://tile.example.com/10/500/300.png');
|
||||
});
|
||||
|
||||
it('replaces {s} with a subdomain character', () => {
|
||||
const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png';
|
||||
const url = buildTileUrl(tmpl, 10, 0, 0);
|
||||
expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/);
|
||||
});
|
||||
|
||||
it('removes {r} (retina placeholder)', () => {
|
||||
const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png';
|
||||
const url = buildTileUrl(tmpl, 10, 0, 0);
|
||||
expect(url).toBe('https://tiles.example.com/10/0/0.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ── countTiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('countTiles', () => {
|
||||
it('returns more tiles at higher zoom levels', () => {
|
||||
const bbox: TileBbox = { minLat: 48.7, maxLat: 49.0, minLng: 2.2, maxLng: 2.5 };
|
||||
const low = countTiles(bbox, 10, 10);
|
||||
const high = countTiles(bbox, 12, 12);
|
||||
expect(high).toBeGreaterThan(low);
|
||||
});
|
||||
|
||||
it('stops counting after exceeding MAX_TILES', () => {
|
||||
// Very large bbox — should hit cap quickly at high zooms
|
||||
const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 };
|
||||
const count = countTiles(bbox, 10, 16);
|
||||
expect(count).toBeGreaterThan(MAX_TILES);
|
||||
});
|
||||
});
|
||||
|
||||
// ── prefetchTiles guards ───────────────────────────────────────────────────────
|
||||
|
||||
describe('prefetchTiles — offline guard', () => {
|
||||
it('returns 0 and does not fetch when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10);
|
||||
expect(count).toBe(0);
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 0 when no service worker controller', async () => {
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
value: { controller: null },
|
||||
configurable: true,
|
||||
});
|
||||
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefetchTiles — normal operation', () => {
|
||||
it('fetches tiles and returns count', async () => {
|
||||
const bbox: TileBbox = { minLat: 48.84, maxLat: 48.87, minLng: 2.33, maxLng: 2.37 };
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops at zoom level where cap is exceeded', async () => {
|
||||
// Use a very small MAX_TILES override by using a huge bbox
|
||||
const bbox: TileBbox = { minLat: -80, maxLat: 80, minLng: -170, maxLng: 170 };
|
||||
// This bbox at zoom 10 alone has thousands of tiles — should trigger early stop
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 16);
|
||||
expect(count).toBeLessThanOrEqual(MAX_TILES);
|
||||
});
|
||||
});
|
||||
|
||||
// ── prefetchTilesForTrip ──────────────────────────────────────────────────────
|
||||
|
||||
describe('prefetchTilesForTrip', () => {
|
||||
it('no-ops when no places have coordinates', async () => {
|
||||
const places = [buildPlace({ lat: null, lng: null })];
|
||||
await prefetchTilesForTrip(1, places);
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates syncMeta tilesBbox after prefetch', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
const meta = await offlineDb.syncMeta.get(1);
|
||||
expect(meta!.tilesBbox).not.toBeNull();
|
||||
expect(meta!.tilesBbox).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
// Places far apart → huge bbox → estimate > MAX_TILES
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: -60, lng: -170 }),
|
||||
buildPlace({ trip_id: 1, lat: 60, lng: 170 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
// No fetches should have been made
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* tripSyncManager unit tests.
|
||||
*
|
||||
* Covers: trip filtering (shouldCache/isStale), bundle fetch → Dexie upsert,
|
||||
* stale trip eviction, offline guard, file blob caching.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildPackingItem,
|
||||
buildTodoItem,
|
||||
buildBudgetItem,
|
||||
buildReservation,
|
||||
buildTripFile,
|
||||
} from '../../helpers/factories';
|
||||
|
||||
// Helper to get today ± N days as YYYY-MM-DD
|
||||
function dateOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function makeBundle(tripId: number) {
|
||||
const trip = buildTrip({ id: tripId, end_date: dateOffset(3) });
|
||||
return {
|
||||
trip,
|
||||
days: [buildDay({ trip_id: tripId, assignments: [], notes_items: [] })],
|
||||
places: [buildPlace({ trip_id: tripId })],
|
||||
packingItems: [buildPackingItem({ trip_id: tripId })],
|
||||
todoItems: [buildTodoItem({ trip_id: tripId })],
|
||||
budgetItems: [buildBudgetItem({ trip_id: tripId })],
|
||||
reservations: [buildReservation({ trip_id: tripId })],
|
||||
files: [buildTripFile({ trip_id: tripId, url: `/api/trips/${tripId}/files/99/download`, mime_type: 'application/pdf' })],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(['data'], { type: 'application/pdf' }),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — offline guard', () => {
|
||||
it('does nothing when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
let listed = false;
|
||||
server.use(
|
||||
http.get('/api/trips', () => { listed = true; return HttpResponse.json({ trips: [] }); }),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(listed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── trip filtering ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — trip filtering', () => {
|
||||
it('caches ongoing trips (end_date >= today)', async () => {
|
||||
const tripId = 100;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(2) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
|
||||
const cached = await offlineDb.trips.get(tripId);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.id).toBe(tripId);
|
||||
});
|
||||
|
||||
it('caches trips with no end_date', async () => {
|
||||
const tripId = 101;
|
||||
const bundle = makeBundle(tripId);
|
||||
const trip = buildTrip({ id: tripId, end_date: null as unknown as string });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...bundle, trip })),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(tripId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not cache past trips (end_date < today)', async () => {
|
||||
const tripId = 102;
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(-1) })] }),
|
||||
),
|
||||
);
|
||||
|
||||
// Bundle should NOT be called for past trips
|
||||
let bundleCalled = false;
|
||||
server.use(
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => {
|
||||
bundleCalled = true;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(bundleCalled).toBe(false);
|
||||
expect(await offlineDb.trips.get(tripId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── stale eviction ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — stale eviction', () => {
|
||||
it('evicts trips that ended more than 7 days ago', async () => {
|
||||
const staleId = 200;
|
||||
// Seed Dexie as if previously cached
|
||||
await upsertTrip(buildTrip({ id: staleId, end_date: dateOffset(-8) }));
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: staleId, end_date: dateOffset(-8) })] }),
|
||||
),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(staleId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT evict trips that ended exactly 6 days ago', async () => {
|
||||
const recentId = 201;
|
||||
const bundle = makeBundle(recentId);
|
||||
const trip = buildTrip({ id: recentId, end_date: dateOffset(-6) });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${recentId}/bundle`, () => HttpResponse.json({ ...bundle, trip })),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
// end_date = -6 days: still within 7d window, but < today so not cached
|
||||
// i.e., shouldCache is false (end_date < today) so won't be fetched
|
||||
// but also isStale is false (end_date = -6 >= cutoff -7), so won't be evicted
|
||||
// → trip should simply not appear in Dexie (not cached, not evicted pre-seeded data)
|
||||
expect(await offlineDb.trips.get(recentId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── bundle upsert ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — bundle upsert', () => {
|
||||
it('writes all bundle entities to Dexie', async () => {
|
||||
const tripId = 300;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
|
||||
expect(await offlineDb.trips.get(tripId)).toBeDefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.places.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.packingItems.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.todoItems.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.budgetItems.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.reservations.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.tripFiles.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
});
|
||||
|
||||
it('writes syncMeta with lastSyncedAt', async () => {
|
||||
const tripId = 301;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
await tripSyncManager.syncAll();
|
||||
const after = Date.now();
|
||||
|
||||
const meta = await offlineDb.syncMeta.get(tripId);
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta!.lastSyncedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(meta!.lastSyncedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
// ── file blob caching ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager — file blob caching', () => {
|
||||
it('caches non-photo files after bundle sync', async () => {
|
||||
const tripId = 400;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
|
||||
// Give fire-and-forget a tick
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const cached = await offlineDb.blobCache.toArray();
|
||||
expect(cached.length).toBeGreaterThan(0);
|
||||
expect(cached[0].url).toContain('/download');
|
||||
});
|
||||
|
||||
it('does not cache photo files (image/* MIME)', async () => {
|
||||
const tripId = 401;
|
||||
const photoFile = buildTripFile({
|
||||
trip_id: tripId,
|
||||
mime_type: 'image/jpeg',
|
||||
url: `/api/trips/${tripId}/files/77/download`,
|
||||
});
|
||||
const bundle = {
|
||||
...makeBundle(tripId),
|
||||
files: [photoFile],
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const cached = await offlineDb.blobCache.toArray();
|
||||
expect(cached.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Generated
+441
-533
File diff suppressed because it is too large
Load Diff
@@ -1578,6 +1578,22 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||
},
|
||||
// Migration 100: Idempotency keys for offline mutation replay
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
response_body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
PRIMARY KEY (key, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -48,6 +48,7 @@ const server = app.listen(PORT, () => {
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
||||
import { applyIdempotency } from './idempotency';
|
||||
|
||||
export function extractToken(req: Request): string | null {
|
||||
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
|
||||
@@ -38,7 +39,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
|
||||
return;
|
||||
}
|
||||
(req as AuthRequest).user = user;
|
||||
next();
|
||||
applyIdempotency(req, res, next, user.id);
|
||||
};
|
||||
|
||||
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
interface IdempotencyRow {
|
||||
status_code: number;
|
||||
response_body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from within `authenticate` after req.user is set.
|
||||
*
|
||||
* For mutating requests carrying X-Idempotency-Key:
|
||||
* - If (key, userId) already stored: replays the cached response.
|
||||
* - Otherwise: wraps res.json to capture and store a successful response.
|
||||
*
|
||||
* Storing happens in idempotency_keys (24h TTL, cleaned by scheduler).
|
||||
*/
|
||||
export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void {
|
||||
if (!MUTATING_METHODS.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = req.headers['x-idempotency-key'] as string | undefined;
|
||||
if (!key) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Return cached response if key already processed for this user
|
||||
const existing = db.prepare(
|
||||
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?'
|
||||
).get(key, userId) as IdempotencyRow | undefined;
|
||||
|
||||
if (existing) {
|
||||
res.status(existing.status_code).json(JSON.parse(existing.response_body));
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap res.json to capture the response on first successful execution
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = function (body: unknown): Response {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(key, userId, req.method, req.path, res.statusCode, JSON.stringify(body), Math.floor(Date.now() / 1000));
|
||||
} catch {
|
||||
// Non-fatal: if storage fails, the request still succeeds
|
||||
}
|
||||
}
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
ValidationError,
|
||||
TRIP_SELECT,
|
||||
} from '../services/tripService';
|
||||
import { listDays } from '../services/dayService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listItems as listPackingItems } from '../services/packingService';
|
||||
import { listItems as listTodoItems } from '../services/todoService';
|
||||
import { listBudgetItems } from '../services/budgetService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listFiles } from '../services/fileService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -294,6 +301,36 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Offline bundle ────────────────────────────────────────────────────────
|
||||
// Returns all trip sub-collections in a single request for offline caching.
|
||||
|
||||
router.get('/:id/bundle', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const tripId = req.params.id;
|
||||
|
||||
const trip = getTrip(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { days } = listDays(tripId);
|
||||
const places = listPlaces(String(tripId), {});
|
||||
const packingItems = listPackingItems(tripId);
|
||||
const todoItems = listTodoItems(tripId);
|
||||
const budgetItems = listBudgetItems(tripId);
|
||||
const reservations = listReservations(tripId);
|
||||
const files = listFiles(tripId, false);
|
||||
|
||||
res.json({
|
||||
trip,
|
||||
days,
|
||||
places,
|
||||
packingItems,
|
||||
todoItems,
|
||||
budgetItems,
|
||||
reservations,
|
||||
files,
|
||||
});
|
||||
});
|
||||
|
||||
// ── ICS calendar export ───────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
+25
-1
@@ -230,11 +230,35 @@ function startVersionCheck(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
|
||||
let idempotencyCleanupTask: ScheduledTask | null = null;
|
||||
|
||||
function startIdempotencyCleanup(): void {
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
||||
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -892,3 +892,75 @@ describe('Copy trip with data', () => {
|
||||
expect(newNotes[0].text).toBe('Pack early!');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bundle endpoint — GET /api/trips/:id/bundle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Trip bundle', () => {
|
||||
it('BUNDLE-001 — returns all sub-collections for owned trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-03' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/bundle`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.id).toBe(trip.id);
|
||||
expect(Array.isArray(res.body.days)).toBe(true);
|
||||
expect(res.body.days).toHaveLength(3);
|
||||
expect(Array.isArray(res.body.places)).toBe(true);
|
||||
expect(Array.isArray(res.body.packingItems)).toBe(true);
|
||||
expect(Array.isArray(res.body.todoItems)).toBe(true);
|
||||
expect(Array.isArray(res.body.budgetItems)).toBe(true);
|
||||
expect(Array.isArray(res.body.reservations)).toBe(true);
|
||||
expect(Array.isArray(res.body.files)).toBe(true);
|
||||
});
|
||||
|
||||
it('BUNDLE-002 — returns 404 for trip that does not exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips/999999/bundle')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('BUNDLE-003 — returns 404 when user has no access to trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/bundle`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('BUNDLE-004 — members can fetch bundle', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/bundle`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip.id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('BUNDLE-005 — returns 401 when unauthenticated', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/bundle`);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── In-memory store + DB mock using vi.hoisted ────────────────────────────────
|
||||
const { rows, dbMock } = vi.hoisted(() => {
|
||||
const rows: Record<string, { status_code: number; response_body: string }> = {};
|
||||
|
||||
const dbMock = {
|
||||
db: {
|
||||
prepare: vi.fn((sql: string) => ({
|
||||
get: vi.fn((...args: unknown[]) => {
|
||||
const [key, userId] = args;
|
||||
return rows[`${key}:${userId}`] ?? undefined;
|
||||
}),
|
||||
run: vi.fn((...args: unknown[]) => {
|
||||
const [key, userId, , , status_code, response_body] = args as [string, number, string, string, number, string];
|
||||
const k = `${key}:${userId}`;
|
||||
if (!rows[k]) rows[k] = { status_code, response_body };
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return { rows, dbMock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
|
||||
import { applyIdempotency } from '../../../src/middleware/idempotency';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
function makeReq(method = 'POST', headers: Record<string, string> = {}): Request {
|
||||
return { method, path: '/api/test', headers } as unknown as Request;
|
||||
}
|
||||
|
||||
function makeRes(statusCode = 200): Response {
|
||||
const ctx = { status: statusCode };
|
||||
const res = {
|
||||
get statusCode() { return ctx.status; },
|
||||
status(code: number) { ctx.status = code; return res; },
|
||||
json: vi.fn((_body: unknown) => res),
|
||||
} as unknown as Response;
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(rows).forEach(k => delete rows[k]);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('applyIdempotency', () => {
|
||||
it('calls next() for GET requests', () => {
|
||||
const req = makeReq('GET', { 'x-idempotency-key': 'key1' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
applyIdempotency(req, res, next, 1);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls next() when header is absent for POST', () => {
|
||||
const req = makeReq('POST', {});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
applyIdempotency(req, res, next, 1);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('replays cached response when key+user already stored', () => {
|
||||
rows['cached-key:42'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) };
|
||||
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
applyIdempotency(req, res, next, 42);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.json as ReturnType<typeof vi.fn>).toHaveBeenCalledWith({ id: 99 });
|
||||
});
|
||||
|
||||
it('different user same key does NOT replay', () => {
|
||||
rows['cached-key:1'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) };
|
||||
const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
applyIdempotency(req, res, next, 99); // different user
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('stores 2xx response on first execution via wrapped res.json', () => {
|
||||
const req = makeReq('POST', { 'x-idempotency-key': 'new-key' });
|
||||
const res = makeRes(201);
|
||||
const next = vi.fn(() => {
|
||||
// Simulate handler calling res.json
|
||||
(res.json as ReturnType<typeof vi.fn>)({ id: 5 });
|
||||
});
|
||||
applyIdempotency(req, res, next, 7);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(rows['new-key:7']).toBeDefined();
|
||||
expect(rows['new-key:7'].status_code).toBe(201);
|
||||
expect(JSON.parse(rows['new-key:7'].response_body)).toEqual({ id: 5 });
|
||||
});
|
||||
|
||||
it('does NOT store 4xx responses', () => {
|
||||
const req = makeReq('POST', { 'x-idempotency-key': 'fail-key' });
|
||||
const res = makeRes(422);
|
||||
const next = vi.fn(() => {
|
||||
(res.json as ReturnType<typeof vi.fn>)({ error: 'Invalid' });
|
||||
});
|
||||
applyIdempotency(req, res, next, 3);
|
||||
expect(rows['fail-key:3']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles PUT, PATCH, and DELETE the same as POST', () => {
|
||||
for (const method of ['PUT', 'PATCH', 'DELETE'] as const) {
|
||||
const req = makeReq(method, { 'x-idempotency-key': `key-${method}` });
|
||||
const res = makeRes(200);
|
||||
const next = vi.fn();
|
||||
applyIdempotency(req, res, next, 1);
|
||||
expect(next).toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user