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. */} + +