From 852f0085d1437365229381cf0ed4885e23562ca0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 21:36:44 +0200 Subject: [PATCH 1/7] feat: complete offline write support with mutation queue + runtime SW cache config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add offline CRUD to todoRepo, budgetRepo, reservationRepo, accommodationRepo, dayRepo, tripRepo, fileRepo with optimistic Dexie writes and mutation queue - Wire all store slices (todo, budget, reservations, files, dayNotes, assignments, tripStore) through repos for offline-aware writes - Cover archive/unarchive, file toggleStar/update/delete, assignment create/delete, day title/notes update offline paths - Migrate service worker from generateSW to injectManifest (custom sw.ts) with runtime-configurable api-data (7d/500) and map-tiles (30d/1000) cache policies - Add Settings → Offline cache configuration UI with save/reset and live SW postMessage - Extend mutationQueue flush to cover all writable Dexie tables --- client/package.json | 6 + client/src/components/Files/FileManager.tsx | 5 +- .../src/components/Planner/DayDetailPanel.tsx | 9 +- client/src/components/Settings/OfflineTab.tsx | 214 ++++++++++++++++-- client/src/pages/DashboardPage.tsx | 4 +- client/src/repo/accommodationRepo.ts | 74 ++++++ client/src/repo/budgetRepo.ts | 69 ++++++ client/src/repo/dayRepo.ts | 21 ++ client/src/repo/fileRepo.ts | 58 +++++ client/src/repo/reservationRepo.ts | 74 ++++++ client/src/repo/todoRepo.ts | 72 ++++++ client/src/repo/tripRepo.ts | 21 ++ client/src/store/slices/assignmentsSlice.ts | 37 +++ client/src/store/slices/budgetSlice.ts | 6 +- client/src/store/slices/dayNotesSlice.ts | 70 +++++- client/src/store/slices/filesSlice.ts | 6 +- client/src/store/slices/reservationsSlice.ts | 13 +- client/src/store/slices/todoSlice.ts | 10 +- client/src/store/tripStore.ts | 20 +- client/src/sw.ts | 128 +++++++++++ client/src/sync/mutationQueue.ts | 15 +- client/src/sync/swConfig.ts | 80 +++++++ client/src/types.ts | 2 +- client/vite.config.js | 63 +----- wiki/Offline-Mode-and-PWA.md | 40 +++- 25 files changed, 993 insertions(+), 124 deletions(-) create mode 100644 client/src/sw.ts create mode 100644 client/src/sync/swConfig.ts 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/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/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 407db408..e8df9aa3 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,7 +118,7 @@ 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 data = await accommodationRepo.create(tripId, { place_id: hotelForm.place_id, start_day_id: hotelDayRange.start, end_day_id: hotelDayRange.end, @@ -142,7 +143,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 +152,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 +584,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..df440723 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -791,7 +791,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 +802,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/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 75e8c345..06cd2890 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -1,5 +1,6 @@ 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 = { @@ -13,4 +14,77 @@ export const accommodationRepo = { upsertAccommodations(result.accommodations || []).catch(() => {}) return result }, + + async create(tripId: number | string, data: Record): Promise<{ accommodation: Accommodation }> { + if (!navigator.onLine) { + 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, + }) + return { accommodation: tempAccommodation } + } + const result = await accommodationsApi.create(tripId, data) + offlineDb.accommodations.put(result.accommodation) + return result + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ accommodation: Accommodation }> { + if (!navigator.onLine) { + 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', + }) + return { accommodation: optimistic } + } + const result = await accommodationsApi.update(tripId, id, data) + offlineDb.accommodations.put(result.accommodation) + return result + }, + + async delete(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + 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, + }) + return { success: true } + } + const result = await accommodationsApi.delete(tripId, id) + offlineDb.accommodations.delete(id) + return result + }, } diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index 3ea50a7b..c26c97b8 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -1,5 +1,6 @@ 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 = { @@ -15,4 +16,72 @@ export const budgetRepo = { upsertBudgetItems(result.items) return result }, + + async create(tripId: number | string, data: Record): Promise<{ item: BudgetItem }> { + if (!navigator.onLine) { + 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, + }) + return { item: tempItem } + } + const result = await budgetApi.create(tripId, data) + offlineDb.budgetItems.put(result.item) + return result + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ item: BudgetItem }> { + if (!navigator.onLine) { + 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', + }) + return { item: optimistic } + } + const result = await budgetApi.update(tripId, id, data) + offlineDb.budgetItems.put(result.item) + return result + }, + + async delete(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + 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, + }) + return { success: true } + } + const result = await budgetApi.delete(tripId, id) + offlineDb.budgetItems.delete(id) + return result + }, } diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index de105748..65ccbbf5 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -1,5 +1,6 @@ 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 = { @@ -15,4 +16,24 @@ export const dayRepo = { upsertDays(result.days) return result }, + + async update(tripId: number | string, dayId: number | string, data: Record): Promise<{ day: Day }> { + if (!navigator.onLine) { + const existing = await offlineDb.days.get(Number(dayId)) + const optimistic: Day = { ...(existing ?? {} as Day), ...(data as Partial), 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', + }) + return { day: optimistic } + } + const result = await daysApi.update(tripId, dayId, data) + offlineDb.days.put(result.day) + return result + }, } diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index db96bad8..95448010 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -1,5 +1,6 @@ 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 = { @@ -15,4 +16,61 @@ export const fileRepo = { upsertTripFiles(result.files) return result }, + + async update(tripId: number | string, id: number, data: Record): Promise { + if (!navigator.onLine) { + const existing = await offlineDb.tripFiles.get(id) + if (existing) await offlineDb.tripFiles.put({ ...existing, ...(data as Partial) }) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/files/${id}`, + body: data, + resource: 'tripFiles', + }) + return { success: true } + } + const result = await filesApi.update(tripId, id, data) + const file = (result as { file?: TripFile }).file + if (file) offlineDb.tripFiles.put(file) + return result + }, + + async toggleStar(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + const existing = await offlineDb.tripFiles.get(id) + if (existing) { + await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 }) + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'PATCH', + url: `/trips/${tripId}/files/${id}/star`, + body: undefined, + }) + return { success: true } + } + return filesApi.toggleStar(tripId, id) + }, + + async delete(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + 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, + }) + return { success: true } + } + const result = await filesApi.delete(tripId, id) + offlineDb.tripFiles.delete(id) + return result + }, } diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index 575b8075..e9598314 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -1,5 +1,6 @@ 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 = { @@ -15,4 +16,77 @@ export const reservationRepo = { upsertReservations(result.reservations) return result }, + + async create(tripId: number | string, data: Record): Promise<{ reservation: Reservation }> { + if (!navigator.onLine) { + 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, + }) + return { reservation: tempReservation } + } + const result = await reservationsApi.create(tripId, data) + offlineDb.reservations.put(result.reservation) + return result + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ reservation: Reservation }> { + if (!navigator.onLine) { + 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', + }) + return { reservation: optimistic } + } + const result = await reservationsApi.update(tripId, id, data) + offlineDb.reservations.put(result.reservation) + return result + }, + + async delete(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + 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, + }) + return { success: true } + } + const result = await reservationsApi.delete(tripId, id) + offlineDb.reservations.delete(id) + return result + }, } diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index e284b23a..c93ba17e 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -1,5 +1,6 @@ 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 = { @@ -15,4 +16,75 @@ export const todoRepo = { upsertTodoItems(result.items) return result }, + + async create(tripId: number | string, data: Record): Promise<{ item: TodoItem }> { + if (!navigator.onLine) { + 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, + }) + return { item: tempItem } + } + const result = await todoApi.create(tripId, data) + offlineDb.todoItems.put(result.item) + return result + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ item: TodoItem }> { + if (!navigator.onLine) { + 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', + }) + return { item: optimistic } + } + const result = await todoApi.update(tripId, id, data) + offlineDb.todoItems.put(result.item) + return result + }, + + async delete(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + 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, + }) + return { success: true } + } + const result = await todoApi.delete(tripId, id) + offlineDb.todoItems.delete(id) + return result + }, } diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index 082e346a..cc98d665 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -1,5 +1,6 @@ import { tripsApi } from '../api/client' import { offlineDb, upsertTrip } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' import type { Trip } from '../types' export const tripRepo = { @@ -30,4 +31,24 @@ export const tripRepo = { upsertTrip(result.trip) return result }, + + async update(tripId: number | string, data: Partial): Promise<{ trip: Trip }> { + if (!navigator.onLine) { + 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', + }) + return { trip: optimistic } + } + const result = await tripsApi.update(tripId, data as Record) + upsertTrip(result.trip) + return result + }, } 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.ts b/client/src/store/slices/budgetSlice.ts index 9f63bc45..e33b0562 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -31,7 +31,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 +41,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 +58,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/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..1c9cb6f8 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import type { StoreApi } from 'zustand' -import { tripsApi, tagsApi, categoriesApi } from '../api/client' +import { tagsApi, categoriesApi } from '../api/client' import { offlineDb } from '../db/offlineDb' import { tripRepo } from '../repo/tripRepo' import { dayRepo } from '../repo/dayRepo' @@ -146,16 +146,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..d57720f4 --- /dev/null +++ b/client/src/sw.ts @@ -0,0 +1,128 @@ +/// + +import { clientsClaim } from 'workbox-core'; +import { + precacheAndRoute, + cleanupOutdatedCaches, + createHandlerBoundToURL, +} 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) +// @ts-expect-error __WB_MANIFEST is injected at build time +precacheAndRoute(self.__WB_MANIFEST); +cleanupOutdatedCaches(); + +// ── Static routes (not user-configurable) ───────────────────────────────────── + +registerRoute( + new NavigationRoute(createHandlerBoundToURL('index.html'), { + 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; + +function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst { + return new NetworkFirst({ + cacheName: 'api-data', + networkTimeoutSeconds: 5, + plugins: [ + 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..d25b6b5b 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] } 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/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..c57c4e06 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 check `navigator.onLine` first; when offline, the app reads directly from IndexedDB without touching the network or the service-worker cache. + +2. **Service-worker cache (Workbox)** — a secondary safety net for *degraded connectivity* (flaky Wi-Fi, captive portals) where `navigator.onLine` is `true` but the network is unreliable. The SW intercepts API calls and serves cached responses if the network times out. + +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 (5 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 From b94060aec1c4b7f322146a0a62b913afe4f4fc46 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 21:54:54 +0200 Subject: [PATCH 2/7] fix: pass through reverse-proxy auth redirects in service worker Navigation requests now use redirect:'manual' + network-first so upstream auth gates (Cloudflare Zero Trust, Pangolin) can redirect the browser to their SSO login page instead of being swallowed by the precached app shell. An authRedirectPlugin on the API NetworkFirst strategy handles mid-session expiry: detects opaqueredirect responses and converts them to a synthetic 401 { code: 'AUTH_REQUIRED' } that the existing Axios interceptor picks up, triggering a full re-auth flow. Offline fallback to the precached app shell is preserved. Closes #836 --- client/src/sw.ts | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/client/src/sw.ts b/client/src/sw.ts index d57720f4..ae8d742f 100644 --- a/client/src/sw.ts +++ b/client/src/sw.ts @@ -4,7 +4,7 @@ import { clientsClaim } from 'workbox-core'; import { precacheAndRoute, cleanupOutdatedCaches, - createHandlerBoundToURL, + matchPrecache, } from 'workbox-precaching'; import { registerRoute, NavigationRoute } from 'workbox-routing'; import { NetworkFirst, CacheFirst } from 'workbox-strategies'; @@ -23,16 +23,28 @@ self.skipWaiting(); clientsClaim(); // Inject precache manifest (replaced by vite-plugin-pwa at build time) -// @ts-expect-error __WB_MANIFEST is injected 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(createHandlerBoundToURL('index.html'), { - denylist: [/^\/api/, /^\/uploads/, /^\/mcp/], - }), + 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( @@ -65,11 +77,32 @@ registerRoute( 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: 5, plugins: [ + authRedirectPlugin, new ExpirationPlugin({ maxEntries: cfg.apiMaxEntries, maxAgeSeconds: cfg.apiTtlDays * DAY, From 79d5fa76703b2ffce4a2ca9e36e3597f90b00d7b Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 22:01:01 +0200 Subject: [PATCH 3/7] chore: update lock file --- client/package-lock.json | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) 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" From a83b369cb73e90ae5eab6d938b808d247620d4c0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 22:43:10 +0200 Subject: [PATCH 4/7] fix: stale-while-revalidate for offline reads + axios timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigator.onLine is unreliable on Android — returns true whenever any network interface is up, regardless of actual reachability. This caused all repo reads to take the API branch and either wait 5 s for the SW NetworkFirst timeout (cache hit) or hang indefinitely (cache miss). - All read repos (list/get) now return cached IndexedDB data instantly and carry a background refresh promise that resolves to fresh data or null on failure. Callers that opted in (loadTrip, loadTrips) apply fresh data silently when it arrives. - tripStore.loadTrip: Promise.all now reads all 7 resources from IndexedDB (instant), fires network refreshes in background, sets isLoading: false immediately, then applies fresh data via a second Promise.all when ready. Tags/categories use upsertTags/upsertCategories. - DashboardPage.loadTrips: same pattern — renders from cache instantly, silently updates trip list on refresh. - axios timeout set to 8 s so requests can never hang indefinitely. - SW networkTimeoutSeconds lowered from 5 to 2 as defence in depth. --- client/src/api/client.ts | 1 + client/src/pages/DashboardPage.tsx | 9 +++- client/src/repo/accommodationRepo.ts | 28 ++++++++---- client/src/repo/budgetRepo.ts | 32 +++++++++----- client/src/repo/dayRepo.ts | 32 +++++++++----- client/src/repo/fileRepo.ts | 32 +++++++++----- client/src/repo/packingRepo.ts | 32 +++++++++----- client/src/repo/placeRepo.ts | 32 +++++++++----- client/src/repo/reservationRepo.ts | 32 +++++++++----- client/src/repo/todoRepo.ts | 32 +++++++++----- client/src/repo/tripRepo.ts | 63 ++++++++++++++++++-------- client/src/store/tripStore.ts | 66 +++++++++++++++++++++------- client/src/sw.ts | 2 +- 13 files changed, 270 insertions(+), 123 deletions(-) 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/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index df440723..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) } } diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 06cd2890..207a824d 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -4,15 +4,25 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index c26c97b8..eef1bfe2 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -4,17 +4,27 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index 65ccbbf5..9725ab07 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -4,17 +4,27 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index 95448010..92502ce0 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -4,17 +4,27 @@ 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 } - } - const result = await filesApi.list(tripId) - upsertTripFiles(result.files) - return result + 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 () => { + 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 { diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 30859fc6..5343209b 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -4,17 +4,27 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 36b1acc2..df486cd1 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -4,17 +4,27 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index e9598314..ee07f285 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -4,17 +4,27 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index c93ba17e..d1748b98 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -4,17 +4,27 @@ 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 () => { + 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 }> { diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index cc98d665..652bcaf6 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -3,33 +3,58 @@ 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 () => { + 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 () => { + 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 }> { diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 1c9cb6f8..518f6b3d 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 { tagsApi, categoriesApi } from '../api/client' -import { offlineDb } from '../db/offlineDb' +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,37 @@ 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([ + // 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 || [] + // Tags/categories background refresh (network-only, applied when ready) + 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) + + 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 +128,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 }) diff --git a/client/src/sw.ts b/client/src/sw.ts index ae8d742f..d1ea15a4 100644 --- a/client/src/sw.ts +++ b/client/src/sw.ts @@ -100,7 +100,7 @@ const authRedirectPlugin = { function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst { return new NetworkFirst({ cacheName: 'api-data', - networkTimeoutSeconds: 5, + networkTimeoutSeconds: 2, plugins: [ authRedirectPlugin, new ExpirationPlugin({ From a6a0521261335488e5d20b32f35780e7779ecda6 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 22:45:42 +0200 Subject: [PATCH 5/7] docs: update Offline-Mode-and-PWA wiki for SWR changes --- wiki/Offline-Mode-and-PWA.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wiki/Offline-Mode-and-PWA.md b/wiki/Offline-Mode-and-PWA.md index c57c4e06..cb9c1ab6 100644 --- a/wiki/Offline-Mode-and-PWA.md +++ b/wiki/Offline-Mode-and-PWA.md @@ -22,9 +22,9 @@ Once installed, TREK launches in **standalone** mode (fullscreen, no browser UI) 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 check `navigator.onLine` first; when offline, the app reads directly from IndexedDB without touching the network or the service-worker cache. +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) where `navigator.onLine` is `true` but the network is unreliable. The SW intercepts API calls and serves cached responses if the network times out. +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). @@ -36,7 +36,7 @@ This means a week-long offline trip works even if the SW cache has expired — t |---------|------------|----------|-------------|---------------------| | 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) | **7 days** | **500** | +| 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 | — | From 3aa6b0952a60f29163868e1eee164f9d6f67ada9 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 01:01:34 +0200 Subject: [PATCH 6/7] fix: repair test suite after SWR offline-read changes Add navigator.onLine guard to SWR refresh IIFEs so background network calls don't fire in offline mode (prevents fake-IDB leakage in tests via MSW default handlers). Fix IDB isolation in affected test files by flushing pending macro tasks then clearing IDB tables in beforeEach, so stale IDB writes from previous tests' background IIFEs don't bleed into the next test. Restore loadBudgetItems and refreshPlaces to apply background refresh results to store state. Move tags/categories API calls before the main Promise.all in loadTrip so MSW handlers resolve during the await window. --- .../src/components/Budget/BudgetPanel.test.tsx | 5 ++++- client/src/pages/FilesPage.test.tsx | 5 ++++- client/src/repo/accommodationRepo.ts | 1 + client/src/repo/budgetRepo.ts | 1 + client/src/repo/dayRepo.ts | 1 + client/src/repo/fileRepo.ts | 1 + client/src/repo/packingRepo.ts | 1 + client/src/repo/placeRepo.ts | 1 + client/src/repo/reservationRepo.ts | 1 + client/src/repo/todoRepo.ts | 1 + client/src/repo/tripRepo.ts | 2 ++ client/src/store/slices/budgetSlice.test.ts | 5 ++++- client/src/store/slices/budgetSlice.ts | 3 +++ client/src/store/slices/placesSlice.ts | 3 +++ client/src/store/tripStore.ts | 17 +++++++++-------- client/tests/unit/slices/placesSlice.test.ts | 5 ++++- client/tests/unit/tripStore.test.ts | 11 ++++++++++- 17 files changed, 51 insertions(+), 13 deletions(-) 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/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 207a824d..08831dbc 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -9,6 +9,7 @@ export const accommodationRepo = { .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(() => {}) diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index eef1bfe2..04158b3c 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -11,6 +11,7 @@ export const budgetRepo = { .toArray() const refresh = (async () => { + if (!navigator.onLine) return null try { const result = await budgetApi.list(tripId) upsertBudgetItems(result.items) diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index 9725ab07..efbf9efe 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -11,6 +11,7 @@ export const dayRepo = { .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) diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index 92502ce0..ae685515 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -11,6 +11,7 @@ export const fileRepo = { .toArray() const refresh = (async () => { + if (!navigator.onLine) return null try { const result = await filesApi.list(tripId) upsertTripFiles(result.files) diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 5343209b..9d1ed060 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -11,6 +11,7 @@ export const packingRepo = { .toArray() const refresh = (async () => { + if (!navigator.onLine) return null try { const result = await packingApi.list(tripId) upsertPackingItems(result.items) diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index df486cd1..dc1f776b 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -11,6 +11,7 @@ export const placeRepo = { .toArray() const refresh = (async () => { + if (!navigator.onLine) return null try { const result = await placesApi.list(tripId, params) upsertPlaces(result.places) diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index ee07f285..a480968e 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -11,6 +11,7 @@ export const reservationRepo = { .toArray() const refresh = (async () => { + if (!navigator.onLine) return null try { const result = await reservationsApi.list(tripId) upsertReservations(result.reservations) diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index d1748b98..f0f9df46 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -11,6 +11,7 @@ export const todoRepo = { .toArray() const refresh = (async () => { + if (!navigator.onLine) return null try { const result = await todoApi.list(tripId) upsertTodoItems(result.items) diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index 652bcaf6..6b32ee27 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -11,6 +11,7 @@ export const tripRepo = { const all = await offlineDb.trips.toArray() const refresh: TripsRefresh = (async () => { + if (!navigator.onLine) return null try { const [active, archived] = await Promise.all([ tripsApi.list(), @@ -41,6 +42,7 @@ export const tripRepo = { 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) diff --git a/client/src/store/slices/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index 371dd682..7b379e68 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(); }); diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index e33b0562..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) } 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/tripStore.ts b/client/src/store/tripStore.ts index 518f6b3d..2b88c4e3 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -89,6 +89,15 @@ export const useTripStore = create((set, get) => ({ loadTrip: async (tripId: number | string) => { set({ isLoading: true, error: null }) try { + // 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), @@ -100,14 +109,6 @@ export const useTripStore = create((set, get) => ({ offlineDb.categories.toArray(), ]) - // Tags/categories background refresh (network-only, applied when ready) - 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) - const buildMaps = (days: Day[]) => { const assignmentsMap: AssignmentsMap = {} const dayNotesMap: DayNotesMap = {} diff --git a/client/tests/unit/slices/placesSlice.test.ts b/client/tests/unit/slices/placesSlice.test.ts index 93a9310e..19df3a9b 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(); }); diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index 8d35c9eb..2d03a1e7 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: [] })), From 69620e727611d8652023ac9b527aeeaa62a31fa5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 02:14:39 +0200 Subject: [PATCH 7/7] feat: always-optimistic write pattern across all repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All create/update/delete repo methods now write to IndexedDB optimistically and fire mutationQueue.flush() as fire-and-forget, returning immediately without waiting for the network. This eliminates the 8-second UX freeze previously seen when the API was unreachable but navigator.onLine was true. - Repos rewritten: trip, day, place, packing, todo, budget, accommodation, reservation, file — write methods never throw, always return optimistic data - mutationQueue.flush() changed to iterative (one item per loop iteration) so mutations enqueued mid-flush (e.g. bulk check-all) are picked up - fileRepo.toggleStar skips the IDB put when the file is not cached locally - DayDetailPanel passes place_name into accommodationRepo.create so the optimistic accommodation renders the correct hotel label immediately - Test suite updated throughout to reflect optimistic-first semantics: no more rollback assertions, IDB cleared in component test beforeEach hooks, FileManager tests switched from filesApi spy to MSW endpoint assertions --- .../src/components/Files/FileManager.test.tsx | 78 +++++++---- .../Packing/PackingListPanel.test.tsx | 5 +- .../Planner/DayDetailPanel.test.tsx | 5 +- .../src/components/Planner/DayDetailPanel.tsx | 2 + client/src/repo/accommodationRepo.ts | 116 ++++++++-------- client/src/repo/budgetRepo.ts | 106 +++++++-------- client/src/repo/dayRepo.ts | 30 ++--- client/src/repo/fileRepo.ts | 86 ++++++------ client/src/repo/packingRepo.ts | 105 +++++++-------- client/src/repo/placeRepo.ts | 126 ++++++++---------- client/src/repo/reservationRepo.ts | 116 ++++++++-------- client/src/repo/todoRepo.ts | 112 +++++++--------- client/src/repo/tripRepo.ts | 30 ++--- client/src/store/slices/budgetSlice.test.ts | 42 +++--- client/src/sync/mutationQueue.ts | 12 +- client/tests/unit/repo/packingRepo.test.ts | 28 ++-- client/tests/unit/repo/placeRepo.test.ts | 16 +-- client/tests/unit/slices/budgetSlice.test.ts | 49 +++---- client/tests/unit/slices/filesSlice.test.ts | 8 +- client/tests/unit/slices/packingSlice.test.ts | 29 ++-- client/tests/unit/slices/placesSlice.test.ts | 9 +- .../unit/slices/reservationsSlice.test.ts | 29 ++-- client/tests/unit/slices/todoSlice.test.ts | 28 ++-- client/tests/unit/tripStore.test.ts | 4 +- 24 files changed, 548 insertions(+), 623 deletions(-) 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/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 e8df9aa3..4ae36718 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -118,8 +118,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const handleSaveAccommodation = async () => { if (!hotelForm.place_id) return try { + 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, diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 08831dbc..c31ea2ba 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -27,75 +27,63 @@ export const accommodationRepo = { }, async create(tripId: number | string, data: Record): Promise<{ accommodation: Accommodation }> { - if (!navigator.onLine) { - 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, - }) - return { accommodation: tempAccommodation } - } - const result = await accommodationsApi.create(tripId, data) - offlineDb.accommodations.put(result.accommodation) - return result + 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 }> { - if (!navigator.onLine) { - 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', - }) - return { accommodation: optimistic } - } - const result = await accommodationsApi.update(tripId, id, data) - offlineDb.accommodations.put(result.accommodation) - return result + 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 { - if (!navigator.onLine) { - 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, - }) - return { success: true } - } - const result = await accommodationsApi.delete(tripId, id) - offlineDb.accommodations.delete(id) - return result + 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 04158b3c..f674faab 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -29,70 +29,58 @@ export const budgetRepo = { }, async create(tripId: number | string, data: Record): Promise<{ item: BudgetItem }> { - if (!navigator.onLine) { - 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, - }) - return { item: tempItem } - } - const result = await budgetApi.create(tripId, data) - offlineDb.budgetItems.put(result.item) - return result + 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 }> { - if (!navigator.onLine) { - 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', - }) - return { item: optimistic } - } - const result = await budgetApi.update(tripId, id, data) - offlineDb.budgetItems.put(result.item) - return result + 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 { - if (!navigator.onLine) { - 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, - }) - return { success: true } - } - const result = await budgetApi.delete(tripId, id) - offlineDb.budgetItems.delete(id) - return result + 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 efbf9efe..74129707 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -29,22 +29,18 @@ export const dayRepo = { }, async update(tripId: number | string, dayId: number | string, data: Record): Promise<{ day: Day }> { - if (!navigator.onLine) { - const existing = await offlineDb.days.get(Number(dayId)) - const optimistic: Day = { ...(existing ?? {} as Day), ...(data as Partial), 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', - }) - return { day: optimistic } - } - const result = await daysApi.update(tripId, dayId, data) - offlineDb.days.put(result.day) - return result + 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 ae685515..030ca815 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -28,60 +28,50 @@ export const fileRepo = { return { files: fresh.files, refresh: Promise.resolve(fresh) } }, - async update(tripId: number | string, id: number, data: Record): Promise { - if (!navigator.onLine) { - const existing = await offlineDb.tripFiles.get(id) - if (existing) await offlineDb.tripFiles.put({ ...existing, ...(data as Partial) }) - await mutationQueue.enqueue({ - id: generateUUID(), - tripId: Number(tripId), - method: 'PUT', - url: `/trips/${tripId}/files/${id}`, - body: data, - resource: 'tripFiles', - }) - return { success: true } - } - const result = await filesApi.update(tripId, id, data) - const file = (result as { file?: TripFile }).file - if (file) offlineDb.tripFiles.put(file) - return result + 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 { - if (!navigator.onLine) { - const existing = await offlineDb.tripFiles.get(id) - if (existing) { - await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 }) - } - await mutationQueue.enqueue({ - id: generateUUID(), - tripId: Number(tripId), - method: 'PATCH', - url: `/trips/${tripId}/files/${id}/star`, - body: undefined, - }) - return { success: true } + const existing = await offlineDb.tripFiles.get(id) + if (existing) { + await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 }) } - return filesApi.toggleStar(tripId, id) + 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 { - if (!navigator.onLine) { - 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, - }) - return { success: true } - } - const result = await filesApi.delete(tripId, id) - offlineDb.tripFiles.delete(id) - return result + 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 9d1ed060..49130813 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -29,71 +29,56 @@ export const packingRepo = { }, 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 dc1f776b..320f44b9 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -29,92 +29,72 @@ export const placeRepo = { }, 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 a480968e..d0a4759c 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -29,75 +29,63 @@ export const reservationRepo = { }, async create(tripId: number | string, data: Record): Promise<{ reservation: Reservation }> { - if (!navigator.onLine) { - 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, - }) - return { reservation: tempReservation } - } - const result = await reservationsApi.create(tripId, data) - offlineDb.reservations.put(result.reservation) - return result + 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 }> { - if (!navigator.onLine) { - 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', - }) - return { reservation: optimistic } - } - const result = await reservationsApi.update(tripId, id, data) - offlineDb.reservations.put(result.reservation) - return result + 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 { - if (!navigator.onLine) { - 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, - }) - return { success: true } - } - const result = await reservationsApi.delete(tripId, id) - offlineDb.reservations.delete(id) - return result + 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 f0f9df46..27b54db6 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -29,73 +29,61 @@ export const todoRepo = { }, async create(tripId: number | string, data: Record): Promise<{ item: TodoItem }> { - if (!navigator.onLine) { - 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, - }) - return { item: tempItem } - } - const result = await todoApi.create(tripId, data) - offlineDb.todoItems.put(result.item) - return result + 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 }> { - if (!navigator.onLine) { - 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', - }) - return { item: optimistic } - } - const result = await todoApi.update(tripId, id, data) - offlineDb.todoItems.put(result.item) - return result + 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 { - if (!navigator.onLine) { - 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, - }) - return { success: true } - } - const result = await todoApi.delete(tripId, id) - offlineDb.todoItems.delete(id) - return result + 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 6b32ee27..07efc624 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -60,22 +60,18 @@ export const tripRepo = { }, async update(tripId: number | string, data: Partial): Promise<{ trip: Trip }> { - if (!navigator.onLine) { - 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', - }) - return { trip: optimistic } - } - const result = await tripsApi.update(tripId, data as Record) - upsertTrip(result.trip) - return result + 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/budgetSlice.test.ts b/client/src/store/slices/budgetSlice.test.ts index 7b379e68..48033d69 100644 --- a/client/src/store/slices/budgetSlice.test.ts +++ b/client/src/store/slices/budgetSlice.test.ts @@ -37,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 () => { @@ -74,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] }); @@ -100,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/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index d25b6b5b..93dc0f1c 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -73,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/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 19df3a9b..8a90e363 100644 --- a/client/tests/unit/slices/placesSlice.test.ts +++ b/client/tests/unit/slices/placesSlice.test.ts @@ -38,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] }); @@ -48,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 2d03a1e7..0aefe291 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -219,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'); }); });