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:
Indiana
2026-07-24 22:00:35 +00:00
parent 7ebedf363b
commit 917cd7f89e
8 changed files with 1469 additions and 18 deletions

View File

@@ -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>
)