Fix alert dedup across reloads, add radar fullscreen, fix swipe conflicts

- Persist alertIdsSeen to localStorage so already-seen alerts don't
  re-notify every time the PWA is opened; cap stored IDs at 200 entries
- Use serviceWorker.showNotification() for alert notifications so they
  work when the app is backgrounded, with fallback to new Notification()
- Add ⛶ fullscreen button to radar embed; uses Fullscreen API and
  resizes the MapLibre canvas after toggle
- Fix swipe-to-change-tab: block gesture when touch starts on a map,
  hourly scroll row, or radar timeline; also require the gesture to be
  more horizontal than vertical (dx > dy*1.5) to avoid conflict with
  vertical scrolling
This commit is contained in:
Claude committed 2026-08-17 01:10:09 +00:00
1 parent a25864d9ed
commit 4d92739df4
3 files changed
+88 -5

No files matched your search

+65 -4
View File
@@ -300,6 +300,7 @@ const STORAGE_KEYS = {
alertThresholds: 'weather.alertThresholds',
digestHour: 'weather.digestHour',
pwsUrl: 'weather.pwsUrl',
alertIdsSeen: 'weather.alertIdsSeen',
};
const state = {
@@ -314,7 +315,7 @@ const state = {
currentAlerts: null,
currentSource: 'startup',
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
alertIdsSeen: new Set(),
alertIdsSeen: new Set(JSON.parse(localStorage.getItem('weather.alertIdsSeen') || '[]')),
autoRefreshTimer: null,
lastRefreshAt: 0,
lightningMarkers: [],
@@ -1053,6 +1054,30 @@ const radarEmbed = radarEmbedEl
)
: { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} };
// ── Radar embed fullscreen toggle ─────────────────────────────────────────────
(function initRadarFullscreen() {
const btn = document.getElementById('embed-fullscreen');
const wrap = document.getElementById('radar-embed-wrap');
if (!btn || !wrap) return;
function updateIcon() {
btn.textContent = document.fullscreenElement === wrap ? '✕' : '⛶';
btn.title = document.fullscreenElement === wrap ? 'Exit fullscreen' : 'Toggle fullscreen';
// MapLibre needs a resize signal after the container size changes.
setTimeout(() => radarEmbed.map && radarEmbed.map.resize(), 120);
}
btn.addEventListener('click', () => {
if (document.fullscreenElement === wrap) {
document.exitFullscreen().catch(() => {});
} else {
wrap.requestFullscreen().catch(() => {});
}
});
document.addEventListener('fullscreenchange', updateIcon);
})();
const radarFullEl = document.getElementById('radar-full');
const radarFull = radarFullEl
? createRadar(
@@ -1806,21 +1831,35 @@ function renderAlerts(data) {
});
show(wrap);
const newlySeen = [];
features.forEach(f => {
const id = f.id || f.properties?.id;
if (!id || state.alertIdsSeen.has(id)) return;
state.alertIdsSeen.add(id);
newlySeen.push(id);
const p = f.properties || {};
const title = `Weather alert: ${p.event || 'Severe weather'}`;
const body = p.headline || p.description || 'An alert was issued for your selected location.';
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready.then(reg =>
reg.showNotification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
).catch(() => new Notification(title, { body, icon: '/icons/icon-192.png' }));
} else {
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
}
}
if (state.ntfyTopic) {
sendNtfyNotification(title, body, { priority: 'high', tags: ['warning,weather'] });
}
});
if (newlySeen.length) {
// Persist seen IDs; keep only the most recent 200 to bound localStorage size.
const stored = Array.from(state.alertIdsSeen).slice(-200);
localStorage.setItem('weather.alertIdsSeen', JSON.stringify(stored));
}
}
async function loadAll(city, source) {
@@ -2477,10 +2516,32 @@ syncOfflineState();
(function initSwipe() {
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
let touchX = 0;
document.addEventListener('touchstart', e => { touchX = e.touches[0].clientX; }, { passive: true });
let touchY = 0;
let blocked = false;
function isSwipeBlocked(target) {
// Don't intercept touches on maps (panning) or horizontal scroll containers.
return !!(
target.closest('.maplibregl-map') ||
target.closest('#hourly-scroll') ||
target.closest('.hourly-track') ||
target.closest('.precip-chart-wrap') ||
target.closest('.radar-timeline')
);
}
document.addEventListener('touchstart', e => {
touchX = e.touches[0].clientX;
touchY = e.touches[0].clientY;
blocked = isSwipeBlocked(e.target);
}, { passive: true });
document.addEventListener('touchend', e => {
if (blocked) return;
const dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) < 60) return;
const dy = e.changedTouches[0].clientY - touchY;
// Require the gesture to be more horizontal than vertical, and meet the distance threshold.
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const cur = TABS.findIndex(t => document.querySelector(`.tab[data-tab="${t}"]`)?.classList.contains('active'));
if (cur < 0) return;
const next = dx < 0 ? Math.min(cur + 1, TABS.length - 1) : Math.max(cur - 1, 0);
+2 -1
View File
@@ -194,13 +194,14 @@
<button class="link-btn" id="open-map-tab">Open full map →</button>
</span>
</div>
<div class="radar-embed-wrap">
<div class="radar-embed-wrap" id="radar-embed-wrap">
<div id="radar-embed" class="radar-map"></div>
<div class="radar-controls">
<button id="embed-play" class="radar-btn play-btn" title="Play/Pause">▶</button>
<button id="embed-layer" class="radar-btn layer-btn" title="Toggle radar/satellite">R</button>
<div class="radar-timeline" id="embed-timeline"></div>
<span id="embed-timestamp" class="radar-ts">--</span>
<button id="embed-fullscreen" class="radar-btn fullscreen-btn" title="Toggle fullscreen">⛶</button>
</div>
</div>
+21
View File
@@ -1680,6 +1680,27 @@ details[open] .drawer-chevron { transform: rotate(90deg); }
.radar-btn:hover { opacity: 0.72; }
.layer-btn { background: var(--accent); }
.fullscreen-btn { font-size: 1rem; }
/* Fullscreen state for the radar embed */
.radar-embed-wrap:fullscreen,
.radar-embed-wrap:-webkit-full-screen {
display: flex;
flex-direction: column;
border-radius: 0;
margin: 0;
background: #000;
}
.radar-embed-wrap:fullscreen #radar-embed,
.radar-embed-wrap:-webkit-full-screen #radar-embed {
flex: 1;
height: 0;
}
.radar-embed-wrap:fullscreen .radar-controls,
.radar-embed-wrap:-webkit-full-screen .radar-controls {
background: rgba(0,0,0,0.8);
border-top: 1px solid rgba(255,255,255,0.15);
}
.radar-timeline {
flex: 1;