Implement Hazard List with server-side cache of last 7 Hazards
This commit is contained in:
parent
2243bc4c9d
commit
763352317f
11 changed files with 539 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ dist/*
|
||||||
.env
|
.env
|
||||||
nohup.out
|
nohup.out
|
||||||
windy-*.txt
|
windy-*.txt
|
||||||
|
data/
|
||||||
|
|
|
||||||
46
index.mjs
46
index.mjs
|
|
@ -21,6 +21,7 @@ import cache from './proxy/cache.mjs';
|
||||||
import devTools from './src/com.chrome.devtools.mjs';
|
import devTools from './src/com.chrome.devtools.mjs';
|
||||||
import { discoverThemes } from './src/theme-discovery.mjs';
|
import { discoverThemes } from './src/theme-discovery.mjs';
|
||||||
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
|
||||||
|
import { getHistory, updateHistory } from './src/hazard-history.mjs';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -97,6 +98,9 @@ const { themes, themeAssets } = await discoverThemes();
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.WS4KP_PORT ?? 8080;
|
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
|
// Set X-Weatherstar header globally for playlist fallback detection
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('X-Weatherstar', 'true');
|
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);
|
app.use('/api/', weatherProxy);
|
||||||
|
|
||||||
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
// Cache management DELETE endpoint to allow "uncaching" specific URLs
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import settings from './modules/settings.mjs';
|
||||||
import './modules/utils/theme.mjs';
|
import './modules/utils/theme.mjs';
|
||||||
import './modules/latestobservations.mjs';
|
import './modules/latestobservations.mjs';
|
||||||
import './modules/groundview.mjs';
|
import './modules/groundview.mjs';
|
||||||
|
import './modules/hazard-list.mjs';
|
||||||
import AutoComplete from './modules/autocomplete.mjs';
|
import AutoComplete from './modules/autocomplete.mjs';
|
||||||
import { loadAllData } from './modules/utils/data-loader.mjs';
|
import { loadAllData } from './modules/utils/data-loader.mjs';
|
||||||
import { debugFlag } from './modules/utils/debug.mjs';
|
import { debugFlag } from './modules/utils/debug.mjs';
|
||||||
|
|
|
||||||
137
server/scripts/modules/hazard-list.mjs
Normal file
137
server/scripts/modules/hazard-list.mjs
Normal 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;
|
||||||
|
|
@ -129,6 +129,9 @@ class Hazards extends WeatherDisplay {
|
||||||
if (this.isEnabled) {
|
if (this.isEnabled) {
|
||||||
this.drawLongCanvas();
|
this.drawLongCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync hazards to backend history
|
||||||
|
await this.syncHazardHistory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
||||||
stopAlertTone();
|
stopAlertTone();
|
||||||
|
|
@ -263,6 +266,55 @@ class Hazards extends WeatherDisplay {
|
||||||
this.getDataCallbacks.push(() => resolve(this.data));
|
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) => {
|
const calcSeverity = (severity, event) => {
|
||||||
|
|
|
||||||
89
server/styles/scss/_hazard-list.scss
Normal file
89
server/styles/scss/_hazard-list.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,4 +17,5 @@
|
||||||
@use 'spc-outlook';
|
@use 'spc-outlook';
|
||||||
@use 'server-observations';
|
@use 'server-observations';
|
||||||
@use 'linux-news';
|
@use 'linux-news';
|
||||||
|
@use 'hazard-list';
|
||||||
@use 'shared/scanlines';
|
@use 'shared/scanlines';
|
||||||
|
|
|
||||||
2
server/styles/ws.min.css
vendored
2
server/styles/ws.min.css
vendored
File diff suppressed because one or more lines are too long
181
src/hazard-history.mjs
Normal file
181
src/hazard-history.mjs
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -151,6 +151,9 @@
|
||||||
<div id="linux-news-html" class="weather-display">
|
<div id="linux-news-html" class="weather-display">
|
||||||
<%- include('partials/linux-news.ejs') %>
|
<%- include('partials/linux-news.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="hazard-list-html" class="weather-display">
|
||||||
|
<%- include('partials/hazard-list.ejs') %>
|
||||||
|
</div>
|
||||||
<%- include('partials/scroll.ejs') %>
|
<%- include('partials/scroll.ejs') %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
27
views/partials/hazard-list.ejs
Normal file
27
views/partials/hazard-list.ejs
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue