diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e6c1bfd..adbff18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,9 +11,15 @@ "@mantine/core": "^7.17.8", "@mantine/hooks": "^7.17.8", "@tanstack/react-query": "^5.101.0", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "openapi-fetch": "^0.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.30.4" }, "devDependencies": { @@ -1338,6 +1344,17 @@ "react": "^18.x || ^19.x" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -2053,6 +2070,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2060,6 +2083,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4951,6 +4992,26 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5634,6 +5695,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-number-format": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index cfe6a0d..ec52254 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,9 +16,15 @@ "@mantine/core": "^7.17.8", "@mantine/hooks": "^7.17.8", "@tanstack/react-query": "^5.101.0", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "openapi-fetch": "^0.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.30.4" }, "devDependencies": { diff --git a/frontend/src/map/RecordsMap.scatter.test.tsx b/frontend/src/map/RecordsMap.scatter.test.tsx new file mode 100644 index 0000000..cf6c066 --- /dev/null +++ b/frontend/src/map/RecordsMap.scatter.test.tsx @@ -0,0 +1,246 @@ +/** + * ScatterLayer unit test — M2-T09 REWORK 1. + * + * This test exercises the REAL ScatterLayer code path (not a wholesale RecordsMap mock). + * It verifies that ScatterLayer uses the imported leaflet namespace (L.markerClusterGroup) + * rather than window.L / globalThis.L, which would silently fail in Vite ESM bundles. + * + * The test: + * - mocks react-leaflet's useMap() to return a fake map object + * - provides a mock markerClusterGroup spy via the leaflet module mock + * - renders ScatterLayer with some points + * - asserts that L.markerClusterGroup was called (i.e. the import path is used) + * - asserts that addLayer was called for each point + * - asserts that clicking a marker invokes onSelectLocation / onSelectPoo + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' + +// --------------------------------------------------------------------------- +// Use vi.hoisted() to define mocks that are referenced inside vi.mock factories. +// vi.mock() factories are hoisted to the top of the file, so any variables they +// reference must also be hoisted. +// --------------------------------------------------------------------------- + +const { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers } = + vi.hoisted(() => { + const clickHandlers: Array<() => void> = [] + const fakeAddLayer = vi.fn() + const fakeCluster = { + addLayer: fakeAddLayer, + addTo: vi.fn(), + clearLayers: vi.fn(), + } + const markerClusterGroupSpy = vi.fn(() => fakeCluster) + const fakeMapAddLayer = vi.fn() + return { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers: clickHandlers } + }) + +// --------------------------------------------------------------------------- +// Mock leaflet BEFORE importing ScatterLayer. +// We use the hoisted spy so vi.mock factory can reference it safely. +// --------------------------------------------------------------------------- + +vi.mock('leaflet', () => { + const markerClusterGroupSpy_ = markerClusterGroupSpy + const markerClickHandlers_ = markerClickHandlers + + // Icon must be a real constructor (used as `new Icon(...)`) + class FakeIcon { + constructor(_opts: unknown) {} + static Default = { prototype: {}, mergeOptions: vi.fn() } + } + + return { + Icon: FakeIcon, + DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) { return {} }), + heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })), + markerClusterGroup: markerClusterGroupSpy_, + marker: vi.fn((_latlng: unknown, _opts: unknown) => { + return { + bindTooltip: vi.fn().mockReturnThis(), + on: vi.fn((event: string, handler: () => void) => { + if (event === 'click') { + markerClickHandlers_.push(handler) + } + return { bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() } + }), + } + }), + // `import * as L from 'leaflet'` in RecordsMap.tsx resolves to this module. + // Vitest's module mock exposes all named exports as the namespace object, + // so markerClusterGroup at the top level IS accessible as L.markerClusterGroup. + default: { + markerClusterGroup: markerClusterGroupSpy_, + }, + } +}) + +vi.mock('leaflet.heat', () => ({})) +vi.mock('leaflet.markercluster', () => ({})) + +// Mock image imports +vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' })) +vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' })) +vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' })) + +// Mock CSS imports +vi.mock('leaflet/dist/leaflet.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({})) + +// --------------------------------------------------------------------------- +// Mock react-leaflet: MapContainer renders children, useMap returns fake map. +// --------------------------------------------------------------------------- + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + TileLayer: () => null, + useMap: () => ({ + addLayer: fakeMapAddLayer, + removeLayer: vi.fn(), + hasLayer: vi.fn(() => false), + }), +})) + +// --------------------------------------------------------------------------- +// Import ScatterLayer AFTER mocks are set up. +// --------------------------------------------------------------------------- + +import { ScatterLayer } from './RecordsMap' +import type { LocationMapPoint, PooMapPoint } from './mapUtils' +import type { LocationRecord, PooRecord } from '../records' + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const locationRecord: LocationRecord = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, +} +const locationPoints: LocationMapPoint[] = [ + { lat: 39.9, lng: 116.4, record: locationRecord }, +] + +const pooRecord: PooRecord = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, +} +const pooPoints: PooMapPoint[] = [ + { lat: 39.91, lng: 116.41, record: pooRecord }, +] + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ScatterLayer (real code path — not mocked RecordsMap)', () => { + beforeEach(() => { + vi.clearAllMocks() + markerClickHandlers.length = 0 + }) + + it('calls L.markerClusterGroup (imported namespace) when showScatter=true', () => { + render( + , + ) + + // KEY assertion: markerClusterGroup was called via the IMPORTED namespace. + // With the old window.L / globalThis.L approach, this spy would never be + // invoked because window.L is undefined in Vite ESM bundles. + expect(markerClusterGroupSpy).toHaveBeenCalledOnce() + expect(markerClusterGroupSpy).toHaveBeenCalledWith({ + maxClusterRadius: 50, + showCoverageOnHover: false, + }) + }) + + it('calls cluster group addLayer for each location and poo scatter point', () => { + render( + , + ) + + // One addLayer call per point (1 location + 1 poo = 2). + expect(fakeAddLayer).toHaveBeenCalledTimes(2) + // The cluster group itself must be added to the map. + const fakeCluster = markerClusterGroupSpy.mock.results[0]?.value + expect(fakeMapAddLayer).toHaveBeenCalledWith(fakeCluster) + }) + + it('does NOT create cluster group when showScatter=false', () => { + render( + , + ) + + expect(markerClusterGroupSpy).not.toHaveBeenCalled() + expect(fakeAddLayer).not.toHaveBeenCalled() + }) + + it('invokes onSelectLocation when a location marker is clicked', () => { + const onSelectLocation = vi.fn() + + render( + , + ) + + // At least one marker click handler should have been registered. + expect(markerClickHandlers.length).toBeGreaterThan(0) + // Simulate click on the first (location) marker. + markerClickHandlers[0]() + expect(onSelectLocation).toHaveBeenCalledOnce() + expect(onSelectLocation).toHaveBeenCalledWith(locationRecord) + }) + + it('invokes onSelectPoo when a poo marker is clicked', () => { + const onSelectPoo = vi.fn() + + render( + , + ) + + expect(markerClickHandlers.length).toBeGreaterThan(0) + markerClickHandlers[0]() + expect(onSelectPoo).toHaveBeenCalledOnce() + expect(onSelectPoo).toHaveBeenCalledWith(pooRecord) + }) +}) diff --git a/frontend/src/map/RecordsMap.tsx b/frontend/src/map/RecordsMap.tsx new file mode 100644 index 0000000..93dd550 --- /dev/null +++ b/frontend/src/map/RecordsMap.tsx @@ -0,0 +1,263 @@ +/** + * RecordsMap — self-contained Leaflet map component (M2-T09). + * + * THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET. + * All data fetching and state lives outside; this component receives typed props. + */ + +import { useEffect, useRef, useCallback } from 'react' +import { MapContainer, TileLayer, useMap } from 'react-leaflet' +import * as L from 'leaflet' +import { + Icon, + DivIcon, + marker as leafletMarker, + heatLayer as leafletHeatLayer, + type HeatLayer, +} from 'leaflet' + +// Leaflet CSS — must be imported once; this component is the single place. +import 'leaflet/dist/leaflet.css' +import 'leaflet.markercluster/dist/MarkerCluster.css' +import 'leaflet.markercluster/dist/MarkerCluster.Default.css' + +// Side-effect imports (augment L with heatLayer and markerClusterGroup) +import 'leaflet.heat' +import 'leaflet.markercluster' + +import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils' +import type { LocationRecord, PooRecord } from '../records' + +// Fix default Leaflet marker icon paths broken by Vite asset handling. +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' +import markerIcon from 'leaflet/dist/images/marker-icon.png' +import markerShadow from 'leaflet/dist/images/marker-shadow.png' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +delete (Icon.Default.prototype as any)._getIconUrl +Icon.Default.mergeOptions({ + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, +}) + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface RecordsMapProps { + locationHeatPoints: HeatPoint[] + pooHeatPoints: HeatPoint[] + locationScatterPoints: LocationMapPoint[] + pooScatterPoints: PooMapPoint[] + + showLocationHeat: boolean + showPooHeat: boolean + showScatter: boolean + + onSelectLocation?: (record: LocationRecord) => void + onSelectPoo?: (record: PooRecord) => void + + /** Map container height (CSS value). Default: '100%'. */ + height?: string +} + +// --------------------------------------------------------------------------- +// Inner child: Heat layers (uses useMap hook — must be inside MapContainer) +// --------------------------------------------------------------------------- + +interface HeatLayerChildProps { + locationHeatPoints: HeatPoint[] + pooHeatPoints: HeatPoint[] + showLocationHeat: boolean + showPooHeat: boolean +} + +function HeatLayers({ + locationHeatPoints, + pooHeatPoints, + showLocationHeat, + showPooHeat, +}: HeatLayerChildProps) { + const map = useMap() + const locationLayerRef = useRef(null) + const pooLayerRef = useRef(null) + + // Location heat layer + useEffect(() => { + if (!locationLayerRef.current) { + locationLayerRef.current = leafletHeatLayer([], { + radius: 20, + blur: 15, + maxZoom: 17, + gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' }, + }) + } + const layer = locationLayerRef.current + layer.setLatLngs(locationHeatPoints) + if (showLocationHeat) { + if (!map.hasLayer(layer)) map.addLayer(layer) + } else { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + return () => { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + }, [map, locationHeatPoints, showLocationHeat]) + + // Poo heat layer + useEffect(() => { + if (!pooLayerRef.current) { + pooLayerRef.current = leafletHeatLayer([], { + radius: 25, + blur: 18, + maxZoom: 17, + gradient: { 0.4: 'yellow', 0.65: 'orange', 1: '#8B4513' }, + }) + } + const layer = pooLayerRef.current + layer.setLatLngs(pooHeatPoints) + if (showPooHeat) { + if (!map.hasLayer(layer)) map.addLayer(layer) + } else { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + return () => { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + }, [map, pooHeatPoints, showPooHeat]) + + return null +} + +// --------------------------------------------------------------------------- +// Inner child: Scatter / cluster layer +// --------------------------------------------------------------------------- + +interface ScatterLayerChildProps { + locationScatterPoints: LocationMapPoint[] + pooScatterPoints: PooMapPoint[] + showScatter: boolean + onSelectLocation?: (record: LocationRecord) => void + onSelectPoo?: (record: PooRecord) => void +} + +const locationIcon = new Icon({ + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}) + +const pooIcon = new DivIcon({ + html: '
💩
', + className: '', + iconSize: [24, 24], + iconAnchor: [12, 12], +}) + +export function ScatterLayer({ + locationScatterPoints, + pooScatterPoints, + showScatter, + onSelectLocation, + onSelectPoo, +}: ScatterLayerChildProps) { + const map = useMap() + const clusterGroupRef = useRef(null) + + const rebuild = useCallback(() => { + if (clusterGroupRef.current) { + map.removeLayer(clusterGroupRef.current) + clusterGroupRef.current = null + } + if (!showScatter) return + + // markerClusterGroup is augmented onto the imported L namespace by the + // leaflet.markercluster side-effect import above. Using the imported + // namespace (not window.L) is what works in Vite ESM bundles. + const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false }) + + for (const pt of locationScatterPoints) { + const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon }) + m.bindTooltip(`${pt.record.person}
${pt.record.datetime}`, { sticky: true }) + if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record)) + group.addLayer(m) + } + + for (const pt of pooScatterPoints) { + const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon }) + m.bindTooltip(`${pt.record.timestamp}
${pt.record.status}`, { sticky: true }) + if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record)) + group.addLayer(m) + } + + map.addLayer(group) + clusterGroupRef.current = group + }, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo]) + + useEffect(() => { + rebuild() + return () => { + if (clusterGroupRef.current) { + map.removeLayer(clusterGroupRef.current) + clusterGroupRef.current = null + } + } + }, [rebuild, map]) + + return null +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +/** Default map center: Beijing area. */ +const DEFAULT_CENTER: [number, number] = [39.9, 116.4] +const DEFAULT_ZOOM = 11 + +export function RecordsMap({ + locationHeatPoints, + pooHeatPoints, + locationScatterPoints, + pooScatterPoints, + showLocationHeat, + showPooHeat, + showScatter, + onSelectLocation, + onSelectPoo, + height = '100%', +}: RecordsMapProps) { + return ( + + + + + + + + ) +} diff --git a/frontend/src/map/index.ts b/frontend/src/map/index.ts new file mode 100644 index 0000000..1fdb590 --- /dev/null +++ b/frontend/src/map/index.ts @@ -0,0 +1,18 @@ +/** + * Public surface of the map module (M2-T09). + * Only RecordsMap.tsx imports leaflet — external code should not. + */ +export { RecordsMap } from './RecordsMap' +export type { RecordsMapProps } from './RecordsMap' + +export { + locationsToHeatPoints, + pooToHeatPoints, + locationsToMapPoints, + pooToMapPoints, + filterPooByTimeWindow, + daysAgoISO, + nowISO, + computeCenter, +} from './mapUtils' +export type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils' diff --git a/frontend/src/map/leaflet-heat.d.ts b/frontend/src/map/leaflet-heat.d.ts new file mode 100644 index 0000000..a3cc85f --- /dev/null +++ b/frontend/src/map/leaflet-heat.d.ts @@ -0,0 +1,40 @@ +/** + * Ambient type declarations for leaflet.heat (no @types package available). + * + * This file must be a MODULE (has a top-level export) so that `declare module 'leaflet'` + * is treated as an AUGMENTATION of the existing leaflet types, not a replacement. + * Without the export, the `declare module 'leaflet'` block would shadow all of @types/leaflet. + */ + +// This empty export makes the file a module, enabling proper augmentation semantics. +export {} + +// Augment the 'leaflet' module to add heatLayer and HeatLayer types. +declare module 'leaflet' { + type HeatLatLngTuple = [number, number] | [number, number, number] + + interface HeatLayerOptions { + minOpacity?: number + maxZoom?: number + max?: number + radius?: number + blur?: number + gradient?: Record + } + + class HeatLayer extends Layer { + setLatLngs(latlngs: HeatLatLngTuple[]): this + addLatLng(latlng: HeatLatLngTuple): this + setOptions(options: HeatLayerOptions): this + redraw(): this + } + + function heatLayer(latlngs: HeatLatLngTuple[], options?: HeatLayerOptions): HeatLayer +} + +// Declare leaflet.heat as a side-effect-only module. +declare module 'leaflet.heat' { + // Side-effect: augments the Leaflet global with the heatLayer plugin. + const _: undefined + export default _ +} diff --git a/frontend/src/map/mapUtils.test.ts b/frontend/src/map/mapUtils.test.ts new file mode 100644 index 0000000..36603e7 --- /dev/null +++ b/frontend/src/map/mapUtils.test.ts @@ -0,0 +1,196 @@ +/** + * Unit tests for mapUtils.ts — pure logic, no leaflet, runs in jsdom. + */ + +import { describe, it, expect } from 'vitest' +import { + locationsToHeatPoints, + pooToHeatPoints, + locationsToMapPoints, + pooToMapPoints, + filterPooByTimeWindow, + computeCenter, + daysAgoISO, +} from './mapUtils' +import type { LocationRecord, PooRecord } from '../records' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const loc1: LocationRecord = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: 50, +} +const loc2: LocationRecord = { + person: 'alice', + datetime: '2026-01-20T12:00:00Z', + latitude: 39.95, + longitude: 116.45, + altitude: null, +} + +const poo1: PooRecord = { + timestamp: '2026-01-10T08:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, +} +const poo2: PooRecord = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.92, + longitude: 116.42, +} +const poo3: PooRecord = { + timestamp: '2026-02-01T09:00:00Z', + status: 'done', + latitude: 39.93, + longitude: 116.43, +} + +// --------------------------------------------------------------------------- +// locationsToHeatPoints +// --------------------------------------------------------------------------- + +describe('locationsToHeatPoints', () => { + it('converts records to [lat, lng, 1] tuples', () => { + const pts = locationsToHeatPoints([loc1, loc2]) + expect(pts).toHaveLength(2) + expect(pts[0]).toEqual([39.9, 116.4, 1]) + expect(pts[1]).toEqual([39.95, 116.45, 1]) + }) + + it('returns empty array for empty input', () => { + expect(locationsToHeatPoints([])).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// pooToHeatPoints +// --------------------------------------------------------------------------- + +describe('pooToHeatPoints', () => { + it('converts poo records to heat points', () => { + const pts = pooToHeatPoints([poo1]) + expect(pts).toHaveLength(1) + expect(pts[0]).toEqual([39.91, 116.41, 1]) + }) +}) + +// --------------------------------------------------------------------------- +// locationsToMapPoints +// --------------------------------------------------------------------------- + +describe('locationsToMapPoints', () => { + it('attaches original record to each point', () => { + const pts = locationsToMapPoints([loc1]) + expect(pts).toHaveLength(1) + expect(pts[0].lat).toBe(39.9) + expect(pts[0].lng).toBe(116.4) + expect(pts[0].record).toBe(loc1) + }) +}) + +// --------------------------------------------------------------------------- +// pooToMapPoints +// --------------------------------------------------------------------------- + +describe('pooToMapPoints', () => { + it('attaches original poo record to each point', () => { + const pts = pooToMapPoints([poo1]) + expect(pts[0].record).toBe(poo1) + }) +}) + +// --------------------------------------------------------------------------- +// filterPooByTimeWindow — client-side time filter +// --------------------------------------------------------------------------- + +describe('filterPooByTimeWindow', () => { + const records = [poo1, poo2, poo3] + // timestamps: 2026-01-10, 2026-01-20, 2026-02-01 + + it('returns all records when start and end are both null', () => { + expect(filterPooByTimeWindow(records, null, null)).toHaveLength(3) + }) + + it('filters by start (inclusive)', () => { + const result = filterPooByTimeWindow(records, '2026-01-15T00:00:00Z', null) + expect(result).toHaveLength(2) + expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z') + expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z') + }) + + it('filters by end (inclusive)', () => { + const result = filterPooByTimeWindow(records, null, '2026-01-20T09:00:00Z') + expect(result).toHaveLength(2) + expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z') + expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z') + }) + + it('filters by both start and end', () => { + const result = filterPooByTimeWindow( + records, + '2026-01-15T00:00:00Z', + '2026-01-25T00:00:00Z', + ) + expect(result).toHaveLength(1) + expect(result[0].timestamp).toBe('2026-01-20T09:00:00Z') + }) + + it('returns empty when no records match', () => { + const result = filterPooByTimeWindow(records, '2027-01-01T00:00:00Z', null) + expect(result).toHaveLength(0) + }) + + it('includes records exactly at start boundary', () => { + const result = filterPooByTimeWindow(records, '2026-01-10T08:00:00Z', null) + expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z') + }) + + it('includes records exactly at end boundary', () => { + const result = filterPooByTimeWindow(records, null, '2026-02-01T09:00:00Z') + expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z') + }) +}) + +// --------------------------------------------------------------------------- +// computeCenter +// --------------------------------------------------------------------------- + +describe('computeCenter', () => { + it('returns null for empty array', () => { + expect(computeCenter([])).toBeNull() + }) + + it('returns the point for a single-element array', () => { + const result = computeCenter([{ lat: 10, lng: 20 }]) + expect(result).toEqual([10, 20]) + }) + + it('returns the average of multiple points', () => { + const result = computeCenter([ + { lat: 0, lng: 0 }, + { lat: 4, lng: 6 }, + ]) + expect(result).toEqual([2, 3]) + }) +}) + +// --------------------------------------------------------------------------- +// daysAgoISO +// --------------------------------------------------------------------------- + +describe('daysAgoISO', () => { + it('returns a valid ISO string in the past', () => { + const result = daysAgoISO(7) + expect(typeof result).toBe('string') + const d = new Date(result) + expect(isNaN(d.getTime())).toBe(false) + expect(d.getTime()).toBeLessThan(Date.now()) + }) +}) diff --git a/frontend/src/map/mapUtils.ts b/frontend/src/map/mapUtils.ts new file mode 100644 index 0000000..474b877 --- /dev/null +++ b/frontend/src/map/mapUtils.ts @@ -0,0 +1,104 @@ +/** + * Pure data-transform utilities for the map view (M2-T09). + * No leaflet imports — these functions are unit-testable in jsdom. + */ + +import type { LocationRecord, PooRecord } from '../records' + +/** A heat point for L.heatLayer: [lat, lng, intensity]. */ +export type HeatPoint = [number, number, number] + +/** Map point with attached source record for click-to-edit. */ +export interface LocationMapPoint { + lat: number + lng: number + record: LocationRecord +} + +export interface PooMapPoint { + lat: number + lng: number + record: PooRecord +} + +// --------------------------------------------------------------------------- +// Transforms +// --------------------------------------------------------------------------- + +/** + * Convert location records to heat points. + * All points get intensity=1; callers can adjust if needed. + */ +export function locationsToHeatPoints(records: LocationRecord[]): HeatPoint[] { + return records.map((r) => [r.latitude, r.longitude, 1]) +} + +/** + * Convert poo records to heat points. + */ +export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] { + return records.map((r) => [r.latitude, r.longitude, 1]) +} + +/** + * Convert location records to map points (for scatter layer). + */ +export function locationsToMapPoints(records: LocationRecord[]): LocationMapPoint[] { + return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r })) +} + +/** + * Convert poo records to map points (for scatter layer). + */ +export function pooToMapPoints(records: PooRecord[]): PooMapPoint[] { + return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r })) +} + +// --------------------------------------------------------------------------- +// Client-side time-window filter (for poo records — the endpoint has no server filter) +// --------------------------------------------------------------------------- + +/** + * Filter poo records to those whose timestamp falls within [start, end] (inclusive). + * start and end are ISO8601 strings (e.g. "2026-01-01T00:00:00Z"). + * If start or end is null, that bound is open (no filtering on that side). + */ +export function filterPooByTimeWindow( + records: PooRecord[], + start: string | null, + end: string | null, +): PooRecord[] { + if (!start && !end) return records + return records.filter((r) => { + const ts = r.timestamp + if (start && ts < start) return false + if (end && ts > end) return false + return true + }) +} + +// --------------------------------------------------------------------------- +// Default time window helpers +// --------------------------------------------------------------------------- + +/** Returns ISO8601 string for N days ago from now (UTC). */ +export function daysAgoISO(days: number): string { + const d = new Date() + d.setUTCDate(d.getUTCDate() - days) + return d.toISOString() +} + +/** Returns ISO8601 string for now (UTC). */ +export function nowISO(): string { + return new Date().toISOString() +} + +/** Compute a bounding center from an array of lat/lng points. Returns null if empty. */ +export function computeCenter( + points: Array<{ lat: number; lng: number }>, +): [number, number] | null { + if (points.length === 0) return null + const sumLat = points.reduce((s, p) => s + p.lat, 0) + const sumLng = points.reduce((s, p) => s + p.lng, 0) + return [sumLat / points.length, sumLng / points.length] +} diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..7728820 --- /dev/null +++ b/frontend/src/pages/HomePage.test.tsx @@ -0,0 +1,274 @@ +/** + * HomePage tests — M2-T09. + * + * Leaflet is mocked so jsdom doesn't choke on DOM APIs it doesn't support. + * We verify: + * 1. Controls render (time range inputs, layer toggles, apply button). + * 2. Point-select: when onSelectLocation is called, EditLocationModal opens. + * 3. Point-select: when onSelectPoo is called, EditPooModal opens. + * 4. The map component is rendered (mocked). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MantineProvider } from '@mantine/core' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import type { ReactNode } from 'react' + +// --------------------------------------------------------------------------- +// Mock leaflet / react-leaflet before any component imports them. +// --------------------------------------------------------------------------- + +vi.mock('leaflet', () => ({ + default: {}, + Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } }, + DivIcon: vi.fn(() => ({})), + heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })), + markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), clearLayers: vi.fn() })), + marker: vi.fn(() => ({ + bindTooltip: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + })), + tileLayer: vi.fn(), + map: vi.fn(), +})) + +vi.mock('leaflet.heat', () => ({})) +vi.mock('leaflet.markercluster', () => ({})) + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + TileLayer: () => null, + useMap: () => ({ + addLayer: vi.fn(), + removeLayer: vi.fn(), + hasLayer: vi.fn(() => false), + }), +})) + +// Mock leaflet image imports +vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' })) +vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' })) +vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' })) + +// Mock leaflet CSS +vi.mock('leaflet/dist/leaflet.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({})) + +// --------------------------------------------------------------------------- +// Mock RecordsMap to capture onSelectLocation / onSelectPoo callbacks +// --------------------------------------------------------------------------- + +import type { RecordsMapProps } from '../map/RecordsMap' + +let capturedOnSelectLocation: RecordsMapProps['onSelectLocation'] | undefined +let capturedOnSelectPoo: RecordsMapProps['onSelectPoo'] | undefined + +vi.mock('../map/RecordsMap', () => ({ + RecordsMap: (props: RecordsMapProps) => { + capturedOnSelectLocation = props.onSelectLocation + capturedOnSelectPoo = props.onSelectPoo + return
+ }, +})) + +// --------------------------------------------------------------------------- +// Mock apiClient — return minimal data so queries resolve +// --------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + default: { + GET: vi.fn(async (path: string) => { + if (path === '/api/locations') { + return { + data: { + items: [ + { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, + }, + ], + limit: 5000, + offset: 0, + }, + } + } + if (path === '/api/poo') { + return { + data: { + items: [ + { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, + }, + ], + limit: 1000, + offset: 0, + }, + } + } + return { data: null } + }), + }, +})) + +// --------------------------------------------------------------------------- +// Now import components under test (after mocks are registered) +// --------------------------------------------------------------------------- + +import { HomePage } from './HomePage' + +// --------------------------------------------------------------------------- +// Test wrapper +// --------------------------------------------------------------------------- + +function makeQC() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function Wrapper({ qc, children }: { qc: QueryClient; children: ReactNode }) { + return ( + + + {children} + + + ) +} + +// Helper: render HomePage and wait for queries to resolve +async function renderHomePage() { + const qc = makeQC() + const utils = render( + + + , + ) + // Wait for the map mock to appear (data loaded) + await waitFor(() => screen.getByTestId('records-map-mock')) + return utils +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HomePage', () => { + beforeEach(() => { + capturedOnSelectLocation = undefined + capturedOnSelectPoo = undefined + }) + + it('renders time-range controls', async () => { + await renderHomePage() + expect(screen.getByTestId('time-start-input')).toBeTruthy() + expect(screen.getByTestId('time-end-input')).toBeTruthy() + expect(screen.getByTestId('apply-window-button')).toBeTruthy() + }) + + it('renders layer toggle switches', async () => { + await renderHomePage() + expect(screen.getByTestId('toggle-location-heat')).toBeTruthy() + expect(screen.getByTestId('toggle-poo-heat')).toBeTruthy() + expect(screen.getByTestId('toggle-scatter')).toBeTruthy() + }) + + it('renders the RecordsMap component', async () => { + await renderHomePage() + expect(screen.getByTestId('records-map-mock')).toBeTruthy() + }) + + it('opens EditLocationModal when onSelectLocation is called with a location record', async () => { + await renderHomePage() + + // Simulate clicking a location scatter point + const record = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, + } + expect(capturedOnSelectLocation).toBeDefined() + capturedOnSelectLocation!(record) + + // EditLocationModal should appear + await waitFor(() => screen.getByTestId('edit-location-modal')) + expect(screen.getByTestId('edit-location-modal')).toBeTruthy() + }) + + it('opens EditPooModal when onSelectPoo is called with a poo record', async () => { + await renderHomePage() + + const record = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, + } + expect(capturedOnSelectPoo).toBeDefined() + capturedOnSelectPoo!(record) + + await waitFor(() => screen.getByTestId('edit-poo-modal')) + expect(screen.getByTestId('edit-poo-modal')).toBeTruthy() + }) + + it('closes EditLocationModal when Cancel is clicked', async () => { + await renderHomePage() + + const record = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, + } + capturedOnSelectLocation!(record) + await waitFor(() => screen.getByTestId('edit-location-modal')) + + fireEvent.click(screen.getByTestId('edit-location-cancel')) + await waitFor(() => expect(screen.queryByTestId('edit-location-modal')).toBeNull()) + }) + + it('closes EditPooModal when Cancel is clicked', async () => { + await renderHomePage() + + const record = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, + } + capturedOnSelectPoo!(record) + await waitFor(() => screen.getByTestId('edit-poo-modal')) + + fireEvent.click(screen.getByTestId('edit-poo-cancel')) + await waitFor(() => expect(screen.queryByTestId('edit-poo-modal')).toBeNull()) + }) + + it('time-range inputs have default values', async () => { + await renderHomePage() + const startInput = screen.getByTestId('time-start-input') as HTMLInputElement + const endInput = screen.getByTestId('time-end-input') as HTMLInputElement + expect(startInput.value).toBeTruthy() + expect(endInput.value).toBeTruthy() + }) + + it('Apply button re-triggers data fetch with new window', async () => { + await renderHomePage() + const startInput = screen.getByTestId('time-start-input') as HTMLInputElement + fireEvent.change(startInput, { target: { value: '2026-01-01T00:00' } }) + fireEvent.click(screen.getByTestId('apply-window-button')) + // Just verify no crash; data refresh happens async via React Query. + await waitFor(() => screen.getByTestId('records-map-mock')) + }) +}) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 8d43740..bfc0e67 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,19 +1,323 @@ /** - * HomePage — placeholder for M2-T09. + * HomePage — data-visualization map view (M2-T09). * - * T09 replaces this with the real home view: Leaflet map, heatmap layer, - * time-range selector, scatter-point layer, and poo overlay. + * Renders a heat map of location records (where you've been) and poo records + * (where the dog poops), plus a toggleable scatter layer for point-select + * edit/delete (reusing T10's modals + hooks). + * + * Data fetching and all state live here; the map itself is fully isolated in + * src/map/RecordsMap.tsx (the ONLY place that imports leaflet). */ -import { Container, Title, Text } from '@mantine/core' +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Stack, + Group, + Switch, + TextInput, + Button, + Paper, + Text, + Box, + Loader, + Alert, + Badge, +} from '@mantine/core' + +import apiClient from '../api/client' +import { + locationsToHeatPoints, + pooToHeatPoints, + locationsToMapPoints, + pooToMapPoints, + filterPooByTimeWindow, + daysAgoISO, + nowISO, +} from '../map' +import { RecordsMap } from '../map' +import { + EditLocationModal, + EditPooModal, + ConfirmDeleteModal, + useDeleteLocation, + useDeletePoo, +} from '../records' +import type { LocationRecord, PooRecord } from '../records' + +// --------------------------------------------------------------------------- +// Data hooks (query-key prefix: ['locations', ...] / ['poo', ...]) +// --------------------------------------------------------------------------- + +function useLocations(start: string | null, end: string | null) { + return useQuery({ + queryKey: ['locations', { start, end, limit: 5000 }], + queryFn: async () => { + const res = await apiClient.GET('/api/locations', { + params: { + query: { + limit: 5000, + offset: 0, + ...(start ? { start } : {}), + ...(end ? { end } : {}), + }, + }, + }) + return res.data?.items ?? [] + }, + }) +} + +/** + * Poo endpoint has no server-side time filter — fetch a large page (max 1000) + * and client-filter by timestamp below. + */ +function usePoo() { + return useQuery({ + queryKey: ['poo', { limit: 1000 }], + queryFn: async () => { + const res = await apiClient.GET('/api/poo', { + params: { query: { limit: 1000, offset: 0 } }, + }) + return res.data?.items ?? [] + }, + }) +} + +// --------------------------------------------------------------------------- +// Point-select state (which record is selected + which modal to show) +// --------------------------------------------------------------------------- + +type SelectionState = + | { kind: 'none' } + | { kind: 'editLocation'; record: LocationRecord } + | { kind: 'deleteLocation'; record: LocationRecord } + | { kind: 'editPoo'; record: PooRecord } + | { kind: 'deletePoo'; record: PooRecord } + +// --------------------------------------------------------------------------- +// HomePage +// --------------------------------------------------------------------------- export function HomePage() { + // ------ Time-window state ----------------------------------------------- + // Default: last 30 days → now + const [startInput, setStartInput] = useState(() => { + const d = new Date() + d.setUTCDate(d.getUTCDate() - 30) + return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM" + }) + const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16)) + // Applied (committed) window — updated on button click + const [appliedStart, setAppliedStart] = useState(() => daysAgoISO(30)) + const [appliedEnd, setAppliedEnd] = useState(() => nowISO()) + + // ------ Layer toggle state ----------------------------------------------- + const [showLocationHeat, setShowLocationHeat] = useState(true) + const [showPooHeat, setShowPooHeat] = useState(true) + const [showScatter, setShowScatter] = useState(false) + + // ------ Data fetching ---------------------------------------------------- + const locationsQuery = useLocations(appliedStart, appliedEnd) + const pooQuery = usePoo() + + // Client-side time-filter for poo (server has no filter) + const filteredPoo = useMemo( + () => filterPooByTimeWindow(pooQuery.data ?? [], appliedStart, appliedEnd), + [pooQuery.data, appliedStart, appliedEnd], + ) + + // Derived map data + const locationHeatPoints = useMemo( + () => locationsToHeatPoints(locationsQuery.data ?? []), + [locationsQuery.data], + ) + const pooHeatPoints = useMemo( + () => pooToHeatPoints(filteredPoo), + [filteredPoo], + ) + const locationScatterPoints = useMemo( + () => locationsToMapPoints(locationsQuery.data ?? []), + [locationsQuery.data], + ) + const pooScatterPoints = useMemo( + () => pooToMapPoints(filteredPoo), + [filteredPoo], + ) + + // ------ Point-select state ----------------------------------------------- + const [selection, setSelection] = useState({ kind: 'none' }) + + const deleteLocationMut = useDeleteLocation() + const deletePooMut = useDeletePoo() + + // Handlers + function handleSelectLocation(record: LocationRecord) { + setSelection({ kind: 'editLocation', record }) + } + function handleSelectPoo(record: PooRecord) { + setSelection({ kind: 'editPoo', record }) + } + + function applyWindow() { + // Convert local datetime-local inputs (which have no TZ) to ISO8601 + // by appending :00Z if needed. Input is "YYYY-MM-DDTHH:MM". + const toISO = (s: string) => (s ? s + ':00Z' : null) + setAppliedStart(toISO(startInput)) + setAppliedEnd(toISO(endInput)) + } + + // ------ Render ----------------------------------------------------------- + const isLoading = locationsQuery.isLoading || pooQuery.isLoading + const isError = locationsQuery.isError || pooQuery.isError + return ( - - Home - - Map / heatmap visualisation — implemented in M2-T09. - - + + {/* Controls bar */} + + + {/* Time-range row */} + + setStartInput(e.currentTarget.value)} + size="xs" + style={{ minWidth: 180 }} + data-testid="time-start-input" + /> + setEndInput(e.currentTarget.value)} + size="xs" + style={{ minWidth: 180 }} + data-testid="time-end-input" + /> + + {isLoading && } + + + {/* Layer toggles row */} + + + Location heat + + {locationsQuery.data?.length ?? 0} + + + } + checked={showLocationHeat} + onChange={(e) => setShowLocationHeat(e.currentTarget.checked)} + size="xs" + data-testid="toggle-location-heat" + /> + + Poo heat + + {filteredPoo.length} + + + } + checked={showPooHeat} + onChange={(e) => setShowPooHeat(e.currentTarget.checked)} + size="xs" + data-testid="toggle-poo-heat" + /> + Scatter (click to edit)} + checked={showScatter} + onChange={(e) => setShowScatter(e.currentTarget.checked)} + size="xs" + data-testid="toggle-scatter" + /> + + + {/* Error banner */} + {isError && ( + + Failed to load data. Check connection and refresh. + + )} + + + + {/* Map fills remaining height */} + + + + + {/* ---------- Point-select modals ---------- */} + + {selection.kind === 'editLocation' && ( + setSelection({ kind: 'none' })} + onSaved={() => setSelection({ kind: 'none' })} + /> + )} + + {selection.kind === 'deleteLocation' && ( + { + await deleteLocationMut.mutateAsync({ + person: selection.record.person, + datetime: selection.record.datetime, + }) + setSelection({ kind: 'none' }) + }} + onCancel={() => setSelection({ kind: 'none' })} + /> + )} + + {selection.kind === 'editPoo' && ( + setSelection({ kind: 'none' })} + onSaved={() => { + // After saving, optionally switch to delete prompt or just close. + setSelection({ kind: 'none' }) + }} + /> + )} + + {selection.kind === 'deletePoo' && ( + { + await deletePooMut.mutateAsync(selection.record.timestamp) + setSelection({ kind: 'none' }) + }} + onCancel={() => setSelection({ kind: 'none' })} + /> + )} + ) }