diff --git a/src/bom-local-radar-card.ts b/src/bom-local-radar-card.ts index cfd6688..b66cba2 100644 --- a/src/bom-local-radar-card.ts +++ b/src/bom-local-radar-card.ts @@ -1,9 +1,19 @@ -import { LitElement, html, css, type CSSResultGroup, type TemplateResult } from 'lit'; +import { LitElement, html, type CSSResultGroup, type TemplateResult } from 'lit'; import { property, customElement, state } from 'lit/decorators.js'; import { HomeAssistant, LovelaceCardEditor, LovelaceCard } from 'custom-card-helpers'; import './editor'; -import { BomLocalRadarCardConfig, RadarResponse, RadarFrame, RadarTimeSeriesResponse, MetadataDisplayConfig, ControlsDisplayConfig, ErrorState, GridOptions, ApiErrorResponse } from './types'; +import { BomLocalRadarCardConfig, RadarResponse, RadarFrame, MetadataDisplayConfig, ControlsDisplayConfig, ErrorState, GridOptions } from './types'; import { CARD_VERSION, DEFAULT_SERVICE_URL, DEFAULT_FRAME_INTERVAL, DEFAULT_REFRESH_INTERVAL, DEFAULT_RESTART_DELAY } from './const'; +import { RadarApiService } from './services/radar-api-service'; +import { cardStyles } from './styles/card-styles'; +import { imageStyles } from './styles/image-styles'; +import { controlsStyles } from './styles/controls-styles'; +import { metadataStyles } from './styles/metadata-styles'; +import { overlayStyles } from './styles/overlay-styles'; +import { errorStyles } from './styles/error-styles'; +import './components/error-display'; +import './components/metadata-section'; +import './components/controls-section'; console.info( `%c BOM-LOCAL-RADAR-CARD \n%c Version ${CARD_VERSION} `, @@ -33,944 +43,14 @@ window.customCards.push({ @customElement('bom-local-radar-card') export class BomLocalRadarCard extends LitElement implements LovelaceCard { - static override styles: 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; - } - - /* 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; - } - .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, #666); - font-size: 0.9em; - margin-top: 10px; - } - .info-section { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - margin-bottom: 15px; - } - .info-card { - background: var(--card-background-color, white); - border-radius: 8px; - padding: 15px; - border-left: 4px solid var(--primary-color, #667eea); - } - .info-card h3 { - font-size: 0.8em; - text-transform: uppercase; - color: var(--secondary-text-color, #666); - margin-bottom: 6px; - letter-spacing: 0.5px; - } - .info-card .value { - font-size: 1.2em; - font-weight: 600; - color: var(--primary-text-color, #333); - } - /* 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; - } - - /* 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; - } - - /* 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%); - } - - /* Controls Section */ - .controls-section { - margin-top: 15px; - } - - .frame-slider-container { - width: 100%; - padding: 15px; - background: var(--bom-card-background); - border-radius: 12px; - border: 2px solid var(--bom-border-color); - 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-buttons { - display: flex; - gap: 8px; - justify-content: center; - margin-bottom: 12px; - flex-wrap: wrap; - } - - .frame-nav-btn { - padding: 10px 16px; - border: 2px solid var(--bom-primary-color); - background: var(--bom-card-background); - color: var(--bom-primary-color); - border-radius: 10px; - cursor: pointer; - font-weight: 700; - font-size: 0.9em; - transition: all 0.2s; - min-width: 50px; - min-height: 44px; - display: flex; - align-items: center; - justify-content: center; - } - - .frame-nav-btn:hover:not(:disabled) { - background: var(--bom-primary-color); - 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: var(--bom-border-color); - 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, var(--bom-primary-color) 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, var(--bom-primary-color) 0%, #764ba2 100%); - } - - .frame-slider::-moz-range-thumb { - width: 24px; - height: 24px; - border-radius: 50%; - background: linear-gradient(135deg, var(--bom-primary-color) 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: var(--bom-border-color); - } - - .frame-slider::-moz-range-progress { - height: 10px; - border-radius: 5px; - background: linear-gradient(to right, var(--bom-primary-color) 0%, #764ba2 100%); - } - - .play-controls { - display: flex; - flex-direction: row; - gap: 8px; - justify-content: center; - align-items: center; - margin-bottom: 12px; - flex-wrap: wrap; - } - - /* Frame Info */ - .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; - } - font-size: 0.85em; - } - - .frame-progress { - font-size: 0.85em; - opacity: 0.8; - } - - .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); - } - - /* Responsive adjustments */ - @media (min-width: 480px) { - .frame-slider-wrapper { - flex-wrap: nowrap; - } - .frame-slider { - order: 0; - } - } - `; + static override styles: CSSResultGroup = [ + cardStyles, + imageStyles, + controlsStyles, + metadataStyles, + overlayStyles, + errorStyles, + ]; @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) private _config!: BomLocalRadarCardConfig; @@ -988,6 +68,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { @state() private isExtendedMode = false; private preloadedImages: HTMLImageElement[] = []; private _debouncedImageLoad?: (url: string) => void; + private apiService = new RadarApiService(); public static async getConfigElement(): Promise { return document.createElement('bom-local-radar-card-editor') as LovelaceCardEditor; @@ -1064,28 +145,6 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { }; } - /** - * 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 - */ - private 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; - } - // If URL is relative (starts with /), resolve against service URL - if (url.startsWith('/')) { - // Remove trailing slash from serviceUrl if present - const baseUrl = serviceUrl.replace(/\/$/, ''); - return `${baseUrl}${url}`; - } - // If URL is relative without leading slash, resolve against service URL with path - const baseUrl = serviceUrl.replace(/\/$/, ''); - return `${baseUrl}/${url}`; - } - /** * Fetches radar data from the local service * Supports both latest frames and historical timeseries @@ -1094,453 +153,57 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { const serviceUrl = this._config.service_url || DEFAULT_SERVICE_URL; const suburb = encodeURIComponent(this._config.suburb); const state = encodeURIComponent(this._config.state); + const timespan = this._config.timespan || 'latest'; - try { - this.isLoading = true; - // Don't clear error immediately - let it fade out smoothly after data loads + this.isLoading = true; - // Check if we're in extended mode (historical data) - const timespan = this._config.timespan || 'latest'; - - if (timespan !== 'latest') { - // Fetch historical data via timeseries endpoint - return await this.fetchHistoricalRadar(serviceUrl, suburb, state, timespan); - } else { - // Fetch latest frames - const url = `${serviceUrl}/api/radar/${suburb}/${state}`; - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json', - }, - }); - - if (!response.ok) { - // Try to parse structured error response - 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' - }; - } - - // Handle structured API error response (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 || {}; - - // Always use structured error if we parsed JSON, or if we have any error information - if (parsedJson || errorCode || message || Object.keys(details).length > 0) { - const retryAfter = suggestions.retryAfter as number || - details.retryAfter as number || 30; - const action = suggestions.action as string || 'retry_after_seconds'; - - // Build enhanced error message based on error details - 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 if we should auto-retry based on action - const shouldAutoRetry = action !== 'manual_refresh_recommended' && - action !== 'check_network_and_retry'; - - this.error = { - message: enhancedMessage, - type: this._mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown')), - retryable: response.status === 404 && (errorCode === 'CACHE_NOT_FOUND' || errorCode === 'NOT_FOUND' || !errorCode), - retryAction: () => this.fetchRadarData(), - 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, - }, - }; - - // Auto-retry only if action suggests it and retryAfter is available - if (this.error.retryable && shouldAutoRetry && retryAfter) { - if (this.retryTimer) { - window.clearTimeout(this.retryTimer); - } - this.retryTimer = window.setTimeout(() => { - this.retryTimer = undefined; - this.fetchRadarData(); - }, retryAfter * 1000); - } - return null; - } - - // Fallback for non-structured errors - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - const data: any = await response.json(); + const options = { + serviceUrl, + suburb, + state, + timespan: timespan !== 'latest' ? timespan : undefined, + customStartTime: this._config.custom_start_time, + customEndTime: this._config.custom_end_time, + onError: (error: ErrorState) => { + this.error = error; + this.isLoading = false; - // Check if response contains an error (shouldn't happen with structured errors, but handle legacy format) - if (data.error) { - const retryAfter = data.retryAfter || 30; - this.error = { - message: data.error || 'Service returned an error', - type: 'cache', - retryable: true, - retryAction: () => this.fetchRadarData(), - retryAfter: retryAfter, - }; + // Auto-retry if applicable + if (error.retryable && error.retryAfter) { if (this.retryTimer) { window.clearTimeout(this.retryTimer); } this.retryTimer = window.setTimeout(() => { this.retryTimer = undefined; this.fetchRadarData(); - }, retryAfter * 1000); - return null; - } - - // Validate response has frames - if (!data.frames || data.frames.length === 0) { - throw new Error('No frames available in response'); + }, error.retryAfter * 1000); } + }, + }; - // Clear error only after successful data load to prevent glitch - this.error = undefined; - - // 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 = this.resolveImageUrl(frame.imageUrl, serviceUrl); - } - }); - - this.radarData = data; - this.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex); - this.isExtendedMode = false; - this.isLoading = false; - - // Preload images - this.preloadImages(this.frames); - - // Start animation if auto-play is enabled and not already playing - // Only restart if frames changed or we're not playing - if (this._config.auto_play !== false && !this.isPlaying) { - this.startAnimation(); - } - - return data; - } - } catch (err) { - this.isLoading = false; - - // Categorize errors - if (err instanceof TypeError && err.message.includes('fetch')) { - this.error = { - message: 'Network error: Unable to connect to service', - type: 'network', - retryable: true, - retryAction: () => this.fetchRadarData(), - }; - } else if (err instanceof Error && err.message.includes('Cache')) { - this.error = { - message: err.message, - type: 'cache', - retryable: true, - retryAction: () => this.fetchRadarData(), - }; - } else { - this.error = { - message: err instanceof Error ? err.message : 'Unknown error occurred', - type: 'unknown', - retryable: true, - retryAction: () => this.fetchRadarData(), - }; - } - - console.error('Error fetching radar data:', err); - return null; + let data: RadarResponse | null = null; + + if (timespan !== 'latest') { + data = await this.apiService.fetchHistoricalFrames(options); + } else { + data = await this.apiService.fetchLatestFrames(options); } - } - /** - * Fetches historical radar data for extended timespan - */ - private async fetchHistoricalRadar( - serviceUrl: string, - suburb: string, - state: string, - timespan: string - ): Promise { - try { - let startTime: Date | null = null; - let endTime = new Date(); - - if (timespan === 'custom') { - if (this._config.custom_start_time) { - startTime = new Date(this._config.custom_start_time); - } - if (this._config.custom_end_time) { - endTime = new Date(this._config.custom_end_time); - } - } else { - // 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'); - } - - 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) { - // Try to parse structured error response - 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' - }; - } - - // Handle structured API error response (support both camelCase and PascalCase) - const errorCode = errorData.errorCode || errorData.ErrorCode; - const message = errorData.message || errorData.Message || response.statusText || 'Failed to fetch historical radar data'; - const errorType = errorData.errorType || errorData.ErrorType; - const details = errorData.details || errorData.Details || {}; - const suggestions = errorData.suggestions || errorData.Suggestions || {}; - - // Always use structured error if we parsed JSON, or if we have any error information - if (parsedJson || errorCode || message || Object.keys(details).length > 0) { - const retryAfter = suggestions.retryAfter as number || 30; - const action = suggestions.action as string || 'retry_after_seconds'; - - // Build enhanced error message based on error details - 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 if we should auto-retry based on action - const shouldAutoRetry = action !== 'manual_refresh_recommended' && - action !== 'check_network_and_retry' && - action !== 'adjust_time_range'; - - this.error = { - message: enhancedMessage, - type: this._mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown')), - retryable: response.status === 404 || response.status === 400, - retryAction: () => this.fetchRadarData(), - 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, - }, - }; - return null; - } - - // Fallback: should not reach here if JSON was parsed, but handle gracefully - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - 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 = this.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 - let metadata: Partial = {}; - 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(), - }; - - // Clear error only after successful data load to prevent glitch + if (data) { this.error = undefined; - - this.radarData = radarResponse; - this.frames = allFrames; - this.isExtendedMode = true; + this.radarData = data; + this.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex); + this.isExtendedMode = timespan !== 'latest'; this.isLoading = false; - - // Preload images + this.preloadImages(this.frames); - - // Start animation if auto-play is enabled and not already playing - // Only restart if frames changed or we're not playing + if (this._config.auto_play !== false && !this.isPlaying) { this.startAnimation(); } - - return radarResponse; - } catch (err) { - this.isLoading = false; - - // Categorize errors - if (err instanceof TypeError && err.message.includes('fetch')) { - this.error = { - message: 'Network error: Unable to connect to service', - type: 'network', - retryable: true, - retryAction: () => this.fetchRadarData(), - }; - } else { - this.error = { - message: err instanceof Error ? err.message : 'Failed to fetch historical radar data', - type: 'unknown', - retryable: true, - retryAction: () => this.fetchRadarData(), - }; - } - - console.error('Error fetching historical radar data:', err); - return null; } + + return data; } /** @@ -1599,104 +262,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { return this._config.locale || this.hass?.locale?.language || 'en-AU'; } - /** - * Map API error type to card error type - */ - private _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'; - } - /** - * Get error title based on error type - */ - private _getErrorTitle(): string { - switch (this.error?.type) { - case 'cache': - return 'Cache Not Ready'; - case 'network': - return 'Connection Error'; - case 'validation': - return 'Configuration Error'; - default: - return 'Error'; - } - } - - /** - * Get error icon based on error type - */ - private _getErrorIcon(): string { - switch (this.error?.type) { - case 'cache': - return 'mdi:database-refresh'; - case 'network': - return 'mdi:wifi-off'; - case 'validation': - return 'mdi:alert-circle'; - default: - return 'mdi:alert'; - } - } - - /** - * Get error color based on error type - */ - private _getErrorColor(): string { - switch (this.error?.type) { - case 'cache': - return 'var(--warning-color, #ff9800)'; - case 'network': - return 'var(--error-color, #f44336)'; - case 'validation': - return 'var(--error-color, #f44336)'; - default: - return 'var(--warning-color, #ff9800)'; - } - } - - /** - * Formats timestamp for display - */ - private formatTimestamp(isoString: string): string { - if (!isoString) return '-'; - const date = new Date(isoString); - const locale = this._getLocale(); - // Use HA timezone if available, otherwise use browser's timezone - const timeZone = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; - - return date.toLocaleString(locale, { - timeZone: timeZone, - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } - - /** - * Relative time (e.g., "5 min ago", "2 hours ago") - */ - private 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`; - } /** * Check if frame should be preloaded (only nearby frames) @@ -2000,139 +566,19 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { return ''; } - // If overlay, render differently (overlay doesn't use style, it has its own compact format) - if (position === 'overlay') { - return this._renderOverlayMetadata(); - } - - // Default style is 'cards' if not specified - const style = displayConfig.style ?? 'cards'; - return html` - + `; } - /** - * Render cache status - */ - private _renderCacheStatus(): TemplateResult { - if (!this.radarData) return html``; - return html` - - `; - } - - /** - * Render observation time - */ - private _renderObservationTime(): TemplateResult { - if (!this.radarData?.observationTime) return html``; - return html` - - `; - } - - /** - * Render forecast time - */ - private _renderForecastTime(): TemplateResult { - if (!this.radarData?.forecastTime) return html``; - return html` - - `; - } - - /** - * Render weather station - */ - private _renderWeatherStation(): TemplateResult { - if (!this.radarData?.weatherStation) return html``; - return html` - - `; - } - - /** - * Render distance - */ - private _renderDistance(): TemplateResult { - if (!this.radarData?.distance) return html``; - return html` - - `; - } - - /** - * Render next update time - */ - private _renderNextUpdate(): TemplateResult { - if (!this.radarData?.nextUpdateTime) return html``; - return html` - - `; - } - - /** - * Render overlay metadata - */ - private _renderOverlayMetadata(): TemplateResult { - const config = this._getMetadataConfig(); - if (!config || (typeof config === 'boolean' && !config)) { - return html``; - } - - // Handle both object config and boolean true (default) - const displayConfig = typeof config === 'object' ? config : {}; - - // Use separate metadata overlay settings if available, otherwise fall back to controls overlay settings - const opacity = this._config.metadata_overlay_opacity ?? 0.85; - const position = this._config.metadata_overlay_position ?? 'top'; - - return html` - - `; - } /** * Render radar image with zoom and overlay support @@ -2171,8 +617,8 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { }; }}" /> - ${controlsPosition ? this._renderOverlayControls() : ''} - ${metadataPosition ? this._renderOverlayMetadata() : ''} + ${controlsPosition ? this._renderControls('overlay') : ''} + ${metadataPosition ? this._renderMetadata('overlay') : ''} ` : '' } @@ -2180,29 +626,6 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { `; } - /** - * Render overlay controls - */ - private _renderOverlayControls(): TemplateResult { - const config = this._getControlsConfig(); - if (!config || (typeof config === 'boolean' && !config)) { - return html``; - } - - const displayConfig = typeof config === 'object' ? config : {}; - const position = this._config.controls_overlay_position ?? 'bottom'; - const opacity = this._config.controls_overlay_opacity ?? 0.9; - - return html` -
- ${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() : ''} -
- `; - } - /** * Render controls section */ @@ -2212,196 +635,29 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { return html``; } - const displayConfig = typeof config === 'object' ? config : {}; - // Default position is 'below' if not specified - const configPosition = displayConfig.position ?? 'below'; - - // Only render if position matches (or if no position filter specified) - if (position !== undefined && configPosition !== position) { - return html``; - } - - // If overlay, render differently - if (configPosition === 'overlay') { - return this._renderOverlayControls(); - } + const metadataConfig = this._getMetadataConfig(); + const showFrameTimes = metadataConfig && typeof metadataConfig === 'object' && metadataConfig.show_frame_times !== false; return html` -
- ${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() : ''} -
- `; - } - - /** - * Render frame slider - */ - private _renderFrameSlider(): TemplateResult { - if (this.frames.length === 0) return html``; - - return html` -
-
- -
-
- `; - } - - /** - * Render navigation buttons - * Only shows skip buttons for timeseries data (extended mode) - */ - 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` -
- ${this.isExtendedMode ? html` - - - - - ` : html` - - - `} -
- `; - } - - /** - * Render play/pause button - */ - private _renderPlayPause(): TemplateResult { - return html` -
- -
- `; - } - - /** - * Render previous/next buttons - */ - private _renderPrevNext(): TemplateResult { - return html` -
- - -
- `; - } - - /** - * Render frame info with observation times - */ - private _renderFrameInfo(): TemplateResult { - const currentFrame = this.frames[this.currentFrameIndex]; - if (!currentFrame) return html``; - - const config = this._getMetadataConfig(); - const showFrameTimes = config && typeof config === 'object' && config.show_frame_times !== 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` -
- ${frameInfoText} - ${showFrameTimes && currentFrame.absoluteObservationTime ? html` - - ${this.formatTimestamp(currentFrame.absoluteObservationTime)} - - ` : showFrameTimes && currentFrame.minutesAgo !== undefined ? html` - - ${currentFrame.minutesAgo} min ago - - ` : ''} - ${this.radarData?.observationTime && showFrameTimes ? html` - - Obs: ${this.formatTimestamp(this.radarData.observationTime)} - - ` : ''} - ${progress}% -
+ this.showFrame(index)} + .onPrevious=${() => this.previousFrame()} + .onNext=${() => this.nextFrame()} + .onJumpFrame=${(offset: number) => this.jumpFrame(offset)} + .onToggleAnimation=${() => this.toggleAnimation()} + > `; } @@ -2417,123 +673,18 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { // Render error state if (this.error) { - 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`
Auto-retrying in ${this.error.retryAfter} seconds...
` - : isManualRefreshRecommended - ? html`
Manual refresh recommended. Previous update failed.
` - : ''; - - // 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`
${line}
` - : html`
` - ); - - // 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` -
-
-
- -
${this._getErrorTitle()}
-
-
-
${errorMessage}
- ${this.error.errorCode ? html` -
- - Error Code: ${this.error.errorCode} -
- ` : ''} - ${showDetails ? html` -
- ${this.error.details?.previousUpdateFailed ? html` -
- -
- Previous Update Failed: ${this.error.details.previousError || 'Unknown error'} - ${this.error.details.previousErrorCode ? html` (${this.error.details.previousErrorCode})` : ''} -
-
- ` : ''} - ${this.error.details?.availableRange ? html` -
- -
- Available Data Range: ${this.error.details.availableRange.oldest ? this.formatTimestamp(this.error.details.availableRange.oldest) : 'N/A'} to ${this.error.details.availableRange.newest ? this.formatTimestamp(this.error.details.availableRange.newest) : 'N/A'} - ${this.error.details.availableRange.totalCacheFolders ? html` (${this.error.details.availableRange.totalCacheFolders} folders)` : ''} -
-
- ` : ''} - ${this.error.details?.requestedRange ? html` -
- -
- Requested Range: ${this.error.details.requestedRange.start ? this.formatTimestamp(this.error.details.requestedRange.start) : 'N/A'} to ${this.error.details.requestedRange.end ? this.formatTimestamp(this.error.details.requestedRange.end) : 'N/A'} -
-
- ` : ''} - ${this.error.details?.suggestedRange ? html` -
- -
- Suggested Range: ${this.error.details.suggestedRange.start ? this.formatTimestamp(this.error.details.suggestedRange.start) : 'N/A'} to ${this.error.details.suggestedRange.end ? this.formatTimestamp(this.error.details.suggestedRange.end) : 'N/A'} -
Try using this time range instead
-
-
- ` : ''} - ${refreshEndpoint ? html` -
- -
- Refresh Endpoint: ${refreshEndpoint} -
-
- ` : ''} -
- ` : ''} - ${retryInfo} - ${this.error.retryable && this.error.retryAction ? html` -
- -
- ` : ''} -
-
-
+ { + if (this.error?.retryAction) { + this.error.retryAction(); + } + }} + >
`; } diff --git a/src/components/controls-section.ts b/src/components/controls-section.ts new file mode 100644 index 0000000..a6678a4 --- /dev/null +++ b/src/components/controls-section.ts @@ -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` +
+ ${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() : ''} +
+ `; + } + + private _renderOverlay(displayConfig: ControlsDisplayConfig): TemplateResult { + const opacity = this.overlayOpacity ?? 0.9; + const position = this.overlayPosition ?? 'bottom'; + + return html` +
+ ${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() : ''} +
+ `; + } + + private _renderFrameSlider(): TemplateResult { + if (this.frames.length === 0) return html``; + + return html` +
+
+ +
+
+ `; + } + + 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` +
+ ${this.isExtendedMode ? html` + + + + + ` : html` + + + `} +
+ `; + } + + private _renderPlayPause(): TemplateResult { + return html` +
+ +
+ `; + } + + private _renderPrevNext(): TemplateResult { + return html` +
+ + +
+ `; + } + + 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` +
+ ${frameInfoText} + ${showFrameTimes && currentFrame.absoluteObservationTime ? html` + + ${formatTimestamp(currentFrame.absoluteObservationTime, this.locale, this.timeZone)} + + ` : showFrameTimes && currentFrame.minutesAgo !== undefined ? html` + + ${currentFrame.minutesAgo} min ago + + ` : ''} + ${this.radarData?.observationTime && showFrameTimes ? html` + + Obs: ${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)} + + ` : ''} + ${progress}% +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bom-controls-section': BomControlsSection; + } +} + diff --git a/src/components/error-display.ts b/src/components/error-display.ts new file mode 100644 index 0000000..05232d2 --- /dev/null +++ b/src/components/error-display.ts @@ -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`
Auto-retrying in ${this.error.retryAfter} seconds...
` + : isManualRefreshRecommended + ? html`
Manual refresh recommended. Previous update failed.
` + : ''; + + // 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`
${line}
` + : html`
` + ); + + // 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` +
+
+
+ +
${this._getErrorTitle()}
+
+
+
${errorMessage}
+ ${this.error.errorCode ? html` +
+ + Error Code: ${this.error.errorCode} +
+ ` : ''} + ${showDetails ? html` +
+ ${this.error.details?.previousUpdateFailed ? html` +
+ +
+ Previous Update Failed: ${this.error.details.previousError || 'Unknown error'} + ${this.error.details.previousErrorCode ? html` (${this.error.details.previousErrorCode})` : ''} +
+
+ ` : ''} + ${this.error.details?.availableRange ? html` +
+ +
+ Available Data Range: ${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)` : ''} +
+
+ ` : ''} + ${this.error.details?.requestedRange ? html` +
+ +
+ Requested Range: ${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'} +
+
+ ` : ''} + ${this.error.details?.suggestedRange ? html` +
+ +
+ Suggested Range: ${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'} +
Try using this time range instead
+
+
+ ` : ''} + ${refreshEndpoint ? html` +
+ +
+ Refresh Endpoint: ${refreshEndpoint} +
+
+ ` : ''} +
+ ` : ''} + ${retryInfo} + ${this.error.retryable && this.onRetry ? html` +
+ +
+ ` : ''} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bom-error-display': BomErrorDisplay; + } +} + diff --git a/src/components/metadata-section.ts b/src/components/metadata-section.ts new file mode 100644 index 0000000..95aabf2 --- /dev/null +++ b/src/components/metadata-section.ts @@ -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` + + `; + } + + private _renderOverlay(): TemplateResult { + const displayConfig = this._getDisplayConfig(); + if (!displayConfig) { + return html``; + } + + const opacity = this.overlayOpacity ?? 0.85; + const position = this.overlayPosition ?? 'top'; + + return html` + + `; + } + + private _renderCacheStatus(): TemplateResult { + if (!this.radarData) return html``; + return html` + + `; + } + + private _renderObservationTime(): TemplateResult { + if (!this.radarData?.observationTime) return html``; + return html` + + `; + } + + private _renderForecastTime(): TemplateResult { + if (!this.radarData?.forecastTime) return html``; + return html` + + `; + } + + private _renderWeatherStation(): TemplateResult { + if (!this.radarData?.weatherStation) return html``; + return html` + + `; + } + + private _renderDistance(): TemplateResult { + if (!this.radarData?.distance) return html``; + return html` + + `; + } + + private _renderNextUpdate(): TemplateResult { + if (!this.radarData?.nextUpdateTime) return html``; + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'bom-metadata-section': BomMetadataSection; + } +} + diff --git a/src/services/radar-api-service.ts b/src/services/radar-api-service.ts new file mode 100644 index 0000000..73780a4 --- /dev/null +++ b/src/services/radar-api-service.ts @@ -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 { + 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 { + 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 = {}; + 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), + }); + } + } +} + diff --git a/src/styles/card-styles.ts b/src/styles/card-styles.ts new file mode 100644 index 0000000..11e1ede --- /dev/null +++ b/src/styles/card-styles.ts @@ -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; + } +`; + diff --git a/src/styles/controls-styles.ts b/src/styles/controls-styles.ts new file mode 100644 index 0000000..3b8413e --- /dev/null +++ b/src/styles/controls-styles.ts @@ -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; + } + } +`; + diff --git a/src/styles/error-styles.ts b/src/styles/error-styles.ts new file mode 100644 index 0000000..5983e54 --- /dev/null +++ b/src/styles/error-styles.ts @@ -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; + } +`; + diff --git a/src/styles/image-styles.ts b/src/styles/image-styles.ts new file mode 100644 index 0000000..2472226 --- /dev/null +++ b/src/styles/image-styles.ts @@ -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; + } +`; + diff --git a/src/styles/metadata-styles.ts b/src/styles/metadata-styles.ts new file mode 100644 index 0000000..bf8bc6a --- /dev/null +++ b/src/styles/metadata-styles.ts @@ -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); + } +`; + diff --git a/src/styles/overlay-styles.ts b/src/styles/overlay-styles.ts new file mode 100644 index 0000000..b3e91ee --- /dev/null +++ b/src/styles/overlay-styles.ts @@ -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%); + } +`; + diff --git a/src/utils/date-formatter.ts b/src/utils/date-formatter.ts new file mode 100644 index 0000000..db5076f --- /dev/null +++ b/src/utils/date-formatter.ts @@ -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`; +} + diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts new file mode 100644 index 0000000..94d955c --- /dev/null +++ b/src/utils/error-handler.ts @@ -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 }; +} + diff --git a/src/utils/url-resolver.ts b/src/utils/url-resolver.ts new file mode 100644 index 0000000..308bebf --- /dev/null +++ b/src/utils/url-resolver.ts @@ -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}`; +} +