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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user