feat: implement live temperature threshold chart logic with auto-visibility policies and snapshot testing
This commit is contained in:
@@ -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",
|
||||
|
||||
+106
-4
@@ -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
Reference in New Issue
Block a user