192 lines
5.7 KiB
TypeScript
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,
|
|
}
|
|
}
|