Refine location handling with Zustand store

This commit is contained in:
Bernard Ngandu
2025-10-10 15:29:39 +02:00
parent 39c441d426
commit f6354370cb
11 changed files with 168 additions and 117 deletions
+39 -1
View File
@@ -24,7 +24,8 @@
"react-dom": "^19.1.1",
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
"tslib": "^2.8.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -4889,6 +4890,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5166,6 +5176,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}
+2 -1
View File
@@ -26,7 +26,8 @@
"react-dom": "^19.1.1",
"react-i18next": "^16.0.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
"tslib": "^2.8.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
+17 -56
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Layers, Loader2, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader'
@@ -26,34 +26,10 @@ import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp
import type { Point } from '@/types/api'
import { Toaster } from '@/components/ui/toaster'
import { useToast } from '@/components/ui/use-toast'
import { useAppStore } from '@/store/useAppStore'
const RADIUS_KM = 1
interface LocationGateProps {
title: string
message: string
actionLabel: string
onRetry: () => void
isLoading: boolean
}
function LocationGate({ title, message, actionLabel, onRetry, isLoading }: LocationGateProps) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-6 text-center">
<div className="max-w-md space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
<Button onClick={onRetry} disabled={isLoading} className="inline-flex items-center gap-2">
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
<span>{actionLabel}</span>
</Button>
</div>
</div>
)
}
export default function App() {
const [pendingSpot, setPendingSpot] = useState<Point | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
@@ -91,12 +67,10 @@ export default function App() {
[t],
)
const {
location: userLocation,
error: locationError,
isRequesting: isRequestingLocation,
refresh: refreshLocation,
} = useUserLocation()
const userLocation = useAppStore((state) => state.userLocation)
const locationError = useAppStore((state) => state.locationError)
const isRequestingLocation = useAppStore((state) => state.isRequestingLocation)
const { refresh: refreshLocation } = useUserLocation()
const {
status,
@@ -207,8 +181,10 @@ export default function App() {
const handleLocateUser = useCallback(() => {
if (userLocation) {
focusOn(userLocation, 14)
} else {
refreshLocation()
}
}, [focusOn, userLocation])
}, [focusOn, refreshLocation, userLocation])
const handleFocusMySignal = useCallback(() => {
if (myVisibleSignal) {
@@ -234,20 +210,21 @@ export default function App() {
lat: formatCoordinate(cell.lat, locale),
lng: formatCoordinate(cell.lng, locale),
})
const distanceLabel = userLocation
? `${distanceFormatter.format(distanceInKm(userLocation, cell))} km`
: null
return {
id: `${cell.lat}-${cell.lng}-${index}`,
title: t('hotspots.itemTitle', { index: index + 1 }),
subtitle: hasLocation
? t('hotspots.itemSubtitleWithDistance', {
distance: `${distanceFormatter.format(distanceInKm(userLocation!, cell))} km`,
coordinates,
})
: t('hotspots.itemSubtitle', { coordinates }),
subtitle:
distanceLabel !== null
? t('hotspots.itemSubtitleWithDistance', { distance: distanceLabel, coordinates })
: t('hotspots.itemSubtitle', { coordinates }),
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
}
}),
[visibleDensity, hasLocation, userLocation, focusOn, distanceFormatter, t, locale],
[visibleDensity, userLocation, focusOn, distanceFormatter, t, locale],
)
const recentActivity = useMemo(
@@ -288,22 +265,6 @@ export default function App() {
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
)
if (!userLocation) {
const gateTitle = t('location.gate.title')
const gateMessage = locationError ? t(locationError) : t('location.gate.description')
const gateAction = isRequestingLocation ? t('location.gate.loading') : t('location.gate.action')
return (
<LocationGate
title={gateTitle}
message={gateMessage}
actionLabel={gateAction}
onRetry={refreshLocation}
isLoading={isRequestingLocation}
/>
)
}
return (
<>
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
+8 -32
View File
@@ -70,19 +70,6 @@ export function useHotspotFeed({
setStatus(next)
}, [])
const resetState = useCallback(() => {
setRawPoints([])
setRawDensity([])
setRawLatestByUser([])
setLastUpdated(null)
setClientKey(null)
setError(null)
eventSourceRef.current?.close()
eventSourceRef.current = null
initialLoadRef.current = true
setStatusSafe('loading')
}, [setStatusSafe])
const applySnapshot = useCallback(
(snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => {
setRawPoints(snapshot.points)
@@ -103,11 +90,6 @@ export function useHotspotFeed({
const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => {
if (!userLocation) {
resetState()
return
}
const silent = options?.silent ?? false
const previousStatus = statusRef.current
const isInitial = initialLoadRef.current
@@ -146,15 +128,14 @@ export function useHotspotFeed({
}
}
},
[userLocation, snapshotLimit, resetState, setStatusSafe, applySnapshot],
[snapshotLimit, setStatusSafe, applySnapshot],
)
const connectToStream = useCallback(() => {
if (!userLocation) {
return
}
try {
eventSourceRef.current?.close()
eventSourceRef.current = null
const url = new URL(mercureHub)
url.searchParams.append('topic', mercureTopic)
@@ -183,14 +164,9 @@ export function useHotspotFeed({
setError({ key: 'errors.feedUnavailable' })
setStatusSafe('error')
}
}, [applySnapshot, mercureHub, mercureTopic, setStatusSafe, userLocation])
}, [applySnapshot, mercureHub, mercureTopic, setStatusSafe])
useEffect(() => {
if (!userLocation) {
resetState()
return undefined
}
fetchSnapshot().catch(() => undefined)
connectToStream()
@@ -198,7 +174,7 @@ export function useHotspotFeed({
eventSourceRef.current?.close()
eventSourceRef.current = null
}
}, [connectToStream, fetchSnapshot, resetState, userLocation])
}, [connectToStream, fetchSnapshot])
const submitPoint = useCallback(
async (target: Point): Promise<SubmitResult> => {
@@ -261,7 +237,7 @@ export function useHotspotFeed({
const filterDensityWithinRadius = useCallback(
(collection: ApiDensityCell[], origin: Point | null) => {
if (!origin) {
return []
return collection
}
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM)
},
@@ -271,7 +247,7 @@ export function useHotspotFeed({
const filterPointsWithinRadius = useCallback(
(collection: ApiPoint[], origin: Point | null) => {
if (!origin) {
return []
return collection
}
return collection.filter((item) => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM)
},
+1
View File
@@ -237,6 +237,7 @@ export function useLeafletHeatmap({
userLayer.clearLayers()
if (!userLocation) {
hasCenteredOnUserRef.current = false
return
}
+24 -22
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import type { Point } from '@/types/api'
import { useAppStore } from '@/store/useAppStore'
function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
@@ -16,9 +17,9 @@ function geolocationErrorMessage(error: GeolocationPositionError): string {
}
export function useUserLocation() {
const [location, setLocation] = useState<Point | null>(null)
const [error, setError] = useState<string | null>(null)
const [isRequesting, setIsRequesting] = useState<boolean>(false)
const setUserLocation = useAppStore((state) => state.setUserLocation)
const setLocationError = useAppStore((state) => state.setLocationError)
const setIsRequestingLocation = useAppStore((state) => state.setIsRequestingLocation)
const watchIdRef = useRef<number | null>(null)
const clearWatch = useCallback(() => {
@@ -33,43 +34,47 @@ export function useUserLocation() {
const start = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setError('location.error.unsupported')
setIsRequesting(false)
setLocationError('location.error.unsupported')
setIsRequestingLocation(false)
return
}
clearWatch()
setIsRequesting(true)
setError(null)
setIsRequestingLocation(true)
setLocationError(null)
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setIsRequesting(false)
setError(null)
const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude }
setUserLocation(coords)
setIsRequestingLocation(false)
setLocationError(null)
},
(geoError) => {
setError(geolocationErrorMessage(geoError))
setIsRequesting(false)
setUserLocation(null)
setLocationError(geolocationErrorMessage(geoError))
setIsRequestingLocation(false)
},
{ enableHighAccuracy: true, timeout: 10000 },
)
const watchId = navigator.geolocation.watchPosition(
(position) => {
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setError(null)
setIsRequesting(false)
const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude }
setUserLocation(coords)
setLocationError(null)
setIsRequestingLocation(false)
},
(geoError) => {
setError(geolocationErrorMessage(geoError))
setIsRequesting(false)
setUserLocation(null)
setLocationError(geolocationErrorMessage(geoError))
setIsRequestingLocation(false)
},
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
)
watchIdRef.current = watchId
}, [clearWatch])
}, [clearWatch, setIsRequestingLocation, setLocationError, setUserLocation])
useEffect(() => {
start()
@@ -80,9 +85,6 @@ export function useUserLocation() {
}, [clearWatch, start])
return {
location,
error,
isRequesting,
refresh: start,
}
}
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand'
import type { Point } from '@/types/api'
type Nullable<T> = T | null
interface AppState {
userLocation: Nullable<Point>
locationError: Nullable<string>
isRequestingLocation: boolean
setUserLocation: (location: Nullable<Point>) => void
setLocationError: (error: Nullable<string>) => void
setIsRequestingLocation: (isRequesting: boolean) => void
}
export const useAppStore = create<AppState>((set) => ({
userLocation: null,
locationError: null,
isRequestingLocation: false,
setUserLocation: (userLocation) => set({ userLocation }),
setLocationError: (locationError) => set({ locationError }),
setIsRequestingLocation: (isRequestingLocation) => set({ isRequestingLocation }),
}))
-1
View File
@@ -8,7 +8,6 @@ export interface Point {
export interface ApiPoint {
id: number
signalLocation: Point
userLocation: Point
createdAt: string
userKey: string
}