diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 57b33dfe..224794c6 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -1,5 +1,10 @@ import Dexie, { type Table } from 'dexie'; -import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types'; +import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types'; + +/** TripMember enriched with tripId so we can index by trip. */ +export interface CachedTripMember extends TripMember { + tripId: number; +} // ── Queue + sync types ──────────────────────────────────────────────────────── @@ -52,6 +57,10 @@ class TrekOfflineDb extends Dexie { budgetItems!: Table; reservations!: Table; tripFiles!: Table; + accommodations!: Table; + tripMembers!: Table; + tags!: Table; + categories!: Table; mutationQueue!: Table; syncMeta!: Table; blobCache!: Table; @@ -72,6 +81,13 @@ class TrekOfflineDb extends Dexie { syncMeta: 'tripId', blobCache: 'url, cachedAt', }); + + this.version(2).stores({ + accommodations: 'id, trip_id', + tripMembers: '[tripId+id], tripId', + tags: 'id', + categories: 'id', + }); } } @@ -111,6 +127,23 @@ export async function upsertTripFiles(files: TripFile[]): Promise { await offlineDb.tripFiles.bulkPut(files); } +export async function upsertAccommodations(items: Accommodation[]): Promise { + await offlineDb.accommodations.bulkPut(items); +} + +export async function upsertTripMembers(tripId: number, members: TripMember[]): Promise { + const rows: CachedTripMember[] = members.map(m => ({ ...m, tripId })); + await offlineDb.tripMembers.bulkPut(rows); +} + +export async function upsertTags(tags: Tag[]): Promise { + await offlineDb.tags.bulkPut(tags); +} + +export async function upsertCategories(categories: Category[]): Promise { + await offlineDb.categories.bulkPut(categories); +} + export async function upsertSyncMeta(meta: SyncMeta): Promise { await offlineDb.syncMeta.put(meta); } @@ -129,6 +162,8 @@ export async function clearTripData(tripId: number): Promise { offlineDb.budgetItems, offlineDb.reservations, offlineDb.tripFiles, + offlineDb.accommodations, + offlineDb.tripMembers, offlineDb.mutationQueue, offlineDb.syncMeta, ], @@ -140,6 +175,8 @@ export async function clearTripData(tripId: number): Promise { await offlineDb.budgetItems.where('trip_id').equals(tripId).delete(); await offlineDb.reservations.where('trip_id').equals(tripId).delete(); await offlineDb.tripFiles.where('trip_id').equals(tripId).delete(); + await offlineDb.accommodations.where('trip_id').equals(tripId).delete(); + await offlineDb.tripMembers.where('tripId').equals(tripId).delete(); await offlineDb.mutationQueue.where('tripId').equals(tripId).delete(); await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); }, diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 069fce14..d893e308 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -26,6 +26,8 @@ import { useToast } from '../components/shared/Toast' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react' import { useTranslation } from '../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' +import { accommodationRepo } from '../repo/accommodationRepo' +import { offlineDb } from '../db/offlineDb' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' @@ -104,7 +106,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const loadAccommodations = useCallback(() => { if (tripId) { - accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) tripActions.loadReservations(tripId) } }, [tripId]) @@ -192,11 +194,16 @@ export default function TripPlannerPage(): React.ReactElement | null { tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) tripActions.loadFiles(tripId) loadAccommodations() - tripsApi.getMembers(tripId).then(d => { - // Combine owner + members into one list - const all = [d.owner, ...(d.members || [])].filter(Boolean) - setTripMembers(all) - }).catch(() => {}) + if (!navigator.onLine) { + offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray() + .then(rows => setTripMembers(rows)) + .catch(() => {}) + } else { + tripsApi.getMembers(tripId).then(d => { + const all = [d.owner, ...(d.members || [])].filter(Boolean) + setTripMembers(all) + }).catch(() => {}) + } } }, [tripId]) diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts new file mode 100644 index 00000000..75e8c345 --- /dev/null +++ b/client/src/repo/accommodationRepo.ts @@ -0,0 +1,16 @@ +import { accommodationsApi } from '../api/client' +import { offlineDb, upsertAccommodations } from '../db/offlineDb' +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 + }, +} diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index f7588442..5168c078 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import type { StoreApi } from 'zustand' import { tripsApi, tagsApi, categoriesApi } from '../api/client' +import { offlineDb } from '../db/offlineDb' import { tripRepo } from '../repo/tripRepo' import { dayRepo } from '../repo/dayRepo' import { placeRepo } from '../repo/placeRepo' @@ -94,8 +95,12 @@ export const useTripStore = create((set, get) => ({ placeRepo.list(tripId), packingRepo.list(tripId), todoRepo.list(tripId), - tagsApi.list().catch(() => ({ tags: [] })), - categoriesApi.list().catch(() => ({ categories: [] })), + 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 })), ]) const assignmentsMap: AssignmentsMap = {} diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index dc868244..4129469b 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -10,7 +10,7 @@ * - trip list refresh (DashboardPage) * - WS reconnect (phase 7) */ -import { tripsApi } from '../api/client' +import { tripsApi, tagsApi, categoriesApi } from '../api/client' import { offlineDb, upsertTrip, @@ -21,12 +21,16 @@ import { upsertBudgetItems, upsertReservations, upsertTripFiles, + upsertAccommodations, + upsertTripMembers, + upsertTags, + upsertCategories, upsertSyncMeta, clearTripData, } from '../db/offlineDb' import { prefetchTilesForTrip } from './tilePrefetcher' import { useSettingsStore } from '../store/settingsStore' -import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types' +import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types' // ── Types ───────────────────────────────────────────────────────────────────── @@ -39,6 +43,8 @@ interface TripBundle { budgetItems: BudgetItem[] reservations: Reservation[] files: TripFile[] + accommodations: Accommodation[] + members: TripMember[] } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -77,6 +83,8 @@ async function syncTrip(tripId: number): Promise { await upsertBudgetItems(bundle.budgetItems) await upsertReservations(bundle.reservations) await upsertTripFiles(bundle.files) + await upsertAccommodations(bundle.accommodations || []) + await upsertTripMembers(tripId, bundle.members || []) await upsertSyncMeta({ tripId, lastSyncedAt: Date.now(), @@ -145,6 +153,10 @@ export const tripSyncManager = { } } + // 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) { diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index 9882e1f5..9b2413ba 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -29,7 +29,7 @@ import { ValidationError, TRIP_SELECT, } from '../services/tripService'; -import { listDays } from '../services/dayService'; +import { listDays, listAccommodations } from '../services/dayService'; import { listPlaces } from '../services/placeService'; import { listItems as listPackingItems } from '../services/packingService'; import { listItems as listTodoItems } from '../services/todoService'; @@ -318,6 +318,9 @@ router.get('/:id/bundle', authenticate, (req: Request, res: Response) => { const budgetItems = listBudgetItems(tripId); const reservations = listReservations(tripId); const files = listFiles(tripId, false); + const accommodations = listAccommodations(tripId); + const { owner, members } = listMembers(tripId, trip.user_id); + const allMembers = [owner, ...(members || [])].filter(Boolean); res.json({ trip, @@ -328,6 +331,8 @@ router.get('/:id/bundle', authenticate, (req: Request, res: Response) => { budgetItems, reservations, files, + accommodations, + members: allMembers, }); });