feat: implement city weather detail API routing, dashboard data models, and monitoring infrastructure
This commit is contained in:
@@ -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 状态。
|
||||
|
||||
## 消息模板
|
||||
|
||||
```
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof getMonitorRefreshRequest>;
|
||||
|
||||
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<MonitorKey>(["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<ReadonlySet<string>>(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({
|
||||
<span className="monitor-airport-name">/ {airportLabel(key, isEn)}</span>
|
||||
<FreshnessDot
|
||||
level={freshness}
|
||||
title={freshnessDotTitle(freshness, age, isEn)}
|
||||
title={freshnessDotTitle(freshness, age, isEn, freshnessInfo)}
|
||||
/>
|
||||
{newHigh && (
|
||||
<span className="monitor-new-high-badge">
|
||||
@@ -502,7 +563,7 @@ export default function MonitorPanel({
|
||||
</div>
|
||||
<div className="monitor-obs-row">
|
||||
<span className="monitor-stat-label">
|
||||
{obsSourceLabel(key, isEn)}
|
||||
{obsSourceLabel(key, isEn, freshnessInfo)}
|
||||
</span>
|
||||
<span className={`monitor-obs-age ${freshness}`}>
|
||||
{age != null ? (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>): 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);
|
||||
}
|
||||
@@ -74,6 +74,10 @@ interface DashboardStoreValue extends DashboardState {
|
||||
}
|
||||
|
||||
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
|
||||
const DashboardActionsContext = createContext<Pick<
|
||||
DashboardStoreValue,
|
||||
"ensureCityDetail"
|
||||
> | null>(null);
|
||||
const CityDetailsContext = createContext<{
|
||||
cityDetailsByName: Record<string, CityDetail>;
|
||||
cityDetailMetaByName: Record<string, { cachedAt: number; revision: string }>;
|
||||
@@ -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<Pick<DashboardStoreValue, "ensureCityDetail">>(
|
||||
() => ({
|
||||
ensureCityDetail: (...args) => latestEnsureCityDetailRef.current(...args),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardStoreContext.Provider value={value}>
|
||||
<CityDetailsContext.Provider value={cityDetailsValue}>
|
||||
{children}
|
||||
</CityDetailsContext.Provider>
|
||||
<DashboardActionsContext.Provider value={dashboardActionsValue}>
|
||||
<CityDetailsContext.Provider value={cityDetailsValue}>
|
||||
{children}
|
||||
</CityDetailsContext.Provider>
|
||||
</DashboardActionsContext.Provider>
|
||||
</DashboardStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T>(url: string, options?: { timeoutMs?: number }): Promise<T> {
|
||||
async function fetchJson<T>(
|
||||
url: string,
|
||||
options?: { cache?: RequestCache; timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const timeoutMs = options?.timeoutMs;
|
||||
const controller = timeoutMs ? new AbortController() : null;
|
||||
const timeoutId = controller
|
||||
@@ -68,7 +72,7 @@ async function fetchJson<T>(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<T>(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<T>;
|
||||
@@ -253,6 +260,7 @@ export const dashboardClient = {
|
||||
|
||||
const request = fetchJson<CitySummary>(
|
||||
`/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<CityDetail>(
|
||||
`/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<MarketScanPayload>(
|
||||
`/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<ScanTerminalResponse>(
|
||||
`/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<ScanTerminalResponse>;
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<string, SourceProfile> = {
|
||||
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<string | null | undefined>) {
|
||||
const pollSec = sourceCodes.length
|
||||
? Math.min(
|
||||
...sourceCodes.map((source) => getObservationSourceProfile(source).pollIntervalSec),
|
||||
)
|
||||
: 60;
|
||||
return Math.max(60_000, pollSec * 1000);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
+228
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user