Refactor client UI with shadcn heatmap layout
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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 function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
|
||||
const [status, setStatus] = useState<FeedStatus>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState<string | 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('Unable to reach the hotspot feed.')
|
||||
}
|
||||
|
||||
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())
|
||||
setErrorMessage(null)
|
||||
initialLoadRef.current = false
|
||||
|
||||
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
|
||||
setStatusSafe(nextStatus)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.'
|
||||
setErrorMessage(message)
|
||||
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 ?? 'Unable to store your signal.'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
await fetchSnapshot({ silent: true })
|
||||
setStatusSafe('idle')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.'
|
||||
setErrorMessage(message)
|
||||
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,
|
||||
errorMessage,
|
||||
submitPoint,
|
||||
fetchSnapshot,
|
||||
rawDensity,
|
||||
rawPoints,
|
||||
rawLatestByUser,
|
||||
clientKey,
|
||||
lastUpdated,
|
||||
hasClientKey,
|
||||
selectVisibleDensity,
|
||||
selectVisiblePoints,
|
||||
selectVisibleLatestByUser,
|
||||
myLatestPoint,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user