Refactor: Extract components, services, utilities, and styles

- Extract error handling to utils/error-handler.ts
- Extract API service to services/radar-api-service.ts
- Extract utilities: url-resolver.ts, date-formatter.ts
- Extract CSS styles to modular style files
- Extract components: error-display, metadata-section, controls-section
- Remove dead code and unused imports
- Reduce main file from 2140 to 739 lines (65% reduction)
This commit is contained in:
2025-12-17 23:47:15 +10:00
parent f9776ec833
commit 571fcdadec
14 changed files with 1922 additions and 1943 deletions
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
import { LitElement, html, type TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { controlsStyles } from '../styles/controls-styles';
import { overlayStyles } from '../styles/overlay-styles';
import { formatTimestamp } from '../utils/date-formatter';
import type { RadarFrame, RadarResponse, ControlsDisplayConfig } from '../types';
@customElement('bom-controls-section')
export class BomControlsSection extends LitElement {
static override styles = [controlsStyles, overlayStyles];
@property({ attribute: false }) frames: RadarFrame[] = [];
@property({ attribute: false }) currentFrameIndex: number = 0;
@property({ attribute: false }) isPlaying: boolean = false;
@property({ attribute: false }) isExtendedMode: boolean = false;
@property({ attribute: false }) radarData?: RadarResponse;
@property({ attribute: false }) config?: ControlsDisplayConfig | boolean;
@property({ attribute: false }) position?: 'above' | 'below' | 'overlay';
@property({ attribute: false }) locale: string = 'en-AU';
@property({ attribute: false }) timeZone?: string;
@property({ attribute: false }) overlayOpacity?: number;
@property({ attribute: false }) overlayPosition?: string;
@property({ attribute: false }) showFrameTimes?: boolean;
// Event handlers
@property({ attribute: false }) onFrameChange?: (index: number) => void;
@property({ attribute: false }) onPrevious?: () => void;
@property({ attribute: false }) onNext?: () => void;
@property({ attribute: false }) onJumpFrame?: (offset: number) => void;
@property({ attribute: false }) onToggleAnimation?: () => void;
private _getDisplayConfig(): ControlsDisplayConfig | null {
if (!this.config || (typeof this.config === 'boolean' && !this.config)) {
return null;
}
return typeof this.config === 'object' ? this.config : {};
}
protected override render(): TemplateResult {
const displayConfig = this._getDisplayConfig();
if (!displayConfig) {
return html``;
}
const configPosition = displayConfig.position ?? 'below';
// Only render if position matches (or if no position filter specified)
if (this.position !== undefined && configPosition !== this.position) {
return html``;
}
// If overlay, render differently
if (configPosition === 'overlay') {
return this._renderOverlay(displayConfig);
}
return html`
<div class="controls-section controls-${configPosition}">
${displayConfig.show_slider !== false ? this._renderFrameSlider() : ''}
${displayConfig.show_nav_buttons !== false ? this._renderNavButtons() : ''}
${displayConfig.show_play_pause !== false ? this._renderPlayPause() : ''}
${displayConfig.show_prev_next !== false ? this._renderPrevNext() : ''}
${displayConfig.show_frame_info !== false ? this._renderFrameInfo() : ''}
</div>
`;
}
private _renderOverlay(displayConfig: ControlsDisplayConfig): TemplateResult {
const opacity = this.overlayOpacity ?? 0.9;
const position = this.overlayPosition ?? 'bottom';
return html`
<div class="controls-overlay overlay-${position}" style="opacity: ${opacity};">
${displayConfig.show_slider !== false ? this._renderFrameSlider() : ''}
${displayConfig.show_play_pause !== false ? this._renderPlayPause() : ''}
${displayConfig.show_prev_next !== false ? this._renderPrevNext() : ''}
${displayConfig.show_frame_info !== false ? this._renderFrameInfo() : ''}
</div>
`;
}
private _renderFrameSlider(): TemplateResult {
if (this.frames.length === 0) return html``;
return html`
<div class="frame-slider-container">
<div class="frame-slider-wrapper">
<input
type="range"
class="frame-slider"
min="0"
max="${this.frames.length - 1}"
.value="${this.currentFrameIndex}"
@input="${(e: Event) => {
const index = parseInt((e.target as HTMLInputElement).value);
if (this.onFrameChange) {
this.onFrameChange(index);
}
}}"
aria-label="Frame slider"
/>
</div>
</div>
`;
}
private _renderNavButtons(): TemplateResult {
if (this.frames.length === 0) return html``;
// Calculate dynamic skip amount based on total frames (1/3 of total, min 1, max 50)
const skipAmount = this.isExtendedMode
? Math.max(1, Math.min(50, Math.round(this.frames.length / 3)))
: 0;
return html`
<div class="frame-nav-buttons">
${this.isExtendedMode ? html`
<button
class="frame-nav-btn"
@click="${() => this.onFrameChange && this.onFrameChange(0)}"
?disabled="${this.currentFrameIndex === 0}"
title="First frame"
aria-label="First frame"
>⏮</button>
<button
class="frame-nav-btn"
@click="${() => this.onJumpFrame && this.onJumpFrame(-skipAmount)}"
?disabled="${this.currentFrameIndex === 0}"
title="Go back ${skipAmount} frames"
aria-label="Go back ${skipAmount} frames"
>-${skipAmount}</button>
<button
class="frame-nav-btn"
@click="${() => this.onJumpFrame && this.onJumpFrame(skipAmount)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Go forward ${skipAmount} frames"
aria-label="Go forward ${skipAmount} frames"
>+${skipAmount}</button>
<button
class="frame-nav-btn"
@click="${() => this.onFrameChange && this.onFrameChange(this.frames.length - 1)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Last frame"
aria-label="Last frame"
>⏭</button>
` : html`
<button
class="frame-nav-btn"
@click="${() => this.onFrameChange && this.onFrameChange(0)}"
?disabled="${this.currentFrameIndex === 0}"
title="First frame"
aria-label="First frame"
>⏮</button>
<button
class="frame-nav-btn"
@click="${() => this.onFrameChange && this.onFrameChange(this.frames.length - 1)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Last frame"
aria-label="Last frame"
>⏭</button>
`}
</div>
`;
}
private _renderPlayPause(): TemplateResult {
return html`
<div class="play-controls">
<button
class="play-btn"
@click="${() => this.onToggleAnimation && this.onToggleAnimation()}"
aria-label="${this.isPlaying ? 'Pause animation' : 'Play animation'}"
aria-pressed="${this.isPlaying}"
>
${this.isPlaying ? '⏸ Pause' : '▶ Play'}
</button>
</div>
`;
}
private _renderPrevNext(): TemplateResult {
return html`
<div class="play-controls">
<button
class="play-btn"
@click="${() => this.onPrevious && this.onPrevious()}"
aria-label="Previous frame"
>◀ Previous</button>
<button
class="play-btn"
@click="${() => this.onNext && this.onNext()}"
aria-label="Next frame"
>Next ▶</button>
</div>
`;
}
private _renderFrameInfo(): TemplateResult {
const currentFrame = this.frames[this.currentFrameIndex];
if (!currentFrame) return html``;
const showFrameTimes = this.showFrameTimes !== false;
// Format frame info
let frameInfoText = '';
if (this.isExtendedMode && currentFrame.absoluteObservationTime) {
frameInfoText = `Frame ${(currentFrame.sequentialIndex ?? this.currentFrameIndex) + 1} of ${this.frames.length}`;
} else {
frameInfoText = `Frame ${currentFrame.frameIndex + 1} of ${this.frames.length}`;
}
const progress = this.frames.length > 0
? Math.round(((this.currentFrameIndex + 1) / this.frames.length) * 100)
: 0;
return html`
<div class="frame-info">
<span class="frame-index">${frameInfoText}</span>
${showFrameTimes && currentFrame.absoluteObservationTime ? html`
<span class="frame-time">
${formatTimestamp(currentFrame.absoluteObservationTime, this.locale, this.timeZone)}
</span>
` : showFrameTimes && currentFrame.minutesAgo !== undefined ? html`
<span class="frame-time">
${currentFrame.minutesAgo} min ago
</span>
` : ''}
${this.radarData?.observationTime && showFrameTimes ? html`
<span class="observation-time">
Obs: ${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)}
</span>
` : ''}
<span class="frame-progress">${progress}%</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bom-controls-section': BomControlsSection;
}
}
+188
View File
@@ -0,0 +1,188 @@
import { LitElement, html, type TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { errorStyles } from '../styles/error-styles';
import { formatTimestamp } from '../utils/date-formatter';
import type { ErrorState } from '../types';
@customElement('bom-error-display')
export class BomErrorDisplay extends LitElement {
static override styles = [errorStyles];
@property({ attribute: false }) error?: ErrorState;
@property({ attribute: false }) locale: string = 'en-AU';
@property({ attribute: false }) timeZone?: string;
@property({ attribute: false }) onRetry?: () => void;
private _getErrorIcon(): string {
if (!this.error) return 'mdi:alert';
switch (this.error.type) {
case 'cache':
return 'mdi:database-alert';
case 'validation':
return 'mdi:alert-circle';
case 'network':
return 'mdi:wifi-off';
default:
return 'mdi:alert';
}
}
private _getErrorColor(): string {
if (!this.error) return '#f44336';
switch (this.error.type) {
case 'cache':
return '#ff9800';
case 'validation':
return '#f44336';
case 'network':
return '#2196f3';
default:
return '#f44336';
}
}
private _getErrorTitle(): string {
if (!this.error) return 'Error';
switch (this.error.type) {
case 'cache':
return 'Cache Error';
case 'validation':
return 'Validation Error';
case 'network':
return 'Network Error';
default:
return 'Error';
}
}
protected override render(): TemplateResult {
if (!this.error) {
return html``;
}
const action = this.error.details?.action as string;
const isManualRefreshRecommended = action === 'manual_refresh_recommended';
const refreshEndpoint = this.error.details?.refreshEndpoint as string;
const retryInfo = this.error.retryAfter
? html`<div class="error-retry-info">Auto-retrying in ${this.error.retryAfter} seconds...</div>`
: isManualRefreshRecommended
? html`<div class="error-retry-info">Manual refresh recommended. Previous update failed.</div>`
: '';
// Format error message with line breaks (preserve empty lines)
const errorLines = this.error.message.split('\n');
const errorMessage = errorLines.map((line, index) =>
line.trim()
? html`<div class="error-line">${line}</div>`
: html`<div class="error-line-spacer"></div>`
);
// Show additional details if available
const showDetails = this.error.details && (
this.error.details.previousUpdateFailed ||
this.error.details.availableRange ||
this.error.details.requestedRange ||
this.error.details.suggestedRange ||
refreshEndpoint
);
// Determine icon and color based on error type
const errorIcon = this._getErrorIcon();
const errorColor = this._getErrorColor();
return html`
<div class="error-container">
<div class="error-card" style="--error-color: ${errorColor}">
<div class="error-header">
<ha-icon class="error-icon" .icon=${errorIcon}></ha-icon>
<div class="error-title">${this._getErrorTitle()}</div>
</div>
<div class="error-content">
<div class="error-message">${errorMessage}</div>
${this.error.errorCode ? html`
<div class="error-code">
<ha-icon class="code-icon" icon="mdi:code-tags"></ha-icon>
<span>Error Code: ${this.error.errorCode}</span>
</div>
` : ''}
${showDetails ? html`
<div class="error-details">
${this.error.details?.previousUpdateFailed ? html`
<div class="error-detail-item">
<ha-icon class="detail-icon" icon="mdi:alert-circle"></ha-icon>
<div class="detail-content">
<strong>Previous Update Failed:</strong> ${this.error.details.previousError || 'Unknown error'}
${this.error.details.previousErrorCode ? html` (${this.error.details.previousErrorCode})` : ''}
</div>
</div>
` : ''}
${this.error.details?.availableRange ? html`
<div class="error-detail-item">
<ha-icon class="detail-icon" icon="mdi:database-clock"></ha-icon>
<div class="detail-content">
<strong>Available Data Range:</strong> ${this.error.details.availableRange.oldest ? formatTimestamp(this.error.details.availableRange.oldest, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.availableRange.newest ? formatTimestamp(this.error.details.availableRange.newest, this.locale, this.timeZone) : 'N/A'}
${this.error.details.availableRange.totalCacheFolders ? html` (${this.error.details.availableRange.totalCacheFolders} folders)` : ''}
</div>
</div>
` : ''}
${this.error.details?.requestedRange ? html`
<div class="error-detail-item">
<ha-icon class="detail-icon" icon="mdi:calendar-range"></ha-icon>
<div class="detail-content">
<strong>Requested Range:</strong> ${this.error.details.requestedRange.start ? formatTimestamp(this.error.details.requestedRange.start, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.requestedRange.end ? formatTimestamp(this.error.details.requestedRange.end, this.locale, this.timeZone) : 'N/A'}
</div>
</div>
` : ''}
${this.error.details?.suggestedRange ? html`
<div class="error-detail-item suggested-range">
<ha-icon class="detail-icon" icon="mdi:lightbulb-on"></ha-icon>
<div class="detail-content">
<strong>Suggested Range:</strong> ${this.error.details.suggestedRange.start ? formatTimestamp(this.error.details.suggestedRange.start, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.suggestedRange.end ? formatTimestamp(this.error.details.suggestedRange.end, this.locale, this.timeZone) : 'N/A'}
<div class="suggestion-hint">Try using this time range instead</div>
</div>
</div>
` : ''}
${refreshEndpoint ? html`
<div class="error-detail-item">
<ha-icon class="detail-icon" icon="mdi:refresh"></ha-icon>
<div class="detail-content">
<strong>Refresh Endpoint:</strong> <code>${refreshEndpoint}</code>
</div>
</div>
` : ''}
</div>
` : ''}
${retryInfo}
${this.error.retryable && this.onRetry ? html`
<div class="error-actions">
<button
class="retry-button"
type="button"
style="--error-color: ${errorColor}"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
if (this.onRetry) {
this.onRetry();
}
}}
>
<ha-icon class="retry-icon" icon="mdi:refresh"></ha-icon>
<span>${isManualRefreshRecommended ? 'Retry Anyway' : 'Retry Now'}</span>
</button>
</div>
` : ''}
</div>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bom-error-display': BomErrorDisplay;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { LitElement, html, type TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { metadataStyles } from '../styles/metadata-styles';
import { overlayStyles } from '../styles/overlay-styles';
import { formatTimestamp, formatRelativeTime } from '../utils/date-formatter';
import type { RadarResponse, MetadataDisplayConfig } from '../types';
@customElement('bom-metadata-section')
export class BomMetadataSection extends LitElement {
static override styles = [metadataStyles, overlayStyles];
@property({ attribute: false }) radarData?: RadarResponse;
@property({ attribute: false }) config?: MetadataDisplayConfig | boolean;
@property({ attribute: false }) position: 'above' | 'below' | 'overlay' = 'above';
@property({ attribute: false }) locale: string = 'en-AU';
@property({ attribute: false }) timeZone?: string;
@property({ attribute: false }) overlayOpacity?: number;
@property({ attribute: false }) overlayPosition?: string;
private _getDisplayConfig(): MetadataDisplayConfig | null {
if (!this.config || (typeof this.config === 'boolean' && !this.config)) {
return null;
}
return typeof this.config === 'object' ? this.config : {};
}
protected override render(): TemplateResult {
const displayConfig = this._getDisplayConfig();
if (!displayConfig) {
return html``;
}
const configPosition = displayConfig.position ?? 'above';
// Only render if position matches
if (configPosition !== this.position) {
return html``;
}
// If overlay, render differently
if (this.position === 'overlay') {
return this._renderOverlay();
}
// Default style is 'cards' if not specified
const style = displayConfig.style ?? 'cards';
return html`
<div class="metadata-section metadata-${this.position} metadata-${style}">
${displayConfig.show_cache_status !== false ? this._renderCacheStatus() : ''}
${displayConfig.show_observation_time !== false ? this._renderObservationTime() : ''}
${displayConfig.show_forecast_time !== false ? this._renderForecastTime() : ''}
${displayConfig.show_weather_station !== false ? this._renderWeatherStation() : ''}
${displayConfig.show_distance !== false ? this._renderDistance() : ''}
${displayConfig.show_next_update !== false ? this._renderNextUpdate() : ''}
</div>
`;
}
private _renderOverlay(): TemplateResult {
const displayConfig = this._getDisplayConfig();
if (!displayConfig) {
return html``;
}
const opacity = this.overlayOpacity ?? 0.85;
const position = this.overlayPosition ?? 'top';
return html`
<div class="metadata-overlay overlay-${position}" style="opacity: ${opacity};">
${displayConfig.show_cache_status !== false ? this._renderCacheStatus() : ''}
${displayConfig.show_observation_time !== false ? this._renderObservationTime() : ''}
${displayConfig.show_forecast_time !== false ? this._renderForecastTime() : ''}
${displayConfig.show_weather_station !== false ? this._renderWeatherStation() : ''}
${displayConfig.show_distance !== false ? this._renderDistance() : ''}
${displayConfig.show_next_update !== false ? this._renderNextUpdate() : ''}
</div>
`;
}
private _renderCacheStatus(): TemplateResult {
if (!this.radarData) return html``;
return html`
<div class="metadata-item cache-status">
<span class="metadata-label">Cache:</span>
<span class="metadata-value">
${this.radarData.isUpdating ? 'Updating' :
this.radarData.cacheIsValid ? 'Valid' : 'Invalid'}
</span>
</div>
`;
}
private _renderObservationTime(): TemplateResult {
if (!this.radarData?.observationTime) return html``;
return html`
<div class="metadata-item observation-time">
<span class="metadata-label">Observation:</span>
<span class="metadata-value">
${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)}
</span>
<span class="metadata-relative">
(${formatRelativeTime(this.radarData.observationTime)})
</span>
</div>
`;
}
private _renderForecastTime(): TemplateResult {
if (!this.radarData?.forecastTime) return html``;
return html`
<div class="metadata-item forecast-time">
<span class="metadata-label">Forecast:</span>
<span class="metadata-value">${formatTimestamp(this.radarData.forecastTime, this.locale, this.timeZone)}</span>
</div>
`;
}
private _renderWeatherStation(): TemplateResult {
if (!this.radarData?.weatherStation) return html``;
return html`
<div class="metadata-item weather-station">
<span class="metadata-label">Station:</span>
<span class="metadata-value">${this.radarData.weatherStation}</span>
</div>
`;
}
private _renderDistance(): TemplateResult {
if (!this.radarData?.distance) return html``;
return html`
<div class="metadata-item distance">
<span class="metadata-label">Distance:</span>
<span class="metadata-value">${this.radarData.distance}</span>
</div>
`;
}
private _renderNextUpdate(): TemplateResult {
if (!this.radarData?.nextUpdateTime) return html``;
return html`
<div class="metadata-item next-update">
<span class="metadata-label">Next Update:</span>
<span class="metadata-value">${formatTimestamp(this.radarData.nextUpdateTime, this.locale, this.timeZone)}</span>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bom-metadata-section': BomMetadataSection;
}
}
+207
View File
@@ -0,0 +1,207 @@
import { RadarResponse, RadarTimeSeriesResponse, RadarFrame, ErrorState } from '../types';
import { parseApiError, parseErrorResponse } from '../utils/error-handler';
import { resolveImageUrl } from '../utils/url-resolver';
import { DEFAULT_SERVICE_URL } from '../const';
export interface FetchRadarOptions {
serviceUrl: string;
suburb: string;
state: string;
timespan?: string;
customStartTime?: string;
customEndTime?: string;
onError: (error: ErrorState) => void;
}
export class RadarApiService {
/**
* Fetches latest radar frames from /api/radar/{suburb}/{state}
*/
async fetchLatestFrames(options: FetchRadarOptions): Promise<RadarResponse | null> {
const { serviceUrl, suburb, state, onError } = options;
const url = `${serviceUrl}/api/radar/${suburb}/${state}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
const { errorData, parsedJson } = await parseErrorResponse(response);
const error = parseApiError(response, errorData, parsedJson, {
retryAction: () => this.fetchLatestFrames(options),
defaultRetryAfter: 30,
});
onError(error);
return null;
}
const data: any = await response.json();
// Handle legacy error format (shouldn't happen with structured errors, but handle for compatibility)
if (data.error) {
onError({
message: data.error || 'Service returned an error',
type: 'cache',
retryable: true,
retryAction: () => this.fetchLatestFrames(options),
retryAfter: data.retryAfter || 30,
});
return null;
}
// Validate response has frames
if (!data.frames || data.frames.length === 0) {
throw new Error('No frames available in response');
}
// Resolve relative image URLs against service URL
// Service returns relative URLs like "/api/radar/.../frame/..."
// which need to be absolute for browser to fetch from correct origin
data.frames.forEach((frame: RadarFrame) => {
if (frame.imageUrl) {
frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl);
}
});
return data;
} catch (err) {
this.handleFetchError(err, options);
return null;
}
}
/**
* Fetches historical radar data from /api/radar/{suburb}/{state}/timeseries
*/
async fetchHistoricalFrames(options: FetchRadarOptions): Promise<RadarResponse | null> {
const { serviceUrl, suburb, state, timespan, customStartTime, customEndTime, onError } = options;
try {
let startTime: Date | null = null;
let endTime = new Date();
if (timespan === 'custom') {
if (customStartTime) startTime = new Date(customStartTime);
if (customEndTime) endTime = new Date(customEndTime);
} else if (timespan) {
// Calculate hours back from now
const hours = parseInt(timespan.replace('h', '')) || 1;
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
}
if (!startTime) {
throw new Error('Invalid timespan configuration');
}
// Build query parameters for timeseries endpoint
const url = `${serviceUrl}/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
const { errorData, parsedJson } = await parseErrorResponse(response);
const error = parseApiError(response, errorData, parsedJson, {
retryAction: () => this.fetchHistoricalFrames(options),
defaultRetryAfter: 30,
});
onError(error);
return null;
}
const data: RadarTimeSeriesResponse = await response.json();
if (!data.cacheFolders || data.cacheFolders.length === 0) {
throw new Error('No historical data found for the specified time range.');
}
// Flatten all frames from all cache folders
// The service already sets absoluteObservationTime on each frame
const allFrames: RadarFrame[] = [];
data.cacheFolders.forEach(cacheFolder => {
cacheFolder.frames.forEach(frame => {
// Store folder metadata for reference (service already sets absoluteObservationTime)
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
frame.observationTime = cacheFolder.observationTime;
frame.cacheFolderName = cacheFolder.cacheFolderName;
// Resolve relative image URLs against service URL
if (frame.imageUrl) {
frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl);
}
// Ensure absoluteObservationTime is set (service should provide this, but handle if missing)
if (!frame.absoluteObservationTime && frame.observationTime && frame.minutesAgo !== undefined) {
// Fallback: calculate from observation time and minutes ago
const obsTime = new Date(frame.observationTime);
frame.absoluteObservationTime = new Date(obsTime.getTime() - (frame.minutesAgo * 60 * 1000)).toISOString();
}
allFrames.push(frame);
});
});
// Re-index frames sequentially
allFrames.forEach((frame, idx) => {
frame.sequentialIndex = idx;
});
// Fetch latest metadata for display from /api/radar/{suburb}/{state}/metadata
let metadata: Partial<RadarResponse> = {};
try {
const metadataUrl = `${serviceUrl}/api/radar/${suburb}/${state}/metadata`;
const metadataResponse = await fetch(metadataUrl);
if (metadataResponse.ok) {
metadata = await metadataResponse.json();
}
} catch (err) {
console.debug('Could not fetch metadata:', err);
}
// Create RadarResponse-like object
const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1];
const radarResponse: RadarResponse = {
frames: allFrames,
lastUpdated: endTime.toISOString(),
observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
forecastTime: endTime.toISOString(),
weatherStation: metadata.weatherStation,
distance: metadata.distance,
cacheIsValid: metadata.cacheIsValid ?? true,
cacheExpiresAt: metadata.cacheExpiresAt || endTime.toISOString(),
isUpdating: metadata.isUpdating || false,
nextUpdateTime: metadata.nextUpdateTime || endTime.toISOString(),
};
return radarResponse;
} catch (err) {
this.handleFetchError(err, options);
return null;
}
}
/**
* Handles fetch errors and categorizes them appropriately
*/
private handleFetchError(err: any, options: FetchRadarOptions): void {
if (err instanceof TypeError && err.message.includes('fetch')) {
options.onError({
message: 'Network error: Unable to connect to service',
type: 'network',
retryable: true,
retryAction: () => this.fetchLatestFrames(options),
});
} else {
options.onError({
message: err instanceof Error ? err.message : 'Unknown error occurred',
type: 'unknown',
retryable: true,
retryAction: () => this.fetchLatestFrames(options),
});
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import { css, CSSResultGroup } from 'lit';
/**
* Base card styles including CSS variables and root layout
*/
export const cardStyles: CSSResultGroup = css`
:host {
--bom-primary-color: var(--primary-color, #667eea);
--bom-secondary-color: var(--secondary-text-color, #666);
--bom-card-background: var(--card-background-color, white);
--bom-text-color: var(--primary-text-color, #333);
--bom-border-color: var(--divider-color, #e0e0e0);
}
#root {
width: 100%;
position: relative;
}
`;
+212
View File
@@ -0,0 +1,212 @@
import { css, CSSResultGroup } from 'lit';
/**
* Styles for frame slider, navigation buttons, and play controls
*/
export const controlsStyles: CSSResultGroup = css`
.frame-slider-container {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
border: 2px solid #e0e0e0;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
margin-bottom: 15px;
box-sizing: border-box;
}
.frame-slider-wrapper {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.frame-nav-btn {
padding: 10px 16px;
border: 2px solid var(--primary-color, #667eea);
background: white;
color: var(--primary-color, #667eea);
border-radius: 10px;
cursor: pointer;
font-weight: 700;
font-size: 0.9em;
transition: all 0.2s;
min-width: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.frame-nav-btn:hover:not(:disabled) {
background: var(--primary-color, #667eea);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f0f0f0;
border-color: #d0d0d0;
color: #999;
}
.frame-slider {
flex: 1;
min-width: 120px;
height: 10px;
border-radius: 5px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
cursor: pointer;
order: 3;
}
.frame-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
border: 3px solid white;
transition: all 0.2s;
margin-top: -7px; /* Center the 24px thumb on the 10px track: (24-10)/2 = 7 */
}
.frame-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
.frame-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
.frame-slider::-moz-range-track {
height: 10px;
border-radius: 5px;
background: #e0e0e0;
}
.frame-slider::-moz-range-progress {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
.play-controls {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.play-btn {
padding: 10px 16px;
border: 2px solid var(--primary-color, #667eea);
background: var(--primary-color, #667eea);
color: white;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
font-size: 0.9em;
transition: all 0.2s;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.play-btn:hover:not(:disabled) {
background: var(--primary-color-dark, #5568d3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.frame-info {
text-align: center;
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
font-size: 0.85em;
margin-top: 6px;
margin-bottom: 4px;
padding: 6px 12px;
background: var(--card-background-color, #fff);
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
line-height: 1.4;
}
.frame-index {
font-weight: 600;
color: var(--primary-text-color, rgba(0, 0, 0, 0.87));
white-space: nowrap;
}
.frame-time {
white-space: nowrap;
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
}
.observation-time {
white-space: nowrap;
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
}
.frame-progress {
white-space: nowrap;
color: var(--secondary-text-color, rgba(0, 0, 0, 0.6));
font-size: 0.9em;
}
.frame-nav-buttons {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.controls-section {
margin-top: 15px;
}
/* Responsive adjustments */
@media (min-width: 480px) {
.frame-slider-wrapper {
flex-wrap: nowrap;
}
.frame-slider {
order: 0;
}
}
`;
+273
View File
@@ -0,0 +1,273 @@
import { css, CSSResultGroup } from 'lit';
/**
* Styles for error display components
*/
export const errorStyles: CSSResultGroup = css`
/* Error Display */
.error-container {
padding: 16px;
}
.error-card {
background: var(--card-background-color, #ffffff);
border-radius: 12px;
border-left: 4px solid var(--error-color);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1));
transition: opacity 0.3s ease, transform 0.3s ease;
}
.error-header {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
background: var(--secondary-background-color, #fafafa);
border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1));
}
.error-icon {
color: var(--error-color);
width: 24px;
height: 24px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.error-title {
font-size: 1.1em;
font-weight: 600;
color: var(--primary-text-color, #212121);
flex: 1;
}
.error-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.error-message {
font-weight: 500;
line-height: 1.7;
display: flex;
flex-direction: column;
gap: 6px;
color: var(--primary-text-color, #212121);
font-size: 1.05em;
margin-bottom: 4px;
}
.error-line {
min-height: 1.2em;
}
.error-line-spacer {
height: 0.5em;
}
.error-code {
font-size: 0.85em;
font-family: monospace;
color: var(--secondary-text-color, #666);
background: var(--secondary-background-color, #f5f5f5);
padding: 6px 12px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1));
}
.code-icon {
width: 16px;
height: 16px;
opacity: 0.7;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.error-retry-info {
font-size: 0.9em;
color: var(--primary-text-color, #212121);
padding: 10px 14px;
background: linear-gradient(135deg,
rgba(33, 150, 243, 0.08) 0%,
rgba(33, 150, 243, 0.04) 100%);
border-radius: 8px;
border-left: 3px solid var(--primary-color, #2196f3);
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.error-retry-info::before {
content: '⏱';
font-size: 1.1em;
}
.error-details {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
display: flex;
flex-direction: column;
gap: 10px;
}
.error-detail-item {
font-size: 0.9em;
line-height: 1.6;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
background: var(--secondary-background-color, #fafafa);
border-radius: 8px;
transition: background 0.2s ease;
}
.error-detail-item:hover {
background: var(--secondary-background-color, #f0f0f0);
}
.detail-icon {
color: var(--error-color);
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 2px;
}
.detail-content {
flex: 1;
color: var(--primary-text-color, #212121);
}
.detail-content strong {
font-weight: 600;
color: var(--primary-text-color, #212121);
}
.detail-content code {
font-family: monospace;
font-size: 0.9em;
background: var(--secondary-background-color, rgba(0, 0, 0, 0.05));
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.15));
color: var(--primary-text-color, #212121);
display: inline-block;
word-break: break-all;
}
.error-detail-item.suggested-range {
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.1) 0%,
rgba(255, 193, 7, 0.05) 100%);
border-left: 3px solid var(--warning-color, #ffc107);
}
.suggestion-hint {
font-size: 0.85em;
color: var(--secondary-text-color, #666);
margin-top: 4px;
font-style: italic;
}
.error-actions {
margin-top: 16px;
display: flex;
gap: 12px;
justify-content: flex-start;
}
.retry-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
min-height: 44px;
background: var(--error-color, #f44336);
color: white !important;
border: 2px solid var(--error-color, #f44336);
border-radius: 8px;
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2);
font-family: inherit;
-webkit-appearance: none;
appearance: none;
user-select: none;
position: relative;
z-index: 1;
filter: brightness(1.05);
}
.retry-button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 6px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0));
pointer-events: none;
z-index: -1;
}
.retry-button:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
filter: brightness(0.95);
}
.retry-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.retry-button:focus {
outline: 2px solid var(--error-color);
outline-offset: 2px;
}
.retry-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.retry-icon {
width: 18px;
height: 18px;
color: white;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.retry-button span {
color: white;
}
`;
+82
View File
@@ -0,0 +1,82 @@
import { css, CSSResultGroup } from 'lit';
/**
* Styles for radar image container and loading states
*/
export const imageStyles: CSSResultGroup = css`
/* Radar Image Container */
.radar-image-container {
position: relative;
width: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
aspect-ratio: 16/9;
min-height: 200px; /* Smaller on mobile */
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 480px) {
.radar-image-container {
min-height: 300px;
}
}
@media (min-width: 768px) {
.radar-image-container {
min-height: 400px;
}
}
.radar-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
image-rendering: crisp-edges;
transition: transform 0.3s ease;
}
.radar-image-contain {
width: 100%;
height: 100%;
object-fit: contain;
}
.radar-image-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.radar-image-fill {
width: 100%;
height: 100%;
object-fit: fill;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.2em;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.loading-message {
font-size: 0.9em;
opacity: 0.9;
}
`;
+146
View File
@@ -0,0 +1,146 @@
import { css, CSSResultGroup } from 'lit';
/**
* Styles for metadata display sections
*/
export const metadataStyles: CSSResultGroup = css`
/* Metadata Section */
.metadata-section {
margin-bottom: 15px;
}
.metadata-section.metadata-cards {
display: grid;
grid-template-columns: 1fr; /* Single column on mobile */
gap: 12px;
}
@media (min-width: 480px) {
.metadata-section.metadata-cards {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.metadata-section.metadata-cards {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
}
.metadata-section.metadata-cards .metadata-item {
background: var(--card-background-color, #ffffff);
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
border-radius: 8px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.metadata-section.metadata-cards .metadata-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
}
.metadata-section.metadata-cards .metadata-label {
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
margin-bottom: 2px;
}
.metadata-section.metadata-cards .metadata-value {
font-size: 1.1em;
font-weight: 600;
}
.metadata-section.metadata-compact {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.9em;
}
.metadata-section.metadata-compact .metadata-item {
background: var(--secondary-background-color, #f5f5f5);
border-radius: 6px;
padding: 8px 12px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.metadata-section.metadata-minimal {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85em;
}
.metadata-section.metadata-minimal .metadata-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
}
.metadata-section.metadata-minimal .metadata-item:last-child {
border-bottom: none;
}
.metadata-item {
display: flex;
align-items: center;
gap: 6px;
}
.metadata-label {
color: var(--bom-secondary-color);
font-weight: 600;
font-size: 0.85em;
}
.metadata-value {
color: var(--bom-text-color);
font-weight: 600;
}
.metadata-relative {
color: var(--bom-secondary-color);
font-size: 0.85em;
}
.info-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.info-card {
background: var(--bom-card-background);
border-radius: 8px;
padding: 15px;
border-left: 4px solid var(--bom-primary-color);
}
.info-card h3 {
font-size: 0.8em;
text-transform: uppercase;
color: var(--bom-secondary-color);
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.info-card .value {
font-size: 1.2em;
font-weight: 600;
color: var(--bom-text-color);
}
`;
+105
View File
@@ -0,0 +1,105 @@
import { css, CSSResultGroup } from 'lit';
/**
* Styles for overlay controls and metadata overlays
*/
export const overlayStyles: CSSResultGroup = css`
/* Overlay Controls */
.controls-overlay {
position: absolute;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
border-radius: 8px;
padding: 12px;
z-index: 10;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 200px;
}
.controls-overlay.overlay-top {
top: 12px;
left: 50%;
transform: translateX(-50%);
}
.controls-overlay.overlay-bottom {
bottom: 12px;
left: 50%;
transform: translateX(-50%);
}
.controls-overlay.overlay-left {
left: 12px;
top: 50%;
transform: translateY(-50%);
}
.controls-overlay.overlay-right {
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.controls-overlay.overlay-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* Metadata Overlay */
.metadata-overlay {
position: absolute;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(6px);
border-radius: 6px;
padding: 8px 12px;
z-index: 5;
font-size: 0.85em;
color: white;
display: flex;
flex-direction: column;
gap: 4px;
}
.metadata-overlay .metadata-item {
color: white;
}
.metadata-overlay .metadata-label {
color: rgba(255, 255, 255, 0.8);
}
.metadata-overlay.overlay-top {
top: 12px;
left: 12px;
right: auto;
}
.metadata-overlay.overlay-bottom {
bottom: 12px;
left: 12px;
right: auto;
}
.metadata-overlay.overlay-left {
left: 12px;
top: 50%;
transform: translateY(-50%);
}
.metadata-overlay.overlay-right {
right: 12px;
top: 50%;
transform: translateY(-50%);
}
.metadata-overlay.overlay-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
`;
+40
View File
@@ -0,0 +1,40 @@
/**
* Formats timestamp for display
*/
export function formatTimestamp(
isoString: string,
locale: string = 'en-AU',
timeZone?: string
): string {
if (!isoString) return '-';
const date = new Date(isoString);
const tz = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
return date.toLocaleString(locale, {
timeZone: tz,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Relative time (e.g., "5 min ago", "2 hours ago")
*/
export function formatRelativeTime(isoString: string): string {
if (!isoString) return '-';
const date = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
}
+131
View File
@@ -0,0 +1,131 @@
import { ApiErrorResponse, ErrorState } from '../types';
export interface ErrorHandlerOptions {
retryAction: () => void;
defaultRetryAfter?: number;
}
/**
* Parses API error response and creates ErrorState
* Consolidates duplicated error handling logic from fetchRadarData and fetchHistoricalRadar
*/
export function parseApiError(
response: Response,
errorData: ApiErrorResponse | any,
parsedJson: boolean,
options: ErrorHandlerOptions
): ErrorState {
// Extract error information (support both camelCase and PascalCase)
const errorCode = errorData.errorCode || errorData.ErrorCode;
const message = errorData.message || errorData.Message || response.statusText || 'An error occurred';
const errorType = errorData.errorType || errorData.ErrorType;
const details = errorData.details || errorData.Details || {};
const suggestions = errorData.suggestions || errorData.Suggestions || {};
// Build enhanced error message
let enhancedMessage = message;
// Enhance message for cache not found scenarios
if (errorCode === 'CACHE_NOT_FOUND' || (response.status === 404 && !errorCode)) {
if (!enhancedMessage.includes('cache') && !enhancedMessage.includes('Cache')) {
enhancedMessage = 'No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments.';
}
}
// Handle previous update failure
if (details.previousUpdateFailed) {
enhancedMessage = `${enhancedMessage}\n\nPrevious update failed: ${details.previousError || 'Unknown error'}`;
if (details.previousErrorCode) {
enhancedMessage += ` (${details.previousErrorCode})`;
}
}
// Handle time range errors with available/requested ranges
if (errorCode === 'TIME_RANGE_ERROR' && details.availableRange) {
const available = details.availableRange;
const requested = details.requestedRange;
enhancedMessage += `\n\nAvailable data: ${available.oldest || 'N/A'} to ${available.newest || 'N/A'}`;
if (available.totalCacheFolders) {
enhancedMessage += ` (${available.totalCacheFolders} cache folders)`;
}
if (requested) {
enhancedMessage += `\nRequested: ${requested.start || 'N/A'} to ${requested.end || 'N/A'}`;
}
if (details.requestedHours) {
enhancedMessage += `\nRequested range: ${details.requestedHours} hours (max: ${details.maxHours || 'N/A'} hours)`;
}
// Add service suggestion if available
if (suggestions.suggestion) {
enhancedMessage += `\n\n💡 ${suggestions.suggestion}`;
}
}
// Determine retry behavior
const retryAfter = suggestions.retryAfter as number ||
details.retryAfter as number ||
options.defaultRetryAfter || 30;
const action = suggestions.action as string || 'retry_after_seconds';
const shouldAutoRetry = action !== 'manual_refresh_recommended' &&
action !== 'check_network_and_retry' &&
action !== 'adjust_time_range';
// Map error type
const mappedType = mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown'));
// Determine retryable status - for historical endpoint, also allow 400
const isHistoricalEndpoint = response.url.includes('/timeseries');
const retryable = response.status === 404 || (isHistoricalEndpoint && response.status === 400);
return {
message: enhancedMessage,
type: mappedType,
retryable: retryable,
retryAction: options.retryAction,
retryAfter: shouldAutoRetry ? retryAfter : undefined,
errorCode: errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : `HTTP_${response.status}`),
details: {
...details,
action: action,
refreshEndpoint: suggestions.refreshEndpoint as string,
statusEndpoint: suggestions.statusEndpoint as string,
suggestedRange: suggestions.suggestedRange as any,
},
};
}
/**
* Maps API error type to card error type
*/
function mapErrorType(apiErrorType: string): ErrorState['type'] {
if (!apiErrorType) return 'unknown';
const type = apiErrorType.toLowerCase();
if (type.includes('cache') || type === 'cache_not_found' || type === 'cacheerror') return 'cache';
if (type.includes('validation') || type === 'validation_error' || type === 'validationerror') return 'validation';
if (type.includes('network') || type.includes('fetch')) return 'network';
if (type.includes('notfound') || type === 'not_found' || type === 'notfounderror') return 'cache';
return 'unknown';
}
/**
* Attempts to parse error response from API
*/
export async function parseErrorResponse(response: Response): Promise<{ errorData: ApiErrorResponse | any; parsedJson: boolean }> {
let errorData: ApiErrorResponse | any;
let parsedJson = false;
try {
errorData = await response.json();
parsedJson = true;
} catch {
// If JSON parsing fails, create a basic error structure
errorData = {
message: response.statusText || 'Unknown error',
errorCode: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
errorType: 'ServiceError'
};
}
return { errorData, parsedJson };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Resolves a URL relative to the service URL if it's a relative path
* Service returns relative URLs like "/api/radar/.../frame/..." which need
* to be resolved against the service_url base
*/
export function resolveImageUrl(url: string, serviceUrl: string): string {
if (!url) return url;
// If URL is already absolute (starts with http:// or https://), use as-is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Remove trailing slash from serviceUrl
const baseUrl = serviceUrl.replace(/\/$/, '');
// If URL is relative (starts with /), resolve against service URL
if (url.startsWith('/')) {
return `${baseUrl}${url}`;
}
// If URL is relative without leading slash, resolve against service URL with path
return `${baseUrl}/${url}`;
}