mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(oref): show history waves timeline with translation and NaN fix (#618)
- Fetch and display alert history waves in OrefSirensPanel (cap 50 most recent) - Last-hour waves highlighted with amber border and RECENT badge - Translate Hebrew history alerts via existing translateAlerts pipeline - Guard formatAlertTime/formatWaveTime against NaN from unparseable OREF dates - Cap relay history bootstrap to 500 records - Add 3-minute TTL to prevent re-fetching history on every 10s poll - Remove dead .oref-footer/.oref-history CSS; add i18n key for history summary
This commit is contained in:
@@ -529,7 +529,8 @@ async function orefBootstrapHistory() {
|
||||
const cleaned = stripBom(raw).trim();
|
||||
if (!cleaned || cleaned === '[]') return;
|
||||
|
||||
const records = JSON.parse(cleaned);
|
||||
const allRecords = JSON.parse(cleaned);
|
||||
const records = allRecords.slice(-500);
|
||||
const waves = new Map();
|
||||
for (const r of records) {
|
||||
const key = r.alertDate;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { t } from '@/services/i18n';
|
||||
import type { OrefAlertsResponse, OrefAlert } from '@/services/oref-alerts';
|
||||
import { fetchOrefHistory } from '@/services/oref-alerts';
|
||||
import type { OrefAlertsResponse, OrefAlert, OrefHistoryEntry } from '@/services/oref-alerts';
|
||||
|
||||
const MAX_HISTORY_WAVES = 50;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const HISTORY_TTL = 3 * 60 * 1000;
|
||||
|
||||
export class OrefSirensPanel extends Panel {
|
||||
private alerts: OrefAlert[] = [];
|
||||
private historyCount24h = 0;
|
||||
private historyWaves: OrefHistoryEntry[] = [];
|
||||
private historyFetchInFlight = false;
|
||||
private historyLastFetchAt = 0;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
@@ -35,11 +43,30 @@ export class OrefSirensPanel extends Panel {
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
if (this.historyFetchInFlight) return;
|
||||
if (Date.now() - this.historyLastFetchAt < HISTORY_TTL) return;
|
||||
this.historyFetchInFlight = true;
|
||||
this.historyLastFetchAt = Date.now();
|
||||
fetchOrefHistory()
|
||||
.then(resp => {
|
||||
if (resp.history?.length) {
|
||||
this.historyWaves = resp.history;
|
||||
this.render();
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { this.historyFetchInFlight = false; });
|
||||
}
|
||||
|
||||
private formatAlertTime(dateStr: string): string {
|
||||
try {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const ts = new Date(dateStr).getTime();
|
||||
if (!Number.isFinite(ts)) return '';
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60_000) return t('components.orefSirens.justNow');
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
@@ -51,7 +78,52 @@ export class OrefSirensPanel extends Panel {
|
||||
}
|
||||
}
|
||||
|
||||
private formatWaveTime(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (!Number.isFinite(d.getTime())) return '';
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
+ ' ' + d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private renderHistoryWaves(): string {
|
||||
if (!this.historyWaves.length) return '';
|
||||
|
||||
const now = Date.now();
|
||||
const withTs = this.historyWaves.map(w => ({ wave: w, ts: new Date(w.timestamp).getTime() }));
|
||||
withTs.sort((a, b) => b.ts - a.ts);
|
||||
const sorted = withTs.slice(0, MAX_HISTORY_WAVES);
|
||||
|
||||
const rows = sorted.map(({ wave, ts }) => {
|
||||
const isRecent = now - ts < ONE_HOUR_MS;
|
||||
const rowClass = isRecent ? 'oref-wave-row oref-wave-recent' : 'oref-wave-row';
|
||||
const badge = isRecent ? '<span class="oref-recent-badge">RECENT</span>' : '';
|
||||
const types = wave.alerts.map(a => escapeHtml(a.title || a.cat));
|
||||
const uniqueTypes = [...new Set(types)];
|
||||
const totalAreas = wave.alerts.reduce((sum, a) => sum + (a.data?.length || 0), 0);
|
||||
const summary = uniqueTypes.join(', ') + (totalAreas > 0 ? ` — ${totalAreas} areas` : '');
|
||||
|
||||
return `<div class="${rowClass}">
|
||||
<div class="oref-wave-header">
|
||||
<span class="oref-wave-time">${this.formatWaveTime(wave.timestamp)}</span>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="oref-wave-summary">${summary}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="oref-history-section">
|
||||
<div class="oref-history-title">${t('components.orefSirens.historySummary', { count: String(this.historyCount24h), waves: String(sorted.length) })}</div>
|
||||
<div class="oref-wave-list">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const historyHtml = this.renderHistoryWaves();
|
||||
|
||||
if (this.alerts.length === 0) {
|
||||
this.setContent(`
|
||||
<div class="oref-panel-content">
|
||||
@@ -59,9 +131,7 @@ export class OrefSirensPanel extends Panel {
|
||||
<span class="oref-status-icon">✅</span>
|
||||
<span>${t('components.orefSirens.noAlerts')}</span>
|
||||
</div>
|
||||
<div class="oref-footer">
|
||||
<span class="oref-history">${t('components.orefSirens.historyCount', { count: String(this.historyCount24h) })}</span>
|
||||
</div>
|
||||
${historyHtml}
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
@@ -86,9 +156,7 @@ export class OrefSirensPanel extends Panel {
|
||||
<span>${t('components.orefSirens.activeSirens', { count: String(this.alerts.length) })}</span>
|
||||
</div>
|
||||
<div class="oref-list">${alertRows}</div>
|
||||
<div class="oref-footer">
|
||||
<span class="oref-history">${t('components.orefSirens.historyCount', { count: String(this.historyCount24h) })}</span>
|
||||
</div>
|
||||
${historyHtml}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -1131,6 +1131,7 @@
|
||||
"time": "Time",
|
||||
"justNow": "just now",
|
||||
"historyCount": "{{count}} alerts in last 24h",
|
||||
"historySummary": "{{count}} alerts in 24h — {{waves}} waves",
|
||||
"infoTooltip": "<strong>Israel Sirens</strong><br>Real-time rocket and missile siren alerts from Israel Home Front Command.<br><br>Data is polled every 10 seconds. A pulsing red indicator means active sirens are sounding."
|
||||
},
|
||||
"satelliteFires": {
|
||||
|
||||
@@ -181,7 +181,19 @@ export async function fetchOrefHistory(): Promise<OrefHistoryResponse> {
|
||||
if (!res.ok) {
|
||||
return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: `HTTP ${res.status}` };
|
||||
}
|
||||
return await res.json();
|
||||
const data: OrefHistoryResponse = await res.json();
|
||||
|
||||
if (data.history?.length) {
|
||||
const recentWaves = data.history.slice(-50);
|
||||
const recentAlerts = recentWaves.flatMap(w => w.alerts);
|
||||
await translateAlerts(recentAlerts);
|
||||
data.history = data.history.map(w => ({
|
||||
...w,
|
||||
alerts: applyTranslations(w.alerts),
|
||||
}));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
return { configured: false, history: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: String(err) };
|
||||
}
|
||||
|
||||
@@ -279,5 +279,12 @@
|
||||
.oref-alert-title { font-size: 11px; font-weight: 600; color: var(--text-primary); }
|
||||
.oref-alert-time { font-size: 9px; color: var(--text-muted); }
|
||||
.oref-alert-areas { font-size: 10px; color: var(--text-secondary); line-height: 1.35; }
|
||||
.oref-footer { display: flex; align-items: center; justify-content: center; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border-subtle); }
|
||||
.oref-history { font-size: 9px; color: var(--text-muted); }
|
||||
.oref-history-section { margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border-subtle); }
|
||||
.oref-history-title { font-size: 9px; color: var(--text-muted); margin-bottom: 6px; text-align: center; }
|
||||
.oref-wave-list { display: flex; flex-direction: column; gap: 3px; max-height: 320px; overflow-y: auto; }
|
||||
.oref-wave-row { padding: 5px 8px; border-radius: 4px; border-left: 3px solid var(--border-subtle); background: var(--overlay-subtle); }
|
||||
.oref-wave-recent { border-left-color: var(--semantic-warning, #f59e0b); }
|
||||
.oref-wave-header { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; }
|
||||
.oref-wave-time { font-size: 9px; color: var(--text-muted); }
|
||||
.oref-wave-summary { font-size: 10px; color: var(--text-secondary); line-height: 1.3; }
|
||||
.oref-recent-badge { font-size: 8px; font-weight: 700; color: var(--semantic-warning, #f59e0b); background: color-mix(in srgb, var(--semantic-warning, #f59e0b) 12%, transparent); padding: 1px 4px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
Reference in New Issue
Block a user