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

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ dist/*
.env
nohup.out
windy-*.txt
data/

View file

@ -21,6 +21,7 @@ import cache from './proxy/cache.mjs';
import devTools from './src/com.chrome.devtools.mjs';
import { discoverThemes } from './src/theme-discovery.mjs';
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
import { getHistory, updateHistory } from './src/hazard-history.mjs';
const execAsync = promisify(exec);
@ -97,6 +98,9 @@ const { themes, themeAssets } = await discoverThemes();
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
// Enable JSON parsing for API endpoints
app.use(express.json());
// Set X-Weatherstar header globally for playlist fallback detection
app.use((req, res, next) => {
res.setHeader('X-Weatherstar', 'true');
@ -284,6 +288,48 @@ if (!process.env?.STATIC) {
}
});
// Hazard History API endpoints
app.get('/api/hazard-history', async (req, res) => {
try {
const history = await getHistory();
res.json({
success: true,
history,
});
} catch (error) {
res.status(500).json({
success: false,
history: [],
error: error.message,
});
}
});
app.post('/api/hazard-history', async (req, res) => {
try {
const { location, hazards } = req.body;
if (!location || !Array.isArray(hazards)) {
res.status(400).json({
success: false,
error: 'Missing or invalid location/hazards',
});
return;
}
const history = await updateHistory({ location, hazards });
res.json({
success: true,
history,
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
});
}
});
app.use('/api/', weatherProxy);
// Cache management DELETE endpoint to allow "uncaching" specific URLs

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

181
src/hazard-history.mjs Normal file
View file

@ -0,0 +1,181 @@
/**
* Hazard History persistence module
* Tracks the last 7 hazard alerts encountered by this server instance
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import path from 'path';
const HISTORY_FILE = path.resolve('./data/hazard-history.json');
const MAX_HISTORY_ENTRIES = 7;
/**
* Ensure the cache directory exists
*/
const ensureCacheDir = async () => {
const cacheDir = path.dirname(HISTORY_FILE);
try {
await mkdir(cacheDir, { recursive: true });
} catch (error) {
// Directory may already exist
}
};
/**
* Load hazard history from disk
* @returns {Array} Array of hazard history entries
*/
const loadHistory = async () => {
try {
await ensureCacheDir();
const data = await readFile(HISTORY_FILE, 'utf8');
const parsed = JSON.parse(data);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
// File doesn't exist or is corrupted, return empty array
return [];
}
};
/**
* Save hazard history to disk
* @param {Array} history - Array of hazard history entries
*/
const saveHistory = async (history) => {
try {
await ensureCacheDir();
await writeFile(HISTORY_FILE, JSON.stringify(history, null, '\t'));
} catch (error) {
console.error('Failed to save hazard history:', error.message);
}
};
/**
* Generate a stable key for a hazard entry
* @param {string} location - Formatted location string
* @param {string} hazardId - Hazard ID
* @returns {string} Stable key
*/
const generateKey = (location, hazardId) => `${location}::${hazardId}`;
/**
* Format location label from weather parameters
* @param {string} city - City name
* @param {string} state - State name
* @param {string} country - Country name
* @param {string} countryCode - Country code
* @returns {string} Formatted location label
*/
const formatLocation = (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;
};
/**
* Update hazard history with current active hazards for a location
* @param {Object} payload - Request payload
* @param {string} payload.location - Formatted location label
* @param {Array} payload.hazards - Array of active hazards
* @returns {Array} Updated history
*/
const updateHistory = async (payload) => {
const { location, hazards = [] } = payload;
// Load existing history
let history = await loadHistory();
const now = new Date().toISOString();
// Create a set of active hazard keys for this location
const activeKeys = new Set();
hazards.forEach((hazard) => {
const key = generateKey(location, hazard.id);
activeKeys.add(key);
});
// Mark previously ongoing hazards for this location as ended if no longer active
history = history.map((entry) => {
// Only process entries for this location
if (entry.location !== location) return entry;
// If this entry is ongoing but not in the current active set, mark it as ended
if (entry.ongoing && !activeKeys.has(entry.key)) {
return {
...entry,
ongoing: false,
lastSeenAt: now,
};
}
return entry;
});
// Add or update active hazards
hazards.forEach((hazard) => {
const key = generateKey(location, hazard.id);
const existingIndex = history.findIndex((entry) => entry.key === key);
if (existingIndex >= 0) {
// Update existing entry
history[existingIndex] = {
...history[existingIndex],
lastSeenAt: now,
ongoing: true,
// Update severity if it changed
severity: hazard.severity || history[existingIndex].severity,
};
} else {
// Create new entry
history.push({
key,
location,
hazardType: hazard.hazardType,
encounteredAt: now,
lastSeenAt: now,
ongoing: true,
severity: hazard.severity,
source: hazard.source,
});
}
});
// Sort by lastSeenAt descending (newest first)
history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt));
// Trim to max entries
if (history.length > MAX_HISTORY_ENTRIES) {
history = history.slice(0, MAX_HISTORY_ENTRIES);
}
// Save updated history
await saveHistory(history);
return history;
};
/**
* Get current hazard history
* @returns {Array} Current history entries
*/
const getHistory = async () => {
const history = await loadHistory();
// Ensure sorted by lastSeenAt descending
return history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt));
};
export {
loadHistory,
saveHistory,
updateHistory,
getHistory,
formatLocation,
generateKey,
MAX_HISTORY_ENTRIES,
};

View file

@ -151,6 +151,9 @@
<div id="linux-news-html" class="weather-display">
<%- include('partials/linux-news.ejs') %>
</div>
<div id="hazard-list-html" class="weather-display">
<%- include('partials/hazard-list.ejs') %>
</div>
<%- include('partials/scroll.ejs') %>
</div>
</div>

View file

@ -0,0 +1,27 @@
<div class="header">
<div class="logo">
<img class="theme-logo" src="images/logos/logo-corner.png" />
</div>
<div class="title dual">
<div class="top">Hazard</div>
<div class="bottom">List</div>
</div>
<div class="date-time date"></div>
<div class="date-time time"></div>
</div>
<div class="main has-scroll hazard-list">
<div class="column-headers">
<div class="location">LOCATION</div>
<div class="hazard">HAZARD</div>
<div class="date">DATE</div>
<div class="ongoing">ONGOING</div>
</div>
<div class="hazard-list-rows">
<div class="hazard-list-row template">
<div class="location"></div>
<div class="hazard"></div>
<div class="date"></div>
<div class="ongoing"></div>
</div>
</div>
</div>