Feature/m2 frontend v2 #8
Generated
+75
@@ -11,9 +11,15 @@
|
|||||||
"@mantine/core": "^7.17.8",
|
"@mantine/core": "^7.17.8",
|
||||||
"@mantine/hooks": "^7.17.8",
|
"@mantine/hooks": "^7.17.8",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@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",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1338,6 +1344,17 @@
|
|||||||
"react": "^18.x || ^19.x"
|
"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": {
|
"node_modules/@redocly/ajv": {
|
||||||
"version": "8.11.2",
|
"version": "8.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||||
@@ -2053,6 +2070,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -2060,6 +2083,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -4951,6 +4992,26 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -5634,6 +5695,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-number-format": {
|
||||||
"version": "5.4.5",
|
"version": "5.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
|
"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/core": "^7.17.8",
|
||||||
"@mantine/hooks": "^7.17.8",
|
"@mantine/hooks": "^7.17.8",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@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",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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,
|
* Renders a heat map of location records (where you've been) and poo records
|
||||||
* time-range selector, scatter-point layer, and poo overlay.
|
* (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() {
|
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 (
|
return (
|
||||||
<Container pt="xl">
|
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Title order={2}>Home</Title>
|
{/* Controls bar */}
|
||||||
<Text c="dimmed" mt="sm">
|
<Paper
|
||||||
Map / heatmap visualisation — implemented in M2-T09.
|
shadow="xs"
|
||||||
</Text>
|
p="xs"
|
||||||
</Container>
|
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