feat: implement MonitorPanel component for tracking real-time airport weather data with automated concurrency and stale-data prioritization
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user