Merge pull request #6 from bernard-ng/codex/remove-userlocation-from-responses
Introduce global location store and stop exposing user coordinates
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# Points of Interest
|
||||||
|
|
||||||
|
This repository hosts a proof-of-concept "points of interest" application composed of a Symfony API and a React client. Users can submit nearby signals and explore aggregated heatmaps that visualise recent activity.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- `server/` – Symfony 7 API that stores and broadcasts signal submissions.
|
||||||
|
- `client/` – React (Vite) front-end that renders the map interface and live statistics.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm 10+
|
||||||
|
- PHP 8.2+
|
||||||
|
- Composer 2+
|
||||||
|
- A running database supported by Doctrine (SQLite is used by default for local development)
|
||||||
|
|
||||||
|
## Running the API server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
composer install
|
||||||
|
|
||||||
|
# Create the database schema (SQLite by default)
|
||||||
|
php bin/console doctrine:database:create --if-not-exists
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
# Start the Symfony development server
|
||||||
|
symfony server:start
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on `http://127.0.0.1:8000` by default. Adjust the Mercure hub and other environment variables in `.env` as needed.
|
||||||
|
|
||||||
|
## Running the client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The client starts on `http://localhost:5173`. Set the `VITE_API_BASE`, `VITE_MERCURE_HUB`, and `VITE_MERCURE_TOPIC` environment variables (see `client/.env.example` if available) to point to the API and Mercure hub instances.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- API: `cd server && ./vendor/bin/phpunit`
|
||||||
|
- Client: `cd client && npm run build` (ensures the TypeScript build succeeds)
|
||||||
|
|
||||||
|
## Additional notes
|
||||||
|
|
||||||
|
- The API uses Mercure for real-time updates. Ensure a Mercure hub is running and reachable by the client when testing streaming features.
|
||||||
|
- Review the individual `README.md` files inside `client/` and `server/` for more detailed configuration guidance.
|
||||||
Generated
+39
-1
@@ -24,7 +24,8 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -5166,6 +5176,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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
@@ -26,7 +26,8 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
+17
-56
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
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 { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { AppHeader } from '@/components/layout/AppHeader'
|
import { AppHeader } from '@/components/layout/AppHeader'
|
||||||
@@ -26,34 +26,10 @@ import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp
|
|||||||
import type { Point } from '@/types/api'
|
import type { Point } from '@/types/api'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { useAppStore } from '@/store/useAppStore'
|
||||||
|
|
||||||
const RADIUS_KM = 1
|
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() {
|
export default function App() {
|
||||||
const [pendingSpot, setPendingSpot] = useState<Point | null>(null)
|
const [pendingSpot, setPendingSpot] = useState<Point | null>(null)
|
||||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||||
@@ -91,12 +67,10 @@ export default function App() {
|
|||||||
[t],
|
[t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const userLocation = useAppStore((state) => state.userLocation)
|
||||||
location: userLocation,
|
const locationError = useAppStore((state) => state.locationError)
|
||||||
error: locationError,
|
const isRequestingLocation = useAppStore((state) => state.isRequestingLocation)
|
||||||
isRequesting: isRequestingLocation,
|
const { refresh: refreshLocation } = useUserLocation()
|
||||||
refresh: refreshLocation,
|
|
||||||
} = useUserLocation()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
@@ -207,8 +181,10 @@ export default function App() {
|
|||||||
const handleLocateUser = useCallback(() => {
|
const handleLocateUser = useCallback(() => {
|
||||||
if (userLocation) {
|
if (userLocation) {
|
||||||
focusOn(userLocation, 14)
|
focusOn(userLocation, 14)
|
||||||
|
} else {
|
||||||
|
refreshLocation()
|
||||||
}
|
}
|
||||||
}, [focusOn, userLocation])
|
}, [focusOn, refreshLocation, userLocation])
|
||||||
|
|
||||||
const handleFocusMySignal = useCallback(() => {
|
const handleFocusMySignal = useCallback(() => {
|
||||||
if (myVisibleSignal) {
|
if (myVisibleSignal) {
|
||||||
@@ -234,20 +210,21 @@ export default function App() {
|
|||||||
lat: formatCoordinate(cell.lat, locale),
|
lat: formatCoordinate(cell.lat, locale),
|
||||||
lng: formatCoordinate(cell.lng, locale),
|
lng: formatCoordinate(cell.lng, locale),
|
||||||
})
|
})
|
||||||
|
const distanceLabel = userLocation
|
||||||
|
? `${distanceFormatter.format(distanceInKm(userLocation, cell))} km`
|
||||||
|
: null
|
||||||
return {
|
return {
|
||||||
id: `${cell.lat}-${cell.lng}-${index}`,
|
id: `${cell.lat}-${cell.lng}-${index}`,
|
||||||
title: t('hotspots.itemTitle', { index: index + 1 }),
|
title: t('hotspots.itemTitle', { index: index + 1 }),
|
||||||
subtitle: hasLocation
|
subtitle:
|
||||||
? t('hotspots.itemSubtitleWithDistance', {
|
distanceLabel !== null
|
||||||
distance: `${distanceFormatter.format(distanceInKm(userLocation!, cell))} km`,
|
? t('hotspots.itemSubtitleWithDistance', { distance: distanceLabel, coordinates })
|
||||||
coordinates,
|
: t('hotspots.itemSubtitle', { coordinates }),
|
||||||
})
|
|
||||||
: t('hotspots.itemSubtitle', { coordinates }),
|
|
||||||
intensity: cell.intensity,
|
intensity: cell.intensity,
|
||||||
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
|
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(
|
const recentActivity = useMemo(
|
||||||
@@ -288,22 +265,6 @@ export default function App() {
|
|||||||
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
|
: '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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
|
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
|
||||||
|
|||||||
@@ -70,19 +70,6 @@ export function useHotspotFeed({
|
|||||||
setStatus(next)
|
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(
|
const applySnapshot = useCallback(
|
||||||
(snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => {
|
(snapshot: ApiSnapshot, options?: { preserveClientKey?: boolean }) => {
|
||||||
setRawPoints(snapshot.points)
|
setRawPoints(snapshot.points)
|
||||||
@@ -103,11 +90,6 @@ export function useHotspotFeed({
|
|||||||
|
|
||||||
const fetchSnapshot = useCallback(
|
const fetchSnapshot = useCallback(
|
||||||
async (options?: { silent?: boolean }) => {
|
async (options?: { silent?: boolean }) => {
|
||||||
if (!userLocation) {
|
|
||||||
resetState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const silent = options?.silent ?? false
|
const silent = options?.silent ?? false
|
||||||
const previousStatus = statusRef.current
|
const previousStatus = statusRef.current
|
||||||
const isInitial = initialLoadRef.current
|
const isInitial = initialLoadRef.current
|
||||||
@@ -146,15 +128,14 @@ export function useHotspotFeed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[userLocation, snapshotLimit, resetState, setStatusSafe, applySnapshot],
|
[snapshotLimit, setStatusSafe, applySnapshot],
|
||||||
)
|
)
|
||||||
|
|
||||||
const connectToStream = useCallback(() => {
|
const connectToStream = useCallback(() => {
|
||||||
if (!userLocation) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
eventSourceRef.current?.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
|
||||||
const url = new URL(mercureHub)
|
const url = new URL(mercureHub)
|
||||||
url.searchParams.append('topic', mercureTopic)
|
url.searchParams.append('topic', mercureTopic)
|
||||||
|
|
||||||
@@ -183,14 +164,9 @@ export function useHotspotFeed({
|
|||||||
setError({ key: 'errors.feedUnavailable' })
|
setError({ key: 'errors.feedUnavailable' })
|
||||||
setStatusSafe('error')
|
setStatusSafe('error')
|
||||||
}
|
}
|
||||||
}, [applySnapshot, mercureHub, mercureTopic, setStatusSafe, userLocation])
|
}, [applySnapshot, mercureHub, mercureTopic, setStatusSafe])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userLocation) {
|
|
||||||
resetState()
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchSnapshot().catch(() => undefined)
|
fetchSnapshot().catch(() => undefined)
|
||||||
connectToStream()
|
connectToStream()
|
||||||
|
|
||||||
@@ -198,7 +174,7 @@ export function useHotspotFeed({
|
|||||||
eventSourceRef.current?.close()
|
eventSourceRef.current?.close()
|
||||||
eventSourceRef.current = null
|
eventSourceRef.current = null
|
||||||
}
|
}
|
||||||
}, [connectToStream, fetchSnapshot, resetState, userLocation])
|
}, [connectToStream, fetchSnapshot])
|
||||||
|
|
||||||
const submitPoint = useCallback(
|
const submitPoint = useCallback(
|
||||||
async (target: Point): Promise<SubmitResult> => {
|
async (target: Point): Promise<SubmitResult> => {
|
||||||
@@ -261,7 +237,7 @@ export function useHotspotFeed({
|
|||||||
const filterDensityWithinRadius = useCallback(
|
const filterDensityWithinRadius = useCallback(
|
||||||
(collection: ApiDensityCell[], origin: Point | null) => {
|
(collection: ApiDensityCell[], origin: Point | null) => {
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return []
|
return collection
|
||||||
}
|
}
|
||||||
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM)
|
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM)
|
||||||
},
|
},
|
||||||
@@ -271,7 +247,7 @@ export function useHotspotFeed({
|
|||||||
const filterPointsWithinRadius = useCallback(
|
const filterPointsWithinRadius = useCallback(
|
||||||
(collection: ApiPoint[], origin: Point | null) => {
|
(collection: ApiPoint[], origin: Point | null) => {
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return []
|
return collection
|
||||||
}
|
}
|
||||||
return collection.filter((item) => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM)
|
return collection.filter((item) => distanceInKm(origin, item.signalLocation) <= VISIBLE_RADIUS_KM)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export function useLeafletHeatmap({
|
|||||||
userLayer.clearLayers()
|
userLayer.clearLayers()
|
||||||
|
|
||||||
if (!userLocation) {
|
if (!userLocation) {
|
||||||
|
hasCenteredOnUserRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import type { Point } from '@/types/api'
|
import type { Point } from '@/types/api'
|
||||||
|
import { useAppStore } from '@/store/useAppStore'
|
||||||
|
|
||||||
function geolocationErrorMessage(error: GeolocationPositionError): string {
|
function geolocationErrorMessage(error: GeolocationPositionError): string {
|
||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
@@ -16,9 +17,9 @@ function geolocationErrorMessage(error: GeolocationPositionError): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUserLocation() {
|
export function useUserLocation() {
|
||||||
const [location, setLocation] = useState<Point | null>(null)
|
const setUserLocation = useAppStore((state) => state.setUserLocation)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const setLocationError = useAppStore((state) => state.setLocationError)
|
||||||
const [isRequesting, setIsRequesting] = useState<boolean>(false)
|
const setIsRequestingLocation = useAppStore((state) => state.setIsRequestingLocation)
|
||||||
const watchIdRef = useRef<number | null>(null)
|
const watchIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const clearWatch = useCallback(() => {
|
const clearWatch = useCallback(() => {
|
||||||
@@ -33,43 +34,47 @@ export function useUserLocation() {
|
|||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
setError('location.error.unsupported')
|
setLocationError('location.error.unsupported')
|
||||||
setIsRequesting(false)
|
setIsRequestingLocation(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearWatch()
|
clearWatch()
|
||||||
setIsRequesting(true)
|
setIsRequestingLocation(true)
|
||||||
setError(null)
|
setLocationError(null)
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
|
const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude }
|
||||||
setIsRequesting(false)
|
setUserLocation(coords)
|
||||||
setError(null)
|
setIsRequestingLocation(false)
|
||||||
|
setLocationError(null)
|
||||||
},
|
},
|
||||||
(geoError) => {
|
(geoError) => {
|
||||||
setError(geolocationErrorMessage(geoError))
|
setUserLocation(null)
|
||||||
setIsRequesting(false)
|
setLocationError(geolocationErrorMessage(geoError))
|
||||||
|
setIsRequestingLocation(false)
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, timeout: 10000 },
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const watchId = navigator.geolocation.watchPosition(
|
const watchId = navigator.geolocation.watchPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
|
const coords: Point = { lat: position.coords.latitude, lng: position.coords.longitude }
|
||||||
setError(null)
|
setUserLocation(coords)
|
||||||
setIsRequesting(false)
|
setLocationError(null)
|
||||||
|
setIsRequestingLocation(false)
|
||||||
},
|
},
|
||||||
(geoError) => {
|
(geoError) => {
|
||||||
setError(geolocationErrorMessage(geoError))
|
setUserLocation(null)
|
||||||
setIsRequesting(false)
|
setLocationError(geolocationErrorMessage(geoError))
|
||||||
|
setIsRequestingLocation(false)
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
|
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
watchIdRef.current = watchId
|
watchIdRef.current = watchId
|
||||||
}, [clearWatch])
|
}, [clearWatch, setIsRequestingLocation, setLocationError, setUserLocation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
start()
|
start()
|
||||||
@@ -80,9 +85,6 @@ export function useUserLocation() {
|
|||||||
}, [clearWatch, start])
|
}, [clearWatch, start])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
location,
|
|
||||||
error,
|
|
||||||
isRequesting,
|
|
||||||
refresh: start,
|
refresh: start,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -8,7 +8,6 @@ export interface Point {
|
|||||||
export interface ApiPoint {
|
export interface ApiPoint {
|
||||||
id: number
|
id: number
|
||||||
signalLocation: Point
|
signalLocation: Point
|
||||||
userLocation: Point
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
userKey: string
|
userKey: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class SignalSnapshotBuilder
|
|||||||
* points: list<array{
|
* points: list<array{
|
||||||
* id: int,
|
* id: int,
|
||||||
* signalLocation: array{lat: float, lng: float},
|
* signalLocation: array{lat: float, lng: float},
|
||||||
* userLocation: array{lat: float, lng: float},
|
|
||||||
* createdAt: string,
|
* createdAt: string,
|
||||||
* userKey: string,
|
* userKey: string,
|
||||||
* }>,
|
* }>,
|
||||||
@@ -23,7 +22,6 @@ class SignalSnapshotBuilder
|
|||||||
* latestByUser: list<array{
|
* latestByUser: list<array{
|
||||||
* id: int,
|
* id: int,
|
||||||
* signalLocation: array{lat: float, lng: float},
|
* signalLocation: array{lat: float, lng: float},
|
||||||
* userLocation: array{lat: float, lng: float},
|
|
||||||
* createdAt: string,
|
* createdAt: string,
|
||||||
* userKey: string,
|
* userKey: string,
|
||||||
* }>,
|
* }>,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class SignalControllerTest extends WebTestCase
|
|||||||
self::assertSame(3, $payload['totals']['points']);
|
self::assertSame(3, $payload['totals']['points']);
|
||||||
self::assertSame(2, $payload['totals']['contributors']);
|
self::assertSame(2, $payload['totals']['contributors']);
|
||||||
self::assertSame(-11.6852, $payload['points'][0]['signalLocation']['lat']);
|
self::assertSame(-11.6852, $payload['points'][0]['signalLocation']['lat']);
|
||||||
|
self::assertArrayNotHasKey('userLocation', $payload['points'][0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexRespectsLimitQueryParameter(): void
|
public function testIndexRespectsLimitQueryParameter(): void
|
||||||
@@ -107,8 +108,7 @@ class SignalControllerTest extends WebTestCase
|
|||||||
self::assertSame('stored', $payload['status']);
|
self::assertSame('stored', $payload['status']);
|
||||||
self::assertSame($body['signalLocation']['lat'], $payload['point']['signalLocation']['lat']);
|
self::assertSame($body['signalLocation']['lat'], $payload['point']['signalLocation']['lat']);
|
||||||
self::assertSame($body['signalLocation']['lng'], $payload['point']['signalLocation']['lng']);
|
self::assertSame($body['signalLocation']['lng'], $payload['point']['signalLocation']['lng']);
|
||||||
self::assertSame($body['userLocation']['lat'], $payload['point']['userLocation']['lat']);
|
self::assertArrayNotHasKey('userLocation', $payload['point']);
|
||||||
self::assertSame($body['userLocation']['lng'], $payload['point']['userLocation']['lng']);
|
|
||||||
|
|
||||||
$repository = $this->entityManager->getRepository(Signal::class);
|
$repository = $this->entityManager->getRepository(Signal::class);
|
||||||
$signals = $repository->findAll();
|
$signals = $repository->findAll();
|
||||||
|
|||||||
Reference in New Issue
Block a user