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