--- name: dynamic-panel-component description: Create a dashboard panel component with advanced dynamic content updates and robust error handling, including retry logic and state management, using vanilla TypeScript DOM API. --- # Dynamic Panel Component Pattern Create dashboard panel components using **vanilla TypeScript** (no framework, no JSX). Each panel is a class extending a `Panel` base class, with enhanced dynamic content updates and error handling. ## Architecture Overview ``` Panel (base class) ├── element: HTMLElement (outer container, .panel) │ ├── header: HTMLElement (.panel-header) │ │ ├── headerLeft (.panel-header-left) │ │ │ ├── title (.panel-title) │ │ │ └── newBadge (.panel-new-badge) [optional] │ │ ├── statusBadge (.panel-data-badge) [optional] │ │ └── countEl (.panel-count) [optional] │ ├── content: HTMLElement (.panel-content) │ └── resizeHandle (.panel-resize-handle) ``` ## Base Panel Class (Enhanced) Create `src/components/Panel.ts`: ```typescript export interface PanelOptions { id: string; title: string; showCount?: boolean; className?: string; } export class Panel { protected element: HTMLElement; protected content: HTMLElement; protected header: HTMLElement; protected countEl: HTMLElement | null = null; protected panelId: string; private _fetching = false; private _errorState = false; constructor(options: PanelOptions) { this.panelId = options.id; this.element = document.createElement('div'); this.element.className = `panel ${options.className || ''}`; this.element.dataset.panel = options.id; // Header this.header = document.createElement('div'); this.header.className = 'panel-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'panel-header-left'; const title = document.createElement('span'); title.className = 'panel-title'; title.textContent = options.title; headerLeft.appendChild(title); this.header.appendChild(headerLeft); // Count badge (optional) if (options.showCount) { this.countEl = document.createElement('span'); this.countEl.className = 'panel-count'; this.countEl.textContent = '0'; this.header.appendChild(this.countEl); } // Content area this.content = document.createElement('div'); this.content.className = 'panel-content'; this.content.id = `${options.id}Content`; this.element.appendChild(this.header); this.element.appendChild(this.content); this.showLoading(); } public getElement(): HTMLElement { return this.element; } public showLoading(message = 'Loading...'): void { this._errorState = false; this.content.innerHTML = `
${message}
`; } public showError(message = 'Failed to load', onRetry?: () => void): void { this._errorState = true; this.content.innerHTML = `
${message}
${onRetry ? '' : ''}
`; if (onRetry) { this.content.querySelector('[data-panel-retry]')?.addEventListener('click', onRetry); } } public setContent(html: string): void { this._errorState = false; this.content.innerHTML = html; } public setCount(count: number): void { if (this.countEl) this.countEl.textContent = count.toString(); } public show(): void { this.element.classList.remove('hidden'); } public hide(): void { this.element.classList.add('hidden'); } protected setFetching(v: boolean): void { this._fetching = v; } protected get isFetching(): boolean { return this._fetching; } protected get isErrorState(): boolean { return this._errorState; } public destroy(): void { this.element.remove(); } } ``` ## Creating a Concrete Panel (Example: StockPanel with Enhanced Error Handling) ```typescript import { Panel } from './Panel'; interface StockQuote { symbol: string; name: string; price: number | null; change: number | null; sparkline?: number[]; } export class StockPanel extends Panel { private refreshTimer: ReturnType | null = null; private retryCount = 0; private maxRetries = 3; constructor() { super({ id: 'stocks', title: 'Stock Market', showCount: true }); this.fetchData(); this.refreshTimer = setInterval(() => this.fetchData(), 60_000); } private async fetchData(): Promise { if (this.isFetching || this.isErrorState) return; this.setFetching(true); try { const quotes = await fetchStockQuotes(); // from data-service this.render(quotes); this.setCount(quotes.length); this.retryCount = 0; // Reset retry count on success } catch (err) { this.retryCount++; if (this.retryCount < this.maxRetries) { setTimeout(() => this.fetchData(), 1000 * this.retryCount); // Exponential backoff } else { this.showError('Failed to load stock data', () => { this.retryCount = 0; this.fetchData(); }); } } finally { this.setFetching(false); } } private render(quotes: StockQuote[]): void { const rows = quotes.map(q => `
${q.symbol} ${q.name} ${q.price != null ? '$' + q.price.toFixed(2) : '—'} ${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'} ${miniSparkline(q.sparkline, q.change)}
`).join(''); this.setContent(`
${rows}
`); } public override destroy(): void { if (this.refreshTimer) clearInterval(this.refreshTimer); super.destroy(); } } ``` ## Key Patterns 1. **Constructor** calls `super()` with panel config, then triggers initial data fetch. 2. **fetchData()** includes retry logic with exponential backoff and max retry limit. 3. **render()** builds HTML strings and calls `this.setContent(html)`. 4. **destroy()** cleans up timers and event listeners. 5. Use `showLoading()` during initial load (auto-called in constructor). 6. Use `showError(msg, retryFn)` on failure with retry button. 7. Track error state with `_errorState` to prevent redundant fetches. 8. Sparkline SVGs use inline `` with `` — see sparkline utility. ## Sparkline Utility ```typescript export function miniSparkline(data: number[] | undefined, change: number | null, w = 50, h = 16): string { if (!data || data.length < 2) return ''; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)'; const points = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((v - min) / range) * (h - 2) - 1; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); return ``; } ``` ## Additional Examples ### Dynamic Content Update Example ```typescript export class NewsPanel extends Panel { private newsItems: NewsItem[] = []; constructor() { super({ id: 'news', title: 'Latest News' }); this.fetchNews(); setInterval(() => this.fetchNews(), 300_000); // Update every 5 minutes } private async fetchNews(): Promise { if (this.isFetching) return; this.setFetching(true); try { const news = await fetchNewsItems(); this.newsItems = news; this.renderNews(); } catch (err) { this.showError('Failed to load news', () => this.fetchNews()); } finally { this.setFetching(false); } } private renderNews(): void { const items = this.newsItems.map(item => `

${item.title}

${item.summary}

${formatTime(item.time)}
`).join(''); this.setContent(`
${items}
`); } } ``` ### Error Handling with State Management ```typescript export class WeatherPanel extends Panel { private lastSuccessfulFetch: Date | null = null; constructor() { super({ id: 'weather', title: 'Weather Forecast' }); this.fetchWeather(); } private async fetchWeather(): Promise { if (this.isFetching) return; this.setFetching(true); try { const weather = await fetchWeatherData(); this.lastSuccessfulFetch = new Date(); this.renderWeather(weather); } catch (err) { const message = this.lastSuccessfulFetch ? `Failed to update (last success: ${this.lastSuccessfulFetch.toLocaleTimeString()})` : 'Failed to load initial data'; this.showError(message, () => this.fetchWeather()); } finally { this.setFetching(false); } } private renderWeather(weather: WeatherData): void { const html = `
${weather.temp}° ${weather.description}
${weather.forecast.map(day => `
${day.day} ${day.high}°/${day.low}°
`).join('')}
`; this.setContent(html); } } ```