Compare commits

...

13 Commits

Author SHA1 Message Date
Julien G. ab03dfe2d5 Merge 69620e7276 into 640e5616e9 2026-05-05 00:14:54 +00:00
jubnl 69620e7276 feat: always-optimistic write pattern across all repos
All create/update/delete repo methods now write to IndexedDB optimistically
and fire mutationQueue.flush() as fire-and-forget, returning immediately
without waiting for the network. This eliminates the 8-second UX freeze
previously seen when the API was unreachable but navigator.onLine was true.

- Repos rewritten: trip, day, place, packing, todo, budget, accommodation,
  reservation, file — write methods never throw, always return optimistic data
- mutationQueue.flush() changed to iterative (one item per loop iteration)
  so mutations enqueued mid-flush (e.g. bulk check-all) are picked up
- fileRepo.toggleStar skips the IDB put when the file is not cached locally
- DayDetailPanel passes place_name into accommodationRepo.create so the
  optimistic accommodation renders the correct hotel label immediately
- Test suite updated throughout to reflect optimistic-first semantics:
  no more rollback assertions, IDB cleared in component test beforeEach hooks,
  FileManager tests switched from filesApi spy to MSW endpoint assertions
2026-05-05 02:14:39 +02:00
jubnl 3aa6b0952a fix: repair test suite after SWR offline-read changes
Add navigator.onLine guard to SWR refresh IIFEs so background
network calls don't fire in offline mode (prevents fake-IDB leakage
in tests via MSW default handlers).

Fix IDB isolation in affected test files by flushing pending macro
tasks then clearing IDB tables in beforeEach, so stale IDB writes
from previous tests' background IIFEs don't bleed into the next test.

Restore loadBudgetItems and refreshPlaces to apply background refresh
results to store state.

Move tags/categories API calls before the main Promise.all in
loadTrip so MSW handlers resolve during the await window.
2026-05-05 01:01:34 +02:00
jubnl a6a0521261 docs: update Offline-Mode-and-PWA wiki for SWR changes 2026-05-04 22:45:42 +02:00
jubnl a83b369cb7 fix: stale-while-revalidate for offline reads + axios timeout
navigator.onLine is unreliable on Android — returns true whenever any
network interface is up, regardless of actual reachability. This caused
all repo reads to take the API branch and either wait 5 s for the SW
NetworkFirst timeout (cache hit) or hang indefinitely (cache miss).

- All read repos (list/get) now return cached IndexedDB data instantly
  and carry a background refresh promise that resolves to fresh data or
  null on failure. Callers that opted in (loadTrip, loadTrips) apply
  fresh data silently when it arrives.
- tripStore.loadTrip: Promise.all now reads all 7 resources from
  IndexedDB (instant), fires network refreshes in background, sets
  isLoading: false immediately, then applies fresh data via a second
  Promise.all when ready. Tags/categories use upsertTags/upsertCategories.
- DashboardPage.loadTrips: same pattern — renders from cache instantly,
  silently updates trip list on refresh.
- axios timeout set to 8 s so requests can never hang indefinitely.
- SW networkTimeoutSeconds lowered from 5 to 2 as defence in depth.
2026-05-04 22:43:10 +02:00
jubnl 79d5fa7670 chore: update lock file 2026-05-04 22:01:01 +02:00
jubnl b94060aec1 fix: pass through reverse-proxy auth redirects in service worker
Navigation requests now use redirect:'manual' + network-first so upstream
auth gates (Cloudflare Zero Trust, Pangolin) can redirect the browser to
their SSO login page instead of being swallowed by the precached app shell.

An authRedirectPlugin on the API NetworkFirst strategy handles mid-session
expiry: detects opaqueredirect responses and converts them to a synthetic
401 { code: 'AUTH_REQUIRED' } that the existing Axios interceptor picks up,
triggering a full re-auth flow.

Offline fallback to the precached app shell is preserved.

Closes #836
2026-05-04 21:54:54 +02:00
jubnl 852f0085d1 feat: complete offline write support with mutation queue + runtime SW cache config
- Add offline CRUD to todoRepo, budgetRepo, reservationRepo, accommodationRepo,
  dayRepo, tripRepo, fileRepo with optimistic Dexie writes and mutation queue
- Wire all store slices (todo, budget, reservations, files, dayNotes, assignments,
  tripStore) through repos for offline-aware writes
- Cover archive/unarchive, file toggleStar/update/delete, assignment create/delete,
  day title/notes update offline paths
- Migrate service worker from generateSW to injectManifest (custom sw.ts) with
  runtime-configurable api-data (7d/500) and map-tiles (30d/1000) cache policies
- Add Settings → Offline cache configuration UI with save/reset and live SW postMessage
- Extend mutationQueue flush to cover all writable Dexie tables
2026-05-04 21:36:44 +02:00
github-actions[bot] 640e5616e9 chore: bump version to 3.0.15 [skip ci] 2026-05-04 12:22:15 +00:00
Julien G. 22f3bf4bfc fix: add APP_VERSION fallback and HOST bind address env var (#952 #953) (#955)
* fix: add APP_VERSION fallback and HOST bind env var (#952 #953)

- Read package.json version when APP_VERSION env var is absent so the
  startup banner shows the correct version for source/Proxmox installs
- Add HOST env var to control the HTTP bind address; only applied when
  set so Docker deployments are unaffected (bind-all-interfaces default)
- Parse PORT as Number() so malformed values like '10.0.0.72:3001' fall
  back to 3001 instead of silently misbehaving
- Document HOST in .env.example, Environment-Variables wiki, and
  Install-Proxmox wiki with explicit warnings against using it in Docker

* fix: correct package.json path in APP_VERSION fallback

index.ts sits at server/src/ — one level up reaches server/package.json,
not two (../../ overshot to the repo root where no package.json exists).
2026-05-04 14:21:55 +02:00
Tranko 256f38d8fa docs: add budget documentation GIFs (create, add expense, final settlement) (#948) 2026-05-03 18:56:56 +02:00
jubnl 9592cc663f docs: document wiki-only PR exemption from branch enforcement 2026-05-03 18:48:39 +02:00
jubnl dba4b28380 ci: exempt wiki-only PRs from branch target enforcement 2026-05-03 18:43:21 +02:00
64 changed files with 1681 additions and 569 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main`
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -32,6 +32,30 @@ jobs:
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
// Wiki-only PRs are exempt — clear label and skip
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
@@ -27,6 +27,33 @@ jobs:
return;
}
// Wiki-only PRs are exempt from branch enforcement
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
console.log('All changed files are under wiki/ — skipping enforcement.');
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
+1 -1
View File
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.14
version: 3.0.15
description: Minimal Helm chart for TREK app
appVersion: "3.0.14"
appVersion: "3.0.15"
+11 -12
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.14",
"version": "3.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.14",
"version": "3.0.15",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -27,6 +27,12 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -6471,7 +6477,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/indent-string": {
@@ -7538,9 +7543,9 @@
}
},
"node_modules/marked": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -12032,7 +12037,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
@@ -12042,14 +12046,12 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
@@ -12083,7 +12085,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0",
@@ -12120,7 +12121,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
@@ -12130,7 +12130,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.14",
"version": "3.0.15",
"private": true,
"type": "module",
"scripts": {
@@ -18,6 +18,12 @@
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
+1
View File
@@ -33,6 +33,7 @@ function translateRateLimit(): string {
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
@@ -10,8 +10,11 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel';
import { offlineDb } from '../../db/offlineDb';
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
@@ -35,6 +35,7 @@ vi.mock('../../api/client', async (importOriginal) => {
});
import { filesApi } from '../../api/client';
import { offlineDb } from '../../db/offlineDb';
const buildFile = (overrides = {}) => ({
id: 1,
@@ -66,7 +67,9 @@ const defaultProps = {
allowedFileTypes: null,
};
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions
@@ -130,15 +133,21 @@ describe('FileManager', () => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
it('FE-COMP-FILEMANAGER-005: star button calls star endpoint', async () => {
let starCalled = false;
server.use(
http.patch('/api/trips/1/files/1/star', () => {
starCalled = true;
return HttpResponse.json({ success: true });
}),
);
render(<FileManager {...defaultProps} files={[buildFile()]} />);
const user = userEvent.setup();
// Find the star button by its title
const starBtn = screen.getByTitle(/star/i);
await user.click(starBtn);
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
await waitFor(() => expect(starCalled).toBe(true));
});
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
@@ -398,39 +407,47 @@ describe('FileManager', () => {
await screen.findByText('Hotel Paris');
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls file update endpoint', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
const file = buildFile({ id: 1 });
const onUpdate = vi.fn().mockResolvedValue(undefined);
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, place_id: 10 } });
}),
);
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Louvre Museum');
// Click on the place button to link it
await user.click(screen.getByText('Louvre Museum'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: 10 }));
});
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls file update endpoint', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const file = buildFile({ id: 1 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, reservation_id: 20 } });
}),
);
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Train Ticket');
// Click on the reservation button to link it
await user.click(screen.getByText('Train Ticket'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: 20 }));
});
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
@@ -507,39 +524,46 @@ describe('FileManager', () => {
await screen.findByText(/Colosseum/);
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls file update endpoint', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Venice Beach' });
// File already has place_id set to 10 (linked)
const file = buildFile({ id: 1, place_id: 10 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, place_id: null } });
}),
);
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Venice Beach');
// Clicking the linked place should unlink it
await user.click(screen.getByText('Venice Beach'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: null }));
});
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls file update endpoint', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
// File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 20 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, reservation_id: null } });
}),
);
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Museum Pass');
// Clicking the linked reservation should unlink it
await user.click(screen.getByText('Museum Pass'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: null }));
});
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
+3 -2
View File
@@ -5,6 +5,7 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
import { fileRepo } from '../../repo/fileRepo'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -290,7 +291,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
await fileRepo.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
@@ -409,7 +410,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try {
await filesApi.update(tripId, fileId, data)
await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
@@ -9,8 +9,11 @@ import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';
import { offlineDb } from '../../db/offlineDb';
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Side-effect APIs PackingListPanel calls on mount
server.use(
@@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel';
import { offlineDb } from '../../db/offlineDb';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
@@ -28,7 +29,9 @@ const defaultProps = {
onAccommodationChange: vi.fn(),
};
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
vi.clearAllMocks();
server.use(
@@ -5,6 +5,7 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
@@ -117,8 +118,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
const selectedPlace = places.find(p => p.id === hotelForm.place_id)
const data = await accommodationRepo.create(tripId, {
place_id: hotelForm.place_id,
place_name: selectedPlace?.name,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
@@ -142,7 +145,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
@@ -151,7 +154,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
await accommodationRepo.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
@@ -583,7 +586,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) {
// Update existing
await accommodationsApi.update(tripId, accommodation.id, {
await accommodationRepo.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
+199 -15
View File
@@ -1,13 +1,21 @@
/**
* Offline settings tab — shows cached trips, storage info, and controls
* to re-sync or clear the offline cache.
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue'
import {
DEFAULT_SW_CONFIG,
loadSwConfig,
saveSwConfig,
validateSwConfig,
SW_CONFIG_BOUNDS,
type SwCacheConfig,
} from '../../sync/swConfig'
import type { SyncMeta } from '../../db/offlineDb'
import type { Trip } from '../../types'
@@ -25,6 +33,12 @@ export default function OfflineTab(): React.ReactElement {
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true)
// Cache config state
const [cacheConfig, setCacheConfig] = useState<SwCacheConfig>({ ...DEFAULT_SW_CONFIG })
const [configSaving, setConfigSaving] = useState(false)
const [configApplied, setConfigApplied] = useState<Date | null>(null)
const appliedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
@@ -53,6 +67,59 @@ export default function OfflineTab(): React.ReactElement {
useEffect(() => { load() }, [load])
// Load persisted cache config on mount
useEffect(() => {
loadSwConfig().then(setCacheConfig).catch(() => {})
}, [])
// Listen for SW acknowledgement
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'CACHE_CONFIG_APPLIED') {
setConfigApplied(new Date())
setConfigSaving(false)
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
appliedTimerRef.current = setTimeout(() => setConfigApplied(null), 5000)
}
}
navigator.serviceWorker?.addEventListener('message', handler)
return () => {
navigator.serviceWorker?.removeEventListener('message', handler)
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
}
}, [])
async function handleSaveConfig() {
const validated = validateSwConfig(cacheConfig)
setCacheConfig(validated)
setConfigSaving(true)
try {
await saveSwConfig(validated)
const controller = navigator.serviceWorker?.controller
if (controller) {
controller.postMessage({ type: 'UPDATE_CACHE_CONFIG', config: validated })
// configSaving cleared by the SW message handler
} else {
// No active SW yet (e.g. first install) — config saved to IDB, applied on next SW activation
setConfigApplied(new Date())
setConfigSaving(false)
}
} catch {
setConfigSaving(false)
}
}
function handleResetConfig() {
setCacheConfig({ ...DEFAULT_SW_CONFIG })
}
function updateField(field: keyof SwCacheConfig) {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v }))
}
}
async function handleResync() {
setSyncing(true)
try {
@@ -120,6 +187,86 @@ export default function OfflineTab(): React.ReactElement {
</button>
</div>
{/* Cache configuration */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Settings2 size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Cache configuration</span>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
Changes apply immediately to the service worker and persist across reloads.
Existing cached entries follow their original TTL; new entries use the updated settings.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<CacheField
label="API cache TTL (days)"
value={cacheConfig.apiTtlDays}
min={SW_CONFIG_BOUNDS.ttlMin}
max={SW_CONFIG_BOUNDS.ttlMax}
onChange={updateField('apiTtlDays')}
/>
<CacheField
label="API max entries"
value={cacheConfig.apiMaxEntries}
min={SW_CONFIG_BOUNDS.entriesMin}
max={SW_CONFIG_BOUNDS.entriesMax}
onChange={updateField('apiMaxEntries')}
/>
<CacheField
label="Map tiles TTL (days)"
value={cacheConfig.tilesTtlDays}
min={SW_CONFIG_BOUNDS.ttlMin}
max={SW_CONFIG_BOUNDS.ttlMax}
onChange={updateField('tilesTtlDays')}
/>
<CacheField
label="Map tiles max entries"
value={cacheConfig.tilesMaxEntries}
min={SW_CONFIG_BOUNDS.entriesMin}
max={SW_CONFIG_BOUNDS.entriesMax}
onChange={updateField('tilesMaxEntries')}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={handleSaveConfig}
disabled={configSaving}
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: configSaving ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500, opacity: configSaving ? 0.6 : 1,
}}
>
<RefreshCw size={14} style={configSaving ? { animation: 'spin 1s linear infinite' } : {}} />
{configSaving ? 'Applying…' : 'Save'}
</button>
<button
onClick={handleResetConfig}
disabled={configSaving}
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-muted)',
cursor: configSaving ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500,
}}
>
<RotateCcw size={14} />
Reset to defaults
</button>
{configApplied && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#22c55e' }}>
<CheckCircle size={12} />
Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
{/* Cached trip list */}
{loading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading</p>
@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement {
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)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ fontWeight: 600, fontSize: 14, color: trip.title ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: trip.title ? 'normal' : 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title || 'Unnamed trip'}
</span>
{trip.description ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description}
</span>
) : null}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{trip.start_date
? `${formatDate(trip.start_date)} ${formatDate(trip.end_date)}`
: 'No dates set'}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null}
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
<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>
@@ -178,3 +333,32 @@ function Stat({ label, value }: { label: string; value: number }) {
</div>
)
}
function CacheField({
label, value, min, max, onChange,
}: {
label: string
value: number
min: number
max: number
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</span>
<input
type="number"
value={value}
min={min}
max={max}
onChange={onChange}
style={{
padding: '6px 10px', borderRadius: 6,
border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
fontSize: 13, width: '100%', boxSizing: 'border-box',
}}
/>
</label>
)
}
+9 -4
View File
@@ -744,12 +744,17 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips } = await tripRepo.list()
const { trips, archivedTrips, refresh } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setIsLoading(false)
refresh.then(fresh => {
if (!fresh) return
setTrips(sortTrips(fresh.trips))
setArchivedTrips(sortTrips(fresh.archivedTrips))
}).catch(() => {})
} catch {
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
@@ -791,7 +796,7 @@ export default function DashboardPage(): React.ReactElement {
const handleArchive = async (id) => {
try {
const data = await tripsApi.archive(id)
const data = await tripRepo.update(id, { is_archived: true })
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
@@ -802,7 +807,7 @@ export default function DashboardPage(): React.ReactElement {
const handleUnarchive = async (id) => {
try {
const data = await tripsApi.unarchive(id)
const data = await tripRepo.update(id, { is_archived: false })
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
+4 -1
View File
@@ -9,6 +9,7 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import FilesPage from './FilesPage';
import { offlineDb } from '../db/offlineDb';
vi.mock('../components/Files/FileManager', () => ({
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) =>
@@ -29,7 +30,9 @@ function renderFilesPage(tripId: number | string = 1) {
);
}
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
+82 -9
View File
@@ -1,16 +1,89 @@
import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Accommodation } from '../types'
export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
if (!navigator.onLine) {
const accommodations = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
return { accommodations }
}
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> {
const cached = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { accommodations: cached, refresh }
const fresh = await refresh
if (!fresh) return { accommodations: [], refresh: Promise.resolve(null) }
return { accommodations: fresh.accommodations, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
const tempId = -(Date.now())
const tempAccommodation: Accommodation = {
...(data as Partial<Accommodation>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New accommodation',
address: null,
check_in: null,
check_in_end: null,
check_out: null,
confirmation_number: null,
notes: null,
url: null,
created_at: new Date().toISOString(),
} as Accommodation
await offlineDb.accommodations.put(tempAccommodation)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/accommodations`,
body: data,
resource: 'accommodations',
tempId,
})
mutationQueue.flush().catch(() => {})
return { accommodation: tempAccommodation }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
const existing = await offlineDb.accommodations.get(id)
const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial<Accommodation>), id }
await offlineDb.accommodations.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/accommodations/${id}`,
body: data,
resource: 'accommodations',
})
mutationQueue.flush().catch(() => {})
return { accommodation: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.accommodations.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/accommodations/${id}`,
body: undefined,
resource: 'accommodations',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+79 -11
View File
@@ -1,18 +1,86 @@
import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
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
async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> {
const cached = await offlineDb.budgetItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
const tempId = -(Date.now())
const tempItem: BudgetItem = {
...(data as Partial<BudgetItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New expense',
amount: (data.amount as number) ?? 0,
currency: (data.currency as string) ?? 'USD',
members: [],
} as BudgetItem
await offlineDb.budgetItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/budget`,
body: data,
resource: 'budgetItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
const existing = await offlineDb.budgetItems.get(id)
const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial<BudgetItem>), id }
await offlineDb.budgetItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/budget/${id}`,
body: data,
resource: 'budgetItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.budgetItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/budget/${id}`,
body: undefined,
resource: 'budgetItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+39 -11
View File
@@ -1,18 +1,46 @@
import { daysApi } from '../api/client'
import { offlineDb, upsertDays } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
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
async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> {
const cached = (await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)) as Day[]
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await daysApi.list(tripId)
upsertDays(result.days)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { days: cached, refresh }
const fresh = await refresh
if (!fresh) return { days: [], refresh: Promise.resolve(null) }
return { days: fresh.days, refresh: Promise.resolve(fresh) }
},
async update(tripId: number | string, dayId: number | string, data: Record<string, unknown>): Promise<{ day: Day }> {
const existing = await offlineDb.days.get(Number(dayId))
const optimistic: Day = { ...(existing ?? {} as Day), ...data, id: Number(dayId) }
await offlineDb.days.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/days/${dayId}`,
body: data,
resource: 'days',
})
mutationQueue.flush().catch(() => {})
return { day: optimistic }
},
}
+69 -10
View File
@@ -1,18 +1,77 @@
import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
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 }
async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { files: cached, refresh }
const fresh = await refresh
if (!fresh) return { files: [], refresh: Promise.resolve(null) }
return { files: fresh.files, refresh: Promise.resolve(fresh) }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ file: TripFile }> {
const existing = await offlineDb.tripFiles.get(id)
const optimistic: TripFile = { ...(existing ?? {} as TripFile), ...(data as Partial<TripFile>), id: Number(id) }
await offlineDb.tripFiles.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/files/${id}`,
body: data,
resource: 'tripFiles',
})
mutationQueue.flush().catch(() => {})
return { file: optimistic }
},
async toggleStar(tripId: number | string, id: number): Promise<unknown> {
const existing = await offlineDb.tripFiles.get(id)
if (existing) {
await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 })
}
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PATCH',
url: `/trips/${tripId}/files/${id}/star`,
body: undefined,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.tripFiles.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/files/${id}`,
body: undefined,
resource: 'tripFiles',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+67 -71
View File
@@ -4,85 +4,81 @@ 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 list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> {
const cached = await offlineDb.packingItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
},
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
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)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/packing`,
body: data,
resource: 'packingItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
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
const existing = await offlineDb.packingItems.get(id)
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/packing/${id}`,
body: data,
resource: 'packingItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
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
await offlineDb.packingItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/packing/${id}`,
body: undefined,
resource: 'packingItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+75 -84
View File
@@ -4,106 +4,97 @@ 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 list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> {
const cached = await offlineDb.places
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { places: cached, refresh }
const fresh = await refresh
if (!fresh) return { places: [], refresh: Promise.resolve(null) }
return { places: fresh.places, refresh: Promise.resolve(fresh) }
},
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
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)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/places`,
body: data,
resource: 'places',
tempId,
})
mutationQueue.flush().catch(() => {})
return { place: tempPlace }
},
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
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)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
})
mutationQueue.flush().catch(() => {})
return { place: optimistic }
},
async delete(tripId: number | string, id: number | string): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
await offlineDb.places.delete(Number(id))
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
await mutationQueue.enqueue({
id: mutId,
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
entityId: id,
})
return { success: true }
}
const result = await placesApi.delete(tripId, id)
offlineDb.places.delete(Number(id))
return result
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: id,
})
}
return { deleted: ids, count: ids.length }
}
const result = await placesApi.bulkDelete(tripId, ids)
await offlineDb.places.bulkDelete(ids)
return result
mutationQueue.flush().catch(() => {})
return { deleted: ids, count: ids.length }
},
}
+84 -11
View File
@@ -1,18 +1,91 @@
import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
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
async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> {
const cached = await offlineDb.reservations
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { reservations: cached, refresh }
const fresh = await refresh
if (!fresh) return { reservations: [], refresh: Promise.resolve(null) }
return { reservations: fresh.reservations, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
const tempId = -(Date.now())
const tempReservation: Reservation = {
...(data as Partial<Reservation>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New reservation',
type: (data.type as string) ?? 'other',
status: 'pending',
date: (data.date as string) ?? null,
time: null,
confirmation_number: null,
notes: null,
url: null,
created_at: new Date().toISOString(),
} as Reservation
await offlineDb.reservations.put(tempReservation)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/reservations`,
body: data,
resource: 'reservations',
tempId,
})
mutationQueue.flush().catch(() => {})
return { reservation: tempReservation }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
const existing = await offlineDb.reservations.get(id)
const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial<Reservation>), id }
await offlineDb.reservations.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/reservations/${id}`,
body: data,
resource: 'reservations',
})
mutationQueue.flush().catch(() => {})
return { reservation: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.reservations.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/reservations/${id}`,
body: undefined,
resource: 'reservations',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+82 -11
View File
@@ -1,18 +1,89 @@
import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
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
async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> {
const cached = await offlineDb.todoItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(fresh) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
const tempId = -(Date.now())
const tempItem: TodoItem = {
...(data as Partial<TodoItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New todo',
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
} as TodoItem
await offlineDb.todoItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/todo`,
body: data,
resource: 'todoItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
const existing = await offlineDb.todoItems.get(id)
const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial<TodoItem>), id }
await offlineDb.todoItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/todo/${id}`,
body: data,
resource: 'todoItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.todoItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/todo/${id}`,
body: undefined,
resource: 'todoItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+63 -19
View File
@@ -1,33 +1,77 @@
import { tripsApi } from '../api/client'
import { offlineDb, upsertTrip } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Trip } from '../types'
type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null>
type TripRefresh = Promise<{ trip: Trip } | null>
export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
if (!navigator.onLine) {
const all = await offlineDb.trips.toArray()
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> {
const all = await offlineDb.trips.toArray()
const refresh: TripsRefresh = (async () => {
if (!navigator.onLine) return null
try {
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
} catch {
return null
}
})()
if (all.length > 0) {
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
refresh,
}
}
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
const fresh = await refresh
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
return { ...fresh, refresh: Promise.resolve(fresh) }
},
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
async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> {
const cached = await offlineDb.trips.get(Number(tripId))
const refresh: TripRefresh = (async () => {
if (!navigator.onLine) return null
try {
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
return result
} catch {
return null
}
})()
if (cached) return { trip: cached, refresh }
const fresh = await refresh
if (!fresh) throw new Error('No cached trip data available offline')
return { trip: fresh.trip, refresh: Promise.resolve(fresh) }
},
async update(tripId: number | string, data: Partial<Trip>): Promise<{ trip: Trip }> {
const existing = await offlineDb.trips.get(Number(tripId))
const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial<Trip>), id: Number(tripId) }
await offlineDb.trips.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}`,
body: data as Record<string, unknown>,
resource: 'trips',
})
mutationQueue.flush().catch(() => {})
return { trip: optimistic }
},
}
@@ -1,4 +1,6 @@
import { assignmentsApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, AssignmentsMap } from '../../types'
@@ -40,6 +42,23 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(parseInt(String(dayId)))
if (day) {
const updated = [...(day.assignments || [])]
updated.splice(insertIdx, 0, tempAssignment)
await offlineDb.days.put({ ...day, assignments: updated })
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/days/${dayId}/assignments`,
body: { place_id: placeId },
})
return tempAssignment
}
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment: Assignment = {
@@ -99,6 +118,24 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(parseInt(String(dayId)))
if (day) {
await offlineDb.days.put({
...day,
assignments: (day.assignments || []).filter(a => a.id !== assignmentId),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/days/${dayId}/assignments/${assignmentId}`,
body: undefined,
})
return
}
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err: unknown) {
+24 -23
View File
@@ -4,8 +4,11 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore';
import { offlineDb } from '../../db/offlineDb';
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
server.resetHandlers();
});
@@ -34,25 +37,28 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toEqual([]);
});
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => {
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: newItem })
HttpResponse.json({ item: buildBudgetItem({ name: 'Hotel', trip_id: 1 }) })
)
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
expect(result.id).toBe(newItem.id);
expect(useTripStore.getState().budgetItems).toContainEqual(newItem);
expect(result.name).toBe('Hotel');
const items = useTripStore.getState().budgetItems;
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Hotel');
});
it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => {
it('FE-STORE-BUDGET-004: addBudgetItem adds item optimistically even on API error', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Item' });
expect(result.name).toBe('Item');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
@@ -71,24 +77,21 @@ describe('budgetSlice', () => {
expect(items[0].name).toBe('New');
});
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1 });
it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 });
seedStore(useTripStore, { budgetItems: [existing] });
const loadReservations = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadReservations });
const itemWithReservation = { ...existing, reservation_id: 99 };
server.use(
http.put('/api/trips/1/budget/20', () =>
HttpResponse.json({ item: itemWithReservation })
HttpResponse.json({ item: { ...existing, amount: 50 } })
)
);
await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 });
expect(loadReservations).toHaveBeenCalledWith(1);
const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 });
expect(result.amount).toBe(50);
expect(useTripStore.getState().budgetItems[0].amount).toBe(50);
});
it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => {
it('FE-STORE-BUDGET-007: deleteBudgetItem removes item permanently even on API error', async () => {
const item = buildBudgetItem({ id: 5, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -97,11 +100,9 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
// The item is removed immediately (optimistic), then restored on error
const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5);
await expect(deletePromise).rejects.toThrow();
// After rollback, item is back
expect(useTripStore.getState().budgetItems).toContainEqual(item);
await useTripStore.getState().deleteBudgetItem(1, 5);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
});
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
+6 -3
View File
@@ -24,6 +24,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
try {
const data = await budgetRepo.list(tripId)
set({ budgetItems: data.items })
data.refresh.then(fresh => {
if (fresh) set({ budgetItems: fresh.items })
}).catch(() => {})
} catch (err: unknown) {
console.error('Failed to load budget items:', err)
}
@@ -31,7 +34,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
const result = await budgetRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -41,7 +44,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
@@ -58,7 +61,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
await budgetRepo.delete(tripId, id)
} catch (err: unknown) {
set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
+67 -3
View File
@@ -1,4 +1,7 @@
import { daysApi, dayNotesApi } from '../../api/client'
import { dayNotesApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { dayRepo } from '../../repo/dayRepo'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types'
@@ -19,7 +22,7 @@ export interface DayNotesSlice {
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
await dayRepo.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
}))
@@ -30,7 +33,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
await dayRepo.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
}))
@@ -48,6 +51,22 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({ ...day, notes_items: [...(day.notes_items || []), tempNote] })
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/days/${dayId}/notes`,
body: data as Record<string, unknown>,
})
return tempNote
}
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
@@ -69,6 +88,32 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
},
updateDayNote: async (tripId, dayId, id, data) => {
if (!navigator.onLine) {
const existing = get().dayNotes[String(dayId)]?.find(n => n.id === id)
const optimistic: DayNote = { ...(existing ?? {} as DayNote), ...(data as Partial<DayNote>), id }
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? optimistic : n),
}
}))
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({
...day,
notes_items: (day.notes_items || []).map(n => n.id === id ? optimistic : n),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
body: data as Record<string, unknown>,
})
return optimistic
}
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
@@ -91,6 +136,25 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({
...day,
notes_items: (day.notes_items || []).filter(n => n.id !== id),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
body: undefined,
})
return
}
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err: unknown) {
+4 -2
View File
@@ -35,10 +35,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
},
deleteFile: async (tripId, id) => {
const prev = get().files
set(state => ({ files: state.files.filter(f => f.id !== id) }))
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
await fileRepo.delete(tripId, id)
} catch (err: unknown) {
set({ files: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
}
},
+3
View File
@@ -20,6 +20,9 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
try {
const data = await placeRepo.list(tripId)
set({ places: data.places })
data.refresh.then(fresh => {
if (fresh) set({ places: fresh.places })
}).catch(() => {})
} catch (err: unknown) {
console.error('Failed to refresh places:', err)
}
+7 -6
View File
@@ -1,4 +1,3 @@
import { reservationsApi } from '../../api/client'
import { reservationRepo } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
@@ -28,7 +27,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
const result = await reservationRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
@@ -38,7 +37,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
@@ -57,17 +56,19 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
await reservationRepo.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
const prev = get().reservations
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
await reservationRepo.delete(tripId, id)
} catch (err: unknown) {
set({ reservations: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
+5 -5
View File
@@ -1,4 +1,4 @@
import { todoApi } from '../../api/client'
import { todoRepo } from '../../repo/todoRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types'
@@ -17,7 +17,7 @@ export interface TodoSlice {
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
addTodoItem: async (tripId, data) => {
try {
const result = await todoApi.create(tripId, data)
const result = await todoRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ todoItems: [...state.todoItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
updateTodoItem: async (tripId, id, data) => {
try {
const result = await todoApi.update(tripId, id, data)
const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
}))
@@ -41,7 +41,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
const prev = get().todoItems
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
try {
await todoApi.delete(tripId, id)
await todoRepo.delete(tripId, id)
} catch (err: unknown) {
set({ todoItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
@@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
)
}))
try {
await todoApi.update(tripId, id, { checked })
await todoRepo.update(tripId, id, { checked })
} catch {
set(state => ({
todoItems: state.todoItems.map(item =>
+63 -24
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import { offlineDb } from '../db/offlineDb'
import { tagsApi, categoriesApi } from '../api/client'
import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb'
import { tripRepo } from '../repo/tripRepo'
import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo'
@@ -89,27 +89,38 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
// Fire tags/categories network refresh immediately — they're global (not trip-specific)
// and must be in-flight before the await below so MSW resolves them during the wait
const tagsRefresh = tagsApi.list()
.then(fresh => { upsertTags(fresh.tags).catch(() => {}); return fresh })
.catch(() => null)
const categoriesRefresh = categoriesApi.list()
.then(fresh => { upsertCategories(fresh.categories).catch(() => {}); return fresh })
.catch(() => null)
// All reads from IndexedDB — instant, no network wait
const [tripData, daysData, placesData, packingData, todoData, cachedTags, cachedCategories] = await Promise.all([
tripRepo.get(tripId),
dayRepo.list(tripId),
placeRepo.list(tripId),
packingRepo.list(tripId),
todoRepo.list(tripId),
navigator.onLine
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
: offlineDb.tags.toArray().then(tags => ({ tags })),
navigator.onLine
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
: offlineDb.categories.toArray().then(categories => ({ categories })),
offlineDb.tags.toArray(),
offlineDb.categories.toArray(),
])
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
const buildMaps = (days: Day[]) => {
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
return { assignmentsMap, dayNotesMap }
}
const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days)
set({
trip: tripData.trip,
days: daysData.days,
@@ -118,10 +129,36 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
tags: cachedTags,
categories: cachedCategories,
isLoading: false,
})
// Apply background refreshes — update state when fresh data arrives
Promise.all([
tripData.refresh,
daysData.refresh,
placesData.refresh,
packingData.refresh,
todoData.refresh,
tagsRefresh,
categoriesRefresh,
]).then(([freshTrip, freshDays, freshPlaces, freshPacking, freshTodo, freshTags, freshCategories]) => {
const updates: Partial<TripStoreState> = {}
if (freshTrip) updates.trip = freshTrip.trip
if (freshDays) {
const { assignmentsMap: am, dayNotesMap: dm } = buildMaps(freshDays.days)
updates.days = freshDays.days
updates.assignments = am
updates.dayNotes = dm
}
if (freshPlaces) updates.places = freshPlaces.places
if (freshPacking) updates.packingItems = freshPacking.items
if (freshTodo) updates.todoItems = freshTodo.items
if (freshTags) updates.tags = freshTags.tags
if (freshCategories) updates.categories = freshCategories.categories
if (Object.keys(updates).length > 0) set(updates)
}).catch(() => {})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
set({ isLoading: false, error: message })
@@ -146,16 +183,18 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
try {
const result = await tripsApi.update(tripId, data)
const result = await tripRepo.update(tripId, data)
set({ trip: result.trip })
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
if (navigator.onLine) {
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
+161
View File
@@ -0,0 +1,161 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core';
import {
precacheAndRoute,
cleanupOutdatedCaches,
matchPrecache,
} from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import {
DEFAULT_SW_CONFIG,
readSwConfigFromIDB,
validateSwConfig,
type SwCacheConfig,
} from './sync/swConfig';
declare const self: ServiceWorkerGlobalScope;
self.skipWaiting();
clientsClaim();
// Inject precache manifest (replaced by vite-plugin-pwa at build time)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// ── Static routes (not user-configurable) ─────────────────────────────────────
// Network-first navigations so reverse-proxy auth redirects (Cloudflare Zero
// Trust, Pangolin, etc.) reach the browser instead of being swallowed by the
// precached app shell. `redirect: 'manual'` produces an opaqueredirect Response
// which, per Fetch spec, the browser follows for navigation requests returned
// from FetchEvent.respondWith. Falls back to precached app shell offline.
registerRoute(
new NavigationRoute(
async ({ request }) => {
try {
return await fetch(request, { redirect: 'manual' });
} catch {
const cached = await matchPrecache('index.html');
return cached ?? Response.error();
}
},
{ denylist: [/^\/api/, /^\/uploads/, /^\/mcp/] },
),
);
registerRoute(
/^https:\/\/unpkg\.com\/.*/i,
new CacheFirst({
cacheName: 'cdn-libs',
plugins: [
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
}),
'GET',
);
registerRoute(
/\/uploads\/(?:covers|avatars)\/.*/i,
new CacheFirst({
cacheName: 'user-uploads',
plugins: [
new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [200] }),
],
}),
'GET',
);
// ── Configurable routes ────────────────────────────────────────────────────────
// Routes are registered once. Strategy instances are replaced on config change
// so the stable handler wrapper always delegates to the current instance.
const DAY = 24 * 60 * 60;
// Detects when an upstream reverse-proxy auth gate (Cloudflare Zero Trust,
// Pangolin, etc.) redirects a mid-session API call to an external SSO login
// page. Uses redirect:'manual' so the response stays as opaqueredirect instead
// of being silently followed; converts it to a 401 that the Axios interceptor
// in api/client.ts already handles (→ window.location.href = '/login').
const authRedirectPlugin = {
async requestWillFetch({ request }: { request: Request }): Promise<Request> {
return new Request(request, { redirect: 'manual' });
},
async fetchDidSucceed({ response }: { response: Response }): Promise<Response> {
if (response.type === 'opaqueredirect') {
return new Response(JSON.stringify({ code: 'AUTH_REQUIRED' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return response;
},
};
function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst {
return new NetworkFirst({
cacheName: 'api-data',
networkTimeoutSeconds: 2,
plugins: [
authRedirectPlugin,
new ExpirationPlugin({
maxEntries: cfg.apiMaxEntries,
maxAgeSeconds: cfg.apiTtlDays * DAY,
}),
new CacheableResponsePlugin({ statuses: [200] }),
],
});
}
function buildTilesStrategy(cfg: SwCacheConfig): CacheFirst {
return new CacheFirst({
cacheName: 'map-tiles',
plugins: [
new ExpirationPlugin({
maxEntries: cfg.tilesMaxEntries,
maxAgeSeconds: cfg.tilesTtlDays * DAY,
}),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
});
}
let apiStrategy = buildApiStrategy(DEFAULT_SW_CONFIG);
let cartoStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
let osmStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
function applyConfig(cfg: SwCacheConfig): void {
apiStrategy = buildApiStrategy(cfg);
cartoStrategy = buildTilesStrategy(cfg);
osmStrategy = buildTilesStrategy(cfg);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i, { handle: (o: any) => apiStrategy.handle(o) }, 'GET');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, { handle: (o: any) => cartoStrategy.handle(o) }, 'GET');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, { handle: (o: any) => osmStrategy.handle(o) }, 'GET');
// Load persisted config asynchronously; replaces defaults if user has saved settings
readSwConfigFromIDB()
.then(cfg => { if (cfg) applyConfig(cfg); })
.catch(() => {});
// ── Message handler ────────────────────────────────────────────────────────────
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const data = event.data as { type?: string; config?: unknown };
if (data?.type !== 'UPDATE_CACHE_CONFIG' || !data.config) return;
const validated = validateSwConfig(data.config as Partial<SwCacheConfig>);
applyConfig(validated);
// Acknowledge back to the sending client
(event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' });
});
+16 -11
View File
@@ -13,12 +13,15 @@ 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,
trips: offlineDb.trips,
days: offlineDb.days,
places: offlineDb.places,
packingItems: offlineDb.packingItems,
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
accommodations: offlineDb.accommodations,
tripFiles: offlineDb.tripFiles,
}
return map[resource]
}
@@ -70,12 +73,14 @@ export const mutationQueue = {
if (_flushing || !navigator.onLine) return
_flushing = true
try {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
while (true) {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
const mutation = pending[0]
if (!mutation) break
for (const mutation of pending) {
// Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
+80
View File
@@ -0,0 +1,80 @@
/**
* SW cache configuration — shared between the service worker and the main thread.
* Uses a dedicated 'trek-sw-config' IndexedDB database (separate from trek-offline)
* so the SW can read it without needing to know the full trek-offline schema versions.
*/
import Dexie, { type Table } from 'dexie';
export interface SwCacheConfig {
apiTtlDays: number;
apiMaxEntries: number;
tilesTtlDays: number;
tilesMaxEntries: number;
}
export const DEFAULT_SW_CONFIG: SwCacheConfig = {
apiTtlDays: 7,
apiMaxEntries: 500,
tilesTtlDays: 30,
tilesMaxEntries: 1000,
};
export const SW_CONFIG_BOUNDS = {
ttlMin: 1,
ttlMax: 365,
entriesMin: 10,
entriesMax: 5000,
};
export function validateSwConfig(raw: Partial<SwCacheConfig>): SwCacheConfig {
const clamp = (v: unknown, min: number, max: number, def: number): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.max(min, Math.min(max, Math.round(n))) : def;
};
return {
apiTtlDays: clamp(raw.apiTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.apiTtlDays),
apiMaxEntries: clamp(raw.apiMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.apiMaxEntries),
tilesTtlDays: clamp(raw.tilesTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.tilesTtlDays),
tilesMaxEntries:clamp(raw.tilesMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.tilesMaxEntries),
};
}
// ── Dedicated IDB for SW config ───────────────────────────────────────────────
interface SwConfigRow extends SwCacheConfig {
id: 'singleton';
updatedAt: number;
}
class SwConfigDb extends Dexie {
config!: Table<SwConfigRow, 'singleton'>;
constructor() {
super('trek-sw-config');
this.version(1).stores({ config: 'id' });
}
}
let _db: SwConfigDb | null = null;
function getDb(): SwConfigDb {
if (!_db) _db = new SwConfigDb();
return _db;
}
export async function readSwConfigFromIDB(): Promise<SwCacheConfig | null> {
try {
const row = await getDb().config.get('singleton');
return row ? validateSwConfig(row) : null;
} catch {
return null;
}
}
export async function saveSwConfig(cfg: SwCacheConfig): Promise<void> {
const validated = validateSwConfig(cfg);
await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() });
}
export async function loadSwConfig(): Promise<SwCacheConfig> {
return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG };
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface User {
export interface Trip {
id: number
name: string
title: string
description: string | null
start_date: string
end_date: string
+9 -19
View File
@@ -66,38 +66,28 @@ describe('packingRepo.list', () => {
});
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 })),
);
it('writes item optimistically to Dexie immediately', async () => {
const result = await packingRepo.create(1, { name: 'Sunscreen' });
expect(result.item.name).toBe('Sunscreen');
// tempId is negative (-(Date.now()))
expect(result.item.id).toBeLessThan(0);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(item.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Sunscreen');
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Sunscreen');
});
});
describe('packingRepo.update', () => {
it('calls REST and updates Dexie cache', async () => {
it('writes optimistic update to Dexie immediately', 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);
expect(result.item.checked).toBeTruthy();
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(original.id);
expect(cached!.checked).toBe(1);
expect(cached!.checked).toBeTruthy();
});
});
+6 -10
View File
@@ -67,19 +67,15 @@ describe('placeRepo.list', () => {
});
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 })),
);
it('writes place optimistically to Dexie immediately', async () => {
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
expect(result.place.name).toBe('Eiffel Tower');
// tempId is negative (-(Date.now()))
expect(result.place.id).toBeLessThan(0);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.places.get(place.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Eiffel Tower');
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Eiffel Tower');
});
});
+21 -28
View File
@@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildBudgetItem, buildReservation } from '../../helpers/factories';
import { buildBudgetItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -49,16 +52,18 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toHaveLength(2);
});
it('FE-BUDGET-003: addBudgetItem on failure throws', async () => {
it('FE-BUDGET-003: addBudgetItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
).rejects.toThrow();
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].name).toBe('Fail');
});
});
@@ -80,38 +85,26 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
});
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
const initialReservation = buildReservation({ trip_id: 1 });
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
seedStore(useTripStore, {
budgetItems: [item],
reservations: [initialReservation],
});
it('FE-BUDGET-005: updateBudgetItem resolves and updates store optimistically', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
seedStore(useTripStore, { budgetItems: [item] });
server.use(
http.put('/api/trips/1/budget/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
// Return item with reservation_id to trigger loadReservations
return HttpResponse.json({ item: { ...item, ...body, reservation_id: 42 } });
}),
http.get('/api/trips/1/reservations', () =>
HttpResponse.json({ reservations: [newReservation] })
),
);
await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record<string, unknown>);
const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record<string, unknown>);
// Wait for the async loadReservations to complete
await new Promise(resolve => setTimeout(resolve, 50));
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
expect(result.amount).toBe(200);
expect(useTripStore.getState().budgetItems[0].amount).toBe(200);
});
});
describe('deleteBudgetItem', () => {
it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => {
it('FE-BUDGET-006: deleteBudgetItem removes item permanently even on API error', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -121,10 +114,10 @@ describe('budgetSlice', () => {
),
);
await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteBudgetItem(1, 10);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].id).toBe(10);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
});
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
+4 -4
View File
@@ -100,7 +100,7 @@ describe('filesSlice', () => {
expect(files[0].id).toBe(20);
});
it('FE-FILES-006: deleteFile on failure throws', async () => {
it('FE-FILES-006: deleteFile removes file permanently even on API error', async () => {
const file = buildTripFile({ id: 10, trip_id: 1 });
seedStore(useTripStore, { files: [file] });
@@ -110,10 +110,10 @@ describe('filesSlice', () => {
),
);
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteFile(1, 10);
// File remains since server-first (only removes after success)
expect(useTripStore.getState().files).toHaveLength(1);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().files).toHaveLength(0);
});
});
});
+16 -13
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPackingItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -36,16 +39,18 @@ describe('packingSlice', () => {
expect(items[items.length - 1].name).toBe('Toothbrush');
});
it('FE-PACKING-002: addPackingItem on failure throws', async () => {
it('FE-PACKING-002: addPackingItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/packing', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
).rejects.toThrow();
const result = await useTripStore.getState().addPackingItem(1, { name: 'Fail item' });
expect(result.name).toBe('Fail item');
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].name).toBe('Fail item');
});
});
@@ -69,7 +74,7 @@ describe('packingSlice', () => {
});
describe('deletePackingItem', () => {
it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => {
it('FE-PACKING-004: deletePackingItem removes item permanently even on API error', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { packingItems: [item] });
@@ -79,10 +84,9 @@ describe('packingSlice', () => {
),
);
await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deletePackingItem(1, 10);
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].id).toBe(10);
expect(useTripStore.getState().packingItems).toHaveLength(0);
});
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
@@ -115,7 +119,7 @@ describe('packingSlice', () => {
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => {
it('FE-PACKING-006: togglePackingItem preserves optimistic checked state even on API failure', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { packingItems: [item] });
@@ -125,11 +129,10 @@ describe('packingSlice', () => {
),
);
// toggle does NOT throw on error (silent rollback)
await useTripStore.getState().togglePackingItem(1, 10, true);
// Should be rolled back to original value
expect(useTripStore.getState().packingItems[0].checked).toBe(0);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
});
});
+10 -4
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPlace, buildAssignment } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -35,7 +38,7 @@ describe('placesSlice', () => {
expect(places[0].name).toBe('New Place'); // prepended
});
it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => {
it('FE-PLACES-002: addPlace always adds place optimistically (no throw on API error)', async () => {
const existing = buildPlace({ trip_id: 1 });
seedStore(useTripStore, { places: [existing] });
@@ -45,8 +48,11 @@ describe('placesSlice', () => {
),
);
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow();
expect(useTripStore.getState().places).toEqual([existing]);
const result = await useTripStore.getState().addPlace(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().places).toHaveLength(2);
expect(useTripStore.getState().places[0].name).toBe('Fail');
});
});
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -58,16 +61,18 @@ describe('reservationsSlice', () => {
expect(reservations[0].name).toBe('New Hotel');
});
it('FE-RESERV-003: addReservation on failure throws', async () => {
it('FE-RESERV-003: addReservation always adds optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/reservations', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addReservation(1, { name: 'Fail' })
).rejects.toThrow();
const result = await useTripStore.getState().addReservation(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Fail');
});
});
@@ -123,7 +128,7 @@ describe('reservationsSlice', () => {
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => {
it('FE-RESERV-007: toggleReservationStatus preserves optimistic status even on API failure', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] });
@@ -133,10 +138,10 @@ describe('reservationsSlice', () => {
),
);
// Does NOT throw (silent rollback)
await useTripStore.getState().toggleReservationStatus(1, 10);
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().reservations[0].status).toBe('pending');
});
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
@@ -162,7 +167,7 @@ describe('reservationsSlice', () => {
expect(reservations[0].id).toBe(20);
});
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
it('FE-RESERV-010: deleteReservation removes permanently even on API error', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1 });
seedStore(useTripStore, { reservations: [reservation] });
@@ -172,10 +177,10 @@ describe('reservationsSlice', () => {
),
);
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteReservation(1, 10);
// Still in state since server-first (only removes after success)
expect(useTripStore.getState().reservations).toHaveLength(1);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().reservations).toHaveLength(0);
});
});
});
+16 -12
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildTodoItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -34,16 +37,18 @@ describe('todoSlice', () => {
expect(items).toHaveLength(2);
});
it('FE-TODO-002: addTodoItem on failure throws', async () => {
it('FE-TODO-002: addTodoItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/todo', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
).rejects.toThrow();
const result = await useTripStore.getState().addTodoItem(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].name).toBe('Fail');
});
});
@@ -69,7 +74,7 @@ describe('todoSlice', () => {
});
describe('deleteTodoItem', () => {
it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => {
it('FE-TODO-004: deleteTodoItem removes item permanently even on API error', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { todoItems: [item] });
@@ -79,10 +84,9 @@ describe('todoSlice', () => {
),
);
await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteTodoItem(1, 10);
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].id).toBe(10);
expect(useTripStore.getState().todoItems).toHaveLength(0);
});
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
@@ -115,7 +119,7 @@ describe('todoSlice', () => {
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => {
it('FE-TODO-006: toggleTodoItem preserves optimistic checked state even on API failure', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { todoItems: [item] });
@@ -125,10 +129,10 @@ describe('todoSlice', () => {
),
);
// Does NOT throw
await useTripStore.getState().toggleTodoItem(1, 10, true);
expect(useTripStore.getState().todoItems[0].checked).toBe(0);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
+12 -3
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../src/store/tripStore';
import { resetAllStores } from '../helpers/store';
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
import { server } from '../helpers/msw/server';
import { offlineDb } from '../../src/db/offlineDb';
vi.mock('../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,11 @@ vi.mock('../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
// Flush pending macro tasks so any in-flight repo IIFEs from the previous test
// finish writing to IDB before we wipe it (prevents stale IDB data in next test).
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -75,6 +80,10 @@ describe('tripStore', () => {
const tag = buildTag();
const category = buildCategory();
// Seed IDB so tags/categories are available for the immediate IDB read in loadTrip
await offlineDb.tags.put(tag);
await offlineDb.categories.put(category);
server.use(
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
@@ -210,8 +219,8 @@ describe('tripStore', () => {
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
expect(result).toEqual(updatedTrip);
expect(useTripStore.getState().trip).toEqual(updatedTrip);
expect(result.name).toBe('Updated Trip');
expect(useTripStore.getState().trip?.name).toBe('Updated Trip');
});
});
+5 -58
View File
@@ -7,65 +7,12 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
runtimeCaching: [
{
// Carto map tiles (default provider)
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// OpenStreetMap tiles (fallback / alternative)
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Leaflet CSS/JS from unpkg CDN
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-libs',
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// API calls — prefer network, fall back to cache
// Exclude sensitive endpoints (auth, admin, backup, settings)
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-data',
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
networkTimeoutSeconds: 5,
cacheableResponse: { statuses: [200] },
},
},
{
// Uploaded files (photos, covers — public assets only)
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'user-uploads',
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},
manifest: {
name: 'TREK \u2014 Travel Planner',
+1
View File
@@ -1,4 +1,5 @@
PORT=3001 # Port to run the server on
# HOST=0.0.0.0 # Bind address for the HTTP server. Only set this when running TREK from sources or via the Proxmox community script — never in Docker (the container handles binding).
NODE_ENV=development # development = development mode; production = production mode
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
# Auto-generated and persisted to ./data/.encryption_key if not set.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "3.0.14",
"version": "3.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "3.0.14",
"version": "3.0.15",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.14",
"version": "3.0.15",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
+7
View File
@@ -2222,6 +2222,13 @@ function runMigrations(db: Database.Database): void {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
}
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT,version INTEGER NOT NULL)`)
db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`)
db.exec(`DROP TABLE schema_version`)
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
},
];
if (currentVersion < migrations.length) {
+12 -4
View File
@@ -20,8 +20,11 @@ const app = createApp();
import * as scheduler from './scheduler';
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST;
const APP_VERSION: string = process.env.APP_VERSION || (require('../package.json') as { version: string }).version;
const onListen = () => {
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./services/auditLog');
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -29,7 +32,8 @@ const server = app.listen(PORT, () => {
const banner = [
'──────────────────────────────────────',
' TREK API started',
` Version ${process.env.APP_VERSION}`,
` Version ${APP_VERSION}`,
...(HOST ? [` Host: ${HOST}`] : []),
` Port: ${PORT}`,
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
` Timezone: ${tz}`,
@@ -57,7 +61,11 @@ const server = app.listen(PORT, () => {
import('./websocket').then(({ setupWebSocket }) => {
setupWebSocket(server);
});
});
};
const server = HOST
? app.listen(PORT, HOST, onListen)
: app.listen(PORT, onListen);
// Graceful shutdown
function shutdown(signal: string): void {
+6
View File
@@ -12,6 +12,8 @@ Open the **Budget** tab inside the trip planner. The tab is only visible when th
> **Admin:** Budget is an addon. Enable it in [Admin-Addons](Admin-Addons).
![Create Budget](assets/BudgetCreateBudget.gif)
## Currency
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
@@ -54,6 +56,8 @@ The **Persons** column behaves differently depending on the trip:
- **Single-user trip** — enter a number of persons directly.
- **Multi-member trip** — a member chip picker appears. Click the edit button to assign or remove members from an expense. Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
![Add Expense](assets/BudgetAddExpensive.gif)
## Settlement calculator
When multiple members are assigned to expenses and there are outstanding debts between members, a collapsible **Settlement** section appears inside the total card. Click the section header to expand it. It shows the minimum number of transfers needed to settle all debts (using a greedy matching algorithm), including:
@@ -61,6 +65,8 @@ When multiple members are assigned to expenses and there are outstanding debts b
- Transfer flows: who pays whom and how much.
- Net balances: each member's overall surplus or deficit.
![Final Settlement](assets/BudgetFinalSettlement.gif)
## Budget summary
The right-hand column contains two widgets:
+1 -1
View File
@@ -6,7 +6,7 @@ Thanks for your interest in contributing to TREK! Here are the guidelines for su
- **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs without prior approval will be closed
- **Check existing issues** — Look for open issues or discussions before starting work
- **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
- **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
- **One thing per PR** — Keep PRs focused on a single change. Don't bundle unrelated fixes
## Pull Request Guidelines
+1 -1
View File
@@ -26,7 +26,7 @@ Items are sorted by their time or position index.
![Adding a place by dragging](assets/DayItineraryAddPlaceDragging.gif)
- **Add button** — click the **+** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
- **Add button** Click on the day and then click the **+** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
![Adding a place by button](assets/DayItineraryAddPlaceByButton.gif)
+1 -1
View File
@@ -127,7 +127,7 @@ git push origin fix/my-changes:dev
git push origin dev
```
Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` branch.
Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev` branch. If your PR only modifies files under `wiki/`, it is exempt from branch enforcement and may target any branch.
---
+17
View File
@@ -16,6 +16,7 @@ Complete reference for all environment variables TREK reads.
| Variable | Description | Default |
|---|---|---|
| `PORT` | Server port | `3000` |
| `HOST` | Bind address for the HTTP server (e.g. `127.0.0.1`, `10.0.0.72`). **Source / Proxmox installs only** — do not set this in Docker or any containerized deployment. See note below. | all interfaces |
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
| `ENCRYPTION_KEY` | At-rest encryption key — see resolution order below | auto |
| `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
@@ -25,6 +26,22 @@ Complete reference for all environment variables TREK reads.
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |
### `HOST` — Source and Proxmox installs only
By default TREK binds to all network interfaces (`0.0.0.0`), which is the correct behaviour inside a container because Docker handles port exposure at the host level. Setting `HOST` overrides the bind address at the Node.js level.
**When to use it:** only when running TREK directly on a host (git sources or the [Proxmox community script](Install-Proxmox)) and you need to restrict which interface the server listens on — for example, to expose TREK only on a LAN interface while keeping it off the public-facing one.
**Never set `HOST` in Docker, Docker Compose, Helm, or Unraid deployments.** Use Docker's `-p <host-ip>:<host-port>:<container-port>` syntax or your orchestrator's port binding instead.
```
# .env — source / Proxmox installs only
HOST=10.0.0.72 # bind only on this LAN interface
PORT=3001
```
When `HOST` is set, the startup banner includes a `Host:` line confirming the bound address.
### `ENCRYPTION_KEY` — Resolution Order
`server/src/config.ts` resolves the encryption key in this order:
+11
View File
@@ -78,6 +78,17 @@ The environment file is located at `/opt/trek/server/.env` inside the container.
systemctl restart trek
```
### Binding to a specific network interface
If your Proxmox host has multiple network interfaces and you want TREK to listen on only one of them, set the `HOST` variable in `/opt/trek/server/.env`:
```
HOST=10.0.0.72 # bind only on this LAN interface
PORT=3001
```
> **Note:** `HOST` is only relevant for source-based and Proxmox installs. Do not use it in Docker or any containerised deployment.
See [Environment-Variables](Environment-Variables) for the full variable reference.
## Updating
+32 -8
View File
@@ -18,25 +18,35 @@ TREK must be served over **HTTPS** — the install prompt does not appear on pla
Once installed, TREK launches in **standalone** mode (fullscreen, no browser UI) using the TREK icon.
## What works offline
## How offline reads work
TREK uses Workbox service-worker caching plus an IndexedDB database (Dexie) for structured trip data. The following content is available offline after the first sync:
TREK uses **two independent offline layers**:
1. **IndexedDB (Dexie)** — the primary offline store. On login and whenever the network comes back online, TREK syncs full trip bundles into IndexedDB. All reads use a **stale-while-revalidate** strategy: cached data is returned instantly from IndexedDB, then a background network request updates the data when it completes. This means the UI is always instant regardless of connectivity — `navigator.onLine` is not used as a gate because it is unreliable on mobile (returns `true` whenever any network interface is active, even without actual internet access).
2. **Service-worker cache (Workbox)** — a secondary safety net for *degraded connectivity* (flaky Wi-Fi, captive portals). The SW intercepts API calls and serves cached responses if the network does not respond within the timeout.
This means a week-long offline trip works even if the SW cache has expired — the IndexedDB data has no time-based eviction (only stale trips older than 7 days are evicted on the next sync).
## What works offline
**Service-worker cache (Workbox)**
| Content | Cache name | Strategy | Duration | Max entries |
|---------|------------|----------|----------|-------------|
| Content | Cache name | Strategy | Default TTL | Default max entries |
|---------|------------|----------|-------------|---------------------|
| CartoDB / OpenStreetMap map tiles | `map-tiles` | CacheFirst | 30 days | 1 000 |
| Leaflet / CDN assets (unpkg) | `cdn-libs` | CacheFirst | 365 days | 30 |
| API responses (trips, places, bookings, etc.) | `api-data` | NetworkFirst (5 s timeout) | 24 hours | 200 |
| API responses (trips, places, bookings, etc.) | `api-data` | NetworkFirst (2 s timeout) | **7 days** | **500** |
| Cover images and avatars (`/uploads/covers`, `/uploads/avatars`) | `user-uploads` | CacheFirst | 7 days | 300 |
| App shell (HTML / JS / CSS) | precache | Precached | Until next deploy | — |
The `api-data` and `map-tiles` caches are **user-configurable at runtime** — see [Cache configuration](#cache-configuration) below.
> **Note:** The API cache excludes sensitive endpoints — `/api/auth`, `/api/admin`, `/api/backup`, and `/api/settings` are always fetched from the network.
**IndexedDB (Dexie) — structured trip data**
On login, after each trip-list refresh, and on WebSocket reconnect, TREK runs a background sync that writes full trip bundles into IndexedDB:
On login, when the network comes back online, and via the manual **Re-sync now** button, TREK runs a background sync that writes full trip bundles into IndexedDB:
- Trips, days, places, packing items, to-dos, budget items, reservations, accommodations, trip members, tags, and categories.
- Non-photo file attachments (PDFs, documents, etc.) are downloaded and stored as blobs in IndexedDB.
@@ -51,8 +61,6 @@ On login, after each trip-list refresh, and on WebSocket reconnect, TREK runs a
The **Offline Cache** section under Settings → Offline shows the current state of the local cache.
<!-- TODO: screenshot: Offline tab showing cached trips -->
**Stats panel:**
- **Cached trips** — number of trips stored in IndexedDB (Dexie).
- **Pending changes** — number of actions taken offline that are queued to sync.
@@ -63,12 +71,28 @@ The **Offline Cache** section under Settings → Offline shows the current state
Each cached trip entry shows the trip name, date range, place count, and file count, plus the time of the last successful sync.
## Cache configuration
The **Cache configuration** section in Settings → Offline lets you tune the service-worker cache limits without rebuilding TREK. Changes are saved to your browser's IndexedDB and sent to the active service worker immediately — no page reload required.
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| API cache TTL (days) | 7 | 1365 | How long API responses stay in the `api-data` cache |
| API max entries | 500 | 105 000 | Maximum number of API responses cached |
| Map tiles TTL (days) | 30 | 1365 | How long map tiles stay in the `map-tiles` cache |
| Map tiles max entries | 1 000 | 105 000 | Maximum number of tiles cached across all trips |
> **Tip:** Existing cached entries follow their original TTL. New entries use the updated settings from the next request onwards.
> **Note on TTL and offline access:** Raising the API cache TTL extends coverage for *degraded connectivity* (flaky Wi-Fi). For a fully offline device, the primary data source is IndexedDB — always available regardless of TTL.
## Limitations
- New trips created while offline are queued and synced when connectivity is restored.
- Photo uploads require connectivity; non-photo file attachments are pre-cached automatically during sync.
- Real-time collaboration features require an active WebSocket connection.
- Mapbox GL tiles are not cached by the service worker (Mapbox manages its own tile cache internally).
- The map tile size cap (~50 MB) means very large trips spanning multiple countries may have tiles skipped entirely rather than partially cached.
## See also
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB