feat: implement live temperature threshold chart logic with auto-visibility policies and snapshot testing

This commit is contained in:
2569718930@qq.com
2026-05-26 23:12:48 +08:00
parent 645e304b3e
commit b6194b4756
6 changed files with 1829 additions and 1475 deletions
@@ -0,0 +1,26 @@
# Live Temperature Chart Split Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split `LiveTemperatureThresholdChart.tsx` so chart data generation and SSE patch merge logic live in focused pure modules, while the visible chart behavior stays unchanged.
**Architecture:** Keep the React component as the orchestration/rendering layer. Move types, constants, time helpers, series builders, fallback rules, and patch merge into adjacent modules under `frontend/components/dashboard/scan-terminal/`. Preserve existing test exports through the component file during the first split to avoid changing test callers.
**Tech Stack:** Next.js/React, TypeScript, Recharts, existing business-state test runner.
---
## Files
- Create: `frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts`
- Modify: `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx`
- Modify: `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts`
- Verify: `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts`
## Tasks
- [ ] Add a failing architecture test that rejects keeping all chart data builders inside `LiveTemperatureThresholdChart.tsx`.
- [ ] Extract pure chart logic into `temperature-chart-logic.ts`.
- [ ] Import extracted functions/types back into `LiveTemperatureThresholdChart.tsx`.
- [ ] Keep the current test-only exports stable from `LiveTemperatureThresholdChart.tsx`.
- [ ] Run `npm run test:business`, `npm run typecheck`, and `npm run build`.
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8",
);
const chartLogicSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"),
"utf8",
);
assert(
querySource.includes("useSsePatchVersion") &&
@@ -35,7 +39,7 @@ export function runTests() {
"scan list should subscribe to SSE patch state instead of running a 5-minute interval",
);
assert(
chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
chartLogicSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("window.setInterval"),
"selected city detail chart cache should align with 5-minute scan/metar cadence",
);
@@ -59,8 +63,8 @@ export function runTests() {
"inactive non-compact charts should not run live polling",
);
assert(
chartSource.includes("_hourlyRequestCache") &&
chartSource.includes("seedHourlyForecastFromRow") &&
chartLogicSource.includes("_hourlyRequestCache") &&
chartLogicSource.includes("seedHourlyForecastFromRow") &&
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
);
@@ -87,11 +87,27 @@ export function runTests() {
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
const chart = readFrontendFile("components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx");
const chartLogicPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts");
assert(fs.existsSync(chartLogicPath), "temperature chart pure data logic must live in temperature-chart-logic.ts");
const chartLogic = fs.readFileSync(chartLogicPath, "utf8");
assert(chartLogic.includes("buildFullDayChartData"), "temperature-chart-logic.ts must own full-day chart data generation");
assert(chartLogic.includes("mergePatchIntoHourly"), "temperature-chart-logic.ts must own SSE patch merge logic");
assert(!chart.includes("function buildFullDayChartData"), "LiveTemperatureThresholdChart.tsx must not define full-day chart data generation inline");
assert(!chart.includes("function mergePatchIntoHourly"), "LiveTemperatureThresholdChart.tsx must not define SSE patch merge logic inline");
assert(chart.includes("useLatestPatch"), "temperature chart must consume useLatestPatch(city)");
assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches");
assert(chart.includes("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete");
assert(chart.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
assert(chartLogic.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback");
assert(chart.includes("TemperatureTooltipContent"), "temperature chart must use a custom tooltip content component");
assert(chart.includes("filterNull={false}"), "temperature chart tooltip must keep null-slot payload so hover works between sparse points");
assert(chart.includes("nearestSeriesValue"), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines");
assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state");
assert(chart.includes("加载图表") && chart.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay");
assert(chart.includes("viewMode"), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view");
assert(chart.includes("getDebPeakWindowRange"), "temperature chart must derive its default view from the DEB peak window");
assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode");
assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
assert(
!chart.includes("setInterval(poll, 60_000)"),
"temperature chart must not use unconditional 60-second full-detail polling after SSE patch migration",
@@ -1,6 +1,8 @@
import {
__buildTemperatureChartDataForTest,
__getActiveTemperatureSeriesForTest,
__getDebPeakWindowRangeForTest,
__getLiveObservationLabelsForTest,
__getObservationDisplayMetricsForTest,
__getVisibleTemperatureSeriesForTest,
__isTemperatureSeriesVisibleByDefaultForTest,
@@ -114,6 +116,51 @@ export function runTests() {
defaultVisibleSeries.some((item) => item.key === "hourly_forecast"),
"DEB fusion forecast should be visible by default",
);
const debPeakWindowChart = __buildTemperatureChartDataForTest(
{
city: "beijing",
local_date: "2026-05-26",
local_time: "12:00",
tz_offset_seconds: 8 * 60 * 60,
deb_prediction: 35,
} as any,
{
localTime: "12:00",
times: [
"00:00", "01:00", "02:00", "03:00", "04:00", "05:00",
"06:00", "07:00", "08:00", "09:00", "10:00", "11:00",
"12:00", "13:00", "14:00", "15:00", "16:00", "17:00",
"18:00", "19:00", "20:00", "21:00", "22:00", "23:00",
],
temps: [
20, 20.5, 21, 21.5, 22, 23,
24, 25, 26, 27, 29, 31,
32, 33, 34.2, 35, 34.4, 33.3,
31.8, 30.2, 28.5, 27, 25.5, 24,
],
debPrediction: 35,
} as any,
"1D",
);
const debPeakWindowRange = __getDebPeakWindowRangeForTest(
debPeakWindowChart.data,
debPeakWindowChart.series as any,
);
assert(debPeakWindowRange, "default chart view should derive an auto high-temperature window from the DEB curve");
const debPeakWindowRows = debPeakWindowChart.data.slice(debPeakWindowRange![0], debPeakWindowRange![1] + 1);
const debPeakWindowStart = debPeakWindowRows[0].ts;
const debPeakWindowEnd = debPeakWindowRows[debPeakWindowRows.length - 1].ts;
assert(
debPeakWindowStart <= Date.UTC(2026, 4, 26, 11, 0, 0) &&
debPeakWindowEnd >= Date.UTC(2026, 4, 26, 19, 0, 0),
"DEB peak auto window should cover roughly peak -4h through peak +4h by default",
);
assert(
debPeakWindowEnd - debPeakWindowStart <= 12 * 60 * 60 * 1000,
"DEB peak auto window should not expand beyond 12 hours",
);
assert(
__isTemperatureSeriesVisibleByDefaultForTest("paris", "model_curve_AROME HD"),
"Paris AROME HD should be the only default-visible model curve exception",
@@ -264,6 +311,26 @@ export function runTests() {
assert(newYorkMetrics.currentRunwayTemp === 73.9, "weather-station header should use detail METAR/current temp before stale row zero");
assert(newYorkMetrics.observedHighMetar === 73.9, "METAR high header should use detail METAR high before stale row zero");
const istanbulLabels = __getLiveObservationLabelsForTest(
{
city: "istanbul",
airport: "LTFM",
metar_context: {
source: "mgm",
station_label: "MGM Istanbul Airport",
},
} as any,
null,
);
assert(
istanbulLabels.runwayHeaderLabel === "气象站实测",
"Istanbul/MGM should be labeled as weather-station observations, not runway observations",
);
assert(
istanbulLabels.runwayHighLabel === "气象站",
"Istanbul/MGM high label should be weather station",
);
const newYorkWithMadis = __buildTemperatureChartDataForTest(
{
city: "new york",
@@ -319,14 +386,49 @@ export function runTests() {
} as any,
"1D",
);
assert(
newYorkMinuteStream.data.length < 120,
"1D live chart should use real timestamp rows instead of preallocating 1440 empty full-day minute slots",
);
const minuteLabels = newYorkMinuteStream.data
.filter((point) => point.madis !== null)
.map((point) => point.label);
assert(
minuteLabels.includes("10:01") &&
minuteLabels.includes("10:02") &&
minuteLabels.includes("10:03"),
"live observation chart should preserve minute-level SSE patch points instead of collapsing them into a 30-minute bucket",
minuteLabels.includes("10:01:00") &&
minuteLabels.includes("10:02:00") &&
minuteLabels.includes("10:03:00"),
"live observation chart should preserve real observation timestamps on the x-axis",
);
const longLivedSingleObservation = __buildTemperatureChartDataForTest(
{
city: "ankara",
local_date: "2026-05-26",
local_time: "14:28",
tz_offset_seconds: 3 * 60 * 60,
current_temp: 21.9,
current_max_so_far: 21.9,
airport: "LTAC",
} as any,
{
localTime: "14:28",
times: [],
temps: [],
airportPrimary: {
source_code: "mgm",
source_label: "MGM",
temp: 21.9,
max_so_far: 21.9,
},
airportPrimaryTodayObs: [["2026-05-26T11:28:00Z", 21.9]],
} as any,
"1D",
);
assert(
longLivedSingleObservation.series.some(
(item) => item.key === "current" && item.values.filter((value: number | null) => value !== null).length >= 2,
),
"long-lived chart with only one fresh observation should keep a renderable current reference line instead of an invisible single-point series",
);
const chengduMergedHourly = __mergePatchIntoHourlyForTest(
File diff suppressed because it is too large Load Diff