M2: frontend walkthrough fixes + explicit dev compose stack
Post-M2 self-walkthrough polish, batched into one commit. Map / heat: - fix heat-layer white-screen crash after login (add layer to map before setLatLngs; an off-map leaflet.heat layer has a null _map and throws) - normalize each heat layer to the densest pixel cell visible in the CURRENT viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend, so sparse poo data reaches red and stays normalized at any zoom level - dark CARTO basemap tiles when the color scheme is dark UI: - dark-mode toggle in the top-right, beside the settings gear - switch top-right nav (records / theme / settings / logout) to Feather icons with hover tooltips - home: Grafana-style quick time-range presets + back/forward shift buttons, placed between the From/To pickers and Apply; fix Select/tooltip z-index (Leaflet stacking) and the shift-button height alignment API client: - stop flooding GET /api/session with 401s: the session probe and the login endpoint own their 401s (no global redirect), which fixes the logout hang and the spinning login page Compose: - rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit, non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB); update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and the README "Docker Compose" section Tests: - pixel-grid peak counter, time-range presets, heat-layer ordering regression, and 401-redirect regression
This commit is contained in:
@@ -338,18 +338,20 @@ python scripts/export_openapi.py
|
|||||||
|
|
||||||
当前 Compose 分成两层:
|
当前 Compose 分成两层:
|
||||||
|
|
||||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取
|
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
|
||||||
- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
|
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
|
||||||
|
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
|
||||||
|
|
||||||
本地开发启动方式:
|
本地开发启动方式(显式叠加 dev 层):
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
docker compose -f docker-compose.yml pull
|
docker compose -f docker-compose.yml pull
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
services:
|
|
||||||
migration:
|
|
||||||
build: .
|
|
||||||
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
Generated
+13
-3
@@ -19,6 +19,7 @@
|
|||||||
"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-feather": "^2.0.10",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4"
|
||||||
},
|
},
|
||||||
@@ -5197,7 +5198,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5638,7 +5638,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -5650,7 +5649,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
@@ -5688,6 +5686,18 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"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-feather": "^2.0.10",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.4"
|
"react-router-dom": "^6.30.4"
|
||||||
},
|
},
|
||||||
|
|||||||
+70
-23
@@ -13,10 +13,17 @@
|
|||||||
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
|
* 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
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.
|
// Mantine requires its CSS to be imported once.
|
||||||
import '@mantine/core/styles.css'
|
import '@mantine/core/styles.css'
|
||||||
@@ -70,9 +77,40 @@ function LogoutButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button">
|
<Tooltip label="Log out">
|
||||||
Log out
|
<ActionIcon
|
||||||
</Button>
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleLogout}
|
||||||
|
aria-label="Log out"
|
||||||
|
data-testid="logout-button"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||||
|
data-testid="color-scheme-toggle"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +128,7 @@ function AppLayout() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
borderBottom: '1px solid #eee',
|
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
||||||
@@ -99,22 +137,31 @@ function AppLayout() {
|
|||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{/* Records nav link */}
|
{/* Records nav link */}
|
||||||
<Link
|
<Tooltip label="Records">
|
||||||
to="/records"
|
<ActionIcon
|
||||||
style={{ textDecoration: 'none', fontSize: '0.9rem' }}
|
component={Link}
|
||||||
title="Records"
|
to="/records"
|
||||||
>
|
variant="default"
|
||||||
Records
|
size="lg"
|
||||||
</Link>
|
aria-label="Records"
|
||||||
{/* Gear icon nav slot — links to config page (§5#10) */}
|
>
|
||||||
<Link
|
<List size={18} />
|
||||||
to="/config"
|
</ActionIcon>
|
||||||
aria-label="Configuration"
|
</Tooltip>
|
||||||
style={{ fontSize: '1.25rem', textDecoration: 'none' }}
|
{/* Dark-mode toggle — directly beside the settings gear */}
|
||||||
title="Configuration"
|
<ColorSchemeToggle />
|
||||||
>
|
{/* Settings — links to config page (§5#10) */}
|
||||||
⚙
|
<Tooltip label="Settings">
|
||||||
</Link>
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
to="/config"
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</Group>
|
</Group>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -133,7 +180,7 @@ function AppLayout() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider defaultColorScheme="auto">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
|||||||
@@ -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<Middleware['onResponse']>
|
||||||
|
type OnResponseParams = Parameters<OnResponse>[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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,7 +51,21 @@ export function registerLoginRedirect(fn: () => void): void {
|
|||||||
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||||
const LOGIN_PATH = '/api/auth/login'
|
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<string>([SESSION_PATH, LOGIN_PATH])
|
||||||
|
|
||||||
|
export const csrfMiddleware: Middleware = {
|
||||||
async onRequest({ request }) {
|
async onRequest({ request }) {
|
||||||
// Always include cookies (same-origin; explicit for clarity)
|
// Always include cookies (same-origin; explicit for clarity)
|
||||||
// Note: credentials is set at client level; this is belt-and-suspenders doc.
|
// Note: credentials is set at client level; this is belt-and-suspenders doc.
|
||||||
@@ -69,11 +83,13 @@ const csrfMiddleware: Middleware = {
|
|||||||
return request
|
return request
|
||||||
},
|
},
|
||||||
|
|
||||||
async onResponse({ response }) {
|
async onResponse({ schemaPath, response }) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Clear any cached session state by triggering a page navigation.
|
// The session probe and the login endpoint own their 401s (see
|
||||||
// The SessionProvider query will refetch and find no session.
|
// NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session
|
||||||
if (_navigateToLogin) {
|
// 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()
|
_navigateToLogin()
|
||||||
}
|
}
|
||||||
// Return the original response so callers can handle 401 if needed.
|
// Return the original response so callers can handle 401 if needed.
|
||||||
|
|||||||
@@ -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 }) => <div>{children}</div>,
|
||||||
|
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(
|
||||||
|
<HeatLayers
|
||||||
|
locationHeatPoints={heatPoints}
|
||||||
|
pooHeatPoints={[]}
|
||||||
|
showLocationHeat={true}
|
||||||
|
showPooHeat={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<HeatLayers
|
||||||
|
locationHeatPoints={heatPoints}
|
||||||
|
pooHeatPoints={heatPoints}
|
||||||
|
showLocationHeat={false}
|
||||||
|
showPooHeat={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hidden layers are never on the map, so setLatLngs must not run on them.
|
||||||
|
expect(setLatLngsSpy).not.toHaveBeenCalled()
|
||||||
|
expect(mapAddLayerSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
|||||||
import 'leaflet.heat'
|
import 'leaflet.heat'
|
||||||
import 'leaflet.markercluster'
|
import 'leaflet.markercluster'
|
||||||
|
|
||||||
|
import { peakGridCount } from './mapUtils'
|
||||||
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
|
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
|
||||||
import type { LocationRecord, PooRecord } from '../records'
|
import type { LocationRecord, PooRecord } from '../records'
|
||||||
|
|
||||||
@@ -60,6 +61,21 @@ export interface RecordsMapProps {
|
|||||||
|
|
||||||
/** Map container height (CSS value). Default: '100%'. */
|
/** Map container height (CSS value). Default: '100%'. */
|
||||||
height?: string
|
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:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
}
|
||||||
|
const DARK_TILES = {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -73,7 +89,34 @@ interface HeatLayerChildProps {
|
|||||||
showPooHeat: boolean
|
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,
|
locationHeatPoints,
|
||||||
pooHeatPoints,
|
pooHeatPoints,
|
||||||
showLocationHeat,
|
showLocationHeat,
|
||||||
@@ -83,20 +126,36 @@ function HeatLayers({
|
|||||||
const locationLayerRef = useRef<HeatLayer | null>(null)
|
const locationLayerRef = useRef<HeatLayer | null>(null)
|
||||||
const pooLayerRef = useRef<HeatLayer | null>(null)
|
const pooLayerRef = useRef<HeatLayer | null>(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
|
// Location heat layer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!locationLayerRef.current) {
|
if (!locationLayerRef.current) {
|
||||||
locationLayerRef.current = leafletHeatLayer([], {
|
locationLayerRef.current = leafletHeatLayer([], {
|
||||||
radius: 20,
|
...LOC_HEAT,
|
||||||
blur: 15,
|
maxZoom: 0,
|
||||||
maxZoom: 17,
|
|
||||||
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
|
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const layer = locationLayerRef.current
|
const layer = locationLayerRef.current
|
||||||
layer.setLatLngs(locationHeatPoints)
|
|
||||||
if (showLocationHeat) {
|
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)
|
if (!map.hasLayer(layer)) map.addLayer(layer)
|
||||||
|
layer.setLatLngs(locationHeatPoints)
|
||||||
|
layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
|
||||||
} else {
|
} else {
|
||||||
if (map.hasLayer(layer)) map.removeLayer(layer)
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
||||||
}
|
}
|
||||||
@@ -109,16 +168,19 @@ function HeatLayers({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pooLayerRef.current) {
|
if (!pooLayerRef.current) {
|
||||||
pooLayerRef.current = leafletHeatLayer([], {
|
pooLayerRef.current = leafletHeatLayer([], {
|
||||||
radius: 25,
|
...POO_HEAT,
|
||||||
blur: 18,
|
maxZoom: 0,
|
||||||
maxZoom: 17,
|
// High-frequency poo spots reach red (per request); mid tones stay
|
||||||
gradient: { 0.4: 'yellow', 0.65: 'orange', 1: '#8B4513' },
|
// yellow/orange to distinguish from the location layer.
|
||||||
|
gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const layer = pooLayerRef.current
|
const layer = pooLayerRef.current
|
||||||
layer.setLatLngs(pooHeatPoints)
|
|
||||||
if (showPooHeat) {
|
if (showPooHeat) {
|
||||||
|
// Add to the map before setLatLngs (see the location heat layer above).
|
||||||
if (!map.hasLayer(layer)) map.addLayer(layer)
|
if (!map.hasLayer(layer)) map.addLayer(layer)
|
||||||
|
layer.setLatLngs(pooHeatPoints)
|
||||||
|
layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
|
||||||
} else {
|
} else {
|
||||||
if (map.hasLayer(layer)) map.removeLayer(layer)
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
||||||
}
|
}
|
||||||
@@ -127,6 +189,26 @@ function HeatLayers({
|
|||||||
}
|
}
|
||||||
}, [map, pooHeatPoints, showPooHeat])
|
}, [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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,18 +313,18 @@ export function RecordsMap({
|
|||||||
onSelectLocation,
|
onSelectLocation,
|
||||||
onSelectPoo,
|
onSelectPoo,
|
||||||
height = '100%',
|
height = '100%',
|
||||||
|
dark = false,
|
||||||
}: RecordsMapProps) {
|
}: RecordsMapProps) {
|
||||||
|
const tiles = dark ? DARK_TILES : LIGHT_TILES
|
||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={DEFAULT_CENTER}
|
center={DEFAULT_CENTER}
|
||||||
zoom={DEFAULT_ZOOM}
|
zoom={DEFAULT_ZOOM}
|
||||||
style={{ height, width: '100%' }}
|
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
|
||||||
data-testid="records-map"
|
data-testid="records-map"
|
||||||
>
|
>
|
||||||
<TileLayer
|
{/* key forces a clean tile-layer swap when the color scheme changes */}
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HeatLayers
|
<HeatLayers
|
||||||
locationHeatPoints={locationHeatPoints}
|
locationHeatPoints={locationHeatPoints}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Tests for peakGridCount — the pure pixel-grid peak counter used to normalize
|
||||||
|
* each heat layer to the densest cell visible in the current viewport.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { peakGridCount } from './mapUtils'
|
||||||
|
|
||||||
|
describe('peakGridCount', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,5 +14,8 @@ export {
|
|||||||
daysAgoISO,
|
daysAgoISO,
|
||||||
nowISO,
|
nowISO,
|
||||||
computeCenter,
|
computeCenter,
|
||||||
|
TIME_PRESETS,
|
||||||
|
presetRange,
|
||||||
|
shiftRange,
|
||||||
} from './mapUtils'
|
} from './mapUtils'
|
||||||
export type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
|
export type { HeatPoint, LocationMapPoint, PooMapPoint, TimePreset } from './mapUtils'
|
||||||
|
|||||||
@@ -40,6 +40,31 @@ export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] {
|
|||||||
return records.map((r) => [r.latitude, r.longitude, 1])
|
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<string, number>()
|
||||||
|
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).
|
* 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)
|
const sumLng = points.reduce((s, p) => s + p.lng, 0)
|
||||||
return [sumLat / points.length, sumLng / points.length]
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,13 +17,18 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
Button,
|
Button,
|
||||||
|
Select,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
Paper,
|
Paper,
|
||||||
Text,
|
Text,
|
||||||
Box,
|
Box,
|
||||||
Loader,
|
Loader,
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
|
useComputedColorScheme,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'react-feather'
|
||||||
|
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +39,9 @@ import {
|
|||||||
filterPooByTimeWindow,
|
filterPooByTimeWindow,
|
||||||
daysAgoISO,
|
daysAgoISO,
|
||||||
nowISO,
|
nowISO,
|
||||||
|
TIME_PRESETS,
|
||||||
|
presetRange,
|
||||||
|
shiftRange,
|
||||||
} from '../map'
|
} from '../map'
|
||||||
import { RecordsMap } from '../map'
|
import { RecordsMap } from '../map'
|
||||||
import {
|
import {
|
||||||
@@ -108,9 +116,37 @@ export function HomePage() {
|
|||||||
return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
|
return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
|
||||||
})
|
})
|
||||||
const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16))
|
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<string | null>(() => daysAgoISO(30))
|
const [appliedStart, setAppliedStart] = useState<string | null>(() => daysAgoISO(30))
|
||||||
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
|
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
|
||||||
|
// Which quick-range preset is currently active (null = custom / shifted range)
|
||||||
|
const [activePreset, setActivePreset] = useState<string | null>(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 -----------------------------------------------
|
// ------ Layer toggle state -----------------------------------------------
|
||||||
const [showLocationHeat, setShowLocationHeat] = useState(true)
|
const [showLocationHeat, setShowLocationHeat] = useState(true)
|
||||||
@@ -165,11 +201,14 @@ export function HomePage() {
|
|||||||
const toISO = (s: string) => (s ? s + ':00Z' : null)
|
const toISO = (s: string) => (s ? s + ':00Z' : null)
|
||||||
setAppliedStart(toISO(startInput))
|
setAppliedStart(toISO(startInput))
|
||||||
setAppliedEnd(toISO(endInput))
|
setAppliedEnd(toISO(endInput))
|
||||||
|
// Manually-applied range is custom, not a preset.
|
||||||
|
setActivePreset(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------ Render -----------------------------------------------------------
|
// ------ Render -----------------------------------------------------------
|
||||||
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
|
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
|
||||||
const isError = locationsQuery.isError || pooQuery.isError
|
const isError = locationsQuery.isError || pooQuery.isError
|
||||||
|
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
|
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -201,6 +240,46 @@ export function HomePage() {
|
|||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
data-testid="time-end-input"
|
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. */}
|
||||||
|
<Group gap={4} align="flex-end">
|
||||||
|
<Select
|
||||||
|
label="Quick range"
|
||||||
|
placeholder="Pick a range"
|
||||||
|
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
||||||
|
value={activePreset}
|
||||||
|
onChange={applyPreset}
|
||||||
|
size="xs"
|
||||||
|
allowDeselect={false}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
comboboxProps={{ zIndex: 3000 }}
|
||||||
|
data-testid="quick-range-select"
|
||||||
|
/>
|
||||||
|
<Tooltip label="Shift earlier (one window back)" zIndex={3000}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="input-xs"
|
||||||
|
aria-label="Shift earlier"
|
||||||
|
onClick={() => shiftWindow(-1)}
|
||||||
|
data-testid="shift-earlier"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Shift later (one window forward)" zIndex={3000}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="input-xs"
|
||||||
|
aria-label="Shift later"
|
||||||
|
onClick={() => shiftWindow(1)}
|
||||||
|
data-testid="shift-later"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
|
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
@@ -255,8 +334,11 @@ export function HomePage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Map fills remaining height */}
|
{/* Map fills remaining height. `isolation: isolate` traps Leaflet's internal
|
||||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
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). */}
|
||||||
|
<Box style={{ flex: 1, minHeight: 0, isolation: 'isolate' }}>
|
||||||
<RecordsMap
|
<RecordsMap
|
||||||
locationHeatPoints={locationHeatPoints}
|
locationHeatPoints={locationHeatPoints}
|
||||||
pooHeatPoints={pooHeatPoints}
|
pooHeatPoints={pooHeatPoints}
|
||||||
@@ -268,6 +350,7 @@ export function HomePage() {
|
|||||||
onSelectLocation={handleSelectLocation}
|
onSelectLocation={handleSelectLocation}
|
||||||
onSelectPoo={handleSelectPoo}
|
onSelectPoo={handleSelectPoo}
|
||||||
height="100%"
|
height="100%"
|
||||||
|
dark={colorScheme === 'dark'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,25 @@ from scripts.run_migrations import run_all_migrations
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
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:
|
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:
|
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:
|
def test_compose_uses_migration_job_before_app() -> None:
|
||||||
compose = _read_yaml("docker-compose.yml")
|
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"]
|
migration_service = compose["services"]["migration"]
|
||||||
app_service = compose["services"]["app"]
|
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["command"] == ["python", "-m", "scripts.run_migrations"]
|
||||||
assert migration_service["restart"] == "no"
|
assert migration_service["restart"] == "no"
|
||||||
assert app_service["depends_on"]["migration"]["condition"] == "service_completed_successfully"
|
assert app_service["depends_on"]["migration"]["condition"] == "service_completed_successfully"
|
||||||
assert override["services"]["migration"]["build"] == "."
|
assert dev["services"]["migration"]["build"] == "."
|
||||||
assert override["services"]["app"]["build"] == "."
|
assert dev["services"]["app"]["build"] == "."
|
||||||
|
|
||||||
|
|
||||||
def test_image_defaults_to_uvicorn_only() -> None:
|
def test_image_defaults_to_uvicorn_only() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user