Merge pull request #653 from mauriceboe/fix/pwa-offline-session-and-file-downloads

fix(offline): complete offline data coverage after initial PWA implementation
This commit is contained in:
Julien G.
2026-04-14 23:57:03 +02:00
committed by GitHub
27 changed files with 140 additions and 25 deletions
+38 -1
View File
@@ -1,5 +1,10 @@
import Dexie, { type Table } from 'dexie'; 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 ──────────────────────────────────────────────────────── // ── Queue + sync types ────────────────────────────────────────────────────────
@@ -52,6 +57,10 @@ class TrekOfflineDb extends Dexie {
budgetItems!: Table<BudgetItem, number>; budgetItems!: Table<BudgetItem, number>;
reservations!: Table<Reservation, number>; reservations!: Table<Reservation, number>;
tripFiles!: Table<TripFile, number>; tripFiles!: Table<TripFile, number>;
accommodations!: Table<Accommodation, number>;
tripMembers!: Table<CachedTripMember, [number, number]>;
tags!: Table<Tag, number>;
categories!: Table<Category, number>;
mutationQueue!: Table<QueuedMutation, string>; mutationQueue!: Table<QueuedMutation, string>;
syncMeta!: Table<SyncMeta, number>; syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>; blobCache!: Table<BlobCacheEntry, string>;
@@ -72,6 +81,13 @@ class TrekOfflineDb extends Dexie {
syncMeta: 'tripId', syncMeta: 'tripId',
blobCache: 'url, cachedAt', 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<void> {
await offlineDb.tripFiles.bulkPut(files); await offlineDb.tripFiles.bulkPut(files);
} }
export async function upsertAccommodations(items: Accommodation[]): Promise<void> {
await offlineDb.accommodations.bulkPut(items);
}
export async function upsertTripMembers(tripId: number, members: TripMember[]): Promise<void> {
const rows: CachedTripMember[] = members.map(m => ({ ...m, tripId }));
await offlineDb.tripMembers.bulkPut(rows);
}
export async function upsertTags(tags: Tag[]): Promise<void> {
await offlineDb.tags.bulkPut(tags);
}
export async function upsertCategories(categories: Category[]): Promise<void> {
await offlineDb.categories.bulkPut(categories);
}
export async function upsertSyncMeta(meta: SyncMeta): Promise<void> { export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
await offlineDb.syncMeta.put(meta); await offlineDb.syncMeta.put(meta);
} }
@@ -129,6 +162,8 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.budgetItems, offlineDb.budgetItems,
offlineDb.reservations, offlineDb.reservations,
offlineDb.tripFiles, offlineDb.tripFiles,
offlineDb.accommodations,
offlineDb.tripMembers,
offlineDb.mutationQueue, offlineDb.mutationQueue,
offlineDb.syncMeta, offlineDb.syncMeta,
], ],
@@ -140,6 +175,8 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.budgetItems.where('trip_id').equals(tripId).delete(); await offlineDb.budgetItems.where('trip_id').equals(tripId).delete();
await offlineDb.reservations.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.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.mutationQueue.where('tripId').equals(tripId).delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
}, },
+1
View File
@@ -149,6 +149,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'الإشعارات', 'settings.tabs.notifications': 'الإشعارات',
'settings.tabs.integrations': 'التكاملات', 'settings.tabs.integrations': 'التكاملات',
'settings.tabs.account': 'الحساب', 'settings.tabs.account': 'الحساب',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'حول', 'settings.tabs.about': 'حول',
'settings.map': 'الخريطة', 'settings.map': 'الخريطة',
'settings.mapTemplate': 'قالب الخريطة', 'settings.mapTemplate': 'قالب الخريطة',
+1
View File
@@ -144,6 +144,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Notificações', 'settings.tabs.notifications': 'Notificações',
'settings.tabs.integrations': 'Integrações', 'settings.tabs.integrations': 'Integrações',
'settings.tabs.account': 'Conta', 'settings.tabs.account': 'Conta',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Sobre', 'settings.tabs.about': 'Sobre',
'settings.map': 'Mapa', 'settings.map': 'Mapa',
'settings.mapTemplate': 'Modelo de mapa', 'settings.mapTemplate': 'Modelo de mapa',
+1
View File
@@ -145,6 +145,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Oznámení', 'settings.tabs.notifications': 'Oznámení',
'settings.tabs.integrations': 'Integrace', 'settings.tabs.integrations': 'Integrace',
'settings.tabs.account': 'Účet', 'settings.tabs.account': 'Účet',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'O aplikaci', 'settings.tabs.about': 'O aplikaci',
'settings.map': 'Mapy', 'settings.map': 'Mapy',
'settings.mapTemplate': 'Šablona mapy', 'settings.mapTemplate': 'Šablona mapy',
+1
View File
@@ -147,6 +147,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Benachrichtigungen', 'settings.tabs.notifications': 'Benachrichtigungen',
'settings.tabs.integrations': 'Integrationen', 'settings.tabs.integrations': 'Integrationen',
'settings.tabs.account': 'Konto', 'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Über', 'settings.tabs.about': 'Über',
'settings.map': 'Karte', 'settings.map': 'Karte',
'settings.mapTemplate': 'Karten-Vorlage', 'settings.mapTemplate': 'Karten-Vorlage',
+1
View File
@@ -147,6 +147,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Notifications', 'settings.tabs.notifications': 'Notifications',
'settings.tabs.integrations': 'Integrations', 'settings.tabs.integrations': 'Integrations',
'settings.tabs.account': 'Account', 'settings.tabs.account': 'Account',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'About', 'settings.tabs.about': 'About',
'settings.map': 'Map', 'settings.map': 'Map',
'settings.mapTemplate': 'Map Template', 'settings.mapTemplate': 'Map Template',
+1
View File
@@ -145,6 +145,7 @@ const es: Record<string, string> = {
'settings.tabs.notifications': 'Notificaciones', 'settings.tabs.notifications': 'Notificaciones',
'settings.tabs.integrations': 'Integraciones', 'settings.tabs.integrations': 'Integraciones',
'settings.tabs.account': 'Cuenta', 'settings.tabs.account': 'Cuenta',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Acerca de', 'settings.tabs.about': 'Acerca de',
'settings.map': 'Mapa', 'settings.map': 'Mapa',
'settings.mapTemplate': 'Plantilla del mapa', 'settings.mapTemplate': 'Plantilla del mapa',
+1
View File
@@ -144,6 +144,7 @@ const fr: Record<string, string> = {
'settings.tabs.notifications': 'Notifications', 'settings.tabs.notifications': 'Notifications',
'settings.tabs.integrations': 'Intégrations', 'settings.tabs.integrations': 'Intégrations',
'settings.tabs.account': 'Compte', 'settings.tabs.account': 'Compte',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'À propos', 'settings.tabs.about': 'À propos',
'settings.map': 'Carte', 'settings.map': 'Carte',
'settings.mapTemplate': 'Modèle de carte', 'settings.mapTemplate': 'Modèle de carte',
+1
View File
@@ -144,6 +144,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Értesítések', 'settings.tabs.notifications': 'Értesítések',
'settings.tabs.integrations': 'Integrációk', 'settings.tabs.integrations': 'Integrációk',
'settings.tabs.account': 'Fiók', 'settings.tabs.account': 'Fiók',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Névjegy', 'settings.tabs.about': 'Névjegy',
'settings.map': 'Térkép', 'settings.map': 'Térkép',
'settings.mapTemplate': 'Térkép sablon', 'settings.mapTemplate': 'Térkép sablon',
+1
View File
@@ -144,6 +144,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Notifiche', 'settings.tabs.notifications': 'Notifiche',
'settings.tabs.integrations': 'Integrazioni', 'settings.tabs.integrations': 'Integrazioni',
'settings.tabs.account': 'Account', 'settings.tabs.account': 'Account',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Informazioni', 'settings.tabs.about': 'Informazioni',
'settings.map': 'Mappa', 'settings.map': 'Mappa',
'settings.mapTemplate': 'Modello Mappa', 'settings.mapTemplate': 'Modello Mappa',
+1
View File
@@ -144,6 +144,7 @@ const nl: Record<string, string> = {
'settings.tabs.notifications': 'Meldingen', 'settings.tabs.notifications': 'Meldingen',
'settings.tabs.integrations': 'Integraties', 'settings.tabs.integrations': 'Integraties',
'settings.tabs.account': 'Account', 'settings.tabs.account': 'Account',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Over', 'settings.tabs.about': 'Over',
'settings.map': 'Kaart', 'settings.map': 'Kaart',
'settings.mapTemplate': 'Kaartsjabloon', 'settings.mapTemplate': 'Kaartsjabloon',
+1
View File
@@ -130,6 +130,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.tabs.notifications': 'Powiadomienia', 'settings.tabs.notifications': 'Powiadomienia',
'settings.tabs.integrations': 'Integracje', 'settings.tabs.integrations': 'Integracje',
'settings.tabs.account': 'Konto', 'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'O aplikacji', 'settings.tabs.about': 'O aplikacji',
'settings.map': 'Mapa', 'settings.map': 'Mapa',
'settings.mapTemplate': 'Szablon mapy', 'settings.mapTemplate': 'Szablon mapy',
+1
View File
@@ -144,6 +144,7 @@ const ru: Record<string, string> = {
'settings.tabs.notifications': 'Уведомления', 'settings.tabs.notifications': 'Уведомления',
'settings.tabs.integrations': 'Интеграции', 'settings.tabs.integrations': 'Интеграции',
'settings.tabs.account': 'Аккаунт', 'settings.tabs.account': 'Аккаунт',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'О приложении', 'settings.tabs.about': 'О приложении',
'settings.map': 'Карта', 'settings.map': 'Карта',
'settings.mapTemplate': 'Шаблон карты', 'settings.mapTemplate': 'Шаблон карты',
+1
View File
@@ -144,6 +144,7 @@ const zh: Record<string, string> = {
'settings.tabs.notifications': '通知', 'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '集成', 'settings.tabs.integrations': '集成',
'settings.tabs.account': '账户', 'settings.tabs.account': '账户',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': '关于', 'settings.tabs.about': '关于',
'settings.map': '地图', 'settings.map': '地图',
'settings.mapTemplate': '地图模板', 'settings.mapTemplate': '地图模板',
+1
View File
@@ -144,6 +144,7 @@ const zhTw: Record<string, string> = {
'settings.tabs.notifications': '通知', 'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '整合', 'settings.tabs.integrations': '整合',
'settings.tabs.account': '帳戶', 'settings.tabs.account': '帳戶',
'settings.tabs.offline': 'Offline',
'settings.tabs.about': '關於', 'settings.tabs.about': '關於',
'settings.map': '地圖', 'settings.map': '地圖',
'settings.mapTemplate': '地圖模板', 'settings.mapTemplate': '地圖模板',
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react' import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { tripsApi } from '../api/client' import { tripsApi } from '../api/client'
import { tripRepo } from '../repo/tripRepo'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
@@ -713,12 +714,9 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => { const loadTrips = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const [active, archived] = await Promise.all([ const { trips, archivedTrips } = await tripRepo.list()
tripsApi.list(), setTrips(sortTrips(trips))
tripsApi.list({ archived: 1 }), setArchivedTrips(sortTrips(archivedTrips))
])
setTrips(sortTrips(active.trips))
setArchivedTrips(sortTrips(archived.trips))
} catch { } catch {
toast.error(t('dashboard.toast.loadError')) toast.error(t('dashboard.toast.loadError'))
} finally { } finally {
+4 -3
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTripStore } from '../store/tripStore' import { useTripStore } from '../store/tripStore'
import { tripsApi, placesApi } from '../api/client' import { tripRepo } from '../repo/tripRepo'
import { placeRepo } from '../repo/placeRepo'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import FileManager from '../components/Files/FileManager' import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
@@ -27,8 +28,8 @@ export default function FilesPage(): React.ReactElement {
setIsLoading(true) setIsLoading(true)
try { try {
const [tripData, placesData] = await Promise.all([ const [tripData, placesData] = await Promise.all([
tripsApi.get(tripId), tripRepo.get(tripId),
placesApi.list(tripId), placeRepo.list(tripId),
]) ])
setTrip(tripData.trip) setTrip(tripData.trip)
setPlaces(placesData.places) setPlaces(placesData.places)
+1 -1
View File
@@ -42,7 +42,7 @@ export default function SettingsPage(): React.ReactElement {
{ id: 'map', label: t('settings.tabs.map') }, { id: 'map', label: t('settings.tabs.map') },
{ id: 'notifications', label: t('settings.tabs.notifications') }, { id: 'notifications', label: t('settings.tabs.notifications') },
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []), ...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
{ id: 'offline', label: t('settings.tabs.offline', 'Offline') }, { id: 'offline', label: t('settings.tabs.offline') },
{ id: 'account', label: t('settings.tabs.account') }, { id: 'account', label: t('settings.tabs.account') },
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []), ...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
] ]
+13 -6
View File
@@ -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 { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' 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 ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels' import { useResizablePanels } from '../hooks/useResizablePanels'
import { useTripWebSocket } from '../hooks/useTripWebSocket' import { useTripWebSocket } from '../hooks/useTripWebSocket'
@@ -104,7 +106,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const loadAccommodations = useCallback(() => { const loadAccommodations = useCallback(() => {
if (tripId) { if (tripId) {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
tripActions.loadReservations(tripId) tripActions.loadReservations(tripId)
} }
}, [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.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId) tripActions.loadFiles(tripId)
loadAccommodations() loadAccommodations()
tripsApi.getMembers(tripId).then(d => { if (!navigator.onLine) {
// Combine owner + members into one list offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
const all = [d.owner, ...(d.members || [])].filter(Boolean) .then(rows => setTripMembers(rows))
setTripMembers(all) .catch(() => {})
}).catch(() => {}) } else {
tripsApi.getMembers(tripId).then(d => {
const all = [d.owner, ...(d.members || [])].filter(Boolean)
setTripMembers(all)
}).catch(() => {})
}
} }
}, [tripId]) }, [tripId])
+16
View File
@@ -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
},
}
+17
View File
@@ -3,6 +3,23 @@ import { offlineDb, upsertTrip } from '../db/offlineDb'
import type { Trip } from '../types' import type { Trip } from '../types'
export const tripRepo = { export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
if (!navigator.onLine) {
const all = await offlineDb.trips.toArray()
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
}
}
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 }
},
async get(tripId: number | string): Promise<{ trip: Trip }> { async get(tripId: number | string): Promise<{ trip: Trip }> {
if (!navigator.onLine) { if (!navigator.onLine) {
const cached = await offlineDb.trips.get(Number(tripId)) const cached = await offlineDb.trips.get(Number(tripId))
+2 -1
View File
@@ -1,4 +1,5 @@
import { budgetApi } from '../../api/client' import { budgetApi } from '../../api/client'
import { budgetRepo } from '../../repo/budgetRepo'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { BudgetItem, BudgetMember } from '../../types' import type { BudgetItem, BudgetMember } from '../../types'
@@ -21,7 +22,7 @@ export interface BudgetSlice {
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
loadBudgetItems: async (tripId) => { loadBudgetItems: async (tripId) => {
try { try {
const data = await budgetApi.list(tripId) const data = await budgetRepo.list(tripId)
set({ budgetItems: data.items }) set({ budgetItems: data.items })
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load budget items:', err) console.error('Failed to load budget items:', err)
+2 -1
View File
@@ -1,4 +1,5 @@
import { filesApi } from '../../api/client' import { filesApi } from '../../api/client'
import { fileRepo } from '../../repo/fileRepo'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { TripFile } from '../../types' import type { TripFile } from '../../types'
@@ -16,7 +17,7 @@ export interface FilesSlice {
export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
loadFiles: async (tripId) => { loadFiles: async (tripId) => {
try { try {
const data = await filesApi.list(tripId) const data = await fileRepo.list(tripId)
set({ files: data.files }) set({ files: data.files })
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load files:', err) console.error('Failed to load files:', err)
+2 -1
View File
@@ -1,4 +1,5 @@
import { reservationsApi } from '../../api/client' import { reservationsApi } from '../../api/client'
import { reservationRepo } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore' import type { TripStoreState } from '../tripStore'
import type { Reservation } from '../../types' import type { Reservation } from '../../types'
@@ -18,7 +19,7 @@ export interface ReservationsSlice {
export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({ export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({
loadReservations: async (tripId) => { loadReservations: async (tripId) => {
try { try {
const data = await reservationsApi.list(tripId) const data = await reservationRepo.list(tripId)
set({ reservations: data.reservations }) set({ reservations: data.reservations })
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load reservations:', err) console.error('Failed to load reservations:', err)
+7 -2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { StoreApi } from 'zustand' import type { StoreApi } from 'zustand'
import { tripsApi, tagsApi, categoriesApi } from '../api/client' import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import { offlineDb } from '../db/offlineDb'
import { tripRepo } from '../repo/tripRepo' import { tripRepo } from '../repo/tripRepo'
import { dayRepo } from '../repo/dayRepo' import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo' import { placeRepo } from '../repo/placeRepo'
@@ -94,8 +95,12 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
placeRepo.list(tripId), placeRepo.list(tripId),
packingRepo.list(tripId), packingRepo.list(tripId),
todoRepo.list(tripId), todoRepo.list(tripId),
tagsApi.list().catch(() => ({ tags: [] })), navigator.onLine
categoriesApi.list().catch(() => ({ categories: [] })), ? 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 = {} const assignmentsMap: AssignmentsMap = {}
+14 -2
View File
@@ -10,7 +10,7 @@
* - trip list refresh (DashboardPage) * - trip list refresh (DashboardPage)
* - WS reconnect (phase 7) * - WS reconnect (phase 7)
*/ */
import { tripsApi } from '../api/client' import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import { import {
offlineDb, offlineDb,
upsertTrip, upsertTrip,
@@ -21,12 +21,16 @@ import {
upsertBudgetItems, upsertBudgetItems,
upsertReservations, upsertReservations,
upsertTripFiles, upsertTripFiles,
upsertAccommodations,
upsertTripMembers,
upsertTags,
upsertCategories,
upsertSyncMeta, upsertSyncMeta,
clearTripData, clearTripData,
} from '../db/offlineDb' } from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher' import { prefetchTilesForTrip } from './tilePrefetcher'
import { useSettingsStore } from '../store/settingsStore' 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 ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -39,6 +43,8 @@ interface TripBundle {
budgetItems: BudgetItem[] budgetItems: BudgetItem[]
reservations: Reservation[] reservations: Reservation[]
files: TripFile[] files: TripFile[]
accommodations: Accommodation[]
members: TripMember[]
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -77,6 +83,8 @@ async function syncTrip(tripId: number): Promise<void> {
await upsertBudgetItems(bundle.budgetItems) await upsertBudgetItems(bundle.budgetItems)
await upsertReservations(bundle.reservations) await upsertReservations(bundle.reservations)
await upsertTripFiles(bundle.files) await upsertTripFiles(bundle.files)
await upsertAccommodations(bundle.accommodations || [])
await upsertTripMembers(tripId, bundle.members || [])
await upsertSyncMeta({ await upsertSyncMeta({
tripId, tripId,
lastSyncedAt: Date.now(), 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) // Cache file blobs + map tiles in background (don't block syncAll)
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
for (const trip of toSync) { for (const trip of toSync) {
+6 -1
View File
@@ -29,7 +29,7 @@ import {
ValidationError, ValidationError,
TRIP_SELECT, TRIP_SELECT,
} from '../services/tripService'; } from '../services/tripService';
import { listDays } from '../services/dayService'; import { listDays, listAccommodations } from '../services/dayService';
import { listPlaces } from '../services/placeService'; import { listPlaces } from '../services/placeService';
import { listItems as listPackingItems } from '../services/packingService'; import { listItems as listPackingItems } from '../services/packingService';
import { listItems as listTodoItems } from '../services/todoService'; 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 budgetItems = listBudgetItems(tripId);
const reservations = listReservations(tripId); const reservations = listReservations(tripId);
const files = listFiles(tripId, false); 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({ res.json({
trip, trip,
@@ -328,6 +331,8 @@ router.get('/:id/bundle', authenticate, (req: Request, res: Response) => {
budgetItems, budgetItems,
reservations, reservations,
files, files,
accommodations,
members: allMembers,
}); });
}); });