Files
TREK/client/src/hooks/useTourPlaceIds.ts
T
Maurice e0efa0314a feat(tours): tours read and write through the offline core
A tourRepo replaces the direct toursApi calls: reads fall back to a new
Dexie tours table, and a tour saved or edited offline is written there and
queued like a place, under one temporary id for the place and its facet.
The queue moves both to the real id on replay and drops both when the
server refuses the create. Trip sync caches each trip's tours while the
addon is on, so a tour no longer reads as a plain place offline.

The tour metrics move to shared so the offline copy shows the server's
numbers, and useTourPlaceIds keeps one Set per change as intended.
2026-10-07 21:30:45 +02:00

121 lines
4.7 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import type { TourListItem } from '@trek/shared'
import { tourRepo } from '../repo/tourRepo'
const NO_TOURS: TourListItem[] = []
export interface ToursInvalidation {
placeIds?: number[]
removedPlaceIds?: number[]
}
/**
* Place ids that are tours (a `tours` facet row exists for them), used to keep
* them out of the Places pool while the Tours addon is on.
* The single discriminator stays the `tours`
* facet row \u2014 this hook just mirrors it into a client-side Set for the
* Places filter, it does not add anything to the `places` table.
*
* Only fetches while `enabled` is true: with the addon off there is nothing to
* exclude, and the Places pool must stay byte-for-byte identical to today.
*/
export function useTourPlaceIds(tripId: number, enabled: boolean) {
const [tourData, setTourData] = useState<{ tripId: number; tours: TourListItem[] } | null>(null)
const [requestState, setRequestState] = useState<{
tripId: number
loading: boolean
error: boolean
} | null>(null)
const [pendingPlaceIds, setPendingPlaceIds] = useState<{ tripId: number; ids: Set<number> } | null>(null)
const requestIdRef = useRef(0)
const mountedRef = useRef(false)
const activeRequestRef = useRef({ tripId, enabled })
activeRequestRef.current = { tripId, enabled }
const reload = useCallback(async () => {
if (!enabled || !mountedRef.current
|| activeRequestRef.current.tripId !== tripId
|| !activeRequestRef.current.enabled) return
const requestId = ++requestIdRef.current
setRequestState({ tripId, loading: true, error: false })
try {
const response = await tourRepo.list(tripId)
if (!mountedRef.current || requestId !== requestIdRef.current) return
setTourData({ tripId, tours: response.tours })
setPendingPlaceIds(current => current?.tripId === tripId ? null : current)
setRequestState({ tripId, loading: false, error: false })
} catch {
if (!mountedRef.current || requestId !== requestIdRef.current) return
// Data is keyed by trip, so this retains only a same-trip last-good list.
setRequestState({ tripId, loading: false, error: true })
}
}, [tripId, enabled])
const invalidateTourPlaceIds = useCallback((change?: ToursInvalidation) => {
if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId
|| !activeRequestRef.current.enabled) return
const added = change?.placeIds ?? []
const removed = change?.removedPlaceIds ?? []
if (removed.length) {
setTourData(current => current?.tripId === tripId
? { tripId, tours: current.tours.filter(tour => !removed.includes(tour.place_id)) }
: current)
}
if (added.length || removed.length) {
setPendingPlaceIds(current => {
const ids = new Set(current?.tripId === tripId ? current.ids : [])
for (const id of removed) ids.delete(id)
for (const id of added) ids.add(id)
return ids.size ? { tripId, ids } : null
})
}
void reload()
}, [enabled, tripId, reload])
useEffect(() => {
mountedRef.current = true
void reload()
return () => {
mountedRef.current = false
requestIdRef.current += 1
}
}, [reload])
const upsertTour = useCallback((tour: TourListItem) => {
if (!enabled || !mountedRef.current || activeRequestRef.current.tripId !== tripId
|| !activeRequestRef.current.enabled) return
requestIdRef.current += 1
setTourData(current => ({
tripId,
tours: [tour, ...(current?.tripId === tripId ? current.tours : [])
.filter(item => item.place_id !== tour.place_id)],
}))
setRequestState({ tripId, loading: false, error: false })
}, [enabled, tripId])
const tourDataReady = !enabled || tourData?.tripId === tripId
// Memoised on the stored state, not on the derived values: an empty list
// made fresh in the render is a new dependency every time.
const visibleTours = useMemo(
() => enabled && tourData?.tripId === tripId ? tourData.tours : NO_TOURS,
[enabled, tourData, tripId],
)
const currentRequest = requestState?.tripId === tripId ? requestState : null
// One Set per change, not per render: consumers memoise on it.
const tourPlaceIds = useMemo(() => {
const pending = enabled && pendingPlaceIds?.tripId === tripId ? pendingPlaceIds.ids : []
return new Set([...visibleTours.map(tour => tour.place_id), ...pending])
}, [visibleTours, enabled, pendingPlaceIds, tripId])
return {
tours: visibleTours,
toursLoading: enabled && (currentRequest?.loading ?? false),
tourLoadError: enabled && (currentRequest?.error ?? false),
tourDataReady,
tourPlaceIds,
reloadTourPlaceIds: reload,
invalidateTourPlaceIds,
upsertTour,
}
}