feat: implement MonitorPanel component for tracking real-time airport weather data with automated concurrency and stale-data prioritization

This commit is contained in:
2569718930@qq.com
2026-05-14 02:02:30 +08:00
parent d7d1744313
commit fb4ba622a8
@@ -43,6 +43,34 @@ function freshnessDotTitle(level: Freshness, ageMin: number | null | undefined,
level === "stale" ? `数据陈旧 · ${age}` : "更新时间未知"; level === "stale" ? `数据陈旧 · ${age}` : "更新时间未知";
} }
/* ── Audio alert (Web Audio API, no external file needed) ────── */
function playNewHighBeep(): void {
try {
const ctx = new AudioContext();
// Two-tone rising beep: signals a new temperature high
const tones = [
{ freq: 880, start: 0, dur: 0.12, peak: 0.40 },
{ freq: 1100, start: 0.15, dur: 0.18, peak: 0.55 },
];
for (const { freq, start, dur, peak } of tones) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
osc.connect(g);
g.connect(ctx.destination);
g.gain.setValueAtTime(0, ctx.currentTime + start);
g.gain.linearRampToValueAtTime(peak, ctx.currentTime + start + 0.03);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
osc.start(ctx.currentTime + start);
osc.stop(ctx.currentTime + start + dur + 0.01);
}
setTimeout(() => ctx.close(), 600);
} catch {
// AudioContext may be blocked before a user gesture — silently ignore
}
}
function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" { function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" {
if (!detail?.airport_current) return "flat"; if (!detail?.airport_current) return "flat";
const ac = detail.airport_current; const ac = detail.airport_current;
@@ -230,33 +258,29 @@ export default function MonitorPanel() {
} }
}; };
/* Browser notifications for new highs */ /* Audio alert when current temp exceeds today's observed high by ≥ 0.3 °C */
useEffect(() => { useEffect(() => {
if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return; if (!notify) return;
const today = new Date().toDateString(); const today = new Date().toDateString();
let notified: Record<string, unknown> = {}; let alerted: Record<string, unknown> = {};
try { notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}"); } catch {} try { alerted = JSON.parse(localStorage.getItem("monitor_alerted_highs") || "{}"); } catch {}
if (notified._day !== today) notified = { _day: today }; if (alerted._day !== today) alerted = { _day: today };
for (const { key, detail } of sorted) { for (const { key, detail } of sorted) {
const ac = detail?.airport_current; const ac = detail?.airport_current;
const cur = ac?.temp ?? detail?.current?.temp ?? null; const cur = ac?.temp ?? detail?.current?.temp ?? null;
const max = ac?.max_so_far ?? null; const max = ac?.max_so_far ?? null;
if (cur != null && max != null && cur >= max + 0.3) { if (cur != null && max != null && cur >= max + 0.3) {
const id = `${key}|${cur}`; // Key: city + rounded temp, so we only beep once per 0.1°C step
if (!notified[id]) { const id = `${key}|${(Math.round(cur * 10) / 10).toFixed(1)}`;
notified[id] = true; if (!alerted[id]) {
localStorage.setItem("monitor_notified_highs", JSON.stringify(notified)); alerted[id] = true;
const name = detail?.display_name || key; localStorage.setItem("monitor_alerted_highs", JSON.stringify(alerted));
new Notification(isEn ? `🔴 New High — ${name}` : `🔴 新高 — ${name}`, { playNewHighBeep();
body: isEn ? `${cur}°C · New daily high.` : `${cur}°C · 今日新高。`,
tag: id,
requireInteraction: true,
});
} }
} }
} }
}, [sorted, notify, isEn]); }, [sorted, notify]);
/* ── Render ── */ /* ── Render ── */
return ( return (
@@ -286,8 +310,8 @@ export default function MonitorPanel() {
className={`monitor-notify-btn${notify ? "" : " muted"}`} className={`monitor-notify-btn${notify ? "" : " muted"}`}
onClick={toggleNotify} onClick={toggleNotify}
title={notify title={notify
? t("Disable alerts", "关闭提醒", lang) ? t("Disable new-high alert", "关闭新高提醒", lang)
: t("Enable alerts", "开启提醒", lang)} : t("Enable new-high alert", "开启新高提醒", lang)}
> >
{notify ? "🔔" : "🔕"} {notify ? "🔔" : "🔕"}
</button> </button>