Files
points-of-interest/client/src/hooks/useHotspotFeed.ts
T
2025-10-10 10:30:28 +02:00

192 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { distanceInKm } from '@/lib/utils'
import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api'
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
const SNAPSHOT_LIMIT = 750
const DEFAULT_REFRESH_MS = 7000
const VISIBLE_RADIUS_KM = 1
interface UseHotspotFeedOptions {
autoRefreshMs?: number
}
interface SubmitResult {
success: boolean
}
export interface FeedError {
key: string
values?: Record<string, unknown>
}
export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
const [status, setStatus] = useState<FeedStatus>('loading')
const [error, setError] = useState<FeedError | null>(null)
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([])
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
const [clientKey, setClientKey] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
const statusRef = useRef<FeedStatus>('loading')
const initialLoadRef = useRef(true)
const setStatusSafe = useCallback((next: FeedStatus) => {
statusRef.current = next
setStatus(next)
}, [])
const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false
const previousStatus = statusRef.current
const isInitial = initialLoadRef.current
if (previousStatus !== 'posting') {
if (isInitial) {
setStatusSafe('loading')
} else if (!silent) {
setStatusSafe('refreshing')
}
}
try {
const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' })
if (!response.ok) {
throw new Error('feed-unavailable')
}
const data: ApiSnapshot = await response.json()
setRawPoints(data.points ?? [])
setRawDensity(data.density ?? [])
setRawLatestByUser(data.latestByUser ?? [])
setClientKey(data.clientKey ?? null)
setLastUpdated(data.updatedAt ?? new Date().toISOString())
setError(null)
initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus)
} catch (error) {
const message = error instanceof Error ? error.message : null
const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown'
setError({ key })
if (initialLoadRef.current) {
setStatusSafe('error')
} else if (previousStatus !== 'posting') {
setStatusSafe('idle')
}
}
},
[setStatusSafe],
)
useEffect(() => {
fetchSnapshot().catch(() => undefined)
if (!autoRefreshMs) {
return
}
const interval = window.setInterval(() => {
fetchSnapshot({ silent: true }).catch(() => undefined)
}, autoRefreshMs)
return () => {
window.clearInterval(interval)
}
}, [autoRefreshMs, fetchSnapshot])
const submitPoint = useCallback(
async (lat: number, lng: number): Promise<SubmitResult> => {
setStatusSafe('posting')
try {
const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lng }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
const message = payload?.message as string | undefined
if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnavailable' })
}
setStatusSafe('error')
return { success: false }
}
await fetchSnapshot({ silent: true })
setError(null)
setStatusSafe('idle')
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : null
if (message) {
setError({ key: 'errors.submitWithReason', values: { message } })
} else {
setError({ key: 'errors.submitUnknown' })
}
setStatusSafe('error')
return { success: false }
}
},
[fetchSnapshot, setStatusSafe],
)
const hasClientKey = Boolean(clientKey)
const filterWithinRadius = useCallback(
<T extends LatLng>(collection: T[], origin: LatLng | null) => {
if (!origin) {
return []
}
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM)
},
[],
)
const selectVisibleDensity = useCallback(
(origin: LatLng | null) => filterWithinRadius(rawDensity, origin),
[filterWithinRadius, rawDensity],
)
const selectVisiblePoints = useCallback(
(origin: LatLng | null) => filterWithinRadius(rawPoints, origin),
[filterWithinRadius, rawPoints],
)
const selectVisibleLatestByUser = useCallback(
(origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin),
[filterWithinRadius, rawLatestByUser],
)
const myLatestPoint = useMemo(() => {
if (!hasClientKey) {
return null
}
return rawLatestByUser.find((point) => point.userKey === clientKey) ?? null
}, [clientKey, hasClientKey, rawLatestByUser])
return {
status,
error,
submitPoint,
fetchSnapshot,
rawDensity,
rawPoints,
rawLatestByUser,
clientKey,
lastUpdated,
hasClientKey,
selectVisibleDensity,
selectVisiblePoints,
selectVisibleLatestByUser,
myLatestPoint,
}
}