feat: implement utility modules and AI-pinned city dashboard components for temperature forecasting
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
useAiCityForecast,
|
||||
useCityMarketScan,
|
||||
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
||||
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getModelView } from "@/lib/model-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
@@ -242,10 +243,11 @@ export function AiPinnedCityCard({
|
||||
"--";
|
||||
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const displayAirportPrimary = getDisplayAirportPrimary(detail);
|
||||
const currentTemp =
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp ?? row?.current_temp
|
||||
: detail?.airport_primary?.temp ??
|
||||
: displayAirportPrimary?.temp ??
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.current?.temp ??
|
||||
row?.current_temp) ?? null;
|
||||
@@ -274,7 +276,7 @@ export function AiPinnedCityCard({
|
||||
: detail?.risk?.icao ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.airport_current?.station_code ||
|
||||
detail?.airport_primary?.station_code ||
|
||||
displayAirportPrimary?.station_code ||
|
||||
"";
|
||||
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
|
||||
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
getDisplayAirportPrimary,
|
||||
shouldSuppressKoreanRunwayObservation,
|
||||
} from "@/lib/airport-observation-display";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function baseDetail(overrides: Partial<CityDetail>): CityDetail {
|
||||
return {
|
||||
name: "seoul",
|
||||
display_name: "Seoul",
|
||||
local_date: "2026-05-17",
|
||||
local_time: "12:00",
|
||||
temp_symbol: "°C",
|
||||
current: {
|
||||
temp: 25,
|
||||
max_so_far: 27,
|
||||
max_temp_time: null,
|
||||
wu_settlement: null,
|
||||
station_code: "RKSI",
|
||||
obs_time: "12:00",
|
||||
obs_age_min: 1,
|
||||
wind_speed_kt: null,
|
||||
wind_dir: null,
|
||||
humidity: null,
|
||||
cloud_desc: null,
|
||||
clouds_raw: [],
|
||||
visibility_mi: null,
|
||||
wx_desc: null,
|
||||
},
|
||||
hourly: {
|
||||
times: ["10:00", "11:00", "12:00", "13:00"],
|
||||
temps: [24, 25, 26, 27],
|
||||
},
|
||||
forecast: { today_high: 29 },
|
||||
deb: { prediction: 29 },
|
||||
...overrides,
|
||||
} as CityDetail;
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const seoul = baseDetail({
|
||||
airport_primary: {
|
||||
temp: 40,
|
||||
max_so_far: 41,
|
||||
obs_time: "12:00",
|
||||
source_code: "amos",
|
||||
source_label: "AMOS",
|
||||
station_code: "RKSI",
|
||||
},
|
||||
airport_current: {
|
||||
temp: 26,
|
||||
obs_time: "12:00",
|
||||
source_label: "METAR",
|
||||
station_code: "RKSI",
|
||||
},
|
||||
amos: {
|
||||
source: "amos",
|
||||
runway_temp_range: [39, 41],
|
||||
},
|
||||
});
|
||||
|
||||
assert(
|
||||
shouldSuppressKoreanRunwayObservation(seoul),
|
||||
"Seoul AMOS runway observation should be suppressed in web city decision",
|
||||
);
|
||||
assert(
|
||||
getDisplayAirportPrimary(seoul) == null,
|
||||
"Seoul airport_primary AMOS runway observation must not be exposed for display",
|
||||
);
|
||||
|
||||
const pace = getTodayPaceView(seoul, "zh-CN");
|
||||
assert(pace?.observedNow === 26, "pace view should fall back to METAR/current, not AMOS runway temp");
|
||||
|
||||
const busan = baseDetail({
|
||||
name: "busan",
|
||||
display_name: "Busan",
|
||||
airport_primary: {
|
||||
temp: 35,
|
||||
obs_time: "12:00",
|
||||
source_label: "AMSC AWOS",
|
||||
station_code: "RKPK",
|
||||
},
|
||||
});
|
||||
assert(
|
||||
getDisplayAirportPrimary(busan) == null,
|
||||
"Busan AMSC/AWOS runway observation must not be exposed for display",
|
||||
);
|
||||
|
||||
const tokyo = baseDetail({
|
||||
name: "tokyo",
|
||||
display_name: "Tokyo",
|
||||
airport_primary: {
|
||||
temp: 31,
|
||||
obs_time: "12:00",
|
||||
source_label: "AMOS",
|
||||
station_code: "RJTT",
|
||||
},
|
||||
});
|
||||
assert(
|
||||
getDisplayAirportPrimary(tokyo)?.temp === 31,
|
||||
"non-Korean cities should not be affected by the Seoul/Busan runway suppression",
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AiCityForecastPayload,
|
||||
AiCityForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/types";
|
||||
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { normalizeCityKey } from "./decision-utils";
|
||||
|
||||
@@ -167,11 +168,12 @@ function computeFallbackPredictedMax(detail: CityDetail | null): number | null {
|
||||
predicted = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
if (predicted == null) {
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const displayAirportPrimary = getDisplayAirportPrimary(detail);
|
||||
const currentTemp =
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp
|
||||
: detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
displayAirportPrimary?.temp ??
|
||||
detail?.current?.temp) ?? null;
|
||||
predicted = currentTemp != null && Number.isFinite(currentTemp)
|
||||
? currentTemp
|
||||
@@ -193,11 +195,12 @@ export function buildAiCityFallbackPayload({
|
||||
}): AiCityForecastPayload {
|
||||
const tempSymbol = detail?.temp_symbol || "°C";
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const displayAirportPrimary = getDisplayAirportPrimary(detail);
|
||||
const currentTemp =
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp
|
||||
: detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
displayAirportPrimary?.temp ??
|
||||
detail?.current?.temp) ?? null;
|
||||
const currentText =
|
||||
currentTemp != null && Number.isFinite(Number(currentTemp))
|
||||
|
||||
Reference in New Issue
Block a user