mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips: - Dexie IndexedDB schema (trips, places, packing, todo, budget, reservations, files, mutationQueue, syncMeta, blobCache) - Repo layer for all domains: offline reads from Dexie, writes optimistically to Dexie and enqueue mutations for later replay - Mutation queue with UUID idempotency keys (X-Idempotency-Key), FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx - Trip sync manager: caches all trips with end_date >= today or null, auto-evicts 7d after end_date, fetches bundle endpoint in one request - Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap, warms SW cache via fetch - Sync triggers: network online → flush + syncAll; WS reconnect → flush only (rate-limiter safe); visibilitychange/30s → flush only - WS remoteEventHandler writes through to Dexie on every event - Server idempotency middleware + idempotency_keys table (migration 100, 24h TTL nightly cleanup) - GET /api/trips/:id/bundle endpoint for efficient single-request sync - OfflineBanner component: amber (offline) / blue (syncing) / hidden - OfflineTab in Settings: cached trip list, re-sync and clear actions - usePendingMutations hook for per-item pending indicators Closes #505 #541
This commit is contained in:
@@ -4,6 +4,8 @@ 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'
|
||||
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
@@ -88,6 +90,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
@@ -108,6 +111,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Verification failed')
|
||||
@@ -128,6 +132,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
@@ -145,6 +150,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { packingApi } from '../../api/client'
|
||||
import { packingRepo } from '../../repo/packingRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface PackingSlice {
|
||||
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
const result = await packingRepo.create(tripId, data as Record<string, unknown>)
|
||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -27,7 +27,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
|
||||
updatePackingItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
const result = await packingRepo.update(tripId, id, data as Record<string, unknown>)
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -41,7 +41,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
const prev = get().packingItems
|
||||
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await packingApi.delete(tripId, id)
|
||||
await packingRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ packingItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting item'))
|
||||
@@ -55,7 +55,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await packingApi.update(tripId, id, { checked })
|
||||
await packingRepo.update(tripId, id, { checked })
|
||||
} catch {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { placesApi } from '../../api/client'
|
||||
import { placeRepo } from '../../repo/placeRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Place, Assignment } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface PlacesSlice {
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
const data = await placeRepo.list(tripId)
|
||||
set({ places: data.places })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to refresh places:', err)
|
||||
@@ -26,7 +26,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
const data = await placeRepo.create(tripId, placeData as Record<string, unknown>)
|
||||
set(state => ({ places: [data.place, ...state.places] }))
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
@@ -36,7 +36,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
const data = await placeRepo.update(tripId, placeId, placeData as Record<string, unknown>)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
@@ -61,7 +61,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
await placeRepo.delete(tripId, placeId)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
|
||||
@@ -1,14 +1,167 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
// ── Dexie write-through ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist remote event to IndexedDB so the data is available offline.
|
||||
* Fire-and-forget: errors are swallowed to never block the Zustand update.
|
||||
* Called AFTER set() so `state` already reflects the update.
|
||||
*/
|
||||
function writeToDexie(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
state: TripStoreState,
|
||||
): void {
|
||||
;(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
// ── Places ──────────────────────────────────────────────────────────
|
||||
case 'place:created':
|
||||
case 'place:updated':
|
||||
await offlineDb.places.put(payload.place as Place)
|
||||
break
|
||||
case 'place:deleted':
|
||||
await offlineDb.places.delete(payload.placeId as number)
|
||||
break
|
||||
|
||||
// ── Assignments (embedded in Day rows) ──────────────────────────────
|
||||
// Read the already-updated Day from the Zustand state and persist it.
|
||||
case 'assignment:created':
|
||||
case 'assignment:updated': {
|
||||
const assignment = payload.assignment as Assignment
|
||||
await _writeDayToDb(assignment.day_id, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
await Promise.all([
|
||||
_writeDayToDb(payload.oldDayId as number, state),
|
||||
_writeDayToDb(movedAssignment.day_id, state),
|
||||
])
|
||||
break
|
||||
}
|
||||
case 'assignment:reordered':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Days ─────────────────────────────────────────────────────────────
|
||||
case 'day:created':
|
||||
case 'day:updated': {
|
||||
const day = payload.day as Day
|
||||
await _writeDayToDb(day.id, state)
|
||||
break
|
||||
}
|
||||
case 'day:deleted':
|
||||
await offlineDb.days.delete(payload.dayId as number)
|
||||
break
|
||||
|
||||
// ── Day notes (embedded in Day rows) ─────────────────────────────────
|
||||
case 'dayNote:created':
|
||||
case 'dayNote:updated':
|
||||
case 'dayNote:deleted':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────
|
||||
case 'packing:created':
|
||||
case 'packing:updated':
|
||||
await offlineDb.packingItems.put(payload.item as PackingItem)
|
||||
break
|
||||
case 'packing:deleted':
|
||||
await offlineDb.packingItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Todo ─────────────────────────────────────────────────────────────
|
||||
case 'todo:created':
|
||||
case 'todo:updated':
|
||||
await offlineDb.todoItems.put(payload.item as TodoItem)
|
||||
break
|
||||
case 'todo:deleted':
|
||||
await offlineDb.todoItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────
|
||||
case 'budget:created':
|
||||
case 'budget:updated':
|
||||
await offlineDb.budgetItems.put(payload.item as BudgetItem)
|
||||
break
|
||||
case 'budget:deleted':
|
||||
await offlineDb.budgetItems.delete(payload.itemId as number)
|
||||
break
|
||||
case 'budget:members-updated':
|
||||
case 'budget:member-paid-updated':
|
||||
case 'budget:reordered': {
|
||||
// Partial update — read canonical item(s) from updated Zustand state
|
||||
if (type === 'budget:reordered') {
|
||||
await offlineDb.budgetItems.bulkPut(state.budgetItems)
|
||||
} else {
|
||||
const item = state.budgetItems.find(i => i.id === (payload.itemId as number))
|
||||
if (item) await offlineDb.budgetItems.put(item)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
case 'reservation:created':
|
||||
case 'reservation:updated':
|
||||
await offlineDb.reservations.put(payload.reservation as Reservation)
|
||||
break
|
||||
case 'reservation:deleted':
|
||||
await offlineDb.reservations.delete(payload.reservationId as number)
|
||||
break
|
||||
|
||||
// ── Trip ─────────────────────────────────────────────────────────────
|
||||
case 'trip:updated':
|
||||
await offlineDb.trips.put(payload.trip as Trip)
|
||||
break
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────
|
||||
case 'file:created':
|
||||
case 'file:updated':
|
||||
await offlineDb.tripFiles.put(payload.file as TripFile)
|
||||
break
|
||||
case 'file:deleted':
|
||||
await offlineDb.tripFiles.delete(payload.fileId as number)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Dexie write failures are non-fatal — online state is source of truth
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** Write a Day (with its current assignments + notes from Zustand) to Dexie. */
|
||||
async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void> {
|
||||
const day = state.days.find(d => d.id === dayId)
|
||||
if (!day) return
|
||||
await offlineDb.days.put({
|
||||
...day,
|
||||
assignments: state.assignments[String(dayId)] ?? [],
|
||||
notes_items: state.dayNotes[String(dayId)] ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Zustand event reducer ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
|
||||
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
|
||||
* After the Zustand update, the change is also written through to IndexedDB for offline access.
|
||||
*/
|
||||
export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocketEvent): void {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
@@ -285,4 +438,7 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// Write the change through to IndexedDB using the post-update state
|
||||
writeToDexie(type, payload as Record<string, unknown>, get())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import { tripsApi, daysApi, placesApi, packingApi, todoApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tripRepo } from '../repo/tripRepo'
|
||||
import { dayRepo } from '../repo/dayRepo'
|
||||
import { placeRepo } from '../repo/placeRepo'
|
||||
import { packingRepo } from '../repo/packingRepo'
|
||||
import { todoRepo } from '../repo/todoRepo'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDayNotesSlice } from './slices/dayNotesSlice'
|
||||
@@ -78,19 +83,19 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event),
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||
|
||||
loadTrip: async (tripId: number | string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
packingApi.list(tripId),
|
||||
todoApi.list(tripId),
|
||||
tagsApi.list(),
|
||||
categoriesApi.list(),
|
||||
tripRepo.get(tripId),
|
||||
dayRepo.list(tripId),
|
||||
placeRepo.list(tripId),
|
||||
packingRepo.list(tripId),
|
||||
todoRepo.list(tripId),
|
||||
tagsApi.list().catch(() => ({ tags: [] })),
|
||||
categoriesApi.list().catch(() => ({ categories: [] })),
|
||||
])
|
||||
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
@@ -121,7 +126,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
refreshDays: async (tripId: number | string) => {
|
||||
try {
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
@@ -138,7 +143,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
|
||||
Reference in New Issue
Block a user