From 852f0085d1437365229381cf0ed4885e23562ca0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 21:36:44 +0200 Subject: [PATCH] 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