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
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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue