diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bfd1049
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/client/package-lock.json b/client/package-lock.json
index 0657b05..b67d1af 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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
+ }
+ }
}
}
}
diff --git a/client/package.json b/client/package.json
index 5e344bb..3dc4d59 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index aa25579..3c50cec 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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 (
-
-
-
-
-
-
- )
-}
-
export default function App() {
const [pendingSpot, setPendingSpot] = useState(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 (
-
- )
- }
-
return (
<>
diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts
index 31595f6..0e7273d 100644
--- a/client/src/hooks/useHotspotFeed.ts
+++ b/client/src/hooks/useHotspotFeed.ts
@@ -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
=> {
@@ -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)
},
diff --git a/client/src/hooks/useLeafletHeatmap.ts b/client/src/hooks/useLeafletHeatmap.ts
index 7dc70ac..2d22075 100644
--- a/client/src/hooks/useLeafletHeatmap.ts
+++ b/client/src/hooks/useLeafletHeatmap.ts
@@ -237,6 +237,7 @@ export function useLeafletHeatmap({
userLayer.clearLayers()
if (!userLocation) {
+ hasCenteredOnUserRef.current = false
return
}
diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts
index fd3f085..f8f4e04 100644
--- a/client/src/hooks/useUserLocation.ts
+++ b/client/src/hooks/useUserLocation.ts
@@ -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(null)
- const [error, setError] = useState(null)
- const [isRequesting, setIsRequesting] = useState(false)
+ const setUserLocation = useAppStore((state) => state.setUserLocation)
+ const setLocationError = useAppStore((state) => state.setLocationError)
+ const setIsRequestingLocation = useAppStore((state) => state.setIsRequestingLocation)
const watchIdRef = useRef(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,
}
}
diff --git a/client/src/store/useAppStore.ts b/client/src/store/useAppStore.ts
new file mode 100644
index 0000000..ecd0add
--- /dev/null
+++ b/client/src/store/useAppStore.ts
@@ -0,0 +1,23 @@
+import { create } from 'zustand'
+
+import type { Point } from '@/types/api'
+
+type Nullable = T | null
+
+interface AppState {
+ userLocation: Nullable
+ locationError: Nullable
+ isRequestingLocation: boolean
+ setUserLocation: (location: Nullable) => void
+ setLocationError: (error: Nullable) => void
+ setIsRequestingLocation: (isRequesting: boolean) => void
+}
+
+export const useAppStore = create((set) => ({
+ userLocation: null,
+ locationError: null,
+ isRequestingLocation: false,
+ setUserLocation: (userLocation) => set({ userLocation }),
+ setLocationError: (locationError) => set({ locationError }),
+ setIsRequestingLocation: (isRequestingLocation) => set({ isRequestingLocation }),
+}))
diff --git a/client/src/types/api.ts b/client/src/types/api.ts
index 255e05f..29f6906 100644
--- a/client/src/types/api.ts
+++ b/client/src/types/api.ts
@@ -8,7 +8,6 @@ export interface Point {
export interface ApiPoint {
id: number
signalLocation: Point
- userLocation: Point
createdAt: string
userKey: string
}
diff --git a/server/src/Service/SignalSnapshotBuilder.php b/server/src/Service/SignalSnapshotBuilder.php
index b7db0d3..b57ec43 100644
--- a/server/src/Service/SignalSnapshotBuilder.php
+++ b/server/src/Service/SignalSnapshotBuilder.php
@@ -15,7 +15,6 @@ class SignalSnapshotBuilder
* points: list,
@@ -23,7 +22,6 @@ class SignalSnapshotBuilder
* latestByUser: list,
diff --git a/server/tests/Functional/SignalControllerTest.php b/server/tests/Functional/SignalControllerTest.php
index b1299c7..92f171e 100644
--- a/server/tests/Functional/SignalControllerTest.php
+++ b/server/tests/Functional/SignalControllerTest.php
@@ -63,6 +63,7 @@ class SignalControllerTest extends WebTestCase
self::assertSame(3, $payload['totals']['points']);
self::assertSame(2, $payload['totals']['contributors']);
self::assertSame(-11.6852, $payload['points'][0]['signalLocation']['lat']);
+ self::assertArrayNotHasKey('userLocation', $payload['points'][0]);
}
public function testIndexRespectsLimitQueryParameter(): void
@@ -107,8 +108,7 @@ class SignalControllerTest extends WebTestCase
self::assertSame('stored', $payload['status']);
self::assertSame($body['signalLocation']['lat'], $payload['point']['signalLocation']['lat']);
self::assertSame($body['signalLocation']['lng'], $payload['point']['signalLocation']['lng']);
- self::assertSame($body['userLocation']['lat'], $payload['point']['userLocation']['lat']);
- self::assertSame($body['userLocation']['lng'], $payload['point']['userLocation']['lng']);
+ self::assertArrayNotHasKey('userLocation', $payload['point']);
$repository = $this->entityManager->getRepository(Signal::class);
$signals = $repository->findAll();