放弃 Jinja2,改用 f-string 直接拼 HTML,零模板引擎依赖

This commit is contained in:
2569718930@qq.com
2026-05-13 23:54:53 +08:00
parent a7ba7aed3a
commit a9b1449680
3 changed files with 181 additions and 292 deletions
-1
View File
@@ -10,5 +10,4 @@ lightgbm
web3 web3
fastapi fastapi
uvicorn uvicorn
jinja2
websockets websockets
+181 -117
View File
@@ -1,4 +1,4 @@
"""市场监控网页版 — 寄生 FastAPI,复用 _analyze() 全量数据""" """市场监控网页版 — f-string 拼 HTML,零模板引擎依赖"""
from __future__ import annotations from __future__ import annotations
@@ -7,15 +7,13 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from loguru import logger from loguru import logger
from web.analysis_service import _analyze from web.analysis_service import _analyze
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
# ── city config (与 telegram_push 一致) ── # ── city config ──
_CITIES: List[Dict[str, Any]] = [ _CITIES: List[Dict[str, Any]] = [
{"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True}, {"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True},
@@ -34,147 +32,213 @@ _CITIES: List[Dict[str, Any]] = [
# ── helpers ── # ── helpers ──
def _sf(v: Any) -> Optional[float]: def _sf(v: Any) -> Optional[float]:
"""Safe float.""" if v is None: return None
if v is None: try: return round(float(v), 1)
return None except: return None
try:
return round(float(v), 1)
except (ValueError, TypeError):
return None
def _trend_info(icao: str) -> tuple[str, str]: def _trend_info(icao: str) -> tuple:
"""Return (symbol, css_class) from _check_rising_trend."""
try: try:
from src.utils.telegram_push import _check_rising_trend from src.utils.telegram_push import _check_rising_trend
ok = _check_rising_trend(icao) if _check_rising_trend(icao): return ("", "rising")
except Exception: except: pass
return ("", "flat")
if ok:
return ("", "rising")
# Check if falling (temp decreasing)
try: try:
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
obs = DBManager().get_airport_obs_recent(icao, minutes=60) obs = DBManager().get_airport_obs_recent(icao, minutes=60)
temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None] temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None]
if len(temps) >= 4 and temps[-1] < temps[len(temps)//2]: if len(temps) >= 4 and temps[-1] < temps[len(temps)//2]:
return ("", "falling") return ("", "falling")
except Exception: except: pass
pass
return ("", "flat") return ("", "flat")
def _obs_age(obs_time_str: Optional[str]) -> Optional[int]: def _obs_age(obs_time_str: Optional[str]) -> Optional[int]:
"""Compute minutes since observation time.""" if not obs_time_str: return None
if not obs_time_str:
return None
try: try:
# Try parsing various formats for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try: try:
dt = datetime.strptime(str(obs_time_str)[:26], fmt) dt = datetime.strptime(str(obs_time_str)[:26], fmt).replace(tzinfo=timezone.utc)
if dt.tzinfo is None: return max(0, int((datetime.now(timezone.utc) - dt).total_seconds() // 60))
dt = dt.replace(tzinfo=timezone.utc) except: continue
age = (datetime.now(timezone.utc) - dt).total_seconds()
return max(0, int(age // 60))
except ValueError:
continue
# Try as epoch
ts = float(obs_time_str) ts = float(obs_time_str)
if ts > 1_000_000_000: if ts > 1_000_000_000:
dt = datetime.fromtimestamp(ts, tz=timezone.utc) dt = datetime.fromtimestamp(ts, tz=timezone.utc)
age = (datetime.now(timezone.utc) - dt).total_seconds() return max(0, int((datetime.now(timezone.utc) - dt).total_seconds() // 60))
return max(0, int(age // 60)) except: pass
except (ValueError, TypeError):
pass
return None return None
def _runway_pairs(city_weather: Dict[str, Any]) -> list: def _build_cards() -> tuple:
"""Extract runway pairs from AMOS data."""
amos = city_weather.get("amos") or {}
rw_obs = (amos.get("runway_obs") or {}) if amos else {}
pairs = rw_obs.get("runway_pairs") or []
temps = rw_obs.get("temperatures") or []
result = []
for (r1, r2), (t, _d) in zip(pairs, temps):
if t is not None:
result.append((f"{r1}/{r2}", round(float(t), 1)))
return result
def _build_city_card(city: str, city_weather: Dict[str, Any], cfg: Dict[str, Any]) -> Dict[str, Any]:
"""Build a single city's card data."""
ac = city_weather.get("airport_current") or {}
cur = city_weather.get("current") or {}
ct = _sf(ac.get("temp")) or _sf(cur.get("temp"))
max_so_far = ac.get("max_so_far")
max_temp_time = ac.get("max_temp_time")
obs_time_str = ac.get("obs_time") or ""
local_time = city_weather.get("local_time") or ""
new_high = (ct is not None and max_so_far is not None and ct >= max_so_far + 0.3)
trend_sym, trend_css = _trend_info(cfg["icao"])
age = _obs_age(obs_time_str)
rw = _runway_pairs(city_weather) if cfg.get("rw") else []
return {
"en_name": cfg["en_name"],
"airport": cfg["airport"],
"icao": cfg["icao"],
"obs_time_str": obs_time_str or local_time,
"local_time": local_time,
"current_temp": ct,
"max_so_far": _sf(max_so_far),
"max_temp_time": max_temp_time,
"trend_sym": trend_sym,
"trend_css": trend_css,
"obs_age_min": age,
"new_high": new_high,
"runway_pairs": rw,
}
def _load_all_cities() -> List[Dict[str, Any]]:
cards = [] cards = []
for cfg in _CITIES: for cfg in _CITIES:
try: try:
cw = _analyze(cfg["key"]) cw = _analyze(cfg["key"])
card = _build_city_card(cfg["key"], cw, cfg) ac = cw.get("airport_current") or {}
cards.append(card) cur = cw.get("current") or {}
ct = _sf(ac.get("temp")) or _sf(cur.get("temp"))
msf = ac.get("max_so_far")
mtt = ac.get("max_temp_time") or ""
obs = ac.get("obs_time") or ""
local_time = cw.get("local_time") or ""
new_high = (ct is not None and msf is not None and ct >= msf + 0.3)
tsym, tcss = _trend_info(cfg["icao"])
age = _obs_age(obs)
rw_html = ""
if cfg.get("rw"):
amos = cw.get("amos") or {}
rw_obs = (amos.get("runway_obs") or {}) if amos else {}
pairs = rw_obs.get("runway_pairs") or []
temps = rw_obs.get("temperatures") or []
for (r1, r2), (t, _d) in zip(pairs, temps):
if t is not None:
rw_html += f'<div class="runway-row"><span class="runway-label">{r1}/{r2}</span><span class="runway-temp">{round(float(t),1):.1f}°C</span></div>\n'
cards.append({
"en_name": cfg["en_name"], "airport": cfg["airport"],
"time": obs or local_time,
"ct": ct, "msf": _sf(msf), "mtt": mtt,
"tsym": tsym, "tcss": tcss, "age": age,
"new_high": new_high, "rw_html": rw_html,
"warm": ct is not None and ct >= 30,
})
except Exception: except Exception:
logger.exception("monitor: failed to load city {}", cfg["key"]) logger.exception("monitor: failed city {}", cfg["key"])
# Sort by temp descending, None at bottom
cards.sort(key=lambda c: (c["current_temp"] is not None, c["current_temp"] or -999), reverse=True) cards.sort(key=lambda c: (c["ct"] is not None, c["ct"] or -999), reverse=True)
return cards return cards, datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
def _render(cards, gts):
"""Build card grid HTML."""
cards_html = []
for c in cards:
nc = " new-high-card" if c["new_high"] else ""
wc = " warm" if c["warm"] else ""
nv = " new-high-val" if c["new_high"] else ""
nh = '◆新高' if c["new_high"] else ""
ct_str = f'{c["ct"]:.1f}' if c["ct"] is not None else "--"
ct_na = ' na' if c["ct"] is None else ""
msf_str = f'{c["msf"]:.1f}°C' if c["msf"] is not None else ""
msf_na = ' na' if c["msf"] is None else ""
mtt_str = f'<span class="max-time">{c["mtt"]}</span>' if c["mtt"] else ""
age_str = f'{c["age"]} min ago' if c["age"] is not None else ""
age_na = ' na' if c["age"] is None else ""
cards_html.append(f'''<div class="card{nc}" data-new-high="{str(c['new_high']).lower()}">
<div class="card-top">
<span class="city-name">{c['en_name']}</span>
<span class="airport">/ {c['airport']}</span>
<span class="local-time">{c['time']}</span>{"<span class='badge new-high'>" + nh + "</span>" if nh else ""}
</div>
<div class="card-temp">
<span class="temp-value{ct_na}{wc}{nv}">{ct_str}</span><span class="temp-unit">°C</span>
</div>
<div class="card-meta">
<div class="meta-row">
<span class="label">High</span>
<span class="value{msf_na}">{msf_str}</span>{mtt_str}
<span class="trend {c['tcss']}">{c['tsym']}</span>
</div>
<div class="meta-row">
<span class="label">Obs</span>
<span class="obs-age{age_na}">{age_str}</span>
</div>
</div>{"<div class='card-runway'><div class='runway-divider'></div>" + c['rw_html'] + "</div>" if c['rw_html'] else ""}
</div>''')
return f'''<div id="card-grid" class="card-grid"
hx-get="/m/cards" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner">
<span style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px;font-size:13px;color:#5a6170">{gts}</span>
{chr(10).join(cards_html)}
</div>
<div id="spinner" class="htmx-indicator">Refreshing…</div>'''
PAGE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Market Monitor — PolyWeather</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f1117;color:#c8cdd4;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
.page{max-width:1500px;margin:0 auto;padding:24px 28px}
.header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:28px;padding-bottom:14px;border-bottom:1px solid #1e2130}
.header h1{font-size:24px;font-weight:600;color:#e8eaed}
.header-r{display:flex;align-items:center;gap:12px}
.nbtn{background:none;border:1px solid #2a2e40;border-radius:6px;padding:3px 8px;font-size:16px;cursor:pointer;transition:border-color .2s}
.nbtn:hover{border-color:#4a5160}.nbtn.muted{opacity:.4}
.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
@media(max-width:1100px){.card-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:600px){.card-grid{grid-template-columns:1fr}}
.card{background:#161822;border:1px solid #1e2130;border-radius:12px;padding:22px 26px;transition:border-color .3s,box-shadow .3s}
.card:hover{border-color:#2a2e40}
.card.new-high-card{border-color:rgba(124,58,237,.3)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:15px;flex-wrap:wrap}
.city-name{color:#e0e3e8;font-weight:700}
.airport{color:#6a7180;font-weight:400}
.local-time{margin-left:auto;color:#4a5160;font-variant-numeric:tabular-nums;font-size:14px}
.badge{font-size:12px;padding:2px 7px;border-radius:4px;font-weight:600}
.badge.new-high{background:rgba(124,58,237,.18);color:#a78bfa}
.card-temp{margin:8px 0 10px;font-weight:700;line-height:1.15}
.temp-value{font-size:52px;color:#e8eaed;letter-spacing:-.03em}
.temp-value.na{color:#3a4050;font-size:32px}
.temp-value.warm{color:#f59e0b}
.temp-value.new-high-val{color:#c084fc}
.temp-unit{font-size:22px;color:#5a6170;margin-left:3px}
.card-meta{display:flex;flex-direction:column;gap:5px;margin-bottom:2px}
.meta-row{display:flex;align-items:baseline;gap:8px;font-size:14px}
.meta-row .label{color:#4a5160}
.meta-row .value{color:#9aa0b0;font-variant-numeric:tabular-nums}
.meta-row .value.na{color:#3a4050}
.meta-row .trend{margin-left:auto}
.trend{font-size:18px;font-weight:700}
.trend.rising{color:#34d399}
.trend.falling{color:#60a5fa}
.trend.flat{color:#5a6170}
.max-time{font-size:12px;color:#4a5160;margin-left:2px}
.obs-age{font-size:13px;color:#5a6170}
.obs-age.na{color:#3a4050}
.card-runway{margin-top:12px}
.runway-divider{height:1px;background:#1e2130;margin-bottom:8px}
.runway-row{display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px}
.runway-label{color:#4a5160}
.runway-temp{color:#7a8290;font-variant-numeric:tabular-nums}
.htmx-indicator{text-align:center;padding:14px;font-size:14px;color:#3a4050;display:none}
.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:block}
</style>
<meta http-equiv="refresh" content="120">
</head>
<body>
<div class="page">
<header class="header">
<h1>🔥 Market Monitor</h1>
<div class="header-r">
<button id="notify-toggle" class="nbtn" onclick="toggleNotify()" title="新高提醒">
<span id="notify-icon">🔔</span>
</button>
<span class="updated" id="update-time">%s</span>
</div>
</header>
%s
</div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
var NF=localStorage.getItem('monitor_notify')!=='off';
var NH=JSON.parse(localStorage.getItem('monitor_notified_highs')||'{}');
function toggleNotify(){NF=!NF;localStorage.setItem('monitor_notify',NF?'on':'off');U();if(NF&&Notification.permission==='default')Notification.requestPermission()}
function U(){var i=document.getElementById('notify-icon');var b=document.getElementById('notify-toggle');if(NF){i.textContent='🔔';b.classList.remove('muted')}else{i.textContent='🔕';b.classList.add('muted')}}
function N(en,t){if(!NF||Notification.permission!=='granted')return;var k=en+'|'+t;var d=new Date().toDateString();if(NH._day!==d)NH={_day:d};if(NH[k])return;NH[k]=true;localStorage.setItem('monitor_notified_highs',JSON.stringify(NH));new Notification('🔴 New High — '+en,{body:t+'°C\\nNew daily high.',tag:k,requireInteraction:true})}
U();
(function(){let p={};document.body.addEventListener('htmx:afterSwap',function(e){if(e.detail.target.id!=='card-grid')return;document.querySelectorAll('.card').forEach(function(c){var n=c.querySelector('.city-name').textContent;var v=c.querySelector('.temp-value').textContent;var o=p[n];p[n]=v;if(o&&o!==v&&v!=='--'){c.style.transition='none';c.style.boxShadow='0 0 14px rgba(52,211,153,0.40)';requestAnimationFrame(function(){c.style.transition='box-shadow 2s ease-out';c.style.boxShadow=''})}if(c.dataset.newHigh==='true')N(n,v)})})})();
</script>
</body>
</html>"""
# ── routes ──
@router.get("/m", response_class=HTMLResponse) @router.get("/m", response_class=HTMLResponse)
async def monitor_page(request: Request): async def monitor_page(request: Request):
cities = _load_all_cities() cards, gts = _build_cards()
# Debug: render without airport_current dicts return HTMLResponse(PAGE_HTML % (gts, _render(cards, gts)))
simple = [{"en_name": c["en_name"], "airport": c["airport"],
"current_temp": c["current_temp"], "new_high": c["new_high"],
"trend_sym": c["trend_sym"], "trend_css": c["trend_css"],
"obs_age_min": c["obs_age_min"], "local_time": c["local_time"],
"obs_time_str": c["obs_time_str"],
"max_so_far": c["max_so_far"], "max_temp_time": c["max_temp_time"]} for c in cities]
return templates.TemplateResponse("monitor.html", {
"request": request,
"cities": simple,
"full_page": True,
"generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"),
})
@router.get("/m/cards", response_class=HTMLResponse) @router.get("/m/cards", response_class=HTMLResponse)
async def monitor_cards(request: Request): async def monitor_cards(request: Request):
cities = _load_all_cities() cards, gts = _build_cards()
simple = [{"en_name": c["en_name"], "airport": c["airport"], return HTMLResponse(_render(cards, gts))
"current_temp": c["current_temp"], "new_high": c["new_high"],
"trend_sym": c["trend_sym"], "trend_css": c["trend_css"],
"obs_age_min": c["obs_age_min"], "local_time": c["local_time"],
"obs_time_str": c["obs_time_str"],
"max_so_far": c["max_so_far"], "max_temp_time": c["max_temp_time"]} for c in cities]
return templates.TemplateResponse("monitor.html", {
"request": request,
"cities": simple,
"full_page": False,
"generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"),
})
-174
View File
@@ -1,174 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Market Monitor — PolyWeather</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f1117;color:#c8cdd4;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
.page{max-width:1500px;margin:0 auto;padding:24px 28px}
.header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:28px;padding-bottom:14px;border-bottom:1px solid #1e2130}
.header h1{font-size:24px;font-weight:600;color:#e8eaed}
.header-right{display:flex;align-items:center;gap:12px}
.notify-btn{background:none;border:1px solid #2a2e40;border-radius:6px;padding:3px 8px;font-size:16px;cursor:pointer;transition:border-color .2s}
.notify-btn:hover{border-color:#4a5160}
.notify-btn.muted{opacity:.4}
.updated{font-size:13px;color:#5a6170}
.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
@media(max-width:1100px){.card-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:600px){.card-grid{grid-template-columns:1fr}}
.card{background:#161822;border:1px solid #1e2130;border-radius:12px;padding:22px 26px;transition:border-color .3s,box-shadow .3s;position:relative}
.card:hover{border-color:#2a2e40}
.card.new-high-card{border-color:rgba(124,58,237,.3)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:15px;flex-wrap:wrap}
.city-name{color:#e0e3e8;font-weight:700}
.airport{color:#6a7180;font-weight:400}
.local-time{margin-left:auto;color:#4a5160;font-variant-numeric:tabular-nums;font-size:14px}
.badge{font-size:12px;padding:2px 7px;border-radius:4px;font-weight:600}
.badge.new-high{background:rgba(124,58,237,.18);color:#a78bfa}
.card-temp{margin:8px 0 10px;font-weight:700;line-height:1.15}
.temp-value{font-size:52px;color:#e8eaed;letter-spacing:-.03em}
.temp-value.na{color:#3a4050;font-size:32px}
.temp-value.warm{color:#f59e0b}
.temp-value.new-high-val{color:#c084fc}
.temp-unit{font-size:22px;color:#5a6170;margin-left:3px}
.card-meta{display:flex;flex-direction:column;gap:5px;margin-bottom:2px}
.meta-row{display:flex;align-items:baseline;gap:8px;font-size:14px}
.meta-row .label{color:#4a5160}
.meta-row .value{color:#9aa0b0;font-variant-numeric:tabular-nums}
.meta-row .value.na{color:#3a4050}
.meta-row .trend{margin-left:auto}
.trend{font-size:18px;font-weight:700}
.trend.rising{color:#34d399}
.trend.falling{color:#60a5fa}
.trend.flat{color:#5a6170}
.max-time{font-size:12px;color:#4a5160;margin-left:2px}
.obs-age{font-size:13px;color:#5a6170}
.card-runway{margin-top:12px}
.runway-divider{height:1px;background:#1e2130;margin-bottom:8px}
.runway-row{display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px}
.runway-label{color:#4a5160}
.runway-temp{color:#7a8290;font-variant-numeric:tabular-nums}
.htmx-indicator{text-align:center;padding:14px;font-size:14px;color:#3a4050;display:none}
.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:block}
</style>
<meta http-equiv="refresh" content="120">
</head>
<body>
<div class="page">
<header class="header">
<h1>🔥 Market Monitor</h1>
<div class="header-right">
<button id="notify-toggle" class="notify-btn" onclick="toggleNotify()" title="新高提醒">
<span id="notify-icon">🔔</span>
</button>
<span class="updated">{{ generated_at }}</span>
</div>
</header>
<div id="card-grid" class="card-grid"
hx-get="/m/cards" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner">
<span class="updated" style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px">{{ generated_at }}</span>
{% for c in cities %}
<div class="card{% if c.new_high %} new-high-card{% endif %}" data-new-high="{{ c.new_high | lower }}">
<div class="card-top">
<span class="city-name">{{ c.en_name }}</span>
<span class="airport">/ {{ c.airport }}</span>
<span class="local-time">{{ c.obs_time_str or c.local_time }}</span>
{% if c.new_high %}
<span class="badge new-high">◆新高</span>
{% endif %}
</div>
<div class="card-temp">
{% if c.current_temp is not none %}
<span class="temp-value{% if c.current_temp >= 30 %} warm{% endif %}{% if c.new_high %} new-high-val{% endif %}">{{ "%.1f"|format(c.current_temp) }}</span><span class="temp-unit">°C</span>
{% else %}
<span class="temp-value na">--</span>
{% endif %}
</div>
<div class="card-meta">
<div class="meta-row">
<span class="label">High</span>
{% if c.max_so_far is not none %}
<span class="value">{{ "%.1f"|format(c.max_so_far) }}°C</span>
{% if c.max_temp_time %}
<span class="max-time">{{ c.max_temp_time }}</span>
{% endif %}
{% else %}
<span class="value na">--</span>
{% endif %}
<span class="trend {{ c.trend_css }}">{{ c.trend_sym }}</span>
</div>
<div class="meta-row">
<span class="label">Obs</span>
{% if c.obs_age_min is not none %}
<span class="obs-age">{{ c.obs_age_min }} min ago</span>
{% else %}
<span class="value na">--</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<div id="spinner" class="htmx-indicator">Refreshing…</div>
</div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
var NOTIFY_ENABLED = localStorage.getItem('monitor_notify') !== 'off';
var NOTIFIED_HIGHS = JSON.parse(localStorage.getItem('monitor_notified_highs') || '{}');
function toggleNotify(){
NOTIFY_ENABLED = !NOTIFY_ENABLED;
localStorage.setItem('monitor_notify', NOTIFY_ENABLED ? 'on' : 'off');
updateNotifyIcon();
if(NOTIFY_ENABLED && Notification.permission === 'default') Notification.requestPermission();
}
function updateNotifyIcon(){
var icon = document.getElementById('notify-icon');
var btn = document.getElementById('notify-toggle');
if(NOTIFY_ENABLED){ icon.textContent = '🔔'; btn.classList.remove('muted'); }
else{ icon.textContent = '🔕'; btn.classList.add('muted'); }
}
function fireNotification(enName, temp){
if(!NOTIFY_ENABLED || Notification.permission !== 'granted') return;
var key = enName + '|' + temp;
var today = new Date().toDateString();
if(NOTIFIED_HIGHS._day !== today) NOTIFIED_HIGHS = {_day: today};
if(NOTIFIED_HIGHS[key]) return;
NOTIFIED_HIGHS[key] = true;
localStorage.setItem('monitor_notified_highs', JSON.stringify(NOTIFIED_HIGHS));
new Notification('🔴 New High — ' + enName, {
body: temp + '°C\nNew daily high.',
tag: key, requireInteraction: true
});
}
updateNotifyIcon();
(function(){
let prev = {};
document.body.addEventListener('htmx:afterSwap', function(evt){
if(evt.detail.target.id !== 'card-grid') return;
document.querySelectorAll('.card').forEach(function(card){
var name = card.querySelector('.city-name').textContent;
var tv = card.querySelector('.temp-value').textContent;
var old = prev[name]; prev[name] = tv;
if(old && old !== tv && tv !== '--'){
card.style.transition = 'none';
card.style.boxShadow = '0 0 14px rgba(52,211,153,0.40)';
requestAnimationFrame(function(){
card.style.transition = 'box-shadow 2s ease-out';
card.style.boxShadow = '';
});
}
if(card.dataset.newHigh === 'true') fireNotification(name, tv);
});
});
})();
</script>
</body>
</html>