M2-T09: build data visualization UI (heatmap map as home page)
- self-contained RecordsMap (only module importing leaflet/react-leaflet/ leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props - heatmap layers for location + poo (primary); time-range selector fetches only the window (locations server-filtered; poo client-filtered) - toggleable scatter layer with marker clustering; point-select reuses T10's edit/delete modals + hooks; query-key prefixes refresh map on mutation - pure map logic isolated + unit-tested; leaflet mocked in component tests - responsive layout; typed client only
This commit is contained in:
Generated
+75
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="map-container">{children}</div>
|
||||
),
|
||||
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(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={[]}
|
||||
showScatter={true}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={pooPoints}
|
||||
showScatter={true}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={pooPoints}
|
||||
showScatter={false}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(markerClusterGroupSpy).not.toHaveBeenCalled()
|
||||
expect(fakeAddLayer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes onSelectLocation when a location marker is clicked', () => {
|
||||
const onSelectLocation = vi.fn()
|
||||
|
||||
render(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={[]}
|
||||
showScatter={true}
|
||||
onSelectLocation={onSelectLocation}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={[]}
|
||||
pooScatterPoints={pooPoints}
|
||||
showScatter={true}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={onSelectPoo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(markerClickHandlers.length).toBeGreaterThan(0)
|
||||
markerClickHandlers[0]()
|
||||
expect(onSelectPoo).toHaveBeenCalledOnce()
|
||||
expect(onSelectPoo).toHaveBeenCalledWith(pooRecord)
|
||||
})
|
||||
})
|
||||
@@ -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<HeatLayer | null>(null)
|
||||
const pooLayerRef = useRef<HeatLayer | null>(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: '<div style="font-size:20px;line-height:1;">💩</div>',
|
||||
className: '',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
})
|
||||
|
||||
export function ScatterLayer({
|
||||
locationScatterPoints,
|
||||
pooScatterPoints,
|
||||
showScatter,
|
||||
onSelectLocation,
|
||||
onSelectPoo,
|
||||
}: ScatterLayerChildProps) {
|
||||
const map = useMap()
|
||||
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(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}<br/>${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}<br/>${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 (
|
||||
<MapContainer
|
||||
center={DEFAULT_CENTER}
|
||||
zoom={DEFAULT_ZOOM}
|
||||
style={{ height, width: '100%' }}
|
||||
data-testid="records-map"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<HeatLayers
|
||||
locationHeatPoints={locationHeatPoints}
|
||||
pooHeatPoints={pooHeatPoints}
|
||||
showLocationHeat={showLocationHeat}
|
||||
showPooHeat={showPooHeat}
|
||||
/>
|
||||
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationScatterPoints}
|
||||
pooScatterPoints={pooScatterPoints}
|
||||
showScatter={showScatter}
|
||||
onSelectLocation={onSelectLocation}
|
||||
onSelectPoo={onSelectPoo}
|
||||
/>
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
Vendored
+40
@@ -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<number, string>
|
||||
}
|
||||
|
||||
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 _
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<div data-testid="records-map">{children}</div>
|
||||
),
|
||||
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 <div data-testid="records-map-mock" />
|
||||
},
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper: render HomePage and wait for queries to resolve
|
||||
async function renderHomePage() {
|
||||
const qc = makeQC()
|
||||
const utils = render(
|
||||
<Wrapper qc={qc}>
|
||||
<HomePage />
|
||||
</Wrapper>,
|
||||
)
|
||||
// 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'))
|
||||
})
|
||||
})
|
||||
+314
-10
@@ -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<string | null>(() => daysAgoISO(30))
|
||||
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => 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<SelectionState>({ 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 (
|
||||
<Container pt="xl">
|
||||
<Title order={2}>Home</Title>
|
||||
<Text c="dimmed" mt="sm">
|
||||
Map / heatmap visualisation — implemented in M2-T09.
|
||||
</Text>
|
||||
</Container>
|
||||
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Controls bar */}
|
||||
<Paper
|
||||
shadow="xs"
|
||||
p="xs"
|
||||
style={{ zIndex: 1000, flexShrink: 0 }}
|
||||
data-testid="map-controls"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{/* Time-range row */}
|
||||
<Group gap="xs" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="From"
|
||||
type="datetime-local"
|
||||
value={startInput}
|
||||
onChange={(e) => setStartInput(e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ minWidth: 180 }}
|
||||
data-testid="time-start-input"
|
||||
/>
|
||||
<TextInput
|
||||
label="To"
|
||||
type="datetime-local"
|
||||
value={endInput}
|
||||
onChange={(e) => setEndInput(e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ minWidth: 180 }}
|
||||
data-testid="time-end-input"
|
||||
/>
|
||||
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
|
||||
Apply
|
||||
</Button>
|
||||
{isLoading && <Loader size="xs" />}
|
||||
</Group>
|
||||
|
||||
{/* Layer toggles row */}
|
||||
<Group gap="md" wrap="wrap">
|
||||
<Switch
|
||||
label={
|
||||
<Group gap={4}>
|
||||
<Text size="xs">Location heat</Text>
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
{locationsQuery.data?.length ?? 0}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
checked={showLocationHeat}
|
||||
onChange={(e) => setShowLocationHeat(e.currentTarget.checked)}
|
||||
size="xs"
|
||||
data-testid="toggle-location-heat"
|
||||
/>
|
||||
<Switch
|
||||
label={
|
||||
<Group gap={4}>
|
||||
<Text size="xs">Poo heat</Text>
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
{filteredPoo.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
checked={showPooHeat}
|
||||
onChange={(e) => setShowPooHeat(e.currentTarget.checked)}
|
||||
size="xs"
|
||||
data-testid="toggle-poo-heat"
|
||||
/>
|
||||
<Switch
|
||||
label={<Text size="xs">Scatter (click to edit)</Text>}
|
||||
checked={showScatter}
|
||||
onChange={(e) => setShowScatter(e.currentTarget.checked)}
|
||||
size="xs"
|
||||
data-testid="toggle-scatter"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Error banner */}
|
||||
{isError && (
|
||||
<Alert color="red" data-testid="map-error-alert">
|
||||
Failed to load data. Check connection and refresh.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Map fills remaining height */}
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<RecordsMap
|
||||
locationHeatPoints={locationHeatPoints}
|
||||
pooHeatPoints={pooHeatPoints}
|
||||
locationScatterPoints={locationScatterPoints}
|
||||
pooScatterPoints={pooScatterPoints}
|
||||
showLocationHeat={showLocationHeat}
|
||||
showPooHeat={showPooHeat}
|
||||
showScatter={showScatter}
|
||||
onSelectLocation={handleSelectLocation}
|
||||
onSelectPoo={handleSelectPoo}
|
||||
height="100%"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ---------- Point-select modals ---------- */}
|
||||
|
||||
{selection.kind === 'editLocation' && (
|
||||
<EditLocationModal
|
||||
record={selection.record}
|
||||
onClose={() => setSelection({ kind: 'none' })}
|
||||
onSaved={() => setSelection({ kind: 'none' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection.kind === 'deleteLocation' && (
|
||||
<ConfirmDeleteModal
|
||||
message={`Delete location record for ${selection.record.person} at ${selection.record.datetime}?`}
|
||||
loading={deleteLocationMut.isPending}
|
||||
onConfirm={async () => {
|
||||
await deleteLocationMut.mutateAsync({
|
||||
person: selection.record.person,
|
||||
datetime: selection.record.datetime,
|
||||
})
|
||||
setSelection({ kind: 'none' })
|
||||
}}
|
||||
onCancel={() => setSelection({ kind: 'none' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection.kind === 'editPoo' && (
|
||||
<EditPooModal
|
||||
record={selection.record}
|
||||
onClose={() => setSelection({ kind: 'none' })}
|
||||
onSaved={() => {
|
||||
// After saving, optionally switch to delete prompt or just close.
|
||||
setSelection({ kind: 'none' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection.kind === 'deletePoo' && (
|
||||
<ConfirmDeleteModal
|
||||
message={`Delete poo record at ${selection.record.timestamp}?`}
|
||||
loading={deletePooMut.isPending}
|
||||
onConfirm={async () => {
|
||||
await deletePooMut.mutateAsync(selection.record.timestamp)
|
||||
setSelection({ kind: 'none' })
|
||||
}}
|
||||
onCancel={() => setSelection({ kind: 'none' })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user