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 ( -
-
-
-

{title}

-

{message}

-
- -
-
- ) -} - 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();