diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts
index e4160313..34cfca86 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts
@@ -21,4 +21,24 @@ export function runTests() {
),
"deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data",
);
+ assert(
+ source.includes("function pickRicherObservationSeries"),
+ "city detail merge should preserve chart observation series when refresh payload is sparse",
+ );
+ assert(
+ /metar_today_obs:\s*pickRicherObservationSeries\(\s*current\.metar_today_obs,\s*incoming\.metar_today_obs,?\s*\)/.test(
+ source,
+ ),
+ "deep-analysis refresh must not wipe METAR observation points used by the intraday path chart",
+ );
+ assert(
+ /settlement_today_obs:\s*pickRicherObservationSeries\(\s*current\.settlement_today_obs,\s*incoming\.settlement_today_obs,?\s*\)/.test(
+ source,
+ ),
+ "deep-analysis refresh must not wipe settlement observation points used by the intraday path chart",
+ );
+ assert(
+ /trend:\s*mergeTrendInfo\(\s*current\.trend,\s*incoming\.trend\s*\)/.test(source),
+ "deep-analysis refresh must merge trend.recent instead of replacing it with sparse trend payloads",
+ );
}
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts
index 0888618a..37ebe8fb 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts
@@ -7,6 +7,12 @@ function assert(condition: unknown, message: string) {
export function runTests() {
const projectRoot = process.cwd();
+ const dashboardPath = path.join(
+ projectRoot,
+ "components",
+ "dashboard",
+ "ScanTerminalDashboard.tsx",
+ );
const bannerPath = path.join(
projectRoot,
"components",
@@ -14,6 +20,7 @@ export function runTests() {
"scan-terminal",
"MarketOverviewBanner.tsx",
);
+ const dashboardSource = fs.readFileSync(dashboardPath, "utf8");
const source = fs.readFileSync(bannerPath, "utf8");
assert(
@@ -22,4 +29,8 @@ export function runTests() {
!source.includes("styles.badge"),
"market overview banner should not render the AI overview badge",
);
+ assert(
+ !dashboardSource.includes("MarketOverviewBanner"),
+ "scan terminal dashboard should not mount the AI market overview banner",
+ );
}
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx
index 5a78bf4f..a454fc2b 100644
--- a/frontend/hooks/useDashboardStore.tsx
+++ b/frontend/hooks/useDashboardStore.tsx
@@ -509,6 +509,61 @@ function pickRicherHourly(
return incomingCount >= currentCount ? incomingValue : currentValue;
}
+function countObservationSeriesPoints(
+ value: T[] | null | undefined,
+) {
+ return (Array.isArray(value) ? value : []).filter((row) => {
+ const time = String(row?.time || "").trim();
+ const temp = Number(row?.temp);
+ return Boolean(time) && Number.isFinite(temp);
+ }).length;
+}
+
+function pickRicherObservationSeries<
+ T extends { time?: string | null; temp?: number | null },
+>(
+ currentValue: T[] | null | undefined,
+ incomingValue: T[] | null | undefined,
+): T[] | undefined {
+ const incomingCount = countObservationSeriesPoints(incomingValue);
+ const currentCount = countObservationSeriesPoints(currentValue);
+ if (incomingCount <= 0) return currentValue || undefined;
+ if (currentCount <= 0) return incomingValue || undefined;
+ return (incomingCount >= currentCount ? incomingValue : currentValue) || undefined;
+}
+
+function mergeTrendInfo(
+ currentValue: CityDetail["trend"] | undefined,
+ incomingValue: CityDetail["trend"] | undefined,
+) {
+ if (!incomingValue) return currentValue;
+ if (!currentValue) return incomingValue;
+ return {
+ ...currentValue,
+ ...incomingValue,
+ recent: pickRicherObservationSeries(
+ currentValue.recent,
+ incomingValue.recent,
+ ),
+ };
+}
+
+function mergeMgmData(
+ currentValue: CityDetail["mgm"] | undefined,
+ incomingValue: CityDetail["mgm"] | undefined,
+) {
+ if (!incomingValue) return currentValue;
+ if (!currentValue) return incomingValue;
+ return {
+ ...currentValue,
+ ...incomingValue,
+ hourly: pickRicherObservationSeries(
+ currentValue.hourly,
+ incomingValue.hourly,
+ ),
+ };
+}
+
function pickPreferredNearbyStations(
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
@@ -546,7 +601,16 @@ function mergeCityDetail(
airport_current: incoming.airport_current || current.airport_current,
deb: incoming.deb || current.deb,
probabilities: incoming.probabilities || current.probabilities,
- trend: incoming.trend || current.trend,
+ trend: mergeTrendInfo(current.trend, incoming.trend),
+ metar_today_obs: pickRicherObservationSeries(
+ current.metar_today_obs,
+ incoming.metar_today_obs,
+ ),
+ settlement_today_obs: pickRicherObservationSeries(
+ current.settlement_today_obs,
+ incoming.settlement_today_obs,
+ ),
+ mgm: mergeMgmData(current.mgm, incoming.mgm),
multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model),
multi_model_daily: mergeDailyModelMap(
current.multi_model_daily,