mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-04 03:28:11 +10:00
Refactor SPA: extract CSS/JS to modular files, add static file serving, fix timestamp parsing
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
// API client for fetching radar data
|
||||
import { API_BASE } from './config.js';
|
||||
import { formatDate, isNetworkError, isNetworkErrorResponse, createCacheStatusData, triggerBackgroundRefresh } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
/**
|
||||
* Fetch cache range information
|
||||
*/
|
||||
export async function fetchCacheRange() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (isNetworkErrorResponse(response)) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
console.warn('Failed to fetch cache range');
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
state.cacheRangeInfo = data;
|
||||
|
||||
// Update UI
|
||||
const rangeTextEl = document.getElementById('cache-range-text');
|
||||
if (rangeTextEl) {
|
||||
if (data.totalCacheFolders > 0) {
|
||||
const spanHours = data.timeSpanMinutes ? Math.round(data.timeSpanMinutes / 60 * 10) / 10 : 0;
|
||||
rangeTextEl.innerHTML = `
|
||||
<div>Oldest: ${formatDate(data.oldestCache.cacheTimestamp)}</div>
|
||||
<div>Newest: ${formatDate(data.newestCache.cacheTimestamp)}</div>
|
||||
<div>Total: ${data.totalCacheFolders} cache folders (${spanHours} hours)</div>
|
||||
`;
|
||||
} else {
|
||||
rangeTextEl.textContent = 'No cache data available';
|
||||
}
|
||||
}
|
||||
|
||||
return { data: data, error: null };
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache range:', error);
|
||||
const rangeTextEl = document.getElementById('cache-range-text');
|
||||
if (rangeTextEl) {
|
||||
rangeTextEl.textContent = 'Unable to load cache range';
|
||||
}
|
||||
|
||||
if (isNetworkError(error)) {
|
||||
return { data: null, error: 'network' };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch historical radar data for extended timespan
|
||||
*/
|
||||
export async function fetchHistoricalRadar(startTime, endTime) {
|
||||
try {
|
||||
let url = `${API_BASE}/timeseries`;
|
||||
const params = new URLSearchParams();
|
||||
if (startTime) params.append('startTime', startTime.toISOString());
|
||||
if (endTime) params.append('endTime', endTime.toISOString());
|
||||
if (params.toString()) url += '?' + params.toString();
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30000) // Longer timeout for potentially large responses
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
|
||||
|
||||
// Handle 400 Bad Request
|
||||
if (response.status === 400) {
|
||||
const errorMessage = errorData.message || 'Invalid request';
|
||||
let details = '';
|
||||
if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.requestedRange) {
|
||||
const requested = errorData.details.requestedRange;
|
||||
if (requested.requestedHours && errorData.details.maxHours) {
|
||||
details = ` Requested: ${requested.requestedHours.toFixed(1)} hours, Maximum: ${errorData.details.maxHours} hours.`;
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage + details);
|
||||
}
|
||||
|
||||
// Handle 404 Not Found
|
||||
if (response.status === 404) {
|
||||
if (errorData.errorCode === 'CACHE_NOT_FOUND') {
|
||||
triggerBackgroundRefresh(errorData.suggestions?.refreshEndpoint);
|
||||
return {
|
||||
frames: null,
|
||||
error: 'location_missing',
|
||||
cacheStatus: errorData.details || errorData
|
||||
};
|
||||
} else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) {
|
||||
const availableRange = errorData.details.availableRange;
|
||||
let rangeMessage = '';
|
||||
|
||||
if (errorData.suggestions?.suggestedRange) {
|
||||
const suggested = errorData.suggestions.suggestedRange;
|
||||
rangeMessage = ` Suggested range: ${formatDate(suggested.start)} to ${formatDate(suggested.end)}.`;
|
||||
} else if (availableRange.oldest && availableRange.newest) {
|
||||
rangeMessage = ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.`;
|
||||
} else if (availableRange.totalCacheFolders > 0) {
|
||||
rangeMessage = ` ${availableRange.totalCacheFolders} cache folders available.`;
|
||||
}
|
||||
|
||||
if (errorData.suggestions?.suggestion) {
|
||||
rangeMessage += ` ${errorData.suggestions.suggestion}`;
|
||||
}
|
||||
|
||||
throw new Error(errorData.message + rangeMessage);
|
||||
} else {
|
||||
throw new Error(errorData.message || 'No historical data found');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorData.message || 'Failed to fetch historical radar');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
state.historicalData = data;
|
||||
|
||||
// Flatten all frames from all cache folders
|
||||
const allFrames = [];
|
||||
data.cacheFolders.forEach(cacheFolder => {
|
||||
cacheFolder.frames.forEach(frame => {
|
||||
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
|
||||
frame.observationTime = cacheFolder.observationTime;
|
||||
frame.cacheFolderName = cacheFolder.cacheFolderName;
|
||||
allFrames.push(frame);
|
||||
});
|
||||
});
|
||||
|
||||
// Filter out frames without absoluteObservationTime
|
||||
const invalidFrames = allFrames.filter(frame => !frame.absoluteObservationTime);
|
||||
if (invalidFrames.length > 0) {
|
||||
console.error('Backend returned frames missing absoluteObservationTime:', {
|
||||
count: invalidFrames.length,
|
||||
frames: invalidFrames.map((f, idx) => ({
|
||||
index: idx,
|
||||
frameIndex: f.frameIndex,
|
||||
cacheFolder: f.cacheFolderName,
|
||||
imageUrl: f.imageUrl
|
||||
}))
|
||||
});
|
||||
}
|
||||
const validFrames = allFrames.filter(frame => frame.absoluteObservationTime);
|
||||
|
||||
// Validate chronological order
|
||||
let previousTime = null;
|
||||
const outOfOrderFrames = [];
|
||||
validFrames.forEach((frame, idx) => {
|
||||
const currentTime = new Date(frame.absoluteObservationTime).getTime();
|
||||
if (previousTime !== null && currentTime < previousTime) {
|
||||
outOfOrderFrames.push({
|
||||
index: idx,
|
||||
frameIndex: frame.frameIndex,
|
||||
cacheFolder: frame.cacheFolderName,
|
||||
absoluteTime: frame.absoluteObservationTime,
|
||||
previousTime: new Date(previousTime).toISOString(),
|
||||
timeDiffMinutes: (currentTime - previousTime) / 1000 / 60
|
||||
});
|
||||
}
|
||||
previousTime = currentTime;
|
||||
});
|
||||
|
||||
if (outOfOrderFrames.length > 0) {
|
||||
console.error('Backend returned frames out of chronological order:', {
|
||||
count: outOfOrderFrames.length,
|
||||
issues: outOfOrderFrames
|
||||
});
|
||||
}
|
||||
|
||||
// Re-index frames sequentially for display
|
||||
validFrames.forEach((frame, idx) => {
|
||||
frame.sequentialIndex = idx;
|
||||
});
|
||||
|
||||
return { frames: validFrames, error: null };
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical radar:', error);
|
||||
if (isNetworkError(error)) {
|
||||
return { frames: null, error: 'network' };
|
||||
}
|
||||
return { frames: [], error: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch metadata separately
|
||||
*/
|
||||
export async function fetchMetadata() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/metadata`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Could not fetch metadata:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest radar data (for latest mode)
|
||||
*/
|
||||
export async function fetchLatestRadar() {
|
||||
const response = await fetch(API_BASE, {
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` }));
|
||||
|
||||
if (response.status === 404) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
state.lastRefreshTime = new Date();
|
||||
|
||||
const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint;
|
||||
triggerBackgroundRefresh(refreshEndpoint);
|
||||
|
||||
const cacheStatusData = createCacheStatusData(errorData);
|
||||
const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30;
|
||||
const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`;
|
||||
|
||||
return {
|
||||
error: 'cache_generating',
|
||||
cacheStatus: cacheStatusData,
|
||||
message: message,
|
||||
refreshEndpoint: refreshEndpoint
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(error.message || error.error || 'Failed to fetch radar data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
data.isExtendedMode = false;
|
||||
return { data: data, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to fetch radar data (handles both latest and extended modes)
|
||||
*/
|
||||
export async function fetchRadarData() {
|
||||
try {
|
||||
// Extended mode
|
||||
if (state.settings.timespan !== 'latest') {
|
||||
// Fetch cache range first if needed
|
||||
if (!state.cacheRangeInfo) {
|
||||
const rangeResult = await fetchCacheRange();
|
||||
if (rangeResult.error === 'network') {
|
||||
throw new Error('Network error - API unavailable');
|
||||
}
|
||||
state.cacheRangeInfo = rangeResult.data;
|
||||
}
|
||||
|
||||
if (!state.cacheRangeInfo || state.cacheRangeInfo.totalCacheFolders === 0) {
|
||||
const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`;
|
||||
triggerBackgroundRefresh(refreshEndpoint);
|
||||
|
||||
// Try to fetch cache status
|
||||
try {
|
||||
const statusResponse = await fetch(`${API_BASE}`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (statusResponse.status === 404) {
|
||||
const errorData = await statusResponse.json().catch(() => ({}));
|
||||
return {
|
||||
error: 'no_cache_data',
|
||||
cacheStatus: createCacheStatusData(errorData),
|
||||
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Could not fetch cache status:', err);
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'no_cache_data',
|
||||
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate time range
|
||||
let startTime = null;
|
||||
let endTime = new Date();
|
||||
|
||||
if (state.settings.timespan === 'custom') {
|
||||
const startInput = document.getElementById('start-time-input');
|
||||
const endInput = document.getElementById('end-time-input');
|
||||
if (startInput && startInput.value) startTime = new Date(startInput.value);
|
||||
if (endInput && endInput.value) endTime = new Date(endInput.value);
|
||||
} else {
|
||||
const hours = parseInt(state.settings.timespan.replace('h', '')) || 1;
|
||||
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
// Fetch historical data
|
||||
const result = await fetchHistoricalRadar(startTime, endTime);
|
||||
if (result.error === 'network') {
|
||||
throw new Error('Network error - API unavailable');
|
||||
}
|
||||
|
||||
if (result.error === 'location_missing') {
|
||||
return {
|
||||
error: 'location_missing',
|
||||
cacheStatus: createCacheStatusData(result.cacheStatus || {}),
|
||||
message: 'No cached data found for this location. Cache update has been triggered in background.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.frames || result.frames.length === 0) {
|
||||
return {
|
||||
error: 'no_frames',
|
||||
message: 'No frames found for selected timespan. Try a different range or wait for more cache data.'
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch metadata
|
||||
let metadata = null;
|
||||
try {
|
||||
const metadataResponse = await fetch(API_BASE, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (metadataResponse.ok) {
|
||||
const latestData = await metadataResponse.json();
|
||||
metadata = {
|
||||
weatherStation: latestData.weatherStation,
|
||||
distance: latestData.distance,
|
||||
observationTime: latestData.observationTime,
|
||||
cacheExpiresAt: latestData.cacheExpiresAt,
|
||||
nextUpdateTime: latestData.nextUpdateTime,
|
||||
cacheIsValid: latestData.cacheIsValid,
|
||||
isUpdating: latestData.isUpdating
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Could not fetch metadata for extended mode:', err);
|
||||
}
|
||||
|
||||
// Create response object
|
||||
const newestCacheFolder = state.historicalData.cacheFolders[state.historicalData.cacheFolders.length - 1];
|
||||
return {
|
||||
data: {
|
||||
frames: result.frames,
|
||||
lastUpdated: endTime.toISOString(),
|
||||
observationTime: metadata?.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
|
||||
forecastTime: endTime.toISOString(),
|
||||
weatherStation: metadata?.weatherStation || null,
|
||||
distance: metadata?.distance || null,
|
||||
cacheIsValid: metadata?.cacheIsValid ?? true,
|
||||
cacheExpiresAt: metadata?.cacheExpiresAt || endTime.toISOString(),
|
||||
isUpdating: metadata?.isUpdating || false,
|
||||
nextUpdateTime: metadata?.nextUpdateTime || endTime.toISOString(),
|
||||
totalFrames: result.frames.length,
|
||||
isExtendedMode: true
|
||||
},
|
||||
error: null
|
||||
};
|
||||
} else {
|
||||
// Latest mode
|
||||
const result = await fetchLatestRadar();
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching radar data:', error);
|
||||
if (isNetworkError(error)) {
|
||||
return { error: 'network', originalError: error };
|
||||
}
|
||||
return { error: 'unknown', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user