diff --git a/docs/AIRPORT_REALTIME_SOURCES.md b/docs/AIRPORT_REALTIME_SOURCES.md index e5fc3654..b21a6340 100644 --- a/docs/AIRPORT_REALTIME_SOURCES.md +++ b/docs/AIRPORT_REALTIME_SOURCES.md @@ -21,6 +21,33 @@ - 仅当当前温度距 DEB 预测最高 ≤3°C 时推送 - 确认过峰值后自动停止 +## 前端市场监控 freshness 契约 + +后端城市详情接口会在 `current.freshness` / `airport_current.freshness` 返回源感知更新时间信息,前端市场监控不再用统一的 `obs_age_min` 判断所有城市。 + +关键字段: + +```json +{ + "source_code": "amos", + "source_label": "AMOS", + "observed_at": "2026-05-14T11:59:10+00:00", + "observed_at_local": "20:59", + "native_update_interval_sec": 60, + "expected_next_update_at": "2026-05-14T12:00:10+00:00", + "freshness_status": "fresh", + "freshness_reason": "within_native_fresh_window", + "age_sec": 50 +} +``` + +前端刷新规则: + +- 首次进入市场监控:强制刷新全部城市,绕过 30 分钟前端缓存。 +- 定时轮询:仍以 60s tick 检查,但只刷新已到 `expected_next_update_at`、`delayed`、`stale` 或缺失的城市。 +- BFF 代理:`force_refresh=true` 时使用 `no-store`,避免 Next fetch revalidate 缓存吞掉强刷。 +- 展示:卡片 tooltip 显示源端名称、原生更新间隔和当前 freshness 状态。 + ## 消息模板 ``` diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index b62b2cad..e60e47b4 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -8,6 +8,7 @@ import { buildUpstreamErrorResponse, } from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -25,6 +26,7 @@ export async function GET( const { name } = await context.params; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; + const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const depth = req.nextUrl.searchParams.get("depth"); const marketSlug = req.nextUrl.searchParams.get("market_slug"); const targetDate = req.nextUrl.searchParams.get("target_date"); @@ -48,7 +50,9 @@ export async function GET( }); const res = await fetch(url, { headers: auth.headers, - next: { revalidate: 15 }, + ...(cachePolicy.fetchMode === "no-store" + ? { cache: "no-store" as const } + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), }); if (!res.ok) { const raw = await res.text(); @@ -59,7 +63,7 @@ export async function GET( const response = buildCachedJsonResponse( req, data, - "public, max-age=0, s-maxage=15, stale-while-revalidate=45", + cachePolicy.responseCacheControl, ); return applyAuthResponseCookies(response, auth.response); } catch (error) { diff --git a/frontend/app/api/city/[name]/route.ts b/frontend/app/api/city/[name]/route.ts index 205d392b..5fa5e8ca 100644 --- a/frontend/app/api/city/[name]/route.ts +++ b/frontend/app/api/city/[name]/route.ts @@ -8,6 +8,7 @@ import { buildUpstreamErrorResponse, } from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -129,6 +130,7 @@ export async function GET( const { name } = await context.params; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const depth = req.nextUrl.searchParams.get("depth") ?? "panel"; + const cachePolicy = buildCityDetailProxyCachePolicy(forceRefresh, 15); const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}&depth=${encodeURIComponent(depth)}`; try { @@ -137,21 +139,27 @@ export async function GET( }); const res = await fetch(url, { headers: auth.headers, - next: { revalidate: 15 }, + ...(cachePolicy.fetchMode === "no-store" + ? { cache: "no-store" as const } + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), }); if (!res.ok) { const raw = await res.text(); const summaryUrl = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`; const summaryRes = await fetch(summaryUrl, { headers: auth.headers, - next: { revalidate: 10 }, + ...(cachePolicy.fetchMode === "no-store" + ? { cache: "no-store" as const } + : { next: { revalidate: 10 } }), }); if (summaryRes.ok) { const summaryData = await summaryRes.json(); const response = buildCachedJsonResponse( req, buildFallbackCityDetail(name, depth, summaryData), - "public, max-age=0, s-maxage=10, stale-while-revalidate=30", + cachePolicy.fetchMode === "no-store" + ? cachePolicy.responseCacheControl + : "public, max-age=0, s-maxage=10, stale-while-revalidate=30", ); response.headers.set("X-PolyWeather-Fallback", "summary"); return applyAuthResponseCookies(response, auth.response); @@ -164,7 +172,7 @@ export async function GET( const response = buildCachedJsonResponse( req, data, - "public, max-age=0, s-maxage=15, stale-while-revalidate=45", + cachePolicy.responseCacheControl, ); return applyAuthResponseCookies(response, auth.response); } catch (error) { diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts index ed239a25..4af05ca7 100644 --- a/frontend/app/api/scan/terminal/route.ts +++ b/frontend/app/api/scan/terminal/route.ts @@ -8,6 +8,7 @@ import { buildUpstreamErrorResponse, } from "@/lib/api-proxy"; import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number( @@ -25,6 +26,7 @@ export async function GET(req: NextRequest) { } const params = new URLSearchParams(); + const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; for (const key of [ "scan_mode", "min_price", @@ -42,6 +44,7 @@ export async function GET(req: NextRequest) { params.set(key, value); } } + const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 10); const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; @@ -55,7 +58,9 @@ export async function GET(req: NextRequest) { }); const res = await fetch(url, { headers: auth.headers, - next: { revalidate: 10 }, + ...(cachePolicy.fetchMode === "no-store" + ? { cache: "no-store" as const } + : { next: { revalidate: cachePolicy.revalidateSeconds ?? 10 } }), signal: controller.signal, }); if (!res.ok) { @@ -67,7 +72,7 @@ export async function GET(req: NextRequest) { const response = buildCachedJsonResponse( req, data, - "public, max-age=0, s-maxage=10, stale-while-revalidate=30", + cachePolicy.responseCacheControl, ); return applyAuthResponseCookies(response, auth.response); } catch (error) { diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index b4c541e8..b0a42e9e 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -1,9 +1,20 @@ "use client"; import { useMemo, useState, useEffect, useRef, useCallback } from "react"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; -import type { CityDetail } from "@/lib/dashboard-types"; +import type { CityDetail, ObservationFreshness } from "@/lib/dashboard-types"; +import { + getMonitorFreshnessLevel, + getObservationFreshness, + shouldRefreshMonitorCity, + type MonitorFreshnessLevel, +} from "@/lib/source-freshness"; +import { + getMonitorRefreshRequest, + MONITOR_REFRESH_INTERVAL_MS, + type MonitorRefreshTrigger, +} from "./monitor-refresh-policy"; /* ── Constants ───────────────────────────────────────────────── */ const MONITOR_KEYS = [ @@ -14,33 +25,42 @@ const MONITOR_KEYS = [ ] as const; type MonitorKey = (typeof MONITOR_KEYS)[number]; +type MonitorRefreshRequest = ReturnType; const CONCURRENCY = 6; -const REFRESH_INTERVAL_MS = 60_000; /* ── Helpers ─────────────────────────────────────────────────── */ type Lang = { isEn: boolean }; function t(en: string, zh: string, { isEn }: Lang) { return isEn ? en : zh; } -type Freshness = "fresh" | "aging" | "stale" | "unknown"; +type Freshness = MonitorFreshnessLevel; -function freshnessLevel(ageMin: number | null | undefined): Freshness { - if (ageMin == null) return "unknown"; - if (ageMin < 20) return "fresh"; - if (ageMin < 45) return "aging"; - return "stale"; -} - -function freshnessDotTitle(level: Freshness, ageMin: number | null | undefined, isEn: boolean): string { +function freshnessDotTitle( + level: Freshness, + ageMin: number | null | undefined, + isEn: boolean, + freshness?: ObservationFreshness | null, +): string { const age = ageMin != null ? (isEn ? `${ageMin} min ago` : `${ageMin} 分钟前`) : "--"; + const source = freshness?.source_label ? `${freshness.source_label} · ` : ""; + const cadence = + freshness?.native_update_interval_sec != null + ? Math.round(freshness.native_update_interval_sec / 60) + : null; + const cadenceText = + cadence != null + ? isEn + ? ` · native cadence ${cadence} min` + : ` · 源端约 ${cadence} 分钟更新` + : ""; if (isEn) { - return level === "fresh" ? `Fresh · ${age}` : - level === "aging" ? `Aging · ${age}` : - level === "stale" ? `Stale · ${age}` : "Unknown age"; + return level === "fresh" ? `${source}Fresh · ${age}${cadenceText}` : + level === "aging" ? `${source}Waiting / aging · ${age}${cadenceText}` : + level === "stale" ? `${source}Stale · ${age}${cadenceText}` : `${source}Unknown age${cadenceText}`; } - return level === "fresh" ? `数据新鲜 · ${age}` : - level === "aging" ? `数据变旧 · ${age}` : - level === "stale" ? `数据陈旧 · ${age}` : "更新时间未知"; + return level === "fresh" ? `${source}数据新鲜 · ${age}${cadenceText}` : + level === "aging" ? `${source}等待源端更新 / 数据变旧 · ${age}${cadenceText}` : + level === "stale" ? `${source}数据陈旧 · ${age}${cadenceText}` : `${source}更新时间未知${cadenceText}`; } /* ── Audio alert (Web Audio API, no external file needed) ────── */ @@ -147,7 +167,13 @@ function airportLabel(key: string, isEn: boolean) { */ const HKO_OBS_CITIES = new Set(["hong kong", "lau fau shan"]); -function obsSourceLabel(key: MonitorKey, isEn: boolean): string { +function obsSourceLabel( + key: MonitorKey, + isEn: boolean, + freshness?: ObservationFreshness | null, +): string { + const sourceLabel = String(freshness?.source_label || "").trim(); + if (sourceLabel) return sourceLabel; if (HKO_OBS_CITIES.has(key)) return isEn ? "HKO Obs" : "天文台观测"; return isEn ? "Airport METAR" : "机场报文"; } @@ -181,14 +207,19 @@ export default function MonitorPanel({ }: { onCityClick?: (cityName: string) => void; }) { - const store = useDashboardStore(); + const { ensureCityDetail } = useDashboardActions(); const { locale } = useI18n(); const isEn = locale === "en-US"; const lang: Lang = { isEn }; - const details = store.cityDetailsByName; + const { cityDetailsByName: details } = useCityDetails(); const detailsRef = useRef(details); detailsRef.current = details; + const ensureCityDetailRef = useRef(ensureCityDetail); + + useEffect(() => { + ensureCityDetailRef.current = ensureCityDetail; + }, [ensureCityDetail]); const [time, setTime] = useState(""); const [fetchingKeys, setFetchingKeys] = useState>(new Set()); @@ -238,10 +269,10 @@ export default function MonitorPanel({ /* Per-city fetch with loading-key tracking */ const fetchCity = useCallback( - async (key: MonitorKey, force: boolean) => { + async (key: MonitorKey, request: MonitorRefreshRequest) => { setFetchingKeys((prev) => new Set([...prev, key])); try { - await store.ensureCityDetail(key, force, "panel"); + await ensureCityDetailRef.current(key, request.force, request.depth); } catch { /* individual city errors are shown as "--" in the card */ } finally { @@ -252,30 +283,52 @@ export default function MonitorPanel({ }); } }, - [store.ensureCityDetail], + [], ); /* Refresh all cities, sorted by staleness (most stale first). */ const refreshAll = useCallback( - async (force: boolean) => { + async (trigger: MonitorRefreshTrigger) => { if (globalFetchingRef.current) return; globalFetchingRef.current = true; + const request = getMonitorRefreshRequest(trigger); - /* Sort keys: cities with no data first, then by obs_age_min descending */ - const sorted = [...MONITOR_KEYS].sort((a, b) => { + const now = new Date(); + /* Sort keys: cities with no data first, then by source-aware freshness/staleness. */ + const dueKeys = [...MONITOR_KEYS].filter((key) => + shouldRefreshMonitorCity({ + detail: detailsRef.current[key], + now, + trigger, + }), + ); + + const sorted = dueKeys.sort((a, b) => { const d = detailsRef.current; - const ageA = d[a]?.airport_current?.obs_age_min ?? Infinity; - const ageB = d[b]?.airport_current?.obs_age_min ?? Infinity; + const freshA = getObservationFreshness(d[a]); + const freshB = getObservationFreshness(d[b]); + const ageA = + freshA?.age_sec != null + ? freshA.age_sec / 60 + : d[a]?.airport_current?.obs_age_min ?? Infinity; + const ageB = + freshB?.age_sec != null + ? freshB.age_sec / 60 + : d[b]?.airport_current?.obs_age_min ?? Infinity; return ageB - ageA; // stale first }); const queue = sorted as MonitorKey[]; + if (queue.length === 0) { + globalFetchingRef.current = false; + return; + } const workers = Array.from({ length: CONCURRENCY }, async () => { while (queue.length > 0) { if (cancelledRef.current) return; const key = queue.shift(); if (!key) break; - await fetchCity(key, force); + await fetchCity(key, request); } }); await Promise.allSettled(workers); @@ -290,10 +343,10 @@ export default function MonitorPanel({ useEffect(() => { cancelledRef.current = false; - void refreshAll(false); + void refreshAll("initial"); const timer = setInterval(() => { - if (!document.hidden) void refreshAll(true); - }, REFRESH_INTERVAL_MS); + if (!document.hidden) void refreshAll("interval"); + }, MONITOR_REFRESH_INTERVAL_MS); return () => { cancelledRef.current = true; clearInterval(timer); @@ -427,9 +480,17 @@ export default function MonitorPanel({ const cur = ac?.temp ?? detail.current?.temp ?? null; const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far const mtt = ac?.max_temp_time ?? detail.current?.max_temp_time ?? null; - const obs = ac?.obs_time ?? detail.local_time ?? ""; - const age = ac?.obs_age_min ?? null; - const freshness = freshnessLevel(age); + const freshnessInfo = getObservationFreshness(detail); + const obs = + ac?.obs_time ?? + freshnessInfo?.observed_at_local ?? + detail.current?.obs_time ?? + detail.local_time ?? + ""; + const age = + ac?.obs_age_min ?? + (freshnessInfo?.age_sec != null ? Math.round(freshnessInfo.age_sec / 60) : null); + const freshness = getMonitorFreshnessLevel(freshnessInfo, age); const tempSymbol = detail.temp_symbol || "°C"; // °F for US cities const newHigh = cur != null && max != null && cur >= max + 0.3; const warm = !newHigh && cur != null && cur >= 30; @@ -462,7 +523,7 @@ export default function MonitorPanel({ / {airportLabel(key, isEn)} {newHigh && ( @@ -502,7 +563,7 @@ export default function MonitorPanel({
- {obsSourceLabel(key, isEn)} + {obsSourceLabel(key, isEn, freshnessInfo)} {age != null ? ( diff --git a/frontend/components/dashboard/monitoring/monitor-refresh-policy.ts b/frontend/components/dashboard/monitoring/monitor-refresh-policy.ts new file mode 100644 index 00000000..339adf70 --- /dev/null +++ b/frontend/components/dashboard/monitoring/monitor-refresh-policy.ts @@ -0,0 +1,11 @@ +export const MONITOR_CITY_DETAIL_DEPTH = "panel" as const; +export const MONITOR_REFRESH_INTERVAL_MS = 60_000; + +export type MonitorRefreshTrigger = "initial" | "interval"; + +export function getMonitorRefreshRequest(_trigger: MonitorRefreshTrigger) { + return { + depth: MONITOR_CITY_DETAIL_DEPTH, + force: true, + }; +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/httpError.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/httpError.test.ts new file mode 100644 index 00000000..19d50c5f --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/httpError.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { formatHttpErrorMessage } from "@/lib/http-error"; + +export function runTests() { + assert.equal(formatHttpErrorMessage(502), "HTTP 502"); + assert.equal( + formatHttpErrorMessage(403, "Forbidden", '{"detail":"missing entitlement"}'), + "HTTP 403 Forbidden: missing entitlement", + ); + assert.equal( + formatHttpErrorMessage(500, null, '{"error":{"code":"boom"}}'), + 'HTTP 500: {"code":"boom"}', + ); + assert.equal( + formatHttpErrorMessage(504, "Gateway Timeout", " upstream timed out\n"), + "HTTP 504 Gateway Timeout: upstream timed out", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/monitorRefreshPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/monitorRefreshPolicy.test.ts new file mode 100644 index 00000000..2bc55173 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/monitorRefreshPolicy.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { + getMonitorRefreshRequest, + MONITOR_CITY_DETAIL_DEPTH, +} from "@/components/dashboard/monitoring/monitor-refresh-policy"; + +export function runTests() { + const initial = getMonitorRefreshRequest("initial"); + assert.equal( + initial.force, + true, + "monitor initial load must force refresh instead of showing 30-minute session cache", + ); + assert.equal(initial.depth, MONITOR_CITY_DETAIL_DEPTH); + + const interval = getMonitorRefreshRequest("interval"); + assert.equal(interval.force, true); + assert.equal(interval.depth, MONITOR_CITY_DETAIL_DEPTH); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts new file mode 100644 index 00000000..9761d1c2 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/proxyCachePolicy.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { + buildCityDetailProxyCachePolicy, + buildForceRefreshProxyCachePolicy, + isForceRefreshValue, +} from "@/lib/proxy-cache-policy"; + +export function runTests() { + assert.equal(isForceRefreshValue("true"), true); + assert.equal(isForceRefreshValue("false"), false); + assert.equal(isForceRefreshValue(null), false); + + const forced = buildCityDetailProxyCachePolicy("true"); + assert.equal(forced.fetchMode, "no-store"); + assert.match(forced.responseCacheControl, /no-store/); + assert.equal(forced.revalidateSeconds, undefined); + + const cached = buildCityDetailProxyCachePolicy("false", 15); + assert.equal(cached.fetchMode, "revalidate"); + assert.equal(cached.revalidateSeconds, 15); + assert.match(cached.responseCacheControl, /s-maxage=15/); + + const scanForced = buildForceRefreshProxyCachePolicy("true", 10); + assert.equal(scanForced.fetchMode, "no-store"); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts new file mode 100644 index 00000000..0cf4dc83 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { + buildObservationFreshness, + getMonitorFreshnessLevel, + getMonitorRefreshCadenceMs, + getObservationSourceProfile, + shouldRefreshMonitorCity, +} from "@/lib/source-freshness"; +import type { CityDetail } from "@/lib/dashboard-types"; + +function detail(extra: Partial): CityDetail { + return { + current: { temp: null }, + display_name: "Test", + lat: 0, + local_date: "2026-05-14", + local_time: "12:00", + lon: 0, + name: "test", + risk: { level: "low" }, + temp_symbol: "°C", + ...extra, + } as CityDetail; +} + +export function runTests() { + const now = new Date("2026-05-14T12:00:00Z"); + + assert.equal(getObservationSourceProfile("amos").nativeUpdateIntervalSec, 60); + assert.equal(getObservationSourceProfile("metar").nativeUpdateIntervalSec, 900); + + const amosFresh = buildObservationFreshness({ + now, + observedAt: "2026-05-14T11:59:10Z", + sourceCode: "amos", + sourceLabel: "AMOS", + }); + assert.equal(amosFresh.freshness_status, "fresh"); + assert.equal(getMonitorFreshnessLevel(amosFresh, null), "fresh"); + assert.equal( + shouldRefreshMonitorCity({ + detail: detail({ airport_current: { freshness: amosFresh, obs_time: "11:59", temp: 20 } }), + now, + trigger: "interval", + }), + false, + ); + + const metarStillExpected = buildObservationFreshness({ + now, + observedAt: "2026-05-14T11:48:00Z", + sourceCode: "metar", + sourceLabel: "METAR", + }); + assert.equal(metarStillExpected.freshness_status, "expected_wait"); + assert.equal(getMonitorFreshnessLevel(metarStillExpected, null), "aging"); + + const metarOld = buildObservationFreshness({ + now, + observedAt: "2026-05-14T10:50:00Z", + sourceCode: "metar", + sourceLabel: "METAR", + }); + assert.equal(metarOld.freshness_status, "stale"); + assert.equal(getMonitorFreshnessLevel(metarOld, null), "stale"); + assert.equal( + shouldRefreshMonitorCity({ + detail: detail({ airport_current: { freshness: metarOld, obs_time: "10:50", temp: 20 } }), + now, + trigger: "interval", + }), + true, + ); + + assert.equal( + shouldRefreshMonitorCity({ detail: undefined, now, trigger: "interval" }), + true, + "missing city detail should always be fetched", + ); + assert.equal( + shouldRefreshMonitorCity({ + detail: detail({ airport_current: { obs_age_min: 5, obs_time: "11:55", temp: 20 } }), + now, + trigger: "interval", + }), + false, + "legacy METAR age under native cadence should not be force-refreshed every minute", + ); + + assert.equal(getMonitorRefreshCadenceMs(["amos", "metar"]), 60_000); + assert.equal(getMonitorRefreshCadenceMs(["metar"]), 300_000); +} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 2e7e9606..4cf26d76 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -74,6 +74,10 @@ interface DashboardStoreValue extends DashboardState { } const DashboardStoreContext = createContext(null); +const DashboardActionsContext = createContext | null>(null); const CityDetailsContext = createContext<{ cityDetailsByName: Record; cityDetailMetaByName: Record; @@ -1550,12 +1554,24 @@ export function DashboardStoreProvider({ () => ({ cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState }), [cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState], ); + const latestEnsureCityDetailRef = useRef(ensureCityDetail); + useEffect(() => { + latestEnsureCityDetailRef.current = ensureCityDetail; + }, [ensureCityDetail]); + const dashboardActionsValue = useMemo>( + () => ({ + ensureCityDetail: (...args) => latestEnsureCityDetailRef.current(...args), + }), + [], + ); return ( - - {children} - + + + {children} + + ); } @@ -1580,6 +1596,16 @@ export function useCityDetails() { return context; } +export function useDashboardActions() { + const context = useContext(DashboardActionsContext); + if (!context) { + throw new Error( + "useDashboardActions must be used within DashboardStoreProvider", + ); + } + return context; +} + export function useCityData(name?: string | null) { const store = useDashboardStore(); const key = name || store.selectedCity; diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index ff942166..3b542e8a 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -13,6 +13,7 @@ import { buildBrowserBackendHeaders, fetchBackendApi, } from "@/lib/backend-api"; +import { formatHttpErrorMessage } from "@/lib/http-error"; const CACHE_KEY = "polyWeather_v1"; const CACHE_TTL_MS = 30 * 60 * 1000; @@ -54,7 +55,10 @@ function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") { return "panel"; } -async function fetchJson(url: string, options?: { timeoutMs?: number }): Promise { +async function fetchJson( + url: string, + options?: { cache?: RequestCache; timeoutMs?: number }, +): Promise { const timeoutMs = options?.timeoutMs; const controller = timeoutMs ? new AbortController() : null; const timeoutId = controller @@ -68,7 +72,7 @@ async function fetchJson(url: string, options?: { timeoutMs?: number }): Prom try { response = await fetchBackendApi(url, { headers, - cache: "default", + cache: options?.cache ?? "default", signal: controller?.signal, }); } catch (error) { @@ -83,7 +87,10 @@ async function fetchJson(url: string, options?: { timeoutMs?: number }): Prom } if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + const body = await response.text().catch(() => ""); + throw new Error( + formatHttpErrorMessage(response.status, response.statusText, body), + ); } return response.json() as Promise; @@ -253,6 +260,7 @@ export const dashboardClient = { const request = fetchJson( `/api/city/${normalizeCityName(cityName)}/summary?force_refresh=${force}`, + force ? { cache: "no-store" } : undefined, ).finally(() => { pendingCitySummaryRequests.delete(requestKey); }); @@ -292,7 +300,7 @@ export const dashboardClient = { }); return fetchJson( `/api/city/${normalizeCityName(cityName)}?${params.toString()}`, - { timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS }, + { cache: "no-store", timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS }, ); }, @@ -340,6 +348,7 @@ export const dashboardClient = { const request = (async () => { const payload = await fetchJson( `/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`, + force ? { cache: "no-store" } : undefined, ); if (!force && options?.marketSlug && isStaleMarketSlugResponse(payload)) { const fallbackParams = new URLSearchParams({ @@ -389,7 +398,10 @@ export const dashboardClient = { } const request = fetchJson( `/api/scan/terminal?${params.toString()}`, - { timeoutMs: SCAN_TERMINAL_CLIENT_TIMEOUT_MS }, + { + cache: force ? "no-store" : "default", + timeoutMs: SCAN_TERMINAL_CLIENT_TIMEOUT_MS, + }, ).finally(() => { pendingScanTerminalRequests.delete(requestKey); }); @@ -426,7 +438,10 @@ export const dashboardClient = { })) .then(async (response) => { if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + const body = await response.text().catch(() => ""); + throw new Error( + formatHttpErrorMessage(response.status, response.statusText, body), + ); } return response.json() as Promise; }) diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index a6b0418b..ab6fd73b 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -53,6 +53,27 @@ export interface CloudLayer { base: number | null; } +export interface ObservationFreshness { + source_code?: string | null; + source_label?: string | null; + observed_at?: string | null; + observed_at_local?: string | null; + ingested_at?: string | null; + native_update_interval_sec?: number | null; + expected_next_update_at?: string | null; + freshness_status?: + | "fresh" + | "expected_wait" + | "delayed" + | "stale" + | "offline" + | "unknown" + | string + | null; + freshness_reason?: string | null; + age_sec?: number | null; +} + export interface CurrentConditions { temp: number | null; max_so_far: number | null; @@ -76,6 +97,8 @@ export interface CurrentConditions { report_time?: string | null; receipt_time?: string | null; obs_time_epoch?: number | null; + source_code?: string | null; + freshness?: ObservationFreshness | null; dewpoint?: number | null; } @@ -95,6 +118,7 @@ export interface AirportCurrentConditions { visibility_mi?: number | null; wx_desc?: string | null; raw_metar?: string | null; + source_code?: string | null; source_label?: string | null; station_code?: string | null; station_label?: string | null; @@ -105,6 +129,7 @@ export interface AirportCurrentConditions { pressure_hpa?: number | null; last_observation_local_date?: string | null; current_local_date?: string | null; + freshness?: ObservationFreshness | null; } export interface NearbyStation { diff --git a/frontend/lib/http-error.ts b/frontend/lib/http-error.ts new file mode 100644 index 00000000..81fcc1e5 --- /dev/null +++ b/frontend/lib/http-error.ts @@ -0,0 +1,29 @@ +export function formatHttpErrorMessage( + status: number, + statusText?: string | null, + body?: string | null, +) { + const base = `HTTP ${status}${statusText ? ` ${statusText}` : ""}`; + const rawBody = String(body || "").trim(); + if (!rawBody) return base; + + let detail = rawBody; + try { + const parsed = JSON.parse(rawBody) as { + detail?: unknown; + error?: unknown; + message?: unknown; + }; + const value = parsed.detail ?? parsed.error ?? parsed.message; + if (value != null) { + detail = + typeof value === "string" ? value : JSON.stringify(value); + } + } catch { + // keep raw body + } + + const normalized = detail.replace(/\s+/g, " ").trim(); + if (!normalized) return base; + return `${base}: ${normalized.slice(0, 300)}`; +} diff --git a/frontend/lib/proxy-cache-policy.ts b/frontend/lib/proxy-cache-policy.ts new file mode 100644 index 00000000..c3f91185 --- /dev/null +++ b/frontend/lib/proxy-cache-policy.ts @@ -0,0 +1,31 @@ +export type ProxyCachePolicy = { + fetchMode: "no-store" | "revalidate"; + responseCacheControl: string; + revalidateSeconds?: number; +}; + +export function isForceRefreshValue(value: string | null | undefined) { + return String(value || "").trim().toLowerCase() === "true"; +} + +export function buildForceRefreshProxyCachePolicy( + forceRefresh: string | null | undefined, + revalidateSeconds = 15, +): ProxyCachePolicy { + if (isForceRefreshValue(forceRefresh)) { + return { + fetchMode: "no-store", + responseCacheControl: "no-store, max-age=0", + }; + } + return { + fetchMode: "revalidate", + responseCacheControl: `public, max-age=0, s-maxage=${revalidateSeconds}, stale-while-revalidate=${Math.max( + revalidateSeconds * 3, + 30, + )}`, + revalidateSeconds, + }; +} + +export const buildCityDetailProxyCachePolicy = buildForceRefreshProxyCachePolicy; diff --git a/frontend/lib/source-freshness.ts b/frontend/lib/source-freshness.ts new file mode 100644 index 00000000..3948c613 --- /dev/null +++ b/frontend/lib/source-freshness.ts @@ -0,0 +1,310 @@ +import type { CityDetail, ObservationFreshness } from "@/lib/dashboard-types"; +import { normalizeObservationSourceCode } from "@/lib/source-labels"; + +export type MonitorFreshnessLevel = "fresh" | "aging" | "stale" | "unknown"; +export type ObservationFreshnessStatus = + | "fresh" + | "expected_wait" + | "delayed" + | "stale" + | "offline" + | "unknown"; + +type SourceProfile = { + code: string; + label: string; + nativeUpdateIntervalSec: number; + freshWindowSec: number; + expectedGraceSec: number; + staleAfterSec: number; + pollIntervalSec: number; +}; + +const DEFAULT_SOURCE_PROFILE: SourceProfile = { + code: "metar", + label: "METAR", + nativeUpdateIntervalSec: 900, + freshWindowSec: 600, + expectedGraceSec: 900, + staleAfterSec: 3600, + pollIntervalSec: 300, +}; + +const SOURCE_PROFILES: Record = { + amos: { + code: "amos", + label: "AMOS", + nativeUpdateIntervalSec: 60, + freshWindowSec: 180, + expectedGraceSec: 180, + staleAfterSec: 900, + pollIntervalSec: 60, + }, + jma: { + code: "jma", + label: "JMA", + nativeUpdateIntervalSec: 600, + freshWindowSec: 900, + expectedGraceSec: 600, + staleAfterSec: 2700, + pollIntervalSec: 300, + }, + fmi: { + code: "fmi", + label: "FMI", + nativeUpdateIntervalSec: 600, + freshWindowSec: 900, + expectedGraceSec: 600, + staleAfterSec: 2700, + pollIntervalSec: 300, + }, + knmi: { + code: "knmi", + label: "KNMI", + nativeUpdateIntervalSec: 600, + freshWindowSec: 900, + expectedGraceSec: 600, + staleAfterSec: 2700, + pollIntervalSec: 300, + }, + hko: { + code: "hko", + label: "HKO", + nativeUpdateIntervalSec: 600, + freshWindowSec: 900, + expectedGraceSec: 600, + staleAfterSec: 2700, + pollIntervalSec: 300, + }, + cwa: { + code: "cwa", + label: "CWA", + nativeUpdateIntervalSec: 600, + freshWindowSec: 900, + expectedGraceSec: 600, + staleAfterSec: 2700, + pollIntervalSec: 300, + }, + mgm: { + code: "mgm", + label: "MGM", + nativeUpdateIntervalSec: 900, + freshWindowSec: 900, + expectedGraceSec: 900, + staleAfterSec: 3600, + pollIntervalSec: 300, + }, + metar: DEFAULT_SOURCE_PROFILE, + noaa: DEFAULT_SOURCE_PROFILE, + wunderground: DEFAULT_SOURCE_PROFILE, + nmc: { + code: "nmc", + label: "NMC", + nativeUpdateIntervalSec: 3600, + freshWindowSec: 3600, + expectedGraceSec: 1800, + staleAfterSec: 7200, + pollIntervalSec: 600, + }, +}; + +function canonicalSourceCode(value?: string | null) { + const code = normalizeObservationSourceCode(value || "metar"); + if (!code) return "metar"; + if (code.includes("amos")) return "amos"; + if (code.includes("jma")) return "jma"; + if (code.includes("fmi")) return "fmi"; + if (code.includes("knmi")) return "knmi"; + if (code.includes("hko")) return "hko"; + if (code.includes("cwa")) return "cwa"; + if (code.includes("mgm")) return "mgm"; + if (code.includes("noaa")) return "noaa"; + if (code.includes("nmc")) return "nmc"; + return code; +} + +export function getObservationSourceProfile(sourceCode?: string | null): SourceProfile { + const code = canonicalSourceCode(sourceCode); + return SOURCE_PROFILES[code] || { ...DEFAULT_SOURCE_PROFILE, code }; +} + +function parseDate(value?: string | null): Date | null { + const raw = String(value || "").trim(); + if (!raw || !raw.includes("T")) return null; + const date = new Date(raw); + return Number.isFinite(date.getTime()) ? date : null; +} + +function isoOrNull(date: Date | null) { + return date ? date.toISOString() : null; +} + +export function buildObservationFreshness({ + ageMin, + ingestedAt, + now = new Date(), + observedAt, + observedAtLocal, + sourceCode, + sourceLabel, +}: { + ageMin?: number | null; + ingestedAt?: string | null; + now?: Date; + observedAt?: string | null; + observedAtLocal?: string | null; + sourceCode?: string | null; + sourceLabel?: string | null; +}): ObservationFreshness { + const profile = getObservationSourceProfile(sourceCode || sourceLabel); + const observedDate = parseDate(observedAt || null); + const ageSec = + typeof ageMin === "number" + ? Math.max(0, Math.round(ageMin * 60)) + : observedDate + ? Math.max(0, Math.round((now.getTime() - observedDate.getTime()) / 1000)) + : null; + const expectedNext = + observedDate == null + ? null + : new Date(observedDate.getTime() + profile.nativeUpdateIntervalSec * 1000); + + let status: ObservationFreshnessStatus = "unknown"; + let reason = ""; + if (ageSec == null) { + status = "unknown"; + reason = "observation_time_missing"; + } else if (ageSec <= profile.freshWindowSec) { + status = "fresh"; + reason = "within_native_fresh_window"; + } else if (ageSec <= profile.nativeUpdateIntervalSec + profile.expectedGraceSec) { + status = "expected_wait"; + reason = "within_source_expected_cadence"; + } else if (ageSec <= profile.staleAfterSec) { + status = "delayed"; + reason = "past_expected_cadence"; + } else { + status = "stale"; + reason = "past_stale_threshold"; + } + + return { + age_sec: ageSec, + expected_next_update_at: isoOrNull(expectedNext), + freshness_reason: reason, + freshness_status: status, + ingested_at: ingestedAt || null, + native_update_interval_sec: profile.nativeUpdateIntervalSec, + observed_at: observedDate ? observedDate.toISOString() : observedAt || null, + observed_at_local: observedAtLocal || null, + source_code: profile.code, + source_label: sourceLabel || profile.label, + }; +} + +export function getObservationFreshness(detail?: CityDetail | null) { + if (!detail) return null; + const currentSource = canonicalSourceCode( + detail.current?.source_code || + detail.current?.settlement_source || + detail.current?.settlement_source_label || + "", + ); + if ( + detail.current?.freshness && + currentSource && + currentSource !== "metar" && + currentSource !== "wunderground" + ) { + return detail.current.freshness; + } + const embedded = + detail.airport_current?.freshness || + detail.current?.freshness || + detail.airport_primary?.freshness || + null; + if (embedded) return embedded; + + const ac = detail.airport_current; + const current = detail.current; + const sourceCode = + ac?.source_code || + current?.settlement_source || + current?.settlement_source_label || + "metar"; + const ageMin = ac?.obs_age_min ?? current?.obs_age_min ?? null; + return buildObservationFreshness({ + ageMin, + observedAt: ac?.report_time || current?.report_time || null, + observedAtLocal: ac?.obs_time || current?.obs_time || null, + sourceCode, + sourceLabel: ac?.source_label || current?.settlement_source_label || undefined, + }); +} + +export function getMonitorFreshnessLevel( + freshness: ObservationFreshness | null | undefined, + fallbackAgeMin: number | null | undefined, +): MonitorFreshnessLevel { + if (freshness?.freshness_status) { + if (freshness.freshness_status === "fresh") return "fresh"; + if ( + freshness.freshness_status === "expected_wait" || + freshness.freshness_status === "delayed" + ) { + return "aging"; + } + if ( + freshness.freshness_status === "stale" || + freshness.freshness_status === "offline" + ) { + return "stale"; + } + } + if (fallbackAgeMin == null) return "unknown"; + if (fallbackAgeMin < 20) return "fresh"; + if (fallbackAgeMin < 45) return "aging"; + return "stale"; +} + +function freshnessDueAt(freshness: ObservationFreshness | null | undefined) { + const due = parseDate(freshness?.expected_next_update_at || null); + return due?.getTime() ?? null; +} + +export function shouldRefreshMonitorCity({ + detail, + now = new Date(), + trigger, +}: { + detail?: CityDetail | null; + now?: Date; + trigger: "initial" | "interval" | "manual"; +}) { + if (trigger === "initial" || trigger === "manual") return true; + if (!detail) return true; + const freshness = getObservationFreshness(detail); + if ( + freshness?.freshness_status === "stale" || + freshness?.freshness_status === "offline" || + freshness?.freshness_status === "delayed" + ) { + return true; + } + const dueAt = freshnessDueAt(freshness); + if (dueAt != null) return dueAt <= now.getTime(); + + const ageMin = detail.airport_current?.obs_age_min ?? detail.current?.obs_age_min ?? null; + if (ageMin == null) return true; + const profile = getObservationSourceProfile(freshness?.source_code); + return ageMin * 60 >= profile.nativeUpdateIntervalSec; +} + +export function getMonitorRefreshCadenceMs(sourceCodes: Array) { + const pollSec = sourceCodes.length + ? Math.min( + ...sourceCodes.map((source) => getObservationSourceProfile(source).pollIntervalSec), + ) + : 60; + return Math.max(60_000, pollSec * 1000); +} diff --git a/frontend/package.json b/frontend/package.json index 8ab47694..8f506e3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build": "next build && node scripts/sync-next-server-chunks.mjs", "start": "next start", "lint": "next lint", + "typecheck": "tsc --noEmit", "test:business": "node scripts/run-business-state-tests.mjs" }, "dependencies": { diff --git a/web/analysis_service.py b/web/analysis_service.py index 4671617e..5e032c13 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -150,6 +150,185 @@ def _metar_is_current_local_day( return local_dt.strftime("%Y-%m-%d") == local_date +_OBSERVATION_SOURCE_PROFILES: Dict[str, Dict[str, Any]] = { + "amos": { + "label": "AMOS", + "native_update_interval_sec": 60, + "fresh_window_sec": 180, + "expected_grace_sec": 180, + "stale_after_sec": 900, + }, + "jma": { + "label": "JMA", + "native_update_interval_sec": 600, + "fresh_window_sec": 900, + "expected_grace_sec": 600, + "stale_after_sec": 2700, + }, + "fmi": { + "label": "FMI", + "native_update_interval_sec": 600, + "fresh_window_sec": 900, + "expected_grace_sec": 600, + "stale_after_sec": 2700, + }, + "knmi": { + "label": "KNMI", + "native_update_interval_sec": 600, + "fresh_window_sec": 900, + "expected_grace_sec": 600, + "stale_after_sec": 2700, + }, + "hko": { + "label": "HKO", + "native_update_interval_sec": 600, + "fresh_window_sec": 900, + "expected_grace_sec": 600, + "stale_after_sec": 2700, + }, + "cwa": { + "label": "CWA", + "native_update_interval_sec": 600, + "fresh_window_sec": 900, + "expected_grace_sec": 600, + "stale_after_sec": 2700, + }, + "mgm": { + "label": "MGM", + "native_update_interval_sec": 900, + "fresh_window_sec": 900, + "expected_grace_sec": 900, + "stale_after_sec": 3600, + }, + "metar": { + "label": "METAR", + "native_update_interval_sec": 900, + "fresh_window_sec": 600, + "expected_grace_sec": 900, + "stale_after_sec": 3600, + }, + "noaa": { + "label": "NOAA", + "native_update_interval_sec": 900, + "fresh_window_sec": 600, + "expected_grace_sec": 900, + "stale_after_sec": 3600, + }, + "wunderground": { + "label": "METAR", + "native_update_interval_sec": 900, + "fresh_window_sec": 600, + "expected_grace_sec": 900, + "stale_after_sec": 3600, + }, + "nmc": { + "label": "NMC", + "native_update_interval_sec": 3600, + "fresh_window_sec": 3600, + "expected_grace_sec": 1800, + "stale_after_sec": 7200, + }, +} + + +def _canonical_observation_source_code(value: Any) -> str: + raw = str(value or "").strip().lower() + if not raw: + return "metar" + if "amos" in raw: + return "amos" + if "jma" in raw: + return "jma" + if "fmi" in raw: + return "fmi" + if "knmi" in raw: + return "knmi" + if "hko" in raw: + return "hko" + if "cwa" in raw: + return "cwa" + if "mgm" in raw: + return "mgm" + if "noaa" in raw: + return "noaa" + if "nmc" in raw: + return "nmc" + if "wunderground" in raw or raw == "wu": + return "wunderground" + return raw + + +def _observation_age_min(value: Any, now_utc: Optional[datetime] = None) -> Optional[int]: + obs_dt = _parse_utc_datetime(value) + if obs_dt is None: + return None + now = now_utc or datetime.now(timezone.utc) + return max(0, int((now - obs_dt).total_seconds() / 60)) + + +def _optional_str(value: Any) -> Optional[str]: + raw = str(value or "").strip() + return raw or None + + +def _build_observation_freshness( + *, + source_code: Any, + source_label: Any = None, + observed_at: Any = None, + observed_at_local: Any = None, + ingested_at: Any = None, + age_min: Optional[int] = None, + now_utc: Optional[datetime] = None, +) -> Dict[str, Any]: + code = _canonical_observation_source_code(source_code or source_label) + profile = _OBSERVATION_SOURCE_PROFILES.get(code) or _OBSERVATION_SOURCE_PROFILES["metar"] + now = now_utc or datetime.now(timezone.utc) + obs_dt = _parse_utc_datetime(observed_at) + age_sec = None + if age_min is not None: + try: + age_sec = max(0, int(age_min) * 60) + except Exception: + age_sec = None + if age_sec is None and obs_dt is not None: + age_sec = max(0, int((now - obs_dt).total_seconds())) + + if age_sec is None: + status = "unknown" + reason = "observation_time_missing" + elif age_sec <= int(profile["fresh_window_sec"]): + status = "fresh" + reason = "within_native_fresh_window" + elif age_sec <= int(profile["native_update_interval_sec"]) + int(profile["expected_grace_sec"]): + status = "expected_wait" + reason = "within_source_expected_cadence" + elif age_sec <= int(profile["stale_after_sec"]): + status = "delayed" + reason = "past_expected_cadence" + else: + status = "stale" + reason = "past_stale_threshold" + + expected_next = ( + obs_dt + timedelta(seconds=int(profile["native_update_interval_sec"])) + if obs_dt is not None + else None + ) + return { + "source_code": code, + "source_label": str(source_label or profile["label"]), + "observed_at": obs_dt.isoformat() if obs_dt is not None else _optional_str(observed_at), + "observed_at_local": _optional_str(observed_at_local), + "ingested_at": _optional_str(ingested_at), + "native_update_interval_sec": int(profile["native_update_interval_sec"]), + "expected_next_update_at": expected_next.isoformat() if expected_next is not None else None, + "freshness_status": status, + "freshness_reason": reason, + "age_sec": age_sec, + } + + def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None: now = datetime.now(timezone.utc).isoformat() with _ANALYSIS_CACHE_STATS_LOCK: @@ -1842,6 +2021,48 @@ def _analyze( int(utc_offset or 0), ) + current_obs_raw = obs_t + if current_source == "amos": + current_obs_raw = amos_data.get("observation_time") + elif current_source == "nmc": + current_obs_raw = ( + nmc_fallback.get("publish_time") + or nmc_fallback.get("timestamp") + if isinstance(nmc_fallback, dict) + else None + ) + current_age_min = metar_age_min + if current_obs_raw: + current_age_min = _observation_age_min(current_obs_raw, now_utc) or current_age_min + current_freshness = _build_observation_freshness( + source_code=current_source, + source_label=current_source_label, + observed_at=current_obs_raw, + observed_at_local=obs_time_str, + ingested_at=primary_current.get("receipt_time") or primary_current.get("report_time"), + age_min=current_age_min, + now_utc=now_utc, + ) + + airport_source_code = "amos" if current_source == "amos" else "metar" + airport_source_label = "AMOS" if current_source == "amos" else "METAR" + airport_obs_raw = amos_data.get("observation_time") if current_source == "amos" else (metar.get("observation_time") if metar else None) + airport_age_min = _observation_age_min(airport_obs_raw, now_utc) if airport_obs_raw else metar_age_min + if airport_age_min is None: + airport_age_min = metar_age_min + airport_temp = _sf(amos_data.get("temp_c")) if current_source == "amos" else _sf(live_mc.get("temp")) + if airport_temp is not None and not _is_plausible_city_temp(city, airport_temp, sym): + airport_temp = None + airport_freshness = _build_observation_freshness( + source_code=airport_source_code, + source_label=airport_source_label, + observed_at=airport_obs_raw, + observed_at_local=obs_time_str, + ingested_at=metar.get("receipt_time") if metar else None, + age_min=airport_age_min, + now_utc=now_utc, + ) + airport_primary_current = dict(network_snapshot.get("airport_primary_current") or {}) if ( airport_primary_current.get("source_code") == "metar" @@ -2443,12 +2664,14 @@ def _analyze( "max_temp_time": max_temp_time, "raw_max_so_far": raw_settlement_max, "wu_settlement": wu_settle, + "source_code": current_source, "settlement_source": current_source, "settlement_source_label": current_source_label, "station_code": current_station_code, "station_name": current_station_name, "obs_time": obs_time_str, "obs_age_min": None if use_settlement_current else metar_age_min, + "freshness": current_freshness, "observation_status": "live" if cur_temp is not None else "missing", "report_time": primary_current.get("report_time"), "receipt_time": primary_current.get("receipt_time"), @@ -2466,11 +2689,11 @@ def _analyze( "raw_metar": amos_data.get("raw_metar") if current_source == "amos" else primary_current.get("raw_metar"), }, "airport_current": { - "temp": _sf(live_mc.get("temp")), + "temp": airport_temp, "obs_time": obs_time_str, "max_so_far": airport_max_so_far, "max_temp_time": airport_max_temp_time, - "obs_age_min": metar_age_min, + "obs_age_min": airport_age_min, "report_time": metar.get("report_time") if metar else None, "receipt_time": metar.get("receipt_time") if metar else None, "obs_time_epoch": metar.get("obs_time_epoch") if metar else None, @@ -2481,7 +2704,9 @@ def _analyze( "visibility_mi": _sf(live_mc.get("visibility_mi")), "wx_desc": live_mc.get("wx_desc"), "raw_metar": amos_data.get("raw_metar") if current_source == "amos" else live_mc.get("raw_metar"), - "source_label": "AMOS" if current_source == "amos" else "METAR", + "source_code": airport_source_code, + "source_label": airport_source_label, + "freshness": airport_freshness, "stale_for_today": False if current_source == "amos" else (bool(metar) and not metar_current_is_today), "last_observation_local_date": metar.get("observation_local_date") if metar else None, "current_local_date": local_date_str,