feat: implement city weather detail API routing, dashboard data models, and monitoring infrastructure
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user