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' })}
+ />
+ )}
+
)
}