diff --git a/README.md b/README.md
index 908704a..16e5510 100644
--- a/README.md
+++ b/README.md
@@ -338,18 +338,20 @@ python scripts/export_openapi.py
当前 Compose 分成两层:
-- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取
-- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
+- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
+- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
+ 容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
-本地开发启动方式:
+本地开发启动方式(显式叠加 dev 层):
```bash
-docker compose up -d --build
+docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
-上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。
+dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
+因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
-如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件:
+如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
```bash
docker compose -f docker-compose.yml pull
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..468a39d
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,28 @@
+# Local dev override — use explicitly:
+# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
+# Isolated from the production stack so both can run on this host at once:
+# - distinct compose project name (separate network/grouping)
+# - distinct container names (-dev suffix; Docker rejects duplicate names)
+# - distinct image tag (local build doesn't clobber the prod :latest tag)
+name: home-automation-dev
+
+services:
+ migration:
+ build: .
+ image: home-automation:dev
+ container_name: home-automation-migration-dev
+ environment:
+ # In-container path for the mounted ./data volume (./data -> /app/data).
+ # Overrides the host-absolute APP_DATABASE_URL in .env for local compose runs.
+ APP_DATABASE_URL: "sqlite:////app/data/app.db"
+
+ app:
+ build: .
+ image: home-automation:dev
+ container_name: home-automation-app-dev
+ # Publish on 8001 for dev. `!override` REPLACES the base ports list instead of
+ # appending to it, so the dev stack does NOT also bind the production 8881.
+ ports: !override
+ - "127.0.0.1:8001:8000"
+ environment:
+ APP_DATABASE_URL: "sqlite:////app/data/app.db"
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
deleted file mode 100644
index 78f2dd7..0000000
--- a/docker-compose.override.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-services:
- migration:
- build: .
-
- app:
- build: .
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index adbff18..6aa9396 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -19,6 +19,7 @@
"openapi-fetch": "^0.17.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-feather": "^2.0.10",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4"
},
@@ -5197,7 +5198,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5638,7 +5638,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5650,7 +5649,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/punycode": {
@@ -5688,6 +5686,18 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-feather": {
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz",
+ "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.7.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.6"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ec52254..25731b0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,6 +24,7 @@
"openapi-fetch": "^0.17.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-feather": "^2.0.10",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4"
},
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2a3a395..16e195f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -13,10 +13,17 @@
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
*/
-import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
-import { Button, Group } from '@mantine/core'
+import {
+ MantineProvider,
+ Group,
+ ActionIcon,
+ Tooltip,
+ useMantineColorScheme,
+ useComputedColorScheme,
+} from '@mantine/core'
+import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
// Mantine requires its CSS to be imported once.
import '@mantine/core/styles.css'
@@ -70,9 +77,40 @@ function LogoutButton() {
}
return (
-
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Dark-mode toggle (sits next to the gear / settings icon)
+// ---------------------------------------------------------------------------
+
+function ColorSchemeToggle() {
+ const { setColorScheme } = useMantineColorScheme()
+ const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
+ const isDark = computed === 'dark'
+ return (
+
+ setColorScheme(isDark ? 'light' : 'dark')}
+ data-testid="color-scheme-toggle"
+ >
+ {isDark ? : }
+
+
)
}
@@ -90,7 +128,7 @@ function AppLayout() {
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.5rem 1rem',
- borderBottom: '1px solid #eee',
+ borderBottom: '1px solid var(--mantine-color-default-border)',
}}
>
@@ -99,22 +137,31 @@ function AppLayout() {
{/* Records nav link */}
-
- Records
-
- {/* Gear icon nav slot — links to config page (§5#10) */}
-
- ⚙
-
+
+
+
+
+
+ {/* Dark-mode toggle — directly beside the settings gear */}
+
+ {/* Settings — links to config page (§5#10) */}
+
+
+
+
+
@@ -133,7 +180,7 @@ function AppLayout() {
export default function App() {
return (
-
+
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
new file mode 100644
index 0000000..419ad93
--- /dev/null
+++ b/frontend/src/api/client.test.ts
@@ -0,0 +1,62 @@
+/**
+ * csrfMiddleware 401-handling regression tests.
+ *
+ * Bug: clicking Logout (or landing on /login) flooded GET /api/session with 401s
+ * and the page hung instead of returning to the login screen.
+ *
+ * Root cause: the middleware redirected on EVERY 401, including the session
+ * probe's own 401. The redirect invalidated the ['session'] query, which
+ * refetched GET /api/session, which 401'd, which redirected again → an infinite
+ * refetch loop. These tests pin the fix: the session probe and the login
+ * endpoint own their 401s (no redirect); any other endpoint's 401 still
+ * redirects (session expired mid-use).
+ *
+ * We call onResponse() directly (rather than going through apiClient.GET) so the
+ * test exercises the exact 401 branch without the singleton's relative baseUrl,
+ * which has no absolute origin to resolve against under jsdom.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { Middleware } from 'openapi-fetch'
+import { csrfMiddleware, registerLoginRedirect } from './client'
+
+type OnResponse = NonNullable
+type OnResponseParams = Parameters[0]
+
+/** Build the minimal onResponse params for the given schema path + response. */
+function params(schemaPath: string, response: Response): OnResponseParams {
+ return { schemaPath, response, request: new Request('http://test.local' + schemaPath) } as OnResponseParams
+}
+
+function response401(): Response {
+ return new Response(JSON.stringify({ detail: 'unauthorized' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+const onResponse = csrfMiddleware.onResponse as OnResponse
+
+describe('csrfMiddleware 401 redirect (session-flood regression)', () => {
+ const redirect = vi.fn()
+
+ beforeEach(() => {
+ redirect.mockReset()
+ registerLoginRedirect(redirect)
+ })
+
+ it('does NOT redirect when GET /api/session returns 401 (probe owns its 401)', async () => {
+ await onResponse(params('/api/session', response401()))
+ expect(redirect).not.toHaveBeenCalled()
+ })
+
+ it('does NOT redirect when POST /api/auth/login returns 401 (bad credentials)', async () => {
+ await onResponse(params('/api/auth/login', response401()))
+ expect(redirect).not.toHaveBeenCalled()
+ })
+
+ it('redirects when a normal endpoint returns 401 (session expired mid-use)', async () => {
+ await onResponse(params('/api/locations', response401()))
+ expect(redirect).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 574117f..f018b29 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -51,7 +51,21 @@ export function registerLoginRedirect(fn: () => void): void {
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const LOGIN_PATH = '/api/auth/login'
-const csrfMiddleware: Middleware = {
+/**
+ * Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT
+ * trigger the global login redirect:
+ * - GET /api/session — the session probe; 401 means "not logged in", handled
+ * by SessionProvider's queryFn (returns null → unauthenticated state).
+ * - POST /api/auth/login — bad-credentials check; 401 handled by LoginPage.
+ *
+ * Redirecting on these would invalidate the session query, which refetches
+ * /api/session, which 401s, which redirects again → an infinite loop that
+ * floods GET /api/session after logout and on the login page.
+ */
+const SESSION_PATH = '/api/session'
+const NO_REDIRECT_ON_401 = new Set([SESSION_PATH, LOGIN_PATH])
+
+export const csrfMiddleware: Middleware = {
async onRequest({ request }) {
// Always include cookies (same-origin; explicit for clarity)
// Note: credentials is set at client level; this is belt-and-suspenders doc.
@@ -69,11 +83,13 @@ const csrfMiddleware: Middleware = {
return request
},
- async onResponse({ response }) {
+ async onResponse({ schemaPath, response }) {
if (response.status === 401) {
- // Clear any cached session state by triggering a page navigation.
- // The SessionProvider query will refetch and find no session.
- if (_navigateToLogin) {
+ // The session probe and the login endpoint own their 401s (see
+ // NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session
+ // expired mid-use → redirect to /login. Crucially, NOT redirecting on the
+ // session probe breaks the refetch→401→redirect→refetch flood loop.
+ if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) {
_navigateToLogin()
}
// Return the original response so callers can handle 401 if needed.
diff --git a/frontend/src/map/RecordsMap.heat.test.tsx b/frontend/src/map/RecordsMap.heat.test.tsx
new file mode 100644
index 0000000..b6d6755
--- /dev/null
+++ b/frontend/src/map/RecordsMap.heat.test.tsx
@@ -0,0 +1,118 @@
+/**
+ * HeatLayers regression test — post-walkthrough fix.
+ *
+ * Bug: the heat layer's `setLatLngs` was called BEFORE the layer was added to the
+ * map. A leaflet.heat layer that is not on a map has a null `_map`, and
+ * `setLatLngs -> redraw` dereferences `_map._animating`, throwing
+ * "Cannot read properties of null (reading '_animating')" and white-screening
+ * the whole SPA right after login.
+ *
+ * This test exercises the REAL HeatLayers code path (not a wholesale RecordsMap
+ * mock) and asserts the layer is added to the map BEFORE setLatLngs is called.
+ * Against the old code (setLatLngs first), the ordering assertion fails.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render } from '@testing-library/react'
+
+const { callLog, setLatLngsSpy, mapAddLayerSpy } = vi.hoisted(() => {
+ const callLog: string[] = []
+ const setLatLngsSpy = vi.fn((_pts: unknown) => {
+ callLog.push('setLatLngs')
+ })
+ const mapAddLayerSpy = vi.fn((_layer: unknown) => {
+ callLog.push('addLayer')
+ })
+ return { callLog, setLatLngsSpy, mapAddLayerSpy }
+})
+
+// Mock leaflet. heatLayer returns a fake layer whose setLatLngs logs call order;
+// Icon/DivIcon/marker exist because RecordsMap.tsx runs icon setup at module load.
+vi.mock('leaflet', () => {
+ 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: setLatLngsSpy, setOptions: vi.fn(), addTo: vi.fn() })),
+ markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), clearLayers: vi.fn() })),
+ marker: vi.fn(() => ({ bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() })),
+ default: {},
+ }
+})
+
+vi.mock('leaflet.heat', () => ({}))
+vi.mock('leaflet.markercluster', () => ({}))
+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' }))
+vi.mock('leaflet/dist/leaflet.css', () => ({}))
+vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
+vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
+
+// useMap returns a fake map; hasLayer=false so addLayer is exercised.
+vi.mock('react-leaflet', () => ({
+ MapContainer: ({ children }: { children: React.ReactNode }) => {children}
,
+ TileLayer: () => null,
+ useMap: () => ({
+ addLayer: mapAddLayerSpy,
+ removeLayer: vi.fn(),
+ hasLayer: () => false,
+ getSize: () => ({ x: 800, y: 600 }),
+ latLngToContainerPoint: () => ({ x: 100, y: 100 }),
+ on: vi.fn(),
+ off: vi.fn(),
+ }),
+}))
+
+import { HeatLayers } from './RecordsMap'
+import type { HeatPoint } from './mapUtils'
+
+const heatPoints: HeatPoint[] = [
+ [39.9, 116.4, 1],
+ [39.91, 116.41, 1],
+]
+
+describe('HeatLayers (real code path — regression for null _map crash)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ callLog.length = 0
+ })
+
+ it('adds the heat layer to the map BEFORE calling setLatLngs', () => {
+ render(
+ ,
+ )
+
+ // Data was applied...
+ expect(setLatLngsSpy).toHaveBeenCalledWith(heatPoints)
+ // ...and the layer was added to the map first. The old buggy order
+ // (setLatLngs before addLayer) makes this fail.
+ expect(callLog).toEqual(['addLayer', 'setLatLngs'])
+ expect(callLog.indexOf('addLayer')).toBeLessThan(callLog.indexOf('setLatLngs'))
+ })
+
+ it('does not call setLatLngs while the layer is hidden (off the map)', () => {
+ render(
+ ,
+ )
+
+ // Hidden layers are never on the map, so setLatLngs must not run on them.
+ expect(setLatLngsSpy).not.toHaveBeenCalled()
+ expect(mapAddLayerSpy).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/map/RecordsMap.tsx b/frontend/src/map/RecordsMap.tsx
index 93dd550..26343f0 100644
--- a/frontend/src/map/RecordsMap.tsx
+++ b/frontend/src/map/RecordsMap.tsx
@@ -25,6 +25,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import 'leaflet.heat'
import 'leaflet.markercluster'
+import { peakGridCount } from './mapUtils'
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'
@@ -60,6 +61,21 @@ export interface RecordsMapProps {
/** Map container height (CSS value). Default: '100%'. */
height?: string
+
+ /** Use dark base tiles to match the app's dark color scheme. */
+ dark?: boolean
+}
+
+// OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key.
+const LIGHT_TILES = {
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution:
+ '© OpenStreetMap contributors',
+}
+const DARK_TILES = {
+ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
+ attribution:
+ '© OpenStreetMap contributors © CARTO',
}
// ---------------------------------------------------------------------------
@@ -73,7 +89,34 @@ interface HeatLayerChildProps {
showPooHeat: boolean
}
-function HeatLayers({
+// Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1
+// at every zoom, so accumulated per-cell intensity equals the raw point count —
+// which lets us normalize with a pixel-grid count below.
+const LOC_HEAT = { radius: 20, blur: 15 }
+const POO_HEAT = { radius: 25, blur: 18 }
+
+/**
+ * leaflet.heat `max` (normalization denominator) for the CURRENT viewport:
+ * project the points that are visible (within the map size + a radius margin) to
+ * container pixels, then count the densest pixel cell using leaflet.heat's own
+ * grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot
+ * color; recomputing on every zoom/pan keeps it normalized to what's on screen.
+ */
+function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number {
+ if (points.length === 0) return 1
+ const cell = (radius + blur) / 2
+ const size = map.getSize()
+ const margin = radius + blur
+ const coords: Array<[number, number]> = []
+ for (let i = 0; i < points.length; i++) {
+ const p = map.latLngToContainerPoint([points[i][0], points[i][1]])
+ if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue
+ coords.push([p.x, p.y])
+ }
+ return peakGridCount(coords, cell)
+}
+
+export function HeatLayers({
locationHeatPoints,
pooHeatPoints,
showLocationHeat,
@@ -83,20 +126,36 @@ function HeatLayers({
const locationLayerRef = useRef(null)
const pooLayerRef = useRef(null)
+ // Latest data/visibility in refs so the once-registered map move/zoom handler
+ // re-normalizes against the current points without re-subscribing.
+ const locPointsRef = useRef(locationHeatPoints)
+ const pooPointsRef = useRef(pooHeatPoints)
+ const showLocRef = useRef(showLocationHeat)
+ const showPooRef = useRef(showPooHeat)
+ useEffect(() => {
+ locPointsRef.current = locationHeatPoints
+ pooPointsRef.current = pooHeatPoints
+ showLocRef.current = showLocationHeat
+ showPooRef.current = showPooHeat
+ })
+
// Location heat layer
useEffect(() => {
if (!locationLayerRef.current) {
locationLayerRef.current = leafletHeatLayer([], {
- radius: 20,
- blur: 15,
- maxZoom: 17,
+ ...LOC_HEAT,
+ maxZoom: 0,
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
})
}
const layer = locationLayerRef.current
- layer.setLatLngs(locationHeatPoints)
if (showLocationHeat) {
+ // Add the layer to the map BEFORE setLatLngs. A heat layer that is not on
+ // a map has a null `_map`, and `setLatLngs -> redraw` dereferences
+ // `_map._animating`, which throws and white-screens the SPA.
if (!map.hasLayer(layer)) map.addLayer(layer)
+ layer.setLatLngs(locationHeatPoints)
+ layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
} else {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
@@ -109,16 +168,19 @@ function HeatLayers({
useEffect(() => {
if (!pooLayerRef.current) {
pooLayerRef.current = leafletHeatLayer([], {
- radius: 25,
- blur: 18,
- maxZoom: 17,
- gradient: { 0.4: 'yellow', 0.65: 'orange', 1: '#8B4513' },
+ ...POO_HEAT,
+ maxZoom: 0,
+ // High-frequency poo spots reach red (per request); mid tones stay
+ // yellow/orange to distinguish from the location layer.
+ gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' },
})
}
const layer = pooLayerRef.current
- layer.setLatLngs(pooHeatPoints)
if (showPooHeat) {
+ // Add to the map before setLatLngs (see the location heat layer above).
if (!map.hasLayer(layer)) map.addLayer(layer)
+ layer.setLatLngs(pooHeatPoints)
+ layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
} else {
if (map.hasLayer(layer)) map.removeLayer(layer)
}
@@ -127,6 +189,26 @@ function HeatLayers({
}
}, [map, pooHeatPoints, showPooHeat])
+ // Re-normalize each visible layer to the viewport peak on pan/zoom.
+ useEffect(() => {
+ const recompute = () => {
+ const loc = locationLayerRef.current
+ if (loc && showLocRef.current && map.hasLayer(loc)) {
+ loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) })
+ }
+ const poo = pooLayerRef.current
+ if (poo && showPooRef.current && map.hasLayer(poo)) {
+ poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) })
+ }
+ }
+ map.on('moveend', recompute)
+ map.on('zoomend', recompute)
+ return () => {
+ map.off('moveend', recompute)
+ map.off('zoomend', recompute)
+ }
+ }, [map])
+
return null
}
@@ -231,18 +313,18 @@ export function RecordsMap({
onSelectLocation,
onSelectPoo,
height = '100%',
+ dark = false,
}: RecordsMapProps) {
+ const tiles = dark ? DARK_TILES : LIGHT_TILES
return (
-
+ {/* key forces a clean tile-layer swap when the color scheme changes */}
+
{
+ it('returns 1 for empty input (no divide-by-zero)', () => {
+ expect(peakGridCount([], 10)).toBe(1)
+ })
+
+ it('counts coords sharing a grid cell and returns the peak', () => {
+ const coords: Array<[number, number]> = [
+ [0, 0],
+ [3, 4], // same 10px cell as [0,0]
+ [9, 9], // same 10px cell
+ [100, 100], // different cell
+ ]
+ expect(peakGridCount(coords, 10)).toBe(3)
+ })
+
+ it('separates coords into different cells by cellSize', () => {
+ const coords: Array<[number, number]> = [
+ [0, 0],
+ [10, 0], // next cell over at cellSize 10
+ [20, 0], // next again
+ ]
+ expect(peakGridCount(coords, 10)).toBe(1)
+ })
+
+ it('a denser cluster yields a larger peak (drives per-layer normalization)', () => {
+ const dense: Array<[number, number]> = Array.from({ length: 12 }, () => [5, 5] as [number, number])
+ const sparse: Array<[number, number]> = [
+ [5, 5],
+ [5, 5],
+ ]
+ expect(peakGridCount(dense, 10)).toBe(12)
+ expect(peakGridCount(sparse, 10)).toBe(2)
+ })
+})
diff --git a/frontend/src/map/index.ts b/frontend/src/map/index.ts
index 1fdb590..1b01953 100644
--- a/frontend/src/map/index.ts
+++ b/frontend/src/map/index.ts
@@ -14,5 +14,8 @@ export {
daysAgoISO,
nowISO,
computeCenter,
+ TIME_PRESETS,
+ presetRange,
+ shiftRange,
} from './mapUtils'
-export type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
+export type { HeatPoint, LocationMapPoint, PooMapPoint, TimePreset } from './mapUtils'
diff --git a/frontend/src/map/mapUtils.ts b/frontend/src/map/mapUtils.ts
index 474b877..5fe256d 100644
--- a/frontend/src/map/mapUtils.ts
+++ b/frontend/src/map/mapUtils.ts
@@ -40,6 +40,31 @@ export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] {
return records.map((r) => [r.latitude, r.longitude, 1])
}
+/**
+ * Peak number of 2D coordinates that fall into the same `cellSize`-sized grid
+ * cell. Pure + leaflet-free so it is unit-testable.
+ *
+ * Used by the map heat normalization: project the VISIBLE points to screen
+ * pixels (in the map component), then this returns the densest pixel cell's
+ * count, which becomes leaflet.heat's `max`. With maxZoom:0 (intensity factor
+ * f=1) the accumulated per-cell value equals this count, so the densest visible
+ * cluster maps to the hot color — recomputed on every zoom/pan so it always
+ * normalizes within the current viewport. Returns at least 1.
+ */
+export function peakGridCount(coords: Array<[number, number]>, cellSize: number): number {
+ if (coords.length === 0) return 1
+ const g = Math.max(1, cellSize)
+ const counts = new Map()
+ let peak = 1
+ for (const [x, y] of coords) {
+ const key = `${Math.floor(x / g)}:${Math.floor(y / g)}`
+ const next = (counts.get(key) ?? 0) + 1
+ counts.set(key, next)
+ if (next > peak) peak = next
+ }
+ return peak
+}
+
/**
* Convert location records to map points (for scatter layer).
*/
@@ -102,3 +127,58 @@ export function computeCenter(
const sumLng = points.reduce((s, p) => s + p.lng, 0)
return [sumLat / points.length, sumLng / points.length]
}
+
+// ---------------------------------------------------------------------------
+// Quick time-range presets + window shifting (Grafana-style)
+// ---------------------------------------------------------------------------
+
+const HOUR_MS = 3_600_000
+const DAY_MS = 24 * HOUR_MS
+
+/** A quick-range preset: a label + a span in milliseconds (month/year approximated). */
+export interface TimePreset {
+ value: string
+ label: string
+ spanMs: number
+}
+
+export const TIME_PRESETS: TimePreset[] = [
+ { value: '24h', label: 'Past 24 hours', spanMs: 24 * HOUR_MS },
+ { value: '1w', label: 'Past 1 week', spanMs: 7 * DAY_MS },
+ { value: '2w', label: 'Past 2 weeks', spanMs: 14 * DAY_MS },
+ { value: '1mo', label: 'Past 1 month', spanMs: 30 * DAY_MS },
+ { value: '6mo', label: 'Past 6 months', spanMs: 182 * DAY_MS },
+ { value: '1y', label: 'Past 1 year', spanMs: 365 * DAY_MS },
+ { value: '5y', label: 'Past 5 years', spanMs: 5 * 365 * DAY_MS },
+]
+
+/** ISO8601 with second precision, no milliseconds: "YYYY-MM-DDTHH:MM:SSZ". */
+function isoSeconds(d: Date): string {
+ return d.toISOString().slice(0, 19) + 'Z'
+}
+
+/**
+ * Compute a [start, end] window of width `spanMs` ending at `now`.
+ * Used when the user picks a quick-range preset.
+ */
+export function presetRange(spanMs: number, now: Date = new Date()): { start: string; end: string } {
+ return { start: isoSeconds(new Date(now.getTime() - spanMs)), end: isoSeconds(now) }
+}
+
+/**
+ * Shift a [start, end] window by its OWN span. direction = -1 moves earlier
+ * (back in time), +1 moves later. The window width is preserved.
+ */
+export function shiftRange(
+ startISO: string,
+ endISO: string,
+ direction: -1 | 1,
+): { start: string; end: string } {
+ const startMs = Date.parse(startISO)
+ const endMs = Date.parse(endISO)
+ const span = endMs - startMs
+ return {
+ start: isoSeconds(new Date(startMs + direction * span)),
+ end: isoSeconds(new Date(endMs + direction * span)),
+ }
+}
diff --git a/frontend/src/map/timeRange.test.ts b/frontend/src/map/timeRange.test.ts
new file mode 100644
index 0000000..7fd3552
--- /dev/null
+++ b/frontend/src/map/timeRange.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Tests for the quick-range preset + window-shift helpers (Grafana-style).
+ */
+
+import { describe, it, expect } from 'vitest'
+import { TIME_PRESETS, presetRange, shiftRange } from './mapUtils'
+
+describe('TIME_PRESETS', () => {
+ it('exposes the 7 expected quick ranges in order', () => {
+ expect(TIME_PRESETS.map((p) => p.value)).toEqual([
+ '24h',
+ '1w',
+ '2w',
+ '1mo',
+ '6mo',
+ '1y',
+ '5y',
+ ])
+ })
+})
+
+describe('presetRange', () => {
+ const now = new Date('2026-06-13T12:00:00Z')
+
+ it('ends at now and spans the given duration (24h)', () => {
+ const { start, end } = presetRange(24 * 3_600_000, now)
+ expect(end).toBe('2026-06-13T12:00:00Z')
+ expect(start).toBe('2026-06-12T12:00:00Z')
+ })
+
+ it('spans a week', () => {
+ const { start, end } = presetRange(7 * 24 * 3_600_000, now)
+ expect(end).toBe('2026-06-13T12:00:00Z')
+ expect(start).toBe('2026-06-06T12:00:00Z')
+ })
+
+ it('emits second-precision ISO with no milliseconds', () => {
+ const { start, end } = presetRange(3_600_000, now)
+ expect(start).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
+ expect(end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
+ })
+})
+
+describe('shiftRange', () => {
+ it('moves a 24h window back by 24h when direction = -1', () => {
+ const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', -1)
+ expect(start).toBe('2026-06-11T12:00:00Z')
+ expect(end).toBe('2026-06-12T12:00:00Z')
+ })
+
+ it('moves a 24h window forward by 24h when direction = +1', () => {
+ const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', 1)
+ expect(start).toBe('2026-06-13T12:00:00Z')
+ expect(end).toBe('2026-06-14T12:00:00Z')
+ })
+
+ it('shifts by the window OWN span (a 1-week window moves a week)', () => {
+ const { start, end } = shiftRange('2026-06-06T12:00:00Z', '2026-06-13T12:00:00Z', -1)
+ expect(start).toBe('2026-05-30T12:00:00Z')
+ expect(end).toBe('2026-06-06T12:00:00Z')
+ })
+
+ it('is reversible: shift back then forward returns to the original window', () => {
+ const orig = { start: '2026-06-06T12:00:00Z', end: '2026-06-13T12:00:00Z' }
+ const back = shiftRange(orig.start, orig.end, -1)
+ const fwd = shiftRange(back.start, back.end, 1)
+ expect(fwd).toEqual(orig)
+ })
+})
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index bfc0e67..8f20e8b 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -17,13 +17,18 @@ import {
Switch,
TextInput,
Button,
+ Select,
+ ActionIcon,
+ Tooltip,
Paper,
Text,
Box,
Loader,
Alert,
Badge,
+ useComputedColorScheme,
} from '@mantine/core'
+import { ChevronLeft, ChevronRight } from 'react-feather'
import apiClient from '../api/client'
import {
@@ -34,6 +39,9 @@ import {
filterPooByTimeWindow,
daysAgoISO,
nowISO,
+ TIME_PRESETS,
+ presetRange,
+ shiftRange,
} from '../map'
import { RecordsMap } from '../map'
import {
@@ -108,9 +116,37 @@ export function HomePage() {
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
+ // Applied (committed) window — updated on Apply / preset / shift
const [appliedStart, setAppliedStart] = useState(() => daysAgoISO(30))
const [appliedEnd, setAppliedEnd] = useState(() => nowISO())
+ // Which quick-range preset is currently active (null = custom / shifted range)
+ const [activePreset, setActivePreset] = useState(null)
+
+ // Set both the committed window and the editable inputs from an ISO [start, end].
+ function setWindow(startISO: string, endISO: string) {
+ setAppliedStart(startISO)
+ setAppliedEnd(endISO)
+ setStartInput(startISO.slice(0, 16))
+ setEndInput(endISO.slice(0, 16))
+ }
+
+ // Pick a quick range: fill from-to ending at now, apply immediately (Grafana-style).
+ function applyPreset(value: string | null) {
+ const preset = TIME_PRESETS.find((p) => p.value === value)
+ if (!preset) return
+ const { start, end } = presetRange(preset.spanMs)
+ setWindow(start, end)
+ setActivePreset(value)
+ }
+
+ // Shift the committed window by its own span. -1 = earlier, +1 = later.
+ function shiftWindow(direction: -1 | 1) {
+ if (!appliedStart || !appliedEnd) return
+ const { start, end } = shiftRange(appliedStart, appliedEnd, direction)
+ setWindow(start, end)
+ // A shifted window is an absolute range, no longer "now - X".
+ setActivePreset(null)
+ }
// ------ Layer toggle state -----------------------------------------------
const [showLocationHeat, setShowLocationHeat] = useState(true)
@@ -165,11 +201,14 @@ export function HomePage() {
const toISO = (s: string) => (s ? s + ':00Z' : null)
setAppliedStart(toISO(startInput))
setAppliedEnd(toISO(endInput))
+ // Manually-applied range is custom, not a preset.
+ setActivePreset(null)
}
// ------ Render -----------------------------------------------------------
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
const isError = locationsQuery.isError || pooQuery.isError
+ const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
return (
@@ -201,6 +240,46 @@ export function HomePage() {
style={{ minWidth: 180 }}
data-testid="time-end-input"
/>
+ {/* Quick range + shift buttons (Grafana-style) — between To and Apply.
+ zIndex raised above Leaflet (~1000) so the dropdown/tooltips are
+ not painted over by the map below. */}
+
+
+
@@ -255,8 +334,11 @@ export function HomePage() {
- {/* Map fills remaining height */}
-
+ {/* Map fills remaining height. `isolation: isolate` traps Leaflet's internal
+ z-indexes (panes/controls up to ~1000) in their own stacking context so
+ they can't paint over portaled popups (Quick-range dropdown, tooltips,
+ and the point-select edit/delete modals). */}
+
diff --git a/tests/test_deployment.py b/tests/test_deployment.py
index 5038f13..678c6bb 100644
--- a/tests/test_deployment.py
+++ b/tests/test_deployment.py
@@ -14,8 +14,25 @@ from scripts.run_migrations import run_all_migrations
PROJECT_ROOT = Path(__file__).resolve().parents[1]
+class _ComposeLoader(yaml.SafeLoader):
+ """SafeLoader that tolerates docker-compose merge tags (e.g. ``!override``,
+ ``!reset``), which appear in docker-compose.dev.yml's ``ports`` and which
+ plain ``safe_load`` rejects as unknown constructors."""
+
+
+def _construct_compose_tag(loader: yaml.Loader, _suffix: str, node: yaml.Node):
+ if isinstance(node, yaml.MappingNode):
+ return loader.construct_mapping(node, deep=True)
+ if isinstance(node, yaml.SequenceNode):
+ return loader.construct_sequence(node, deep=True)
+ return loader.construct_scalar(node)
+
+
+_ComposeLoader.add_multi_constructor("!", _construct_compose_tag)
+
+
def _read_yaml(path: str) -> dict:
- return yaml.safe_load((PROJECT_ROOT / path).read_text())
+ return yaml.load((PROJECT_ROOT / path).read_text(), Loader=_ComposeLoader)
async def _run_lifespan(app) -> None:
@@ -41,7 +58,9 @@ def _configure_database_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
def test_compose_uses_migration_job_before_app() -> None:
compose = _read_yaml("docker-compose.yml")
- override = _read_yaml("docker-compose.override.yml")
+ # Local dev overrides live in docker-compose.dev.yml (explicitly layered;
+ # see README "Docker Compose"). It supplies build: . for local-source builds.
+ dev = _read_yaml("docker-compose.dev.yml")
migration_service = compose["services"]["migration"]
app_service = compose["services"]["app"]
@@ -49,8 +68,8 @@ def test_compose_uses_migration_job_before_app() -> None:
assert migration_service["command"] == ["python", "-m", "scripts.run_migrations"]
assert migration_service["restart"] == "no"
assert app_service["depends_on"]["migration"]["condition"] == "service_completed_successfully"
- assert override["services"]["migration"]["build"] == "."
- assert override["services"]["app"]["build"] == "."
+ assert dev["services"]["migration"]["build"] == "."
+ assert dev["services"]["app"]["build"] == "."
def test_image_defaults_to_uvicorn_only() -> None: