diff --git a/client/package-lock.json b/client/package-lock.json index e7111ba5..5c173cc1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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" diff --git a/client/package.json b/client/package.json index 12ff1a02..488233e6 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e39c6a47..c8801411 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -33,6 +33,7 @@ function translateRateLimit(): string { export const apiClient: AxiosInstance = axios.create({ baseURL: '/api', withCredentials: true, + timeout: 8000, headers: { 'Content-Type': 'application/json', }, diff --git a/client/src/components/Budget/BudgetPanel.test.tsx b/client/src/components/Budget/BudgetPanel.test.tsx index 244cbc96..b92cf4f8 100644 --- a/client/src/components/Budget/BudgetPanel.test.tsx +++ b/client/src/components/Budget/BudgetPanel.test.tsx @@ -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(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); // Settlement and per-person APIs needed by BudgetPanel server.use( diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 273387c3..048cd784 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -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(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(); 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 | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, place_id: 10 } }); + }), + ); render(); 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 | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, reservation_id: 20 } }); + }), + ); render(); 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 | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, place_id: null } }); + }), + ); render(); 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 | null = null; + server.use( + http.put('/api/trips/1/files/1', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ file: { ...file, reservation_id: null } }); + }), + ); render(); 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 () => { diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index f3b93546..7e98f5cb 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -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) refreshFiles() } catch { toast.error(t('files.toast.assignError')) diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 2e1414ec..121a24b6 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -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(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); // Side-effect APIs PackingListPanel calls on mount server.use( diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index a70fd9d5..5c185d62 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -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(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); resetAllStores(); vi.clearAllMocks(); server.use( diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 407db408..4ae36718 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -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 + {/* Cache configuration */} +
+
+ + Cache configuration +
+

+ Changes apply immediately to the service worker and persist across reloads. + Existing cached entries follow their original TTL; new entries use the updated settings. +

+ +
+ + + + +
+ +
+ + + {configApplied && ( + + + Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} + + )} +
+
+ {/* Cached trip list */} {loading ? (

Loading…

@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement { display: 'flex', flexDirection: 'column', gap: 2, }} > -
- - {trip.name} - - +
+
+ + {trip.title || 'Unnamed trip'} + + {trip.description ? ( + + {trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description} + + ) : null} + + {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} + +
+ {meta.lastSyncedAt ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '—'}
- - {formatDate(trip.start_date)} – {formatDate(trip.end_date)} - {' · '} - {placeCount} place{placeCount !== 1 ? 's' : ''} - {' · '} - {fileCount} file{fileCount !== 1 ? 's' : ''} -
))} @@ -178,3 +333,32 @@ function Stat({ label, value }: { label: string; value: number }) { ) } + +function CacheField({ + label, value, min, max, onChange, +}: { + label: string + value: number + min: number + max: number + onChange: (e: React.ChangeEvent) => void +}) { + return ( + + ) +} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index c3e4fa85..dfffdcc0 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -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')) diff --git a/client/src/pages/FilesPage.test.tsx b/client/src/pages/FilesPage.test.tsx index 22298b8c..b6e18177 100644 --- a/client/src/pages/FilesPage.test.tsx +++ b/client/src/pages/FilesPage.test.tsx @@ -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(resolve => setTimeout(resolve, 0)); + await Promise.all(offlineDb.tables.map(t => t.clear())); vi.clearAllMocks(); resetAllStores(); seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 75e8c345..c31ea2ba 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -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): Promise<{ accommodation: Accommodation }> { + const tempId = -(Date.now()) + const tempAccommodation: Accommodation = { + ...(data as Partial), + 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): Promise<{ accommodation: Accommodation }> { + const existing = await offlineDb.accommodations.get(id) + const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial), 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 { + 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 } }, } diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index 3ea50a7b..f674faab 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -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): Promise<{ item: BudgetItem }> { + const tempId = -(Date.now()) + const tempItem: BudgetItem = { + ...(data as Partial), + 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): Promise<{ item: BudgetItem }> { + const existing = await offlineDb.budgetItems.get(id) + const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial), 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 { + 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 } }, } diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index de105748..74129707 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -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): 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 } }, } diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index db96bad8..030ca815 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -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): Promise<{ file: TripFile }> { + const existing = await offlineDb.tripFiles.get(id) + const optimistic: TripFile = { ...(existing ?? {} as TripFile), ...(data as Partial), 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 { + 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 { + 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 } }, } diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 30859fc6..49130813 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -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): Promise<{ item: PackingItem }> { - if (!navigator.onLine) { - const tempId = -(Date.now()) - const tempItem: PackingItem = { - ...(data as Partial), - 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), + 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): Promise<{ item: PackingItem }> { - if (!navigator.onLine) { - const existing = await offlineDb.packingItems.get(id) - const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial), 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), 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 { - 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 } }, } diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 36b1acc2..320f44b9 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -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): 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): 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): Promise<{ place: Place }> { - if (!navigator.onLine) { - const tempId = -(Date.now()) - const tempPlace: Place = { - ...(data as Partial), - 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), + 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): Promise<{ place: Place }> { - if (!navigator.onLine) { - const existing = await offlineDb.places.get(Number(id)) - const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial), 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), 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 { - 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 { + 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 { - 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 } }, } diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index 575b8075..d0a4759c 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -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): Promise<{ reservation: Reservation }> { + const tempId = -(Date.now()) + const tempReservation: Reservation = { + ...(data as Partial), + 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): Promise<{ reservation: Reservation }> { + const existing = await offlineDb.reservations.get(id) + const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial), 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 { + 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 } }, } diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index e284b23a..27b54db6 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -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): Promise<{ item: TodoItem }> { + const tempId = -(Date.now()) + const tempItem: TodoItem = { + ...(data as Partial), + 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): Promise<{ item: TodoItem }> { + const existing = await offlineDb.todoItems.get(id) + const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial), 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 { + 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 } }, } diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index 082e346a..07efc624 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -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): Promise<{ trip: Trip }> { + const existing = await offlineDb.trips.get(Number(tripId)) + const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial), 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, + resource: 'trips', + }) + mutationQueue.flush().catch(() => {}) + return { trip: optimistic } }, } diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index 8d44fb50..a8cc4f5e 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -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) { diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index 371dd682..48033d69 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -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(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 () => { diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index 9f63bc45..35b1dc44 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -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) 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) 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')) diff --git a/client/src/store/slices/dayNotesSlice.ts b/client/src/store/slices/dayNotesSlice.ts index 53ab0c6d..f48d8b1d 100644 --- a/client/src/store/slices/dayNotesSlice.ts +++ b/client/src/store/slices/dayNotesSlice.ts @@ -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, + }) + 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), 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, + }) + 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) { diff --git a/client/src/store/slices/filesSlice.ts b/client/src/store/slices/filesSlice.ts index 13617515..322e8882 100644 --- a/client/src/store/slices/filesSlice.ts +++ b/client/src/store/slices/filesSlice.ts @@ -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')) } }, diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 224a0488..53ec214e 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -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) } diff --git a/client/src/store/slices/reservationsSlice.ts b/client/src/store/slices/reservationsSlice.ts index c020a593..ace7ddf0 100644 --- a/client/src/store/slices/reservationsSlice.ts +++ b/client/src/store/slices/reservationsSlice.ts @@ -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) 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) 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')) } }, diff --git a/client/src/store/slices/todoSlice.ts b/client/src/store/slices/todoSlice.ts index 58070a85..abdaf3d8 100644 --- a/client/src/store/slices/todoSlice.ts +++ b/client/src/store/slices/todoSlice.ts @@ -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) 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) 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 => diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 5168c078..2b88c4e3 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -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((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((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 = {} + 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((set, get) => ({ updateTrip: async (tripId: number | string, data: Partial) => { 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')) diff --git a/client/src/sw.ts b/client/src/sw.ts new file mode 100644 index 00000000..d1ea15a4 --- /dev/null +++ b/client/src/sw.ts @@ -0,0 +1,161 @@ +/// + +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 { + return new Request(request, { redirect: 'manual' }); + }, + async fetchDidSucceed({ response }: { response: Response }): Promise { + 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); + applyConfig(validated); + + // Acknowledge back to the sending client + (event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' }); +}); diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 0b68826c..93dc0f1c 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -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 = { - 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' }) diff --git a/client/src/sync/swConfig.ts b/client/src/sync/swConfig.ts new file mode 100644 index 00000000..7a1604b2 --- /dev/null +++ b/client/src/sync/swConfig.ts @@ -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 { + 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; + 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 { + try { + const row = await getDb().config.get('singleton'); + return row ? validateSwConfig(row) : null; + } catch { + return null; + } +} + +export async function saveSwConfig(cfg: SwCacheConfig): Promise { + const validated = validateSwConfig(cfg); + await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() }); +} + +export async function loadSwConfig(): Promise { + return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG }; +} diff --git a/client/src/types.ts b/client/src/types.ts index 8c9c3039..0faf351a 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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 diff --git a/client/tests/unit/repo/packingRepo.test.ts b/client/tests/unit/repo/packingRepo.test.ts index 4c25ada2..97c9b44b 100644 --- a/client/tests/unit/repo/packingRepo.test.ts +++ b/client/tests/unit/repo/packingRepo.test.ts @@ -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(); }); }); diff --git a/client/tests/unit/repo/placeRepo.test.ts b/client/tests/unit/repo/placeRepo.test.ts index 45387841..9ee808ff 100644 --- a/client/tests/unit/repo/placeRepo.test.ts +++ b/client/tests/unit/repo/placeRepo.test.ts @@ -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'); }); }); diff --git a/client/tests/unit/slices/budgetSlice.test.ts b/client/tests/unit/slices/budgetSlice.test.ts index e847dd86..6a9074f2 100644 --- a/client/tests/unit/slices/budgetSlice.test.ts +++ b/client/tests/unit/slices/budgetSlice.test.ts @@ -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(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; - // 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); + const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record); - // 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 () => { diff --git a/client/tests/unit/slices/filesSlice.test.ts b/client/tests/unit/slices/filesSlice.test.ts index 7f5adc8c..814eceaa 100644 --- a/client/tests/unit/slices/filesSlice.test.ts +++ b/client/tests/unit/slices/filesSlice.test.ts @@ -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); }); }); }); diff --git a/client/tests/unit/slices/packingSlice.test.ts b/client/tests/unit/slices/packingSlice.test.ts index 901c0a08..587b8379 100644 --- a/client/tests/unit/slices/packingSlice.test.ts +++ b/client/tests/unit/slices/packingSlice.test.ts @@ -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(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); }); }); }); diff --git a/client/tests/unit/slices/placesSlice.test.ts b/client/tests/unit/slices/placesSlice.test.ts index 93a9310e..8a90e363 100644 --- a/client/tests/unit/slices/placesSlice.test.ts +++ b/client/tests/unit/slices/placesSlice.test.ts @@ -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(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'); }); }); diff --git a/client/tests/unit/slices/reservationsSlice.test.ts b/client/tests/unit/slices/reservationsSlice.test.ts index b0b5e134..021b8e21 100644 --- a/client/tests/unit/slices/reservationsSlice.test.ts +++ b/client/tests/unit/slices/reservationsSlice.test.ts @@ -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(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); }); }); }); diff --git a/client/tests/unit/slices/todoSlice.test.ts b/client/tests/unit/slices/todoSlice.test.ts index 123426bc..257f02cf 100644 --- a/client/tests/unit/slices/todoSlice.test.ts +++ b/client/tests/unit/slices/todoSlice.test.ts @@ -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(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 () => { diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index 8d35c9eb..0aefe291 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -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(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'); }); }); diff --git a/client/vite.config.js b/client/vite.config.js index 8a5334b6..3cb475b4 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -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', diff --git a/wiki/Offline-Mode-and-PWA.md b/wiki/Offline-Mode-and-PWA.md index 3f4cc08b..cb9c1ab6 100644 --- a/wiki/Offline-Mode-and-PWA.md +++ b/wiki/Offline-Mode-and-PWA.md @@ -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. - - **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