mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 23:31:47 +00:00
Compare commits
13 Commits
v3.0.14
...
ab03dfe2d5
| Author | SHA1 | Date | |
|---|---|---|---|
| ab03dfe2d5 | |||
| 69620e7276 | |||
| 3aa6b0952a | |||
| a6a0521261 | |||
| a83b369cb7 | |||
| 79d5fa7670 | |||
| b94060aec1 | |||
| 852f0085d1 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+11
-12
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## Budget summary
|
||||
|
||||
The right-hand column contains two widgets:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@ Items are sorted by their time or position index.
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 1–365 | How long API responses stay in the `api-data` cache |
|
||||
| API max entries | 500 | 10–5 000 | Maximum number of API responses cached |
|
||||
| Map tiles TTL (days) | 30 | 1–365 | How long map tiles stay in the `map-tiles` cache |
|
||||
| Map tiles max entries | 1 000 | 10–5 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 |
Reference in New Issue
Block a user