Implement Hazard List with server-side cache of last 7 Hazards

This commit is contained in:
mrkmntal 2026-04-16 17:02:25 -04:00
commit 763352317f
11 changed files with 539 additions and 1 deletions

View file

@ -9,6 +9,7 @@ import settings from './modules/settings.mjs';
import './modules/utils/theme.mjs';
import './modules/latestobservations.mjs';
import './modules/groundview.mjs';
import './modules/hazard-list.mjs';
import AutoComplete from './modules/autocomplete.mjs';
import { loadAllData } from './modules/utils/data-loader.mjs';
import { debugFlag } from './modules/utils/debug.mjs';

View file

@ -0,0 +1,137 @@
// Hazard List display - shows the last 7 hazard alerts encountered by this server
import STATUS from './status.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
class HazardList extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Hazard List', true);
this.history = [];
}
async getData(weatherParameters, refresh) {
const superResult = super.getData(weatherParameters, refresh);
try {
// Fetch hazard history from backend
const response = await fetch('/api/hazard-history');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.data = result.history || [];
this.setStatus(STATUS.loaded);
} else {
throw new Error(result.error || 'Failed to load hazard history');
}
} catch (error) {
// In static mode or if backend is unavailable, show empty state
this.data = [];
if (error.message.includes('404') || error.message.includes('Failed to fetch')) {
// Backend not available (static mode) - disable this display
this.setStatus(STATUS.disabled);
} else {
this.setStatus(STATUS.failed);
}
}
return superResult;
}
async drawCanvas() {
super.drawCanvas();
if (!this.data || this.data.length === 0) {
this.showEmptyState();
this.finishDraw();
return;
}
// Templates are extracted by WeatherDisplay.loadTemplates()
const container = this.elem.querySelector('.hazard-list-rows');
container.innerHTML = '';
// Add hazard rows
this.data.forEach((hazard) => {
const row = this.fillTemplate('hazard-list-row', {
location: hazard.location,
hazard: this.abbreviateHazardType(hazard.hazardType),
date: this.formatDate(hazard.encounteredAt),
ongoing: hazard.ongoing ? 'YES' : 'NO',
});
if (row) container.appendChild(row);
});
this.finishDraw();
}
showEmptyState() {
const container = this.elem.querySelector('.hazard-list-rows');
container.innerHTML = '';
const emptyRow = this.fillTemplate('hazard-list-row', {
location: 'No hazard history available',
hazard: '',
date: '',
ongoing: '',
});
if (emptyRow) container.appendChild(emptyRow);
}
// Format ISO date to MM/DD
formatDate(isoDate) {
if (!isoDate) return '--/--';
try {
const date = new Date(isoDate);
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${month}/${day}`;
} catch {
return '--/--';
}
}
// Abbreviate hazard type to 8 characters max with weather-specific shortcuts
abbreviateHazardType(type) {
if (!type) return '';
// Apply weather-specific abbreviations
let abbreviated = type
.replace(/Thunderstorm/g, 'T-storm')
.replace(/Warning/g, 'Warn')
.replace(/Advisory/g, 'Adv')
.replace(/Visibility/g, 'Vis')
.replace(/Reduced/g, 'Red')
.replace(/Severe/g, 'Sev')
.replace(/Extreme/g, 'Ext')
.replace(/Weather/g, 'Wx')
.replace(/Condition/g, 'Cond')
.replace(/Temperature/g, 'Temp')
.replace(/Precipitation/g, 'Precip')
.replace(/Tornado/g, 'Torn')
.replace(/Hurricane/g, 'Hurr')
.replace(/Tropical/g, 'Trop')
.replace(/Storm/g, 'Stm')
.replace(/Wind/g, 'Wnd')
.replace(/Snow/g, 'Snw')
.replace(/Rain/g, 'Rn')
.replace(/Fog/g, 'Fg')
.replace(/Hail/g, 'Hl')
.replace(/Freezing/g, 'Frz')
.replace(/ Dense/g, 'Dns');
// Hard truncate to 8 characters if still too long
if (abbreviated.length > 8) {
abbreviated = abbreviated.substring(0, 8);
}
return abbreviated;
}
}
// register display
const display = new HazardList(15, 'hazard-list');
registerDisplay(display);
export default display;

View file

@ -129,6 +129,9 @@ class Hazards extends WeatherDisplay {
if (this.isEnabled) {
this.drawLongCanvas();
}
// Sync hazards to backend history
await this.syncHazardHistory();
} catch (error) {
console.error(`Unexpected Active Alerts error: ${error.message}`);
stopAlertTone();
@ -263,6 +266,55 @@ class Hazards extends WeatherDisplay {
this.getDataCallbacks.push(() => resolve(this.data));
});
}
// Sync current hazards to backend history
async syncHazardHistory() {
try {
// Skip if no weather parameters available
if (!this.weatherParameters) return;
// Format location
const { city, state, country, countryCode } = this.weatherParameters;
const location = this.formatLocationLabel(city, state, country, countryCode);
// Normalize hazards for the API
const hazards = (this.data || []).map((hazard) => ({
id: hazard.id,
hazardType: hazard.properties?.event || 'Unknown',
severity: hazard.properties?.severity || 'Unknown',
source: hazard.properties?.senderName?.includes('NOAA') ? 'noaa' : 'derived',
}));
// Send to backend
await fetch('/api/hazard-history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ location, hazards }),
});
} catch (error) {
// Silently fail - hazard history is non-critical
if (debugFlag('verbose-failures')) {
console.warn('Failed to sync hazard history:', error.message);
}
}
}
// Format location label for history
formatLocationLabel(city, state, country, countryCode) {
const cleanCity = city?.trim() || 'Unknown';
// US locations: "City, State"
if (countryCode === 'US' || countryCode === 'USA') {
const cleanState = state?.trim();
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
}
// Non-US locations: "City, Country"
const cleanCountry = country?.trim();
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
}
}
const calcSeverity = (severity, event) => {

View file

@ -0,0 +1,89 @@
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .main.hazard-list {
&.main {
padding-top: 18px;
.column-headers {
display: flex;
font-family: 'Star4000';
font-size: 14pt;
font-weight: bold;
color: #ff0;
width: 70%;
margin: 4px auto 2px;
padding-top: 20px;
text-shadow: 3px 3px 0 #000,-1.5px -1.5px 0 #000,0 -1.5px 0 #000,1.5px -1.5px 0 #000,1.5px 0 0 #000,1.5px 1.5px 0 #000,0 1.5px 0 #000,-1.5px 1.5px 0 #000,-1.5px 0 0 #000;
.location {
width: 34%;
}
.hazard {
width: 30%;
text-align: center;
}
.date {
width: 22%;
text-align: center;
}
.ongoing {
width: 14%;
text-align: right;
padding-right: 4px;
}
}
.hazard-list-rows {
width: 70%;
margin: 0 auto;
.hazard-list-row {
display: flex;
font-family: 'Star4000';
font-size: 14pt;
line-height: 1.4;
margin-bottom: 4px;
@include u.text-shadow();
&.template {
display: none;
}
.location {
width: 34%;
overflow: visible;
text-overflow: ellipsis;
white-space: nowrap;
color: #fff;
}
.hazard {
width: 30%;
text-align: center;
color: c.$title-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
width: 22%;
text-align: center;
color: #fff;
}
.ongoing {
width: 14%;
text-align: right;
padding-right: 4px;
color: #fff;
}
}
}
}
}

View file

@ -17,4 +17,5 @@
@use 'spc-outlook';
@use 'server-observations';
@use 'linux-news';
@use 'hazard-list';
@use 'shared/scanlines';

File diff suppressed because one or more lines are too long