Spaces:
Sleeping
Sleeping
| 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' | |
| import { packingRepo } from '../repo/packingRepo' | |
| import { todoRepo } from '../repo/todoRepo' | |
| import { budgetRepo } from '../repo/budgetRepo' | |
| import { reservationRepo } from '../repo/reservationRepo' | |
| import { fileRepo } from '../repo/fileRepo' | |
| import { createPlacesSlice } from './slices/placesSlice' | |
| import { createAssignmentsSlice } from './slices/assignmentsSlice' | |
| import { createDaysSlice } from './slices/daysSlice' | |
| import { createDayNotesSlice } from './slices/dayNotesSlice' | |
| import { createPackingSlice } from './slices/packingSlice' | |
| import { createTodoSlice } from './slices/todoSlice' | |
| import { createBudgetSlice } from './slices/budgetSlice' | |
| import { createReservationsSlice } from './slices/reservationsSlice' | |
| import { createFilesSlice } from './slices/filesSlice' | |
| import { handleRemoteEvent } from './slices/remoteEventHandler' | |
| import type { | |
| Trip, Day, Place, Assignment, DayNote, PackingItem, TodoItem, | |
| Tag, Category, BudgetItem, TripFile, Reservation, | |
| AssignmentsMap, DayNotesMap, WebSocketEvent, | |
| } from '../types' | |
| import { getApiErrorMessage } from '../types' | |
| import type { PlacesSlice } from './slices/placesSlice' | |
| import type { AssignmentsSlice } from './slices/assignmentsSlice' | |
| import type { DaysSlice } from './slices/daysSlice' | |
| import type { DayNotesSlice } from './slices/dayNotesSlice' | |
| import type { PackingSlice } from './slices/packingSlice' | |
| import type { TodoSlice } from './slices/todoSlice' | |
| import type { BudgetSlice } from './slices/budgetSlice' | |
| import type { ReservationsSlice } from './slices/reservationsSlice' | |
| import type { FilesSlice } from './slices/filesSlice' | |
| export interface TripStoreState | |
| extends PlacesSlice, | |
| AssignmentsSlice, | |
| DaysSlice, | |
| DayNotesSlice, | |
| PackingSlice, | |
| TodoSlice, | |
| BudgetSlice, | |
| ReservationsSlice, | |
| FilesSlice { | |
| trip: Trip | null | |
| days: Day[] | |
| places: Place[] | |
| assignments: AssignmentsMap | |
| dayNotes: DayNotesMap | |
| packingItems: PackingItem[] | |
| todoItems: TodoItem[] | |
| tags: Tag[] | |
| categories: Category[] | |
| budgetItems: BudgetItem[] | |
| files: TripFile[] | |
| reservations: Reservation[] | |
| selectedDayId: number | null | |
| isLoading: boolean | |
| error: string | null | |
| setSelectedDay: (dayId: number | null) => void | |
| handleRemoteEvent: (event: WebSocketEvent) => void | |
| resetTrip: () => void | |
| loadTrip: (tripId: number | string) => Promise<void> | |
| hydrateActiveTrip: (tripId: number | string) => Promise<void> | |
| refreshDays: (tripId: number | string) => Promise<void> | |
| updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip> | |
| addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag> | |
| addCategory: (data: Partial<Category> & { name: string }) => Promise<Category> | |
| } | |
| export const useTripStore = create<TripStoreState>((set, get) => ({ | |
| trip: null, | |
| days: [], | |
| places: [], | |
| assignments: {}, | |
| dayNotes: {}, | |
| packingItems: [], | |
| todoItems: [], | |
| tags: [], | |
| categories: [], | |
| budgetItems: [], | |
| files: [], | |
| reservations: [], | |
| selectedDayId: null, | |
| isLoading: false, | |
| error: null, | |
| setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }), | |
| handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event), | |
| // Clear every trip-scoped slice so switching trips (or losing access to one) | |
| // can never leave a previous trip's data visible. Global tags/categories are | |
| // left intact. Called at the top of loadTrip. | |
| resetTrip: () => set({ | |
| trip: null, | |
| days: [], | |
| places: [], | |
| assignments: {}, | |
| dayNotes: {}, | |
| packingItems: [], | |
| todoItems: [], | |
| budgetItems: [], | |
| files: [], | |
| reservations: [], | |
| selectedDayId: null, | |
| error: null, | |
| }), | |
| loadTrip: async (tripId: number | string) => { | |
| get().resetTrip() | |
| set({ isLoading: true, error: null }) | |
| try { | |
| const [tripData, daysData, placesData, packingData, todoData, budgetData, reservationsData, filesData, tagsData, categoriesData] = await Promise.all([ | |
| tripRepo.get(tripId), | |
| dayRepo.list(tripId), | |
| placeRepo.list(tripId), | |
| packingRepo.list(tripId), | |
| todoRepo.list(tripId), | |
| // Budget / reservations / files are hydrated here too so the offline | |
| // path is uniform (no separate tab-gated effects). Non-fatal: a failure | |
| // in any of these must not blank the whole trip. | |
| budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })), | |
| reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })), | |
| fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })), | |
| 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 = {} | |
| const dayNotesMap: DayNotesMap = {} | |
| for (const day of daysData.days) { | |
| assignmentsMap[String(day.id)] = day.assignments || [] | |
| dayNotesMap[String(day.id)] = day.notes_items || [] | |
| } | |
| set({ | |
| trip: tripData.trip, | |
| days: daysData.days, | |
| places: placesData.places, | |
| assignments: assignmentsMap, | |
| dayNotes: dayNotesMap, | |
| packingItems: packingData.items, | |
| todoItems: todoData.items, | |
| budgetItems: budgetData.items, | |
| reservations: reservationsData.reservations, | |
| files: filesData.files, | |
| tags: tagsData.tags, | |
| categories: categoriesData.categories, | |
| isLoading: false, | |
| }) | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : 'Unknown error' | |
| set({ isLoading: false, error: message }) | |
| throw err | |
| } | |
| }, | |
| // Silently re-fetch the active trip's collaborative state into the store after | |
| // the network comes back (WS reconnect or `online` event) so edits missed while | |
| // offline appear in place — no splash, no resetTrip. Each resource is | |
| // best-effort; a failure on one must not wipe the others. | |
| hydrateActiveTrip: async (tripId: number | string) => { | |
| await Promise.all([ | |
| get().refreshDays(tripId), | |
| placeRepo.list(tripId).then(d => set({ places: d.places })).catch(() => {}), | |
| packingRepo.list(tripId).then(d => set({ packingItems: d.items })).catch(() => {}), | |
| todoRepo.list(tripId).then(d => set({ todoItems: d.items })).catch(() => {}), | |
| get().loadBudgetItems(tripId), | |
| get().loadReservations(tripId), | |
| get().loadFiles(tripId), | |
| ]) | |
| }, | |
| refreshDays: async (tripId: number | string) => { | |
| try { | |
| 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 }) | |
| } catch (err: unknown) { | |
| console.error('Failed to refresh days:', err) | |
| } | |
| }, | |
| updateTrip: async (tripId: number | string, data: Partial<Trip>) => { | |
| try { | |
| const result = await tripsApi.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 || [] | |
| } | |
| set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap }) | |
| return result.trip | |
| } catch (err: unknown) { | |
| throw new Error(getApiErrorMessage(err, 'Error updating trip')) | |
| } | |
| }, | |
| addTag: async (data: Partial<Tag> & { name: string }) => { | |
| try { | |
| const result = await tagsApi.create(data) | |
| set((state) => ({ tags: [...state.tags, result.tag] })) | |
| return result.tag | |
| } catch (err: unknown) { | |
| throw new Error(getApiErrorMessage(err, 'Error creating tag')) | |
| } | |
| }, | |
| addCategory: async (data: Partial<Category> & { name: string }) => { | |
| try { | |
| const result = await categoriesApi.create(data) | |
| set((state) => ({ categories: [...state.categories, result.category] })) | |
| return result.category | |
| } catch (err: unknown) { | |
| throw new Error(getApiErrorMessage(err, 'Error creating category')) | |
| } | |
| }, | |
| ...createPlacesSlice(set, get), | |
| ...createAssignmentsSlice(set, get), | |
| ...createDaysSlice(set, get), | |
| ...createDayNotesSlice(set, get), | |
| ...createPackingSlice(set, get), | |
| ...createTodoSlice(set, get), | |
| ...createBudgetSlice(set, get), | |
| ...createReservationsSlice(set, get), | |
| ...createFilesSlice(set, get), | |
| })) | |