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:
Elie Habib
2026-03-01 04:23:47 +04:00
committed by GitHub
parent e0d86613e3
commit d24e094487
5 changed files with 101 additions and 12 deletions

View File

@@ -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;

View File

@@ -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">&#x2705;</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>
`);
}

View File

@@ -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": {

View File

@@ -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) };
}

View File

@@ -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; }