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 分成两层:
|
||||
|
||||
- `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
|
||||
|
||||
@@ -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",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+66
-19
@@ -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 (
|
||||
<Button variant="subtle" size="xs" onClick={handleLogout} data-testid="logout-button">
|
||||
Log out
|
||||
</Button>
|
||||
<Tooltip label="Log out">
|
||||
<ActionIcon
|
||||
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',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.5rem 1rem',
|
||||
borderBottom: '1px solid #eee',
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
||||
@@ -99,22 +137,31 @@ function AppLayout() {
|
||||
|
||||
<Group gap="xs">
|
||||
{/* Records nav link */}
|
||||
<Link
|
||||
<Tooltip label="Records">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/records"
|
||||
style={{ textDecoration: 'none', fontSize: '0.9rem' }}
|
||||
title="Records"
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Records"
|
||||
>
|
||||
Records
|
||||
</Link>
|
||||
{/* Gear icon nav slot — links to config page (§5#10) */}
|
||||
<Link
|
||||
<List size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* Dark-mode toggle — directly beside the settings gear */}
|
||||
<ColorSchemeToggle />
|
||||
{/* Settings — links to config page (§5#10) */}
|
||||
<Tooltip label="Settings">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/config"
|
||||
aria-label="Configuration"
|
||||
style={{ fontSize: '1.25rem', textDecoration: 'none' }}
|
||||
title="Configuration"
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Settings"
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
<Settings size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<LogoutButton />
|
||||
</Group>
|
||||
</nav>
|
||||
@@ -133,7 +180,7 @@ function AppLayout() {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<MantineProvider defaultColorScheme="auto">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<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 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 }) {
|
||||
// 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.
|
||||
|
||||
@@ -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.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:
|
||||
'© <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
|
||||
}
|
||||
|
||||
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<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
|
||||
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 (
|
||||
<MapContainer
|
||||
center={DEFAULT_CENTER}
|
||||
zoom={DEFAULT_ZOOM}
|
||||
style={{ height, width: '100%' }}
|
||||
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
|
||||
data-testid="records-map"
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{/* key forces a clean tile-layer swap when the color scheme changes */}
|
||||
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
|
||||
|
||||
<HeatLayers
|
||||
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,
|
||||
nowISO,
|
||||
computeCenter,
|
||||
TIME_PRESETS,
|
||||
presetRange,
|
||||
shiftRange,
|
||||
} 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])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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<string | null>(() => daysAgoISO(30))
|
||||
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 -----------------------------------------------
|
||||
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 (
|
||||
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -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. */}
|
||||
<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">
|
||||
Apply
|
||||
</Button>
|
||||
@@ -255,8 +334,11 @@ export function HomePage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Map fills remaining height */}
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
{/* 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). */}
|
||||
<Box style={{ flex: 1, minHeight: 0, isolation: 'isolate' }}>
|
||||
<RecordsMap
|
||||
locationHeatPoints={locationHeatPoints}
|
||||
pooHeatPoints={pooHeatPoints}
|
||||
@@ -268,6 +350,7 @@ export function HomePage() {
|
||||
onSelectLocation={handleSelectLocation}
|
||||
onSelectPoo={handleSelectPoo}
|
||||
height="100%"
|
||||
dark={colorScheme === 'dark'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user