diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 90a7f9d7..65a1d366 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -19,7 +19,7 @@ import { buildFullDayChartData, buildIntDegreeTicks, buildRunwayPlates, - fetchHourlyForecastForCity, + fetchFullChartDetailForCity, fetchLiveObservationForCity, getActiveTemperatureSeries, getDebPeakWindowRange, @@ -41,10 +41,10 @@ import { selectCompactSecondaryTemp, selectDisplayRunwayTemp, selectInitialHourlyForRowChange, - seedHourlyForecastFromRow, + seedChartRenderStateFromRow, shouldPollLiveChart, validNumber, - type HourlyForecast, + type ChartRenderState, type ObservationSnapshot, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; @@ -125,7 +125,7 @@ function formatCityLocalDateTime(tzOffsetSeconds: number | null | undefined) { return `${y}-${mo}-${d} ${hh}:${mm}:${ss}`; } -function getLiveTempFromHourly(data: HourlyForecast) { +function getLiveTempFromHourly(data: ChartRenderState) { return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null; } @@ -268,7 +268,7 @@ function patchObservationTimeForFreshness(patch: { changes?: Record void; markDetailDegraded: (options?: { showUserError?: boolean }) => void; - applySuccessfulHourlyDetail: (data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => void; + applySuccessfulHourlyDetail: (data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => void; }) { return useCallback( async ({ @@ -596,7 +596,7 @@ function useHourlyDetailFetcher({ markDetailRequest(source); try { - const data = await fetchHourlyForecastForCity(city, { + const data = await fetchFullChartDetailForCity(city, { ...fetchOptions, resolution: targetResolution, }); @@ -660,7 +660,7 @@ export function LiveTemperatureThresholdChart({ activationRefreshKey?: number; slotIndex?: number; }) { - const [hourly, setHourly] = useState(null); + const [hourly, setHourly] = useState(null); const city = String(row?.city || "").toLowerCase().trim(); const latestPatch = useLatestPatch(city); const resyncVersion = useSseResyncVersion(); @@ -807,15 +807,15 @@ export function LiveTemperatureThresholdChart({ return () => clearInterval(id); }, [row?.tz_offset_seconds]); - const commitHourlySnapshot = useCallback((buildNext: (previous: HourlyForecast) => HourlyForecast) => { + const commitHourlySnapshot = useCallback((buildNext: (previous: ChartRenderState) => ChartRenderState) => { setHourly((prev) => buildNext(prev)); }, []); - const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { + const applySuccessfulHourlyDetail = useCallback((data: ChartRenderState, options?: { updateLiveTemp?: boolean }) => { if (!data) return; const loadedAtMs = Date.now(); const latestRow = getLatestRowSnapshot(); - const rowSeed = seedHourlyForecastFromRow(latestRow); + const rowSeed = seedChartRenderStateFromRow(latestRow); const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, latestRow); hasLoadedHourlyDetailRef.current = true; if (options?.updateLiveTemp) { @@ -859,7 +859,7 @@ export function LiveTemperatureThresholdChart({ } commitHourlySnapshot((prev) => mergeObservationSnapshotIntoHourly( - prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), + prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()), snapshot, ), ); @@ -875,7 +875,7 @@ export function LiveTemperatureThresholdChart({ if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return; const now = Date.now(); lastRowObservationSignatureRef.current = currentRowObservationSignature; - const rowSeed = seedHourlyForecastFromRow(row); + const rowSeed = seedChartRenderStateFromRow(row); const temp = getLiveTempFromHourly(rowSeed); if (temp !== null) setLiveTemp(temp); commitHourlySnapshot((prev) => mergeRowObservationIntoHourly(prev, row)); @@ -1003,7 +1003,7 @@ export function LiveTemperatureThresholdChart({ const tempValue = validNumber(latestPatch.changes.temp); if (tempValue !== null) setLiveTemp(tempValue); commitHourlySnapshot((prev) => { - const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch); + const mergedHourly = mergePatchIntoHourly(prev ?? seedChartRenderStateFromRow(getLatestRowSnapshot()), latestPatch); return mergedHourly; }); setChartFreshness((prev) => ({ @@ -1160,7 +1160,7 @@ export function LiveTemperatureThresholdChart({ }; }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]); - const chartHourly = useMemo(() => { + const chartHourly = useMemo(() => { if (!hourly) return hourly; const loadedLocalDate = hourly.localDate || row?.local_date || ""; if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) { @@ -1681,7 +1681,7 @@ export function LiveTemperatureThresholdChart({ export function __buildTemperatureChartDataForTest( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, _timeframe = "1D", isEn = false, ) { diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 9a5ba7f2..f06e1461 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -20,7 +20,7 @@ import { __resetHourlyDetailRequestQueueForTest, __runQueuedHourlyDetailRequestForTest, clearCityDetailCache, - fetchHourlyForecastForCity, + fetchFullChartDetailForCity, readCityDetailBatchDiagnostics, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; @@ -95,13 +95,13 @@ export async function runTests() { chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"), "visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes", ); - const componentHourlyFetchCalls = chartSource.match(/fetchHourlyForecastForCity\(city,/g) || []; + const componentHourlyFetchCalls = chartSource.match(/fetchFullChartDetailForCity\(city,/g) || []; const hourlyFetcherBlock = /function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || ""; assert( chartSource.includes("function useHourlyDetailFetcher") && componentHourlyFetchCalls.length === 1 && - !/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock), + !/fetchFullChartDetailForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock), "temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects", ); assert( @@ -171,9 +171,16 @@ export async function runTests() { "live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail", ); assert( - /type FullChartDetail\s*=\s*NonNullable\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) && + chartLogicSource.includes("type ChartRenderState = {") && + /type FullChartDetail\s*=\s*NonNullable\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) && + !chartLogicSource.includes("HourlyForecast") && + !chartSource.includes("HourlyForecast"), + "chart render state should be named ChartRenderState; the historical HourlyForecast name must not appear in chart data APIs", + ); + assert( + /type FullChartDetail\s*=\s*NonNullable\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) && /type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/.test(chartLogicSource), - "full detail and no-store observation payloads should be separate branded types instead of sharing raw HourlyForecast", + "full detail and no-store observation payloads should be separate branded types instead of sharing raw ChartRenderState", ); assert( chartLogicSource.includes("type HourlyCacheEntry = { ts: number; data: FullChartDetail }") && @@ -187,7 +194,7 @@ export async function runTests() { "live observation fetches should return ObservationSnapshot and enter chart state through the observation snapshot merge path", ); assert( - /async function fetchHourlyForecastForCity\([\s\S]*Promise/.test(chartLogicSource) && + /async function fetchFullChartDetailForCity\([\s\S]*Promise/.test(chartLogicSource) && /type CityDetailBatchWaiter = \{[\s\S]*resolve: \(value: FullChartDetail \| null\)/.test(chartLogicSource), "model/detail fetches and batch waiters should return FullChartDetail or null, not observation-shaped hourly state", ); @@ -290,8 +297,8 @@ export async function runTests() { ); assert( chartLogicSource.includes("_hourlyRequestCache") && - chartLogicSource.includes("seedHourlyForecastFromRow") && - !chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))") && + chartLogicSource.includes("seedChartRenderStateFromRow") && + !chartSource.includes("setHourly(seedChartRenderStateFromRow(getLatestRowSnapshot()))") && chartSource.includes("mergeRowObservationIntoHourly"), "terminal charts should render from row data through the same merge path instead of racing a row-only skeleton against detail fetches", ); @@ -335,7 +342,7 @@ export async function runTests() { !flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"), "whole-batch failures should stop at the chart batch layer instead of fanning out into single-city full-detail requests", ); - const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || ""; + const fetchHourlyBlock = chartLogicSource.match(/async function fetchFullChartDetailForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || ""; assert( fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") && !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"), @@ -616,9 +623,9 @@ export async function runTests() { }; }; - const firstDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" }); - const cachedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" }); - const revalidatedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { + const firstDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" }); + const cachedDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" }); + const revalidatedDetail = await fetchFullChartDetailForCity("fallback-revalidate", { bypassLocalCache: true, resolution: "10m", }); @@ -717,7 +724,7 @@ export async function runTests() { partial: false, }), }); - const forceRefreshedRunwayDetail = await fetchHourlyForecastForCity("chengdu", { + const forceRefreshedRunwayDetail = await fetchFullChartDetailForCity("chengdu", { ignoreCache: true, resolution: "1m", }); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 7946d2ac..7bc0b673 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -11,7 +11,7 @@ import { readCachedHourlyForInitialRow, rememberHourlyDetailSnapshot, selectInitialHourlyForRowChange, - seedHourlyForecastFromRow, + seedChartRenderStateFromRow, toFullChartDetail, _hourlyCache, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; @@ -434,7 +434,7 @@ export function runTests() { airport_max_so_far: 29, }, } as any; - const seededGuangzhou = seedHourlyForecastFromRow(guangzhouRow); + const seededGuangzhou = seedChartRenderStateFromRow(guangzhouRow); const guangzhouLiveChart = buildFullDayChartData(guangzhouRow, seededGuangzhou, false); const guangzhouSettlementRunway = guangzhouLiveChart.series.find((item) => item.key === "runway_02L_20R"); assert( @@ -851,7 +851,7 @@ export function runTests() { tz_offset_seconds: 8 * 3600, } as any; const shenzhenFullDetail = { - ...seedHourlyForecastFromRow(shenzhenRow), + ...seedChartRenderStateFromRow(shenzhenRow), localDate: "2026-06-10", localTime: "12:00", times: ["10:00", "11:00", "12:00"], @@ -899,7 +899,7 @@ export function runTests() { ); const hongKongCachedDetail = { - ...seedHourlyForecastFromRow({ ...shenzhenRow, city: "hongkong" } as any), + ...seedChartRenderStateFromRow({ ...shenzhenRow, city: "hongkong" } as any), localDate: "2026-06-10", times: ["10:00", "11:00"], temps: [29.5, 30.2], @@ -956,14 +956,14 @@ export function runTests() { tz_offset_seconds: 8 * 3600, metar_context: { source: "amsc_awos" }, } as any; - rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow) as any); + rememberHourlyDetailSnapshot("chengdu", "1m", seedChartRenderStateFromRow(cachedChengduRow) as any); assert( !_hourlyCache.has(chengduCacheKey), "instant-restore cache must not persist a row-only seed that would block the full detail fetch", ); const cachedChengduDetail = toFullChartDetail({ - ...seedHourlyForecastFromRow(cachedChengduRow), + ...seedChartRenderStateFromRow(cachedChengduRow), localDate: "2026-06-15", times: ["08:00", "09:00"], temps: [25.8, 26.4], @@ -1063,7 +1063,7 @@ export function runTests() { tz_offset_seconds: 0, } as any; const cacheDetail = toFullChartDetail({ - ...seedHourlyForecastFromRow(row), + ...seedChartRenderStateFromRow(row), times: ["09:00"], temps: [20 + i / 100], modelTimes: ["09:00"], diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index d7aab8a1..085dceca 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -109,7 +109,7 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) { function prefersHighFrequencyRunwayResolution( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, ) { const cityKey = normalizeCityKey(row?.city); if ((SETTLEMENT_RUNWAY_PAIRS[cityKey] || []).length > 0) return true; @@ -590,7 +590,7 @@ function readHourlyDetailSnapshotAgeMs( function readCachedHourlyForInitialRow( city: string, preferredResolution: string, -): HourlyForecast { +): ChartRenderState { const cityKey = normalizeCityKey(city); if (!cityKey) return null; const resolutions = [ @@ -777,7 +777,7 @@ function laterLocalDate(left?: string | null, right?: string | null) { return isDate(a) ? a : isDate(b) ? b : null; } -function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) { +function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: ChartRenderState) { const hourlyDate = hourly?.localDate || dateFromLocalTime(hourly?.localTime); const rowDate = row?.local_date || dateFromLocalTime(row?.local_time); return ( @@ -871,7 +871,7 @@ function appendLatestAirportObservation( return merged; } -function isMgmAirportPrimary(hourly: HourlyForecast) { +function isMgmAirportPrimary(hourly: ChartRenderState) { const primary = hourly?.airportPrimary; const sourceTokens = [ primary?.source_code, @@ -881,7 +881,7 @@ function isMgmAirportPrimary(hourly: HourlyForecast) { return sourceTokens.some((value) => value === "mgm" || value.includes("turkey_mgm")); } -function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) { +function canonicalAirportPrimarySourceLabel(hourly: ChartRenderState) { const primary = hourly?.airportPrimary; const tokens = [ primary?.source_code, @@ -900,7 +900,7 @@ function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) { } function airportCodeForSeriesLabel( - hourly: HourlyForecast, + hourly: ChartRenderState, row?: ScanOpportunityRow | null, ) { const candidates = [ @@ -933,7 +933,7 @@ function isGenericAirportPrimaryLabel(label: string) { } function airportPrimarySeriesLabel( - hourly: HourlyForecast, + hourly: ChartRenderState, isHKO: boolean, row?: ScanOpportunityRow | null, ) { @@ -956,7 +956,7 @@ function airportPrimarySeriesLabel( } function airportPrimaryUsesMetarFallback( - hourly: HourlyForecast, + hourly: ChartRenderState, isHKO: boolean, row?: ScanOpportunityRow | null, ) { @@ -969,7 +969,7 @@ function airportPrimaryUsesMetarFallback( return !payloadLabel || isGenericAirportPrimaryLabel(payloadLabel); } -function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: HourlyForecast) { +function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: ChartRenderState) { const candidates = [ row?.metar_context?.station, hourly?.settlementStationCode, @@ -981,13 +981,13 @@ function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: Hou .find((value) => /^[A-Z0-9]{4}$/.test(value)) || ""; } -function airportPrimaryMatchesMetarStation(hourly: HourlyForecast, row?: ScanOpportunityRow | null) { +function airportPrimaryMatchesMetarStation(hourly: ChartRenderState, row?: ScanOpportunityRow | null) { const primaryCode = airportCodeForSeriesLabel(hourly, row); const metarCode = metarStationCodeForSeries(row, hourly); return Boolean(primaryCode && metarCode && primaryCode === metarCode); } -function airportPrimaryObservationPoints(hourly: HourlyForecast) { +function airportPrimaryObservationPoints(hourly: ChartRenderState) { return appendLatestAirportObservation( hourly?.airportPrimaryTodayObs, hourly?.airportPrimary, @@ -1016,7 +1016,7 @@ function maxObservationValue(obs: Array<{ ts: number; value: number }>) { function getRunwayHistoryObservationMetrics( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, ) { const tzOffset = row?.tz_offset_seconds ?? 0; const localDateStr = resolveChartLocalDate(row, hourly); @@ -1061,7 +1061,7 @@ function observationSetContains( function getObservationDisplayMetrics( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, settlementPlate?: { maxTemp: number | null } | null, ) { const tzOffset = row?.tz_offset_seconds ?? 0; @@ -1372,7 +1372,7 @@ function mergeRunwayPlateHistory( return Object.keys(result).length ? result : undefined; } -function hasFullHourlyDetailPayload(hourly: HourlyForecast) { +function hasFullHourlyDetailPayload(hourly: ChartRenderState) { if (!hourly) return false; const probabilityBuckets = hourly.probabilities?.distribution_all || @@ -1389,7 +1389,7 @@ function hasFullHourlyDetailPayload(hourly: HourlyForecast) { ); } -function toFullChartDetail(hourly: HourlyForecast): FullChartDetail | null { +function toFullChartDetail(hourly: ChartRenderState): FullChartDetail | null { if (!hourly || !hasFullHourlyDetailPayload(hourly)) return null; if ((hourly as any).__detailKind === "full_chart_detail") return hourly as FullChartDetail; return { @@ -1470,7 +1470,7 @@ function latestRunwayHistoryRank( } function latestHourlyObservationRank( - hourly: HourlyForecast, + hourly: ChartRenderState, row: ScanOpportunityRow | null, ) { if (!hourly) return null; @@ -1488,8 +1488,8 @@ function latestHourlyObservationRank( } function shouldKeepLiveHourlyDetailPayload( - base: HourlyForecast, - live: HourlyForecast, + base: ChartRenderState, + live: ChartRenderState, row: ScanOpportunityRow | null, ) { if (!base || !live) return false; @@ -1500,8 +1500,8 @@ function shouldKeepLiveHourlyDetailPayload( } function hourlyLocalDatesConflict( - base: HourlyForecast, - live: HourlyForecast, + base: ChartRenderState, + live: ChartRenderState, row: ScanOpportunityRow | null, ) { const baseDate = String(base?.localDate || "").trim(); @@ -1563,7 +1563,7 @@ function seedRunwayPlateHistoryFromRow( return Object.keys(history).length ? history : existing; } -type HourlyForecast = { +type ChartRenderState = { forecastTodayHigh?: number | null; debPrediction?: number | null; debQuality?: Pick | null; @@ -1591,11 +1591,11 @@ type HourlyForecast = { airportPrimaryTodayObs?: RawObsPoint[]; } | null; -type FullChartDetail = NonNullable & { +type FullChartDetail = NonNullable & { readonly __detailKind: "full_chart_detail"; }; -function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { +function seedChartRenderStateFromRow(row: ScanOpportunityRow | null): ChartRenderState { if (!row) return null; const current = rowCurrentObservation(row); const sourceCode = current?.sourceCode || undefined; @@ -1658,10 +1658,10 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca } function mergeHourlyWithLiveObservations( - base: HourlyForecast, - live: HourlyForecast, + base: ChartRenderState, + live: ChartRenderState, row: ScanOpportunityRow | null, -): HourlyForecast { +): ChartRenderState { if (!base) return live; if (!live) return base; if (hourlyLocalDatesConflict(base, live, row)) return base; @@ -1708,9 +1708,9 @@ function mergeHourlyWithLiveObservations( } function mergeObservationSnapshotIntoHourly( - prev: HourlyForecast, + prev: ChartRenderState, snapshot: ObservationSnapshot | null | undefined, -): HourlyForecast { +): ChartRenderState { const live = observationSnapshotToHourly(snapshot); if (!prev) return live; if (!live) return prev; @@ -1718,10 +1718,10 @@ function mergeObservationSnapshotIntoHourly( } function mergeRowObservationIntoHourly( - prev: HourlyForecast, + prev: ChartRenderState, row: ScanOpportunityRow | null, -): HourlyForecast { - const seeded = seedHourlyForecastFromRow(row); +): ChartRenderState { + const seeded = seedChartRenderStateFromRow(row); if (!prev) return seeded; return mergeHourlyWithLiveObservations(prev, seeded, row); } @@ -1732,12 +1732,12 @@ function selectInitialHourlyForRowChange({ previousHourly, row, }: { - cachedHourly?: HourlyForecast; + cachedHourly?: ChartRenderState; previousCity?: string | null; - previousHourly?: HourlyForecast; + previousHourly?: ChartRenderState; row: ScanOpportunityRow | null; -}): HourlyForecast { - const seeded = seedHourlyForecastFromRow(row); +}): ChartRenderState { + const seeded = seedChartRenderStateFromRow(row); const nextCity = normalizeCityKey(row?.city); if (!nextCity) return seeded; @@ -1752,7 +1752,7 @@ function selectInitialHourlyForRowChange({ return seeded; } -type HourlyForecastFetchOptions = { +type ChartDetailFetchOptions = { bypassLocalCache?: boolean; ignoreCache?: boolean; resolution?: string; @@ -1890,7 +1890,7 @@ function observationPayloadToSnapshot(payload: CityObservationPayload | null | u }; } -function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | undefined): HourlyForecast { +function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | undefined): ChartRenderState { if (!snapshot || typeof snapshot !== "object") return null; const airportCurrent = normalizeObservationCondition(snapshot.airport_current || snapshot.current); const airportPrimary = normalizeObservationCondition(snapshot.airport_primary || snapshot.airport_current || snapshot.current); @@ -1933,10 +1933,10 @@ function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | unde }; } -function parseHourlyForecastFromCityDetail(json: CityDetail | null): FullChartDetail | null { +function parseFullChartDetailFromCityDetail(json: CityDetail | null): FullChartDetail | null { const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; if (!json || !hourlySource) return null; - const parsed: HourlyForecast = { + const parsed: ChartRenderState = { forecastTodayHigh: json.forecast?.today_high ?? null, debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, debQuality: json.deb ? { @@ -2001,7 +2001,7 @@ function primeCityDetailCache( resolution: string, detail: CityDetail | null | undefined, ): FullChartDetail | null { - let data = parseHourlyForecastFromCityDetail(detail || null); + let data = parseFullChartDetailFromCityDetail(detail || null); if (!data) return null; const cacheKey = hourlyCacheKey(city, resolution); data = preserveCachedRunwayHistory(cacheKey, data); @@ -2176,9 +2176,9 @@ async function fetchLiveObservationForCity(city: string): Promise null); } -async function fetchHourlyForecastForCity( +async function fetchFullChartDetailForCity( city: string, - options: HourlyForecastFetchOptions = {}, + options: ChartDetailFetchOptions = {}, ): Promise { const resParam = options.resolution || "10m"; const cacheKey = hourlyCacheKey(city, resParam); @@ -2232,7 +2232,7 @@ function shouldPollLiveChart({ function getLiveObservationLabels( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, ) { const normalizedKey = normalizeCityKey(row?.city); const runwaySensorCities = new Set([ @@ -2316,19 +2316,19 @@ function getLiveObservationLabels( } function mergePatchIntoHourly( - prev: HourlyForecast, + prev: ChartRenderState, patch: CityPatch, -): HourlyForecast { +): ChartRenderState { const changes = patch.changes || {}; const tempValue = validNumber(changes.temp); const observedAtUtc = typeof changes.observed_at_utc === "string" ? changes.observed_at_utc : null; const obsTime = observedAtUtc || (typeof changes.obs_time === "string" ? changes.obs_time : null); const source = typeof changes.source === "string" ? changes.source : ""; const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object" - ? changes.hourly as Partial> + ? changes.hourly as Partial> : {}; - const next: NonNullable = { + const next: NonNullable = { ...(prev || { forecastTodayHigh: null, debPrediction: null, @@ -2470,7 +2470,7 @@ function parseRunwayHistoryTime( function buildRunwayHistorySeries( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, tzOffset: number, localDateStr: string, minPoints = 2, @@ -2607,7 +2607,7 @@ function formatDailyDateLabel(dateStr: string): string { function buildDailyChartData( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, daysCount: number, ): { data: Array>; series: EvidenceSeries[] } { const localDateStr = resolveChartLocalDate(row, hourly); @@ -2700,7 +2700,7 @@ function addLocalDayAxisSlots(timeline: Set, bounds: LocalDayBounds | nu function resolveFullDayFallbackAnchor( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, tzOffsetSeconds: number, localDateStr: string, ) { @@ -2792,7 +2792,7 @@ function addHourlyTimesToTimeline( } function resolveModelCurveTimes( - hourly: HourlyForecast, + hourly: ChartRenderState, modelTemps: Array, ) { if (hourly?.modelTimes?.length) return hourly.modelTimes; @@ -2827,7 +2827,7 @@ function probabilityBucketRange(bucket: ProbabilityBucket, value: number) { function buildLegacyGaussianProbabilityOverlay( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, ): ProbabilityOverlay | null { const source = hourly?.probabilities || null; const rowBuckets = ((row as any)?.distribution_full || (row as any)?.distribution_preview || []) as ProbabilityBucket[]; @@ -2880,7 +2880,7 @@ function buildLegacyGaussianProbabilityOverlay( function buildFullDayChartData( row: ScanOpportunityRow | null, - hourly: HourlyForecast, + hourly: ChartRenderState, isEn: boolean, ): { data: Array>; series: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null } { const tzOffset = row?.tz_offset_seconds ?? 0; @@ -3510,7 +3510,7 @@ export { buildIntDegreeTicks, buildModelSummaryCards, buildRunwayPlates, - fetchHourlyForecastForCity, + fetchFullChartDetailForCity, fetchLiveObservationForCity, getActiveTemperatureSeries, getTemperatureSeriesForRunwayDetailsMode, @@ -3534,7 +3534,7 @@ export { selectCompactSecondaryTemp, selectDisplayRunwayTemp, selectInitialHourlyForRowChange, - seedHourlyForecastFromRow, + seedChartRenderStateFromRow, seriesStats, shouldPollLiveChart, observationPayloadToSnapshot, @@ -3543,4 +3543,4 @@ export { rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest, }; -export type { EvidenceSeries, FullChartDetail, HourlyForecast, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay }; +export type { EvidenceSeries, FullChartDetail, ChartRenderState, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };