M2: frontend walkthrough fixes + explicit dev compose stack
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s

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:
2026-06-13 15:20:35 +02:00
parent bd09523e94
commit da236643f2
16 changed files with 722 additions and 66 deletions
+118
View File
@@ -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()
})
})