From 852f0085d1437365229381cf0ed4885e23562ca0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 4 May 2026 21:36:44 +0200 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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'); }); }); From 86129bbfbca498250cf0a57b48aa6b7bd207e39d Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 13:01:32 +0200 Subject: [PATCH 08/27] feat: migrate OAuth public endpoints to MCP SDK auth handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes issue #959 — two bugs causing ChatGPT's custom MCP connector to fail: 1. RFC 9728 path-based PRM: ChatGPT requests /.well-known/oauth-protected-resource/mcp (path-aware URL per RFC 9728 §5). The old TREK handler only registered the base path; requests for the path variant fell through to the SPA catch-all and returned HTML. mcpAuthMetadataRouter registers the path-aware URL automatically. 2. DCR without scope: ChatGPT never sends scope during Dynamic Client Registration (RFC 7591 makes it optional). The old handler returned 400 for missing scope. clientRegistrationHandler accepts it; trekClientsStore.registerClient defaults to ALL_SCOPES when absent, and the user still grants only what they approve at the consent UI (scopeSelectable=true for DCR clients is unchanged). Hybrid approach: SDK handles /.well-known, /oauth/authorize (redirect to consent SPA), and /oauth/register. TREK keeps its own /oauth/token and /oauth/revoke because SDK clientAuth does plain-text secret comparison while TREK uses SHA-256 hashing — incompatible without a full clientAuth rewrite. SPA consent page renamed /oauth/authorize → /oauth/consent to avoid routing conflict with the SDK's backend authorize handler now mounted at that path. Existing URL paths (/oauth/token etc.) are unchanged so active Claude.ai connections are unaffected. Other: lazy-init SDK metadata router so getAppUrl() (DB query) is not called at createApp() time; path-aware mcpAddonGate so only /.well-known returns 404 when MCP is disabled (previously a blanket middleware blocked all routes including static files); /api/oauth mounted before the SDK middleware chain so SPA-facing routes with their own 403 gates are reached correctly. --- client/src/App.tsx | 2 +- .../pages/LoginPage.oidc-redirect.test.tsx | 10 +- client/src/pages/OAuthAuthorizePage.test.tsx | 2 +- client/src/pages/OAuthAuthorizePage.tsx | 2 +- client/vite.config.js | 25 +- server/src/app.ts | 78 ++++++- server/src/mcp/oauthProvider.ts | 220 ++++++++++++++++++ server/src/routes/oauth.ts | 138 +---------- server/tests/integration/oauth.test.ts | 46 +++- server/tsconfig.json | 10 +- 10 files changed, 380 insertions(+), 153 deletions(-) create mode 100644 server/src/mcp/oauthProvider.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index f5d96b51..efe22501 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -218,7 +218,7 @@ export default function App() { } /> } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} - } /> + } /> { describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => { it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => { - setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo'); + setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo'); render(); await waitFor(() => { - expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo'); + expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo'); }); }); @@ -67,13 +67,13 @@ describe('LoginPage — OIDC redirect preservation', () => { }); it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => { - sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz'); + sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz'); setSearch('?oidc_code=testcode123'); render(); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith( - '/oauth/authorize?client_id=foo&state=xyz', + '/oauth/consent?client_id=foo&state=xyz', { replace: true }, ); }); @@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => { describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => { it('removes oidc_redirect from sessionStorage on OIDC error', async () => { - sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo'); + sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo'); setSearch('?oidc_error=token_failed'); render(); diff --git a/client/src/pages/OAuthAuthorizePage.test.tsx b/client/src/pages/OAuthAuthorizePage.test.tsx index aad94171..fa84f016 100644 --- a/client/src/pages/OAuthAuthorizePage.test.tsx +++ b/client/src/pages/OAuthAuthorizePage.test.tsx @@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage'; const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256'; function setSearchParams(search: string) { - window.history.pushState({}, '', '/oauth/authorize' + search); + window.history.pushState({}, '', '/oauth/consent' + search); } const VALIDATE_OK = { diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx index 681326f2..f0a09df7 100644 --- a/client/src/pages/OAuthAuthorizePage.tsx +++ b/client/src/pages/OAuthAuthorizePage.tsx @@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { } function handleLoginRedirect() { - const next = '/oauth/authorize?' + params.toString() + window.location.hash + const next = '/oauth/consent?' + params.toString() + window.location.hash window.location.href = '/login?redirect=' + encodeURIComponent(next) } diff --git a/client/vite.config.js b/client/vite.config.js index 3cb475b4..bca18320 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -57,7 +57,30 @@ export default defineConfig({ '/mcp': { target: 'http://localhost:3001', changeOrigin: true, - } + }, + // OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke) + // /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent + // /oauth/consent is served by Vite as a SPA route (no proxy entry needed) + '/oauth/authorize': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/oauth/token': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/oauth/register': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/oauth/revoke': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/.well-known': { + target: 'http://localhost:3001', + changeOrigin: true, + }, } } }) diff --git a/server/src/app.ts b/server/src/app.ts index cc83b645..be9694f3 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -43,11 +43,18 @@ import journeyPublicRoutes from './routes/journeyPublic'; import publicConfigRoutes from './routes/publicConfig'; import systemNoticesRoutes from './routes/systemNotices'; import { mcpHandler } from './mcp'; +import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider'; import { Addon } from './types'; import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getCollabFeatures } from './services/adminService'; import { isAddonEnabled } from './services/adminService'; import { ADDON_IDS } from './addons'; +import { ALL_SCOPES } from './mcp/scopes'; +import { getAppUrl } from './services/oidcService'; +import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; +import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize'; +import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register'; +import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; export function createApp(): express.Application { const app = express(); @@ -89,9 +96,15 @@ export function createApp(): express.Application { const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; // RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config + // Covers both the base path and the RFC 9728 path-based variant (/.well-known/oauth-protected-resource/mcp) app.use( - ['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'], - cors({ origin: '*', credentials: false }), + (req: Request, _res: Response, next: NextFunction) => { + if (req.path.startsWith('/.well-known/oauth-')) { + cors({ origin: '*', credentials: false })(req, _res, next); + } else { + next(); + } + }, ); app.use(cors({ origin: corsOrigin, credentials: true })); app.use(helmet({ @@ -340,11 +353,68 @@ export function createApp(): express.Application { app.use('/api/notifications', notificationRoutes); app.use('/api', shareRoutes); - // OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke) - app.use('/', oauthPublicRouter); + // OAuth 2.1 — public endpoints + // Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting) + const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + next(); + }; + // OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*) + // Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate app.use('/api/oauth', oauthApiRouter); + // SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB) + // is not called at createApp() time, before test tables have been created. + // mcpAuthMetadataRouter serves: + // /.well-known/oauth-authorization-server — RFC 8414 AS metadata + // /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1) + let _sdkMetaRouter: express.Router | null = null; + function getMetaRouter(): express.Router { + if (_sdkMetaRouter) return _sdkMetaRouter; + const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, ''); + const oauthMetadata: OAuthMetadata = { + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + revocation_endpoint: `${base}/oauth/revoke`, + registration_endpoint: `${base}/oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + scopes_supported: ALL_SCOPES, + }; + _sdkMetaRouter = mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: new URL(`${base}/mcp`), + scopesSupported: ALL_SCOPES as string[], + resourceName: 'TREK MCP', + }); + return _sdkMetaRouter; + } + + // Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through + // so static files and SPA routes are unaffected when MCP is off. + app.use((req: Request, res: Response, next: NextFunction) => { + const isMetadataPath = + req.path === '/.well-known/oauth-authorization-server' || + req.path.startsWith('/.well-known/oauth-protected-resource'); + if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + getMetaRouter()(req, res, next); + }); + + // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects + // to the SPA consent page at /oauth/consent + app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider })); + + // SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2) + app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore })); + + // Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth) + // oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here + app.use('/', oauthPublicRouter); + // MCP endpoint app.post('/mcp', mcpHandler); app.get('/mcp', mcpHandler); diff --git a/server/src/mcp/oauthProvider.ts b/server/src/mcp/oauthProvider.ts new file mode 100644 index 00000000..08266c25 --- /dev/null +++ b/server/src/mcp/oauthProvider.ts @@ -0,0 +1,220 @@ +import type { Response } from 'express'; +import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider'; +import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types'; +import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider'; +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients'; +import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors'; +import { db } from '../db/database'; +import { + createOAuthClient, + consumeAuthCode, + issueTokens, + refreshTokens, + revokeToken as serviceRevokeToken, + verifyPKCE, + getUserByAccessToken, +} from '../services/oauthService'; +import { ALL_SCOPES } from './scopes'; +import { getAppUrl } from '../services/oidcService'; +import { writeAudit } from '../services/auditLog'; + +// --------------------------------------------------------------------------- +// DB row type (mirrors oauthService.ts) +// --------------------------------------------------------------------------- + +interface OAuthClientRow { + client_id: string; + name: string; + redirect_uris: string; // JSON array + allowed_scopes: string; // JSON array + is_public: number; // 0 | 1 + created_via: string; +} + +// --------------------------------------------------------------------------- +// Redirect URI validation (mirrors oauth.ts DCR checks) +// --------------------------------------------------------------------------- + +const DANGEROUS_SCHEMES = new Set([ + 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', +]); + +function assertValidRedirectUris(uris: string[]): void { + for (const u of uris) { + let url: URL; + try { url = new URL(u); } catch { + throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`); + } + if (DANGEROUS_SCHEMES.has(url.protocol)) + throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`); + if (url.protocol === 'https:') continue; + if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue; + const scheme = url.protocol.slice(0, -1); + if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue; + throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme'); + } +} + +// --------------------------------------------------------------------------- +// Row → SDK client info shape +// --------------------------------------------------------------------------- + +function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull { + return { + client_id: row.client_id, + client_name: row.name, + redirect_uris: JSON.parse(row.redirect_uris) as string[], + scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '), + token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }; +} + +// --------------------------------------------------------------------------- +// Clients store +// --------------------------------------------------------------------------- + +export const trekClientsStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + const row = db.prepare( + 'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?' + ).get(clientId) as OAuthClientRow | undefined; + return row ? rowToInfo(row) : undefined; + }, + + async registerClient( + metadata: Omit, + ): Promise { + const uris = metadata.redirect_uris as string[]; + assertValidRedirectUris(uris); + + const isPublic = metadata.token_endpoint_auth_method === 'none'; + const name = (typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client'; + + // When scope is absent (ChatGPT DCR), default to all scopes. + // The user still grants only what they approve at the consent screen. + const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES; + const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s)); + if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested'); + + const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' }); + if (result.error) throw new InvalidClientMetadataError(result.error); + + const c = result.client!; + return { + client_id: c.client_id as string, + client_name: c.name as string, + redirect_uris: c.redirect_uris as string[], + scope: (c.allowed_scopes as string[]).join(' '), + token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + ...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}), + }; + }, +}; + +// --------------------------------------------------------------------------- +// OAuthServerProvider +// --------------------------------------------------------------------------- + +export const trekOAuthProvider: OAuthServerProvider = { + get clientsStore() { return trekClientsStore; }, + + // Redirects browser to the SPA consent page with OAuth params forwarded. + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; + const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource; + + if (resource !== mcpResource) { + const url = new URL(params.redirectUri); + url.searchParams.set('error', 'invalid_target'); + url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint'); + if (params.state) url.searchParams.set('state', params.state); + res.redirect(302, url.toString()); + return; + } + + const qs = new URLSearchParams({ + client_id: client.client_id, + redirect_uri: params.redirectUri, + scope: params.scopes.join(' '), + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + }); + if (params.state) qs.set('state', params.state); + if (params.resource) qs.set('resource', params.resource.href); + + res.redirect(302, `/oauth/consent?${qs.toString()}`); + }, + + // Not called because skipLocalPkceValidation = true. + // PKCE verification is done inline in exchangeAuthorizationCode. + skipLocalPkceValidation: true, + + async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise { + throw new ServerError('PKCE validation is handled by the provider directly'); + }, + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + code: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL, + ): Promise { + const pending = consumeAuthCode(code); + if (!pending || pending.clientId !== client.client_id) + throw new Error('Authorization grant is invalid.'); + + if (redirectUri && pending.redirectUri !== redirectUri) + throw new Error('Authorization grant is invalid.'); + + const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null; + if (pending.resource && resourceStr && pending.resource !== resourceStr) + throw new Error('Authorization grant is invalid.'); + + if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge)) + throw new Error('Authorization grant is invalid.'); + + const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null); + writeAudit({ + userId: pending.userId, + action: 'oauth.token.issue', + details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null }, + ip: null, + }); + return tokens; + }, + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + _scopes?: string[], + _resource?: URL, + ): Promise { + const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null); + if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired'); + return result.tokens!; + }, + + async verifyAccessToken(token: string): Promise { + const info = getUserByAccessToken(token); + if (!info) throw new Error('Invalid or expired token'); + return { + token, + clientId: info.clientId, + scopes: info.scopes, + extra: { user: info.user }, + }; + }, + + async revokeToken( + client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest, + ): Promise { + serviceRevokeToken(request.token, client.client_id, undefined, null); + }, +}; diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts index 8d890faf..5f70d1b3 100644 --- a/server/src/routes/oauth.ts +++ b/server/src/routes/oauth.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth'; import { AuthRequest, OptionalAuthRequest } from '../types'; import { isAddonEnabled } from '../services/adminService'; -import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes'; +import { ALL_SCOPES } from '../mcp/scopes'; import { ADDON_IDS } from '../addons'; import { validateAuthorizeRequest, @@ -14,7 +14,6 @@ import { revokeToken, verifyPKCE, authenticateClient, - isValidRedirectUri, listOAuthClients, createOAuthClient, deleteOAuthClient, @@ -23,7 +22,6 @@ import { revokeSession, AuthorizeParams, } from '../services/oauthService'; -import { getAppUrl } from '../services/oidcService'; import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; // --------------------------------------------------------------------------- @@ -59,53 +57,18 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`); const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown'); const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); -const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); // --------------------------------------------------------------------------- -// Public router: /.well-known, /oauth/token, /oauth/revoke +// Public router: /oauth/token and /oauth/revoke +// (/.well-known and /oauth/register are now handled by SDK in app.ts) // --------------------------------------------------------------------------- export const oauthPublicRouter = express.Router(); -// RFC 8414 discovery document -oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => { - // M2: return 404 (not 403) so feature presence isn't fingerprinted - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - - const base = (getAppUrl() || '').replace(/\/+$/, ''); - res.json({ - issuer: base, - authorization_endpoint: `${base}/oauth/authorize`, - token_endpoint: `${base}/oauth/token`, - revocation_endpoint: `${base}/oauth/revoke`, - registration_endpoint: `${base}/oauth/register`, - response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], - code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], - scopes_supported: ALL_SCOPES, - scope_descriptions: Object.fromEntries( - ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label]) - ), - resource_parameter_supported: true, - }); -}); - -// RFC 9728 Protected Resource Metadata -oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - const base = (getAppUrl() || '').replace(/\/+$/, ''); - res.json({ - resource: `${base}/mcp`, - authorization_servers: [base], - bearer_methods_supported: ['header'], - scopes_supported: ALL_SCOPES, - resource_name: 'TREK MCP', - }); -}); - // Token endpoint — handles authorization_code and refresh_token grants oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + // M1: RFC 6749 §5.1 — token responses must not be cached res.set('Cache-Control', 'no-store'); res.set('Pragma', 'no-cache'); @@ -115,10 +78,6 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body; const ip = getClientIp(req); - if (!isAddonEnabled(ADDON_IDS.MCP)) { - return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' }); - } - if (!client_id) { return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' }); } @@ -194,96 +153,9 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); }); -// RFC 7591 Dynamic Client Registration endpoint -oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - - const body: Record = typeof req.body === 'object' && req.body !== null ? req.body : {}; - const ip = getClientIp(req); - - const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : []; - if (redirectUris.length === 0) { - return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' }); - } - // OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public - // clients (MCP, native) are limited to loopback or a reverse-DNS - // private-use scheme. This rejects `http://evil.example` DCR payloads - // that today would otherwise be accepted since we previously only - // checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.) - // are explicitly rejected — the authorize flow later 302s the - // browser to this URI, which with `javascript:` would execute - // attacker-controlled script under our redirect origin's context. - const DANGEROUS_SCHEMES = new Set([ - 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:', - ]); - const allowed = redirectUris.every((u) => { - try { - const url = new URL(u); - if (DANGEROUS_SCHEMES.has(url.protocol)) return false; - if (url.protocol === 'https:') return true; - if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true; - // RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name - // (e.g. `com.example.myapp:/callback`). Requiring a dot in the - // scheme is a cheap heuristic that rules out bare `myapp:` and - // `x:` one-off schemes the spec explicitly discourages. - const schemeBody = url.protocol.slice(0, -1); - if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true; - return false; - } catch { - return false; - } - }); - if (!allowed) { - return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' }); - } - - const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : ''; - const clientName = rawName || 'MCP Client'; - - // Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only - const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post'; - const isPublic = authMethod === 'none'; - - // Resolve requested scopes — scope is required; no implicit full-access grant - if (typeof body.scope !== 'string' || body.scope.trim() === '') { - return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' }); - } - const rawScope = body.scope; - const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s)); - if (requestedScopes.length === 0) { - return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' }); - } - - const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, { - isPublic, - createdVia: 'dcr', - }); - - if (result.error) { - return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error }); - } - - const client = result.client!; - const now = Math.floor(Date.now() / 1000); - - return res.status(201).json({ - client_id: client.client_id, - ...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}), - client_id_issued_at: now, - redirect_uris: client.redirect_uris, - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - scope: (client.allowed_scopes as string[]).join(' '), - client_name: client.name, - token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post', - }); -}); - // Token revocation endpoint (RFC 7009) oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { - // M2: return 404 when MCP is disabled if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - const body: Record = typeof req.body === 'object' ? req.body : {}; const { token, client_id, client_secret } = body; const ip = getClientIp(req); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index da247acd..04f70541 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -103,12 +103,48 @@ describe('GET /.well-known/oauth-authorization-server', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Issue #959 regression tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('RFC 9728 — path-based protected resource metadata (issue #959 bug 1)', () => { + it('OAUTH-959A — /.well-known/oauth-protected-resource/mcp returns JSON (not SPA HTML)', async () => { + const res = await request(app).get('/.well-known/oauth-protected-resource/mcp'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/json/); + expect(res.body.resource).toContain('/mcp'); + expect(Array.isArray(res.body.authorization_servers)).toBe(true); + }); +}); + +describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () => { + it('OAUTH-959B — POST /oauth/register without scope field returns 201 with default scopes', async () => { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['https://chatgpt.example.com/cb'], token_endpoint_auth_method: 'none' }); + expect(res.status).toBe(201); + expect(res.body.client_id).toBeDefined(); + expect(typeof res.body.scope).toBe('string'); + expect(res.body.scope.length).toBeGreaterThan(0); + }); + + it('OAUTH-959C — POST /oauth/register with explicit scope registers only requested scopes', async () => { + const res = await request(app) + .post('/oauth/register') + .set('Content-Type', 'application/json') + .send({ redirect_uris: ['https://example.com/cb'], token_endpoint_auth_method: 'none', scope: 'trips:read' }); + expect(res.status).toBe(201); + expect(res.body.scope).toBe('trips:read'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/token — authorization_code grant // ───────────────────────────────────────────────────────────────────────────── describe('POST /oauth/token — authorization_code grant', () => { - it('OAUTH-002 — missing client_id/client_secret returns 401 invalid_client', async () => { + it('OAUTH-002 — missing client_id returns 401 invalid_client', async () => { const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' }); @@ -116,13 +152,12 @@ describe('POST /oauth/token — authorization_code grant', () => { expect(res.body.error).toBe('invalid_client'); }); - it('OAUTH-003 — MCP addon disabled returns 403 mcp_disabled', async () => { + it('OAUTH-003 — MCP addon disabled returns 404', async () => { isAddonEnabledMock.mockReturnValue(false); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); - expect(res.status).toBe(403); - expect(res.body.error).toBe('mcp_disabled'); + expect(res.status).toBe(404); }); it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => { @@ -211,7 +246,7 @@ describe('POST /oauth/token — authorization_code grant', () => { expect(res.body.error).toBe('invalid_grant'); }); - it('OAUTH-008 — wrong client_secret returns 401 invalid_client', async () => { + it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); const { verifier, challenge } = makePkce(); @@ -909,7 +944,6 @@ describe('M1 — Cache-Control headers on /oauth/token', () => { .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); expect(res.headers['cache-control']).toBe('no-store'); - expect(res.headers['pragma']).toBe('no-cache'); }); }); diff --git a/server/tsconfig.json b/server/tsconfig.json index b443a5ba..48b19a9e 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -20,7 +20,15 @@ // These paths manually redirect to the CJS dist until the SDK fixes its exports map. "paths": { "@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"], - "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"] + "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"], + "@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"], + "@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"], + "@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"], + "@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"], + "@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"], + "@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"], + "@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"], + "@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"] } }, "include": ["src"], From fb6eaaf06d3a0fabaa17d91d28bad7ed4a667da1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 13:29:40 +0200 Subject: [PATCH 09/27] fix: open CORS for OAuth register/authorize + correct WWW-Authenticate PRM path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up fixes after the SDK auth migration: 1. CORS for browser-based OAuth clients (ChatGPT DCR 403) The global cors({ origin: false }) intercepts OPTIONS preflight for /oauth/register and /oauth/authorize before the SDK's own cors() middleware inside clientRegistrationHandler/authorizationHandler runs, causing the browser to reject the response with no Access-Control-Allow-Origin header. ChatGPT's connector makes DCR from the browser, so this manifested as a 403. Fix: extend the open-CORS pre-middleware to also cover /oauth/register and /oauth/authorize (same pattern as /.well-known). 2. WWW-Authenticate resource_metadata URL (RFC 9728 §5) The MCP handler was advertising the base PRM path (/.well-known/oauth-protected-resource) instead of the path-aware variant (/.well-known/oauth-protected-resource/mcp). RFC 9728 requires the resource path to be appended when the resource URI has a path component. The SDK registers the path-aware URL; the WWW-Authenticate header now points to the same location. --- server/src/app.ts | 14 +++++++++++--- server/src/mcp/index.ts | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index be9694f3..e86de386 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -95,11 +95,19 @@ export function createApp(): express.Application { const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; - // RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config - // Covers both the base path and the RFC 9728 path-based variant (/.well-known/oauth-protected-resource/mcp) + // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable — + // open CORS for external MCP clients regardless of the deployment's ALLOWED_ORIGINS config. + // /oauth/register and /oauth/authorize need it because browser-based clients (ChatGPT, etc.) + // send a CORS preflight that the global cors({ origin: false }) would answer WITHOUT + // Access-Control-Allow-Origin, causing the browser to reject the response before the + // SDK's own cors() middleware inside clientRegistrationHandler/authorizationHandler runs. app.use( (req: Request, _res: Response, next: NextFunction) => { - if (req.path.startsWith('/.well-known/oauth-')) { + if ( + req.path.startsWith('/.well-known/oauth-') || + req.path === '/oauth/register' || + req.path === '/oauth/authorize' + ) { cors({ origin: '*', credentials: false })(req, _res, next); } else { next(); diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index e46c03db..2984811a 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -154,8 +154,9 @@ sessionSweepInterval.unref(); function setAuthChallenge(res: Response, error = 'invalid_token'): void { const base = (getAppUrl() || '').replace(/\/+$/, ''); + // RFC 9728 §5: resource with path component /mcp → PRM URL must include the path res.set('WWW-Authenticate', - `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`); + `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`); } interface VerifyTokenResult { From 8e14434a1b358f82692c77cce1d8fe147f93e509 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 13:48:43 +0200 Subject: [PATCH 10/27] fix: thread resource indicator through OAuth consent flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent page extracted client_id, redirect_uri, scope, state, code_challenge from URL params but silently dropped `resource`. Without it the auth code had no resource binding, tokens were issued with audience=null, and the MCP handler's RFC 8707 audience check rejected every token — "There was a problem connecting TREK." Fix: extract `resource` from URLSearchParams and forward it through oauthApi.validate() and oauthApi.authorize(). Add the field to both API type signatures. --- client/src/api/client.ts | 2 ++ client/src/pages/OAuthAuthorizePage.tsx | 3 +++ 2 files changed, 5 insertions(+) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index c8801411..3147e5c2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -143,6 +143,7 @@ export const oauthApi = { state?: string code_challenge: string code_challenge_method: string + resource?: string }) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data), /** Submit user consent (approve or deny) */ @@ -154,6 +155,7 @@ export const oauthApi = { code_challenge: string code_challenge_method: string approved: boolean + resource?: string }) => apiClient.post('/oauth/authorize', body).then(r => r.data), clients: { diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx index f0a09df7..b1cd5b3e 100644 --- a/client/src/pages/OAuthAuthorizePage.tsx +++ b/client/src/pages/OAuthAuthorizePage.tsx @@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { const state = params.get('state') || '' const codeChallenge = params.get('code_challenge') || '' const ccMethod = params.get('code_challenge_method') || '' + const resource = params.get('resource') || undefined // Load auth state once, then validate useEffect(() => { @@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { code_challenge: codeChallenge, code_challenge_method: ccMethod, response_type: 'code', + resource, }) setValidation(result) @@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { code_challenge: codeChallenge, code_challenge_method: ccMethod, approved, + resource, }) setPageState('done') window.location.href = result.redirect From af10ab1c9350db4e9faea6cc282532b44764c45f Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 13:57:52 +0200 Subject: [PATCH 11/27] fix: add /mcp to open-CORS pre-middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External MCP clients (ChatGPT, Claude.ai, MCP Inspector) call /mcp cross-origin with Bearer tokens. The OPTIONS preflight was hitting the SPA catch-all because the global cors({ origin: false }) didn't add Access-Control-Allow-Origin. Without a valid CORS response the browser blocked the subsequent POST, preventing the 401 WWW-Authenticate header from being read — ChatGPT reported 'does not implement OAuth'. --- server/src/app.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index e86de386..916dbb28 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -95,18 +95,18 @@ export function createApp(): express.Application { const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; - // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable — - // open CORS for external MCP clients regardless of the deployment's ALLOWED_ORIGINS config. - // /oauth/register and /oauth/authorize need it because browser-based clients (ChatGPT, etc.) - // send a CORS preflight that the global cors({ origin: false }) would answer WITHOUT - // Access-Control-Allow-Origin, causing the browser to reject the response before the - // SDK's own cors() middleware inside clientRegistrationHandler/authorizationHandler runs. + // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable. + // /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it + // with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for + // browser-based DCR/authorization preflights — the global cors({ origin: false }) would + // answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs. app.use( (req: Request, _res: Response, next: NextFunction) => { if ( req.path.startsWith('/.well-known/oauth-') || req.path === '/oauth/register' || - req.path === '/oauth/authorize' + req.path === '/oauth/authorize' || + req.path === '/mcp' ) { cors({ origin: '*', credentials: false })(req, _res, next); } else { From 001cc6431bfdb9822138e7990ce52f115a6e2bca Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 14:16:28 +0200 Subject: [PATCH 12/27] fix: 404 JSON + open CORS for all /.well-known/* paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT probes /.well-known/openid-configuration and the RFC 8414 path-suffixed form /.well-known/oauth-authorization-server/mcp before (or instead of) following the RFC 9728 WWW-Authenticate chain. Both returned 200 HTML from the SPA catch-all, which clients can't parse as JSON — ChatGPT reported 'does not implement OAuth'. Two fixes: - Extend open-CORS pre-middleware from /.well-known/oauth-* to all /.well-known/* so browser-based probes aren't CORS-blocked - Add a 404 JSON catch-all for /.well-known/* paths the SDK metadata router doesn't handle, placed before the SPA catch-all --- server/src/app.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/app.ts b/server/src/app.ts index 916dbb28..a57c359f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -100,10 +100,12 @@ export function createApp(): express.Application { // with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for // browser-based DCR/authorization preflights — the global cors({ origin: false }) would // answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs. + // All /.well-known/* paths get open CORS so clients probing openid-configuration or the + // RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead). app.use( (req: Request, _res: Response, next: NextFunction) => { if ( - req.path.startsWith('/.well-known/oauth-') || + req.path.startsWith('/.well-known/') || req.path === '/oauth/register' || req.path === '/oauth/authorize' || req.path === '/mcp' @@ -428,6 +430,15 @@ export function createApp(): express.Application { app.get('/mcp', mcpHandler); app.delete('/mcp', mcpHandler); + // Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle. + // Without this, the SPA catch-all serves HTML — clients probing + // /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL + // receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth". + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' }); + next(); + }); + // Production static file serving if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); From f9db7e1104ef0200fbaf00710ace37a78cbc9268 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 14:25:05 +0200 Subject: [PATCH 13/27] fix: serve OAuth AS metadata at /.well-known/openid-configuration ChatGPT uses OIDC discovery to bootstrap the OAuth flow: it fetches /.well-known/openid-configuration to find the registration_endpoint, authorization_endpoint, and token_endpoint before attempting DCR. Without this endpoint responding, it cannot proceed and reports 'does not implement OAuth'. Serve the same AS metadata at the OIDC discovery URL so OIDC-first clients can bootstrap the full OAuth 2.1 + DCR flow. --- server/src/app.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/app.ts b/server/src/app.ts index a57c359f..46a0a556 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -409,11 +409,31 @@ export function createApp(): express.Application { app.use((req: Request, res: Response, next: NextFunction) => { const isMetadataPath = req.path === '/.well-known/oauth-authorization-server' || + req.path === '/.well-known/openid-configuration' || req.path.startsWith('/.well-known/oauth-protected-resource'); if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); getMetaRouter()(req, res, next); }); + // ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via + // /.well-known/openid-configuration. Serve the same AS metadata there so + // they can find the registration_endpoint, authorization_endpoint, etc. + app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => { + const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, ''); + res.json({ + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + revocation_endpoint: `${base}/oauth/revoke`, + registration_endpoint: `${base}/oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + scopes_supported: ALL_SCOPES, + }); + }); + // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects // to the SPA consent page at /oauth/consent app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider })); From 895f34debaaa0b73dc828000ae85adbbdbba403e Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 14:32:59 +0200 Subject: [PATCH 14/27] refactor: extract getOAuthMetadata() shared by both discovery endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both /.well-known/oauth-authorization-server (via SDK router) and /.well-known/openid-configuration now serve the same OAuthMetadata object built once from a shared lazy getter. The MCP spec explicitly states clients try OIDC Discovery or RFC 8414 depending on server support — ChatGPT uses OIDC Discovery first. Serving the OAuth AS metadata at the OIDC URL is the correct approach; clients only read the OAuth fields (authorization_endpoint, token_endpoint, registration_endpoint) from it. --- server/src/app.ts | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 46a0a556..85e06990 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -379,11 +379,13 @@ export function createApp(): express.Application { // mcpAuthMetadataRouter serves: // /.well-known/oauth-authorization-server — RFC 8414 AS metadata // /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1) + let _oauthMetadata: OAuthMetadata | null = null; let _sdkMetaRouter: express.Router | null = null; - function getMetaRouter(): express.Router { - if (_sdkMetaRouter) return _sdkMetaRouter; + + function getOAuthMetadata(): OAuthMetadata { + if (_oauthMetadata) return _oauthMetadata; const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, ''); - const oauthMetadata: OAuthMetadata = { + _oauthMetadata = { issuer: base, authorization_endpoint: `${base}/oauth/authorize`, token_endpoint: `${base}/oauth/token`, @@ -395,9 +397,15 @@ export function createApp(): express.Application { token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], scopes_supported: ALL_SCOPES, }; + return _oauthMetadata; + } + + function getMetaRouter(): express.Router { + if (_sdkMetaRouter) return _sdkMetaRouter; + const metadata = getOAuthMetadata(); _sdkMetaRouter = mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: new URL(`${base}/mcp`), + oauthMetadata: metadata, + resourceServerUrl: new URL(`${metadata.issuer}/mcp`), scopesSupported: ALL_SCOPES as string[], resourceName: 'TREK MCP', }); @@ -416,22 +424,10 @@ export function createApp(): express.Application { }); // ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via - // /.well-known/openid-configuration. Serve the same AS metadata there so - // they can find the registration_endpoint, authorization_endpoint, etc. + // /.well-known/openid-configuration. Serve the same typed AS metadata so + // they can find registration_endpoint, authorization_endpoint, token_endpoint. app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => { - const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, ''); - res.json({ - issuer: base, - authorization_endpoint: `${base}/oauth/authorize`, - token_endpoint: `${base}/oauth/token`, - revocation_endpoint: `${base}/oauth/revoke`, - registration_endpoint: `${base}/oauth/register`, - response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], - code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], - scopes_supported: ALL_SCOPES, - }); + res.json(getOAuthMetadata()); }); // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects From 55ef0f3ca971bc1e4e367e89aff963544787de12 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 14:41:15 +0200 Subject: [PATCH 15/27] feat: add OIDC userinfo endpoint for ChatGPT domain claiming ChatGPT enables OIDC when it finds /.well-known/openid-configuration and uses the userinfo endpoint to fetch the authenticated user's email for authorization domain claiming. - Add GET /oauth/userinfo: validates Bearer token, returns sub/email/ email_verified/preferred_username from the OAuth access token - Add userinfo_endpoint to /.well-known/openid-configuration response - Add /oauth/userinfo to open-CORS pre-middleware --- server/src/app.ts | 12 +++++++++--- server/src/routes/oauth.ts | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 85e06990..38e70c36 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -108,6 +108,7 @@ export function createApp(): express.Application { req.path.startsWith('/.well-known/') || req.path === '/oauth/register' || req.path === '/oauth/authorize' || + req.path === '/oauth/userinfo' || req.path === '/mcp' ) { cors({ origin: '*', credentials: false })(req, _res, next); @@ -424,10 +425,15 @@ export function createApp(): express.Application { }); // ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via - // /.well-known/openid-configuration. Serve the same typed AS metadata so - // they can find registration_endpoint, authorization_endpoint, token_endpoint. + // /.well-known/openid-configuration. Serve the AS metadata plus the OIDC + // userinfo_endpoint so ChatGPT can fetch the authenticated user's email + // for authorization domain claiming. app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => { - res.json(getOAuthMetadata()); + const meta = getOAuthMetadata(); + res.json({ + ...meta, + userinfo_endpoint: `${meta.issuer}/oauth/userinfo`, + }); }); // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts index 5f70d1b3..91558ff0 100644 --- a/server/src/routes/oauth.ts +++ b/server/src/routes/oauth.ts @@ -20,6 +20,7 @@ import { rotateOAuthClientSecret, listOAuthSessions, revokeSession, + getUserByAccessToken, AuthorizeParams, } from '../services/oauthService'; import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; @@ -153,6 +154,29 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); }); +// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3) +// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming. +oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + const auth = req.headers['authorization']; + if (!auth || !auth.toLowerCase().startsWith('bearer ')) { + res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"'); + return res.status(401).json({ error: 'invalid_token' }); + } + const token = auth.slice(7); + const info = getUserByAccessToken(token); + if (!info) { + res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"'); + return res.status(401).json({ error: 'invalid_token' }); + } + return res.json({ + sub: String(info.user.id), + email: info.user.email, + email_verified: true, + preferred_username: info.user.username, + }); +}); + // Token revocation endpoint (RFC 7009) oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); From a1f4643b90b54f8b26794b48dda97311dce7b56c Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 15:37:59 +0200 Subject: [PATCH 16/27] docs: document ChatGPT MCP + Cloudflare Bot Fight Mode issue --- wiki/MCP-Setup.md | 10 ++++++++++ wiki/Troubleshooting.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/wiki/MCP-Setup.md b/wiki/MCP-Setup.md index 2ff069ea..1d4dc6c0 100644 --- a/wiki/MCP-Setup.md +++ b/wiki/MCP-Setup.md @@ -41,6 +41,16 @@ Claude Desktop connects via `mcp-remote`. After creating an OAuth client using t When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow. +### ChatGPT + +ChatGPT's custom MCP connector supports Dynamic Client Registration (DCR) — no pre-created client is required: + +1. In ChatGPT, open **Settings → Connected Apps → Add a custom app**. +2. Set the **MCP Server URL** to `https:///mcp`. +3. ChatGPT will automatically discover TREK's OAuth metadata, register itself, and redirect you to the TREK consent screen to approve access. + +> **Cloudflare users:** If your TREK instance is behind Cloudflare and you are on the **free plan**, you must disable **Bot Fight Mode** (`Security → Bots → Bot Fight Mode → Off`). ChatGPT's backend uses a Python HTTP client (`aiohttp`) whose TLS fingerprint is classified as a bot by Cloudflare. Because the free plan does not support path-based bot exceptions, the feature must be disabled globally. On **Pro and above**, create a WAF custom rule (position #1) that skips Bot Fight Mode for paths `/oauth/*`, `/.well-known/*`, and `/mcp`. + ### Cursor, VS Code, Windsurf, and Zed Clients that support `mcp-remote` can connect in one of two ways. diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index aae97181..98f63c98 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -240,6 +240,37 @@ Restart the container after adding the variable. Once set, clicking **Connect** --- +## ChatGPT MCP connector: "Dynamic client registration failed" / 403 + +**Cause:** ChatGPT's MCP backend runs on OpenAI's datacenter IPs and uses a Python HTTP client (`aiohttp`). Cloudflare's **Bot Fight Mode** identifies the TLS fingerprint of this client as bot traffic and blocks the request at the edge — before it ever reaches your server. Because the request is dropped by Cloudflare, nothing appears in TREK's logs. + +This affects the OAuth Dynamic Client Registration (`/oauth/register`), the `/mcp` endpoint, and the OAuth metadata endpoints (`/.well-known/*`). + +**Fix — Cloudflare free plan:** + +Disable Bot Fight Mode entirely: + +**Security → Bots → Bot Fight Mode → Off** + +The free plan does not support path-based exceptions, so the feature must be turned off globally. Your TREK data remains protected by its own authentication — Bot Fight Mode is not a substitute for application-level auth. + +**Fix — Cloudflare Pro and above (Super Bot Fight Mode):** + +Create a WAF custom rule at **position #1** (rules fire in order — it must be first): + +``` +Expression: + (http.request.uri.path contains "/oauth/") or + (http.request.uri.path contains "/.well-known/") or + (http.request.uri.path eq "/mcp") + +Action: Skip → All remaining custom rules + Bot Fight Mode +``` + +Ensure the **"Bot Fight Mode"** checkbox in the Skip action is checked, not just "All remaining custom rules." + +--- + ## MCP integration: "Too many requests" or "Session limit reached" **Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response. From 81a59edf03b15dd3d9389fd5655c6c815740d855 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 16:27:53 +0200 Subject: [PATCH 17/27] fix: surface app-config load failure so SSO button is never silently hidden When /api/auth/app-config fails (ZT redirect, network blip) the login page now shows a warning banner with a Refresh button instead of silently omitting the SSO sign-in button. The SW also now applies authRedirectPlugin to this endpoint so ZT opaque redirects are converted to 401 AUTH_REQUIRED rather than causing a JSON parse failure that went undetected. Translations added for all 15 supported languages. --- client/src/i18n/translations/ar.ts | 2 ++ client/src/i18n/translations/br.ts | 2 ++ client/src/i18n/translations/cs.ts | 2 ++ client/src/i18n/translations/de.ts | 2 ++ client/src/i18n/translations/en.ts | 2 ++ client/src/i18n/translations/es.ts | 2 ++ client/src/i18n/translations/fr.ts | 2 ++ client/src/i18n/translations/hu.ts | 2 ++ client/src/i18n/translations/id.ts | 2 ++ client/src/i18n/translations/it.ts | 2 ++ client/src/i18n/translations/nl.ts | 2 ++ client/src/i18n/translations/pl.ts | 2 ++ client/src/i18n/translations/ru.ts | 2 ++ client/src/i18n/translations/zh.ts | 2 ++ client/src/i18n/translations/zhTw.ts | 2 ++ client/src/pages/LoginPage.tsx | 23 +++++++++++++++++++---- client/src/sw.ts | 11 ++++++++++- 17 files changed, 59 insertions(+), 5 deletions(-) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 390b05c0..99295939 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -464,6 +464,8 @@ const ar: Record = { 'login.mfaVerify': 'تحقق', 'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية', 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC', + 'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.', + 'login.configLoadRetry': 'تحديث', 'login.usernameRequired': 'اسم المستخدم مطلوب', 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل', 'login.forgotPassword': 'نسيت كلمة المرور؟', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0757c3d2..e23ca5a1 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -459,6 +459,8 @@ const br: Record = { 'login.mfaVerify': 'Verificar', 'login.invalidInviteLink': 'Link de convite inválido ou expirado', 'login.oidcFailed': 'Falha no login OIDC', + 'login.configLoadError': 'Não foi possível carregar as opções de login.', + 'login.configLoadRetry': 'Atualizar', 'login.usernameRequired': 'Nome de usuário é obrigatório', 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres', 'login.forgotPassword': 'Esqueceu a senha?', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a14b633d..cc951bce 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -459,6 +459,8 @@ const cs: Record = { 'login.mfaVerify': 'Ověřit', 'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou', 'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo', + 'login.configLoadError': 'Nepodařilo se načíst možnosti přihlášení.', + 'login.configLoadRetry': 'Obnovit', 'login.usernameRequired': 'Uživatelské jméno je povinné', 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků', 'login.forgotPassword': 'Zapomenuté heslo?', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index cbb6d153..1aab8743 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -464,6 +464,8 @@ const de: Record = { 'login.mfaVerify': 'Bestätigen', 'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink', 'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen', + 'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.', + 'login.configLoadRetry': 'Aktualisieren', 'login.usernameRequired': 'Benutzername ist erforderlich', 'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein', 'login.forgotPassword': 'Passwort vergessen?', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ce8321a6..145c6c5b 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -537,6 +537,8 @@ const en: Record = { 'login.mfaVerify': 'Verify', 'login.invalidInviteLink': 'Invalid or expired invite link', 'login.oidcFailed': 'OIDC login failed', + 'login.configLoadError': 'Could not load login options.', + 'login.configLoadRetry': 'Refresh', 'login.usernameRequired': 'Username is required', 'login.passwordMinLength': 'Password must be at least 8 characters', 'login.forgotPassword': 'Forgot password?', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a66bdfb6..a4562409 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -451,6 +451,8 @@ const es: Record = { 'login.mfaVerify': 'Verificar', 'login.invalidInviteLink': 'Enlace de invitación inválido o expirado', 'login.oidcFailed': 'Error de inicio de sesión OIDC', + 'login.configLoadError': 'No se pudieron cargar las opciones de inicio de sesión.', + 'login.configLoadRetry': 'Actualizar', 'login.usernameRequired': 'El nombre de usuario es obligatorio', 'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres', 'login.forgotPassword': '¿Olvidaste tu contraseña?', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c7cd1605..ef26c1df 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -452,6 +452,8 @@ const fr: Record = { 'login.mfaVerify': 'Vérifier', 'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré', 'login.oidcFailed': 'Échec de connexion OIDC', + 'login.configLoadError': 'Impossible de charger les options de connexion.', + 'login.configLoadRetry': 'Actualiser', 'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire', 'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères', 'login.forgotPassword': 'Mot de passe oublié ?', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index f8046fab..a07397d5 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -459,6 +459,8 @@ const hu: Record = { 'login.mfaVerify': 'Ellenőrzés', 'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink', 'login.oidcFailed': 'OIDC bejelentkezés sikertelen', + 'login.configLoadError': 'A bejelentkezési lehetőségek betöltése nem sikerült.', + 'login.configLoadRetry': 'Frissítés', 'login.usernameRequired': 'A felhasználónév kötelező', 'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'login.forgotPassword': 'Elfelejtetted a jelszavad?', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 112d17fc..f9e51bb3 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -521,6 +521,8 @@ const id: Record = { 'login.mfaVerify': 'Verifikasi', 'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa', 'login.oidcFailed': 'Login OIDC gagal', + 'login.configLoadError': 'Gagal memuat opsi login.', + 'login.configLoadRetry': 'Segarkan', 'login.usernameRequired': 'Nama pengguna wajib diisi', 'login.passwordMinLength': 'Kata sandi minimal 8 karakter', 'login.forgotPassword': 'Lupa kata sandi?', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 2ac5424f..44bdcfe8 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -459,6 +459,8 @@ const it: Record = { 'login.mfaVerify': 'Verifica', 'login.invalidInviteLink': 'Link di invito non valido o scaduto', 'login.oidcFailed': 'Accesso OIDC non riuscito', + 'login.configLoadError': 'Impossibile caricare le opzioni di accesso.', + 'login.configLoadRetry': 'Aggiorna', 'login.usernameRequired': 'Il nome utente è obbligatorio', 'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri', 'login.forgotPassword': 'Password dimenticata?', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0cb55bc1..c16c68cb 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -452,6 +452,8 @@ const nl: Record = { 'login.mfaVerify': 'Verifiëren', 'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink', 'login.oidcFailed': 'OIDC-aanmelding mislukt', + 'login.configLoadError': 'Kan aanmeldingsopties niet laden.', + 'login.configLoadRetry': 'Vernieuwen', 'login.usernameRequired': 'Gebruikersnaam is vereist', 'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten', 'login.forgotPassword': 'Wachtwoord vergeten?', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 87f768a9..005541ae 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -426,6 +426,8 @@ const pl: Record = { 'login.mfaVerify': 'Weryfikuj', 'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia', 'login.oidcFailed': 'Logowanie OIDC nie powiodło się', + 'login.configLoadError': 'Nie można załadować opcji logowania.', + 'login.configLoadRetry': 'Odśwież', 'login.usernameRequired': 'Nazwa użytkownika jest wymagana', 'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków', 'login.forgotPassword': 'Nie pamiętasz hasła?', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f4f23fb8..0f82d22a 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -452,6 +452,8 @@ const ru: Record = { 'login.mfaVerify': 'Подтвердить', 'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение', 'login.oidcFailed': 'Ошибка входа через OIDC', + 'login.configLoadError': 'Не удалось загрузить параметры входа.', + 'login.configLoadRetry': 'Обновить', 'login.usernameRequired': 'Имя пользователя обязательно', 'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов', 'login.forgotPassword': 'Забыли пароль?', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index ffa564b6..72e9e9d2 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -452,6 +452,8 @@ const zh: Record = { 'login.mfaVerify': '验证', 'login.invalidInviteLink': '邀请链接无效或已过期', 'login.oidcFailed': 'OIDC 登录失败', + 'login.configLoadError': '无法加载登录选项。', + 'login.configLoadRetry': '刷新', 'login.usernameRequired': '用户名为必填项', 'login.passwordMinLength': '密码至少需要8个字符', 'login.forgotPassword': '忘记密码?', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 331596c5..276a76f6 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -511,6 +511,8 @@ const zhTw: Record = { 'login.mfaVerify': '驗證', 'login.invalidInviteLink': '邀請連結無效或已過期', 'login.oidcFailed': 'OIDC 登入失敗', + 'login.configLoadError': '無法載入登入選項。', + 'login.configLoadRetry': '重新整理', 'login.usernameRequired': '使用者名稱為必填', 'login.passwordMinLength': '密碼至少需要8個字元', 'login.forgotPassword': '忘記密碼?', diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 6fa2c192..0104f97e 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [appConfig, setAppConfig] = useState(null) + const [configError, setConfigError] = useState(false) const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) @@ -117,15 +118,15 @@ export default function LoginPage(): React.ReactElement { return } - authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => { - if (config) { + authApi.getAppConfig?.() + .then((config: AppConfig) => { setAppConfig(config) if (!config.has_users) setMode('register') if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { window.location.href = '/api/auth/oidc/login' } - } - }) + }) + .catch(() => setConfigError(true)) }, [navigate, t, noRedirect]) // Language detection chain (runs once on mount, only if user has no saved preference): @@ -860,6 +861,20 @@ export default function LoginPage(): React.ReactElement { )} + {/* Config load error — shown when /api/auth/app-config fails (e.g. ZT redirect, + network blip). Hides the SSO button; prompt user to refresh. */} + {configError && !appConfig && ( +
+ {t('login.configLoadError')} + +
+ )} + {/* Demo login button */} {appConfig?.demo_mode && ( + )} ) } diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 865e9803..e773396b 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -27,6 +27,7 @@ import { upsertCategories, upsertSyncMeta, clearTripData, + clearBlobCache, clearAll, } from '../db/offlineDb' import { prefetchTilesForTrip } from './tilePrefetcher' @@ -135,6 +136,7 @@ async function cacheFilesForTrip(files: TripFile[]): Promise { // ── Public API ──────────────────────────────────────────────────────────────── let _syncing = false +let _interrupted = false export const tripSyncManager = { /** @@ -145,6 +147,7 @@ export const tripSyncManager = { async syncAll(): Promise { if (_syncing || !navigator.onLine) return _syncing = true + _interrupted = false try { const { trips } = await tripsApi.list() as { trips: Trip[] } @@ -152,9 +155,10 @@ export const tripSyncManager = { const stale = trips.filter(isStale) await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) - // Sync eligible trips + // Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page) const toSync = trips.filter(shouldCache) for (const trip of toSync) { + if (_interrupted) break try { await syncTrip(trip.id) } catch (err) { @@ -165,11 +169,19 @@ export const tripSyncManager = { await syncTrip(trip.id) } catch (retryErr) { if (isQuotaError(retryErr)) { - console.warn('[tripSync] quota still exceeded after eviction — clearing all IDB data') - await clearAll() - return + // Trip data + blob cache — free largest storage first before nuking everything + console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying') + await clearBlobCache() + try { + await syncTrip(trip.id) + } catch { + console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data') + await clearAll() + return + } + } else { + console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) } - console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) } } else { console.error(`[tripSync] failed for trip ${trip.id}:`, err) @@ -177,6 +189,8 @@ export const tripSyncManager = { } } + if (_interrupted) return + // Cache global user data (tags + categories) — fire-and-forget tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) @@ -184,6 +198,7 @@ export const tripSyncManager = { // Cache file blobs + map tiles in background (don't block syncAll) const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined for (const trip of toSync) { + if (_interrupted) break const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() cacheFilesForTrip(files).catch(console.error) @@ -195,8 +210,17 @@ export const tripSyncManager = { } }, + /** + * Signal syncAll to stop after the current in-flight bundle request. + * Call when the user navigates to a trip page so loadTrip gets priority. + */ + interrupt(): void { + _interrupted = true + }, + /** Reset syncing flag — useful in tests. */ _resetSyncing(): void { _syncing = false + _interrupted = false }, } From 6b90c7b2557b5d3a375de572d6429627f5aeeb97 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 20:08:02 +0200 Subject: [PATCH 22/27] refactor: make syncAll manual-only via offline settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove automatic syncAll() calls on login, MFA login, register, and the window 'online' event. Background bundle sync was the primary cause of request storms that slowed down initial page loads. Mutation flushing on reconnect is preserved — only the expensive trip-bundle sync is removed from auto-triggers. --- client/src/store/authStore.ts | 4 ---- client/src/sync/syncTriggers.ts | 13 ++++++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 8d8c342d..6237cd6d 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -4,7 +4,6 @@ import { authApi } from '../api/client' import { connect, disconnect } from '../api/websocket' import type { User } from '../types' import { getApiErrorMessage } from '../types' -import { tripSyncManager } from '../sync/tripSyncManager' import { clearAll } from '../db/offlineDb' import { useSystemNoticeStore } from './systemNoticeStore.js' @@ -100,7 +99,6 @@ export const useAuthStore = create()( error: null, }) connect() - tripSyncManager.syncAll().catch(console.error) if (!data.user?.must_change_password) { useSystemNoticeStore.getState().fetch() } @@ -124,7 +122,6 @@ export const useAuthStore = create()( error: null, }) connect() - tripSyncManager.syncAll().catch(console.error) if (!data.user?.must_change_password) { useSystemNoticeStore.getState().fetch() } @@ -148,7 +145,6 @@ export const useAuthStore = create()( error: null, }) connect() - tripSyncManager.syncAll().catch(console.error) useSystemNoticeStore.getState().fetch() return data } catch (err: unknown) { diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts index 2c84afe1..7680fcfc 100644 --- a/client/src/sync/syncTriggers.ts +++ b/client/src/sync/syncTriggers.ts @@ -1,19 +1,19 @@ /** * Sync triggers — register event listeners that flush the mutation queue - * and/or run a full trip sync based on the connectivity trigger source. + * based on the connectivity trigger source. * * Trigger matrix: - * window 'online' → flush mutations + full syncAll (network truly back) + * window 'online' → flush mutations (network truly back) * visibilitychange visible → flush mutations only (avoid hammering server on tab switch) * periodic 30s → flush mutations only - * WS reconnect → flush mutations only (no syncAll — avoids rate-limiter - * on server restart / socket timeout while already online) + * WS reconnect → flush mutations only + * + * Full trip sync (syncAll) is manual-only via the Offline settings tab. * * Call `registerSyncTriggers()` once on app mount. * Call `unregisterSyncTriggers()` on unmount / logout. */ import { mutationQueue } from './mutationQueue' -import { tripSyncManager } from './tripSyncManager' import { setPreReconnectHook } from '../api/websocket' const PERIODIC_MS = 30_000 @@ -21,10 +21,9 @@ const PERIODIC_MS = 30_000 let _intervalId: ReturnType | null = null let _registered = false -/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */ +/** Network came back — flush any pending mutations. */ function onOnline() { mutationQueue.flush().catch(console.error) - tripSyncManager.syncAll().catch(console.error) } /** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */ From 544d5641d04aef6e77d1cbcda7d7ad1fbd84744d Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 20:52:00 +0200 Subject: [PATCH 23/27] fix: resolve splash hang, dashboard skeleton, and sync-stuck regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TripPlannerPage: change splash effect dep from `trip` (object ref) to `trip?.id` (primitive) — background refreshes no longer reset the 1500 ms timer on every new object reference, fixing the forever-splash on SPA nav - tripRepo.list: await upserts on the cold-IDB path so the next mount reads from Dexie instead of hitting the network again, fixing the remount skeleton - tripSyncManager: add stale-flag detection (>2 min resets _syncing), 90 s hard timeout via Promise.race, parallel post-sync prefetch via Promise.allSettled, and updated header comment to reflect manual-only policy - OfflineTab: guard handleResync with a 120 s client-side timeout that interrupts and clears the spinner if syncAll stalls --- client/src/components/Settings/OfflineTab.tsx | 7 +- client/src/pages/TripPlannerPage.tsx | 2 +- client/src/repo/tripRepo.ts | 6 +- client/src/sync/tripSyncManager.ts | 144 ++++++++++-------- 4 files changed, 95 insertions(+), 64 deletions(-) diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx index ddc8aa0a..578e8d45 100644 --- a/client/src/components/Settings/OfflineTab.tsx +++ b/client/src/components/Settings/OfflineTab.tsx @@ -123,7 +123,12 @@ export default function OfflineTab(): React.ReactElement { async function handleResync() { setSyncing(true) try { - await tripSyncManager.syncAll() + const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000)) + const result = await Promise.race([tripSyncManager.syncAll().then(() => 'done' as const), timeout]) + if (result === 'timeout') { + tripSyncManager.interrupt() + console.warn('[OfflineTab] sync timed out after 120 s') + } await load() } finally { setSyncing(false) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index adba3ad7..ced6b085 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -735,7 +735,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const timer = setTimeout(() => setSplashDone(true), 1500) return () => clearTimeout(timer) } - }, [isLoading, trip]) + }, [isLoading, trip?.id]) // Show escape hatch after 12 seconds on splash (covers slow first-load scenarios) useEffect(() => { const timer = setTimeout(() => setSlowLoad(true), 12000) diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index 43ca0de8..06bc9e5e 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -36,7 +36,11 @@ export const tripRepo = { const fresh = await refresh if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) } - // Data came straight from network — no background re-fetch needed + // Await upserts on cold path so next mount reads from IDB instead of hitting network again + await Promise.all([ + ...fresh.trips.map(t => upsertTrip(t)), + ...fresh.archivedTrips.map(t => upsertTrip(t)), + ]).catch(() => {}) return { ...fresh, refresh: Promise.resolve(null) } }, diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index e773396b..4916b31c 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -5,10 +5,8 @@ * Eviction: trips where end_date < today - 7 days. * File blobs: all non-photo files (MIME type != image/*) for cached trips. * - * Call syncAll() on: - * - login success - * - trip list refresh (DashboardPage) - * - WS reconnect (phase 7) + * syncAll() is manual-only — triggered via Settings → Offline tab. + * No automatic sync on login, dashboard load, or WS reconnect. */ import { tripsApi, tagsApi, categoriesApi } from '../api/client' import { @@ -135,81 +133,104 @@ async function cacheFilesForTrip(files: TripFile[]): Promise { // ── Public API ──────────────────────────────────────────────────────────────── +const SYNC_TIMEOUT_MS = 90_000 +const SYNC_STALE_MS = 120_000 + let _syncing = false let _interrupted = false +let _syncStartedAt = 0 export const tripSyncManager = { /** * Sync all cache-eligible trips. * Evicts stale trips. Caches file blobs in the background. - * No-ops when offline. + * No-ops when offline or already syncing (unless stale flag). */ async syncAll(): Promise { - if (_syncing || !navigator.onLine) return + // Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync) + if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return + if (!navigator.onLine) return _syncing = true + _syncStartedAt = Date.now() _interrupted = false + + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('syncAll timeout')), SYNC_TIMEOUT_MS) + ) + try { - const { trips } = await tripsApi.list() as { trips: Trip[] } - - // Evict stale trips first - const stale = trips.filter(isStale) - await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) - - // Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page) - const toSync = trips.filter(shouldCache) - for (const trip of toSync) { - if (_interrupted) break - try { - await syncTrip(trip.id) - } catch (err) { - if (isQuotaError(err)) { - console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`) - try { - await clearTripData(trip.id) - await syncTrip(trip.id) - } catch (retryErr) { - if (isQuotaError(retryErr)) { - // Trip data + blob cache — free largest storage first before nuking everything - console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying') - await clearBlobCache() - try { - await syncTrip(trip.id) - } catch { - console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data') - await clearAll() - return - } - } else { - console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) - } - } - } else { - console.error(`[tripSync] failed for trip ${trip.id}:`, err) - } - } - } - - if (_interrupted) return - - // Cache global user data (tags + categories) — fire-and-forget - tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) - categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) - - // Cache file blobs + map tiles in background (don't block syncAll) - const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined - for (const trip of toSync) { - if (_interrupted) break - const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() - cacheFilesForTrip(files).catch(console.error) - - const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() - prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + await Promise.race([this._doSync(), timeout]) + } catch (err) { + if (err instanceof Error && err.message === 'syncAll timeout') { + console.warn('[tripSync] syncAll timed out after 90 s — interrupting') + _interrupted = true } } finally { _syncing = false } }, + async _doSync(): Promise { + const { trips } = await tripsApi.list() as { trips: Trip[] } + + // Evict stale trips first + const stale = trips.filter(isStale) + await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) + + // Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page) + const toSync = trips.filter(shouldCache) + for (const trip of toSync) { + if (_interrupted) return + try { + await syncTrip(trip.id) + } catch (err) { + if (isQuotaError(err)) { + console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`) + try { + await clearTripData(trip.id) + await syncTrip(trip.id) + } catch (retryErr) { + if (isQuotaError(retryErr)) { + console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying') + await clearBlobCache() + try { + await syncTrip(trip.id) + } catch { + console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data') + await clearAll() + return + } + } else { + console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr) + } + } + } else { + console.error(`[tripSync] failed for trip ${trip.id}:`, err) + } + } + } + + if (_interrupted) return + + // Cache global user data (tags + categories) — fire-and-forget + tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) + categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) + + // Cache file blobs + map tiles for all synced trips in parallel (fire-and-forget) + const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined + const prefetchWork = toSync + .filter(() => !_interrupted) + .map(async trip => { + const [files, places] = await Promise.all([ + offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray(), + offlineDb.places.where('trip_id').equals(trip.id).toArray(), + ]) + cacheFilesForTrip(files).catch(console.error) + prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + }) + await Promise.allSettled(prefetchWork) + }, + /** * Signal syncAll to stop after the current in-flight bundle request. * Call when the user navigates to a trip page so loadTrip gets priority. @@ -222,5 +243,6 @@ export const tripSyncManager = { _resetSyncing(): void { _syncing = false _interrupted = false + _syncStartedAt = 0 }, } From 37d9a321abd4815af8c30e29e1a774654deb674e Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 21:02:25 +0200 Subject: [PATCH 24/27] feat: add sync progress and result feedback to offline settings tab --- client/src/components/Settings/OfflineTab.tsx | 43 ++++++++++++++++++- client/src/sync/tripSyncManager.ts | 28 ++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx index 578e8d45..cdd273f1 100644 --- a/client/src/components/Settings/OfflineTab.tsx +++ b/client/src/components/Settings/OfflineTab.tsx @@ -7,6 +7,7 @@ import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } import Section from './Section' import { offlineDb, clearAll } from '../../db/offlineDb' import { tripSyncManager } from '../../sync/tripSyncManager' +import type { SyncProgress } from '../../sync/tripSyncManager' import { mutationQueue } from '../../sync/mutationQueue' import { DEFAULT_SW_CONFIG, @@ -30,6 +31,9 @@ export default function OfflineTab(): React.ReactElement { const [rows, setRows] = useState([]) const [pendingCount, setPendingCount] = useState(0) const [syncing, setSyncing] = useState(false) + const [syncProgress, setSyncProgress] = useState<{ current: number; total: number } | null>(null) + const [syncResult, setSyncResult] = useState<{ ok: number; failed: number } | null>(null) + const syncResultTimerRef = useRef | null>(null) const [clearing, setClearing] = useState(false) const [loading, setLoading] = useState(true) @@ -89,6 +93,10 @@ export default function OfflineTab(): React.ReactElement { } }, []) + useEffect(() => { + return () => { if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current) } + }, []) + async function handleSaveConfig() { const validated = validateSwConfig(cacheConfig) setCacheConfig(validated) @@ -122,9 +130,26 @@ export default function OfflineTab(): React.ReactElement { async function handleResync() { setSyncing(true) + setSyncProgress(null) + setSyncResult(null) + if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current) + + function handleProgress(p: SyncProgress) { + if (p.phase === 'trip') { + setSyncProgress({ current: p.index + 1, total: p.total }) + } else if (p.phase === 'done') { + setSyncProgress(null) + setSyncResult({ ok: p.ok, failed: p.failed }) + syncResultTimerRef.current = setTimeout(() => setSyncResult(null), 5000) + } + } + try { const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000)) - const result = await Promise.race([tripSyncManager.syncAll().then(() => 'done' as const), timeout]) + const result = await Promise.race([ + tripSyncManager.syncAll({ onProgress: handleProgress }).then(() => 'done' as const), + timeout, + ]) if (result === 'timeout') { tripSyncManager.interrupt() console.warn('[OfflineTab] sync timed out after 120 s') @@ -173,7 +198,11 @@ export default function OfflineTab(): React.ReactElement { }} > - {syncing ? 'Syncing…' : 'Re-sync now'} + {syncing + ? syncProgress + ? `Syncing ${syncProgress.current}/${syncProgress.total}…` + : 'Syncing…' + : 'Re-sync now'}