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:
jubnl
2026-04-14 23:04:13 +02:00
parent 8c7567faf3
commit b194e8317d
64 changed files with 3837 additions and 638 deletions
+168 -74
View File
@@ -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": {
+2
View File
@@ -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",
+8
View File
@@ -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 />} />
+14 -1
View File
@@ -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 = {
+26 -5
View File
@@ -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>
)
}
+156
View File
@@ -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();
}
+43
View File
@@ -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(),
}));
+3
View File
@@ -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>
+18
View File
@@ -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
},
}
+18
View File
@@ -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
},
}
+18
View File
@@ -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
},
}
+88
View File
@@ -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
},
}
+87
View File
@@ -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
},
}
+18
View File
@@ -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
},
}
+18
View File
@@ -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
},
}
+16
View File
@@ -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
},
}
+7
View File
@@ -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,
+5 -5
View File
@@ -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 =>
+5 -5
View File
@@ -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
+157 -1
View File
@@ -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())
}
+16 -11
View File
@@ -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) {
+162
View File
@@ -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
},
}
+68
View File
@@ -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
}
}
+203
View File
@@ -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 1016, 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 (ad).
*/
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`)
}
}
+166
View File
@@ -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
},
}
+17 -1
View File
@@ -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
+2
View File
@@ -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
+273
View File
@@ -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);
});
});
+119
View File
@@ -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();
});
});
+120
View File
@@ -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);
});
});
+1
View File
@@ -14,6 +14,7 @@ vi.mock('../../src/api/websocket', () => ({
addListener: vi.fn(),
removeListener: vi.fn(),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {