feat: Cold Spot Detector / Atmospheric Disturbance Index on Device Bay
Real-time anomaly visualization for temperature/pressure readings on the device-feed dashboard, folklore's two most iconic paranormal markers: sudden cold spots and rapid barometric swings. - lib/coldSpot.ts: pure, directly-testable rolling-baseline tracker (time-aware EMA, since hardware doesn't report on a fixed schedule), cold-spot and pressure-anomaly classifiers, and a composite Atmospheric Disturbance Index that rewards correlated anomalies (a lone signal caps at 50/100; only both deviating together can reach 100) — modeled on evilMeter.ts's threaded-state pattern. - components/ColdSpotPanel.tsx/.css: frost treatment + sparkline for temperature, ripple treatment for pressure, and a crescent-arc composite gauge (GhostLog's evil-meter gauge as the visual family reference) that only appears once a device has reported both sensors. - DevicesPage.tsx: owns per-device baseline state, feeds it from `reading` frames, falls back to the existing generic row for any non-numeric temperature/pressure value. 26 new coldSpot.test.ts cases (warm-up, genuine vs. fluctuation, correlated-vs-solo index, gappy/out-of-order data) and 7 new DevicesPage integration tests. Full suite: 302/302 passing, tsc clean, i18n coverage clean (en/es). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,15 @@
|
||||
// hardware line, per the spec), so rendering is fully generic — an
|
||||
// unrecognized sensor_type renders its raw value + unit with no
|
||||
// special-casing, and can never crash the page.
|
||||
//
|
||||
// One exception carved out of that genericity: `temperature` and
|
||||
// `pressure` get the Cold Spot Detector / Atmospheric Disturbance Index
|
||||
// treatment (../lib/coldSpot.ts + ../components/ColdSpotPanel.tsx) —
|
||||
// folklore's two most iconic paranormal markers, a sudden cold spot and a
|
||||
// rapid barometric swing. This page owns the rolling-baseline state per
|
||||
// device (mirrors GhostLog.tsx owning tell history for computeEvilMeter)
|
||||
// and hands the *result* down to ColdSpotPanel, a pure presentational
|
||||
// component.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
@@ -24,10 +33,95 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { DeviceFeedSocket } from '../lib/deviceFeed'
|
||||
import type { DeviceFeedConnectionState, DeviceFeedFrame, DeviceSummary } from '../lib/deviceFeed'
|
||||
import {
|
||||
applyPressureReading,
|
||||
applyTemperatureReading,
|
||||
initPressureBaseline,
|
||||
initTemperatureBaseline,
|
||||
pushSample,
|
||||
} from '../lib/coldSpot'
|
||||
import type { PressureBaselineState, TemperatureBaselineState } from '../lib/coldSpot'
|
||||
import { ColdSpotPanel } from '../components/ColdSpotPanel'
|
||||
import type { PressureReadout, TemperatureReadout } from '../components/ColdSpotPanel'
|
||||
import './DevicesPage.css'
|
||||
|
||||
type Reading = { value: unknown; unit: string; at: string }
|
||||
type DeviceRow = DeviceSummary & { readings: Record<string, Reading> }
|
||||
|
||||
// Cold Spot Detector / Atmospheric Disturbance Index — rolling-baseline
|
||||
// state for this device's temperature/pressure readings (lib/coldSpot.ts),
|
||||
// plus the last computed readout for each and a bounded temperature
|
||||
// sample history for the sparkline. Threaded through DeviceRow the same
|
||||
// way `readings` is: plain immutable state replaced on each update, no
|
||||
// mutation.
|
||||
type AtmosphereState = {
|
||||
tempBaseline: TemperatureBaselineState
|
||||
pressureBaseline: PressureBaselineState
|
||||
temp: TemperatureReadout | null
|
||||
pressure: PressureReadout | null
|
||||
tempSparkline: number[]
|
||||
}
|
||||
|
||||
function initAtmosphere(): AtmosphereState {
|
||||
return {
|
||||
tempBaseline: initTemperatureBaseline(),
|
||||
pressureBaseline: initPressureBaseline(),
|
||||
temp: null,
|
||||
pressure: null,
|
||||
tempSparkline: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one `reading` frame into a device's atmosphere state. A no-op for
|
||||
* any sensor_type other than temperature/pressure, and for a
|
||||
* temperature/pressure reading whose value isn't a finite number (a
|
||||
* malformed reading falls back to the existing generic sensor-row
|
||||
* rendering instead of silently vanishing — see the `hideFromGeneric`
|
||||
* logic in the dashboard render below).
|
||||
*/
|
||||
function applyAtmosphereReading(
|
||||
atmosphere: AtmosphereState,
|
||||
frame: { sensor_type: string; value: unknown; unit: string; at: string },
|
||||
): AtmosphereState {
|
||||
if (typeof frame.value !== 'number' || !Number.isFinite(frame.value)) return atmosphere
|
||||
const atMs = parseAtMs(frame.at)
|
||||
|
||||
if (frame.sensor_type === 'temperature') {
|
||||
const result = applyTemperatureReading(atmosphere.tempBaseline, frame.value, atMs)
|
||||
return {
|
||||
...atmosphere,
|
||||
tempBaseline: result.state,
|
||||
temp: {
|
||||
value: frame.value,
|
||||
unit: frame.unit,
|
||||
deviation: result.deviation,
|
||||
isColdSpot: result.isColdSpot,
|
||||
severity: result.severity,
|
||||
},
|
||||
tempSparkline: pushSample(atmosphere.tempSparkline, frame.value),
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.sensor_type === 'pressure') {
|
||||
const result = applyPressureReading(atmosphere.pressureBaseline, frame.value, atMs)
|
||||
return {
|
||||
...atmosphere,
|
||||
pressureBaseline: result.state,
|
||||
pressure: {
|
||||
value: frame.value,
|
||||
unit: frame.unit,
|
||||
deviation: result.deviation,
|
||||
isPressureAnomaly: result.isPressureAnomaly,
|
||||
severity: result.severity,
|
||||
direction: result.direction,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return atmosphere
|
||||
}
|
||||
|
||||
type DeviceRow = DeviceSummary & { readings: Record<string, Reading>; atmosphere: AtmosphereState }
|
||||
type PairState = 'idle' | 'pending' | 'error'
|
||||
|
||||
type PairResponse = {
|
||||
@@ -72,6 +166,16 @@ function formatTimestamp(iso: string, locale: string): string {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/** Epoch ms for a reading's `at` timestamp, defensively — the rolling
|
||||
* baseline needs a real clock value to weight its EMA by elapsed time
|
||||
* (lib/coldSpot.ts), and hardware/network hiccups can in principle deliver
|
||||
* a malformed timestamp. Falling back to "now" keeps the baseline moving
|
||||
* forward sanely instead of propagating NaN into device state. */
|
||||
function parseAtMs(iso: string): number {
|
||||
const ms = Date.parse(iso)
|
||||
return Number.isFinite(ms) ? ms : Date.now()
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
const { user, checking } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
@@ -110,7 +214,11 @@ function DevicesSession() {
|
||||
const upsertPairedDevice = useCallback((summary: DeviceSummary) => {
|
||||
setDevices((prev) => ({
|
||||
...prev,
|
||||
[summary.id]: { ...summary, readings: prev[summary.id]?.readings ?? {} },
|
||||
[summary.id]: {
|
||||
...summary,
|
||||
readings: prev[summary.id]?.readings ?? {},
|
||||
atmosphere: prev[summary.id]?.atmosphere ?? initAtmosphere(),
|
||||
},
|
||||
}))
|
||||
setOrder((prev) => (prev.includes(summary.id) ? prev : [...prev, summary.id]))
|
||||
}, [])
|
||||
@@ -171,7 +279,11 @@ function DevicesSession() {
|
||||
setDevices((prev) => {
|
||||
const next: Record<string, DeviceRow> = {}
|
||||
for (const d of frame.devices) {
|
||||
next[d.id] = { ...d, readings: prev[d.id]?.readings ?? {} }
|
||||
next[d.id] = {
|
||||
...d,
|
||||
readings: prev[d.id]?.readings ?? {},
|
||||
atmosphere: prev[d.id]?.atmosphere ?? initAtmosphere(),
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -186,6 +298,7 @@ function DevicesSession() {
|
||||
name: frame.device_id,
|
||||
last_seen_at: null,
|
||||
readings: {},
|
||||
atmosphere: initAtmosphere(),
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
@@ -196,6 +309,7 @@ function DevicesSession() {
|
||||
...base.readings,
|
||||
[frame.sensor_type]: { value: frame.value, unit: frame.unit, at: frame.at },
|
||||
},
|
||||
atmosphere: applyAtmosphereReading(base.atmosphere, frame),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -305,6 +419,18 @@ function DevicesSession() {
|
||||
const device = devices[id]
|
||||
if (!device) return null
|
||||
const sensorTypes = Object.keys(device.readings)
|
||||
// temperature/pressure get the Cold Spot Detector treatment
|
||||
// (ColdSpotPanel below) once they've reported a usable
|
||||
// numeric value at least once; a malformed non-numeric
|
||||
// reading for either falls back to the generic row instead
|
||||
// of vanishing (see applyAtmosphereReading's no-op guard).
|
||||
const genericSensorTypes = sensorTypes.filter((sensorType) => {
|
||||
if (sensorType === 'temperature' && device.atmosphere.temp) return false
|
||||
if (sensorType === 'pressure' && device.atmosphere.pressure) return false
|
||||
return true
|
||||
})
|
||||
const hasAtmosphere = device.atmosphere.temp !== null || device.atmosphere.pressure !== null
|
||||
const hasAnyReadout = hasAtmosphere || genericSensorTypes.length > 0
|
||||
return (
|
||||
<article className="device-card" key={id} data-testid={`device-card-${id}`}>
|
||||
<header className="device-card-head">
|
||||
@@ -316,25 +442,36 @@ function DevicesSession() {
|
||||
: t('devices.dashboard.lastSeenNever', 'never')}
|
||||
</p>
|
||||
</header>
|
||||
{sensorTypes.length === 0 ? (
|
||||
{!hasAnyReadout ? (
|
||||
<p className="device-noreadings">
|
||||
{t('devices.dashboard.noReadings', 'awaiting first reading…')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="sensor-grid">
|
||||
{sensorTypes.map((sensorType) => {
|
||||
const r = device.readings[sensorType]
|
||||
return (
|
||||
<div className="sensor-row" key={sensorType}>
|
||||
<span className="sensor-type">{sensorType}</span>
|
||||
<span className="sensor-value flicker-soft">
|
||||
{formatSensorValue(r.value)}{' '}
|
||||
<span className="sensor-unit">{r.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
{hasAtmosphere && (
|
||||
<ColdSpotPanel
|
||||
temperature={device.atmosphere.temp}
|
||||
pressure={device.atmosphere.pressure}
|
||||
tempSparkline={device.atmosphere.tempSparkline}
|
||||
/>
|
||||
)}
|
||||
{genericSensorTypes.length > 0 && (
|
||||
<div className="sensor-grid">
|
||||
{genericSensorTypes.map((sensorType) => {
|
||||
const r = device.readings[sensorType]
|
||||
return (
|
||||
<div className="sensor-row" key={sensorType}>
|
||||
<span className="sensor-type">{sensorType}</span>
|
||||
<span className="sensor-value flicker-soft">
|
||||
{formatSensorValue(r.value)}{' '}
|
||||
<span className="sensor-unit">{r.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user