Dashboard panel components (class-based, self-fetching) and push-update sidebar modules (functional, externally-driven) using vanilla TypeScript DOM API, with retry logic, localStorage persistence, and guarded CSS injection.
Two complementary patterns for building dashboard UI in vanilla TypeScript (no framework, no JSX):
| Pattern | Use when… |
|---|---|
Panel class (extends Panel) | The component fetches its own data on a timer |
| Push-update sidebar module | Data arrives from outside (caller pushes it in) |
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)
Create src/components/Panel.ts:
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;
// --- Retry state (reset before each logical fetch sequence) ---
private retryAttempts = 0;
private maxRetries = 3;
private retryDelay = 1000; // ms; doubles on each attempt
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 API
// ----------------------------------------------------------------
public getElement(): HTMLElement { return this.element; }
public showLoading(message = 'Loading...'): void {
this.content.innerHTML = `
<div class="panel-loading">
<div class="panel-loading-spinner"></div>
<div class="panel-loading-text">${message}</div>
</div>`;
}
public showError(message = 'Failed to load', onRetry?: () => void): void {
this.content.innerHTML = `
<div class="panel-error-state">
<div class="panel-error-msg">${message}</div>
${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
</div>`;
if (onRetry) {
this.content.querySelector('[data-panel-retry]')
?.addEventListener('click', onRetry);
}
}
public setContent(html: string): void { 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'); }
public destroy(): void { this.element.remove(); }
// ----------------------------------------------------------------
// Protected API — available in subclasses
// ----------------------------------------------------------------
protected setFetching(v: boolean): void { this._fetching = v; }
protected get isFetching(): boolean { return this._fetching; }
/**
* Fetch a URL with exponential-backoff retry.
* Call resetRetry() before each new fetch sequence.
*/
protected async fetchWithRetry(url: string): Promise<unknown> {
while (this.retryAttempts < this.maxRetries) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err: unknown) {
this.retryAttempts++;
if (this.retryAttempts >= this.maxRetries) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed after ${this.maxRetries} attempts: ${msg}`);
}
await new Promise(r => setTimeout(r, this.retryDelay));
this.retryDelay *= 2;
}
}
}
/** Reset retry counters before a fresh fetch sequence. */
protected resetRetry(): void {
this.retryAttempts = 0;
this.retryDelay = 1000;
}
// ----------------------------------------------------------------
// State persistence (localStorage)
// ----------------------------------------------------------------
public saveState(): void {
localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify({
isExpanded: !this.element.classList.contains('collapsed'),
width: this.element.style.width,
height: this.element.style.height,
}));
}
public loadState(): void {
const raw = localStorage.getItem(`panelState_${this.panelId}`);
if (!raw) return;
const { isExpanded, width, height } = JSON.parse(raw) as {
isExpanded: boolean; width: string; height: string;
};
if (!isExpanded) this.element.classList.add('collapsed');
if (width) this.element.style.width = width;
if (height) this.element.style.height = height;
}
}
| Member / Method | Type | Description |
|---|---|---|
element | HTMLElement | Outer container div (.panel) |
header | HTMLElement | Header bar — append extra controls here |
content | HTMLElement | Scrollable content area |
countEl | HTMLElement | null | Count badge, or null if showCount not set |
panelId | string | The id from PanelOptions |
isFetching | boolean getter | true while an async fetch is in progress |
setFetching(v) | void | Set/clear the fetching guard |
showLoading(msg?) | void | Replace content with a spinner |
showError(msg?, onRetry?) | void | Replace content with error + optional retry button |
setContent(html) | void | Set raw HTML into the content area |
setCount(n) | void | Update count badge (no-op if countEl is null) |
fetchWithRetry(url) | Promise<unknown> | Fetch with exponential-backoff retry (3 attempts) |
resetRetry() | void | Reset retry counters before a new fetch sequence |
saveState() | void | Persist expanded/size state to localStorage |
loadState() | void | Restore state from localStorage |
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<typeof setInterval> | null = null;
constructor() {
super({ id: 'stocks', title: 'Stock Market', showCount: true });
this.loadState(); // restore saved size/collapsed state
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
}
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
this.resetRetry(); // start a fresh retry sequence
try {
const quotes = await this.fetchWithRetry('/api/stocks') as StockQuote[];
this.render(quotes);
this.setCount(quotes.length);
this.saveState();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error';
this.showError(`Failed to load stock data: ${msg}`, () => this.fetchData());
} finally {
this.setFetching(false);
}
}
private render(quotes: StockQuote[]): void {
const rows = quotes.map(q => `
<div class="stock-row">
<span class="stock-symbol">${q.symbol}</span>
<span class="stock-name">${q.name}</span>
<span class="stock-price">${q.price != null ? '$' + q.price.toFixed(2) : '—'}</span>
<span class="stock-change ${(q.change ?? 0) >= 0 ? 'positive' : 'negative'}">
${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'}
</span>
${miniSparkline(q.sparkline, q.change)}
</div>`).join('');
this.setContent(`<div class="stock-list">${rows}</div>`);
}
public override destroy(): void {
if (this.refreshTimer) clearInterval(this.refreshTimer);
super.destroy();
}
}
super() → loadState() → initial fetchData() → start refresh timerisFetching guard → resetRetry() → fetchWithRetry() → render() + saveState()this.setContent(html)super.destroy()showLoading() during initial load (auto-called in constructor)showError(msg, retryFn) on failure; retryFn must call fetchData() (which calls resetRetry())Use this pattern when the component does not fetch data itself — instead it receives data pushed by an external caller (e.g. a WebSocket handler, a store subscription, or a parent orchestrator).
Self-fetching? → Panel class (Part 1)
Data pushed in? → Push-update sidebar module (Part 2)
A push-update sidebar is a plain TypeScript module (not a class) with:
| Export | Purpose |
|---|---|
createXSidebar(): HTMLElement | Build and return the root element (idempotent singleton) |
updateX(data: XData): void | Re-render only the sections that changed |
interface XData | The data shape the caller must provide |
Internally the module uses:
let sidebarEl: HTMLElement | null = nullSectionRefs interface caching live DOM node references to avoid repeated querySelector callsinjectStyles() function that inserts a <style> tag exactly once// src/components/FooSidebar.ts
// ── Types ────────────────────────────────────────────────────────────────────
export interface FooData {
title: string;
items: { id: string; label: string; value: number }[];
lastUpdated: Date;
}
// ── DOM node cache ────────────────────────────────────────────────────────────
interface SectionRefs {
root: HTMLElement;
titleEl: HTMLElement;
listEl: HTMLElement;
footerEl: HTMLElement;
}
// ── Singleton state ───────────────────────────────────────────────────────────
let sidebarEl: HTMLElement | null = null;
let refs: SectionRefs | null = null;
let stylesInjected = false;
// ── CSS injection ─────────────────────────────────────────────────────────────
function injectStyles(): void {
if (stylesInjected) return; // guard: run exactly once
stylesInjected = true;
const style = document.createElement('style');
style.dataset.owner = 'foo-sidebar'; // easy to find in DevTools
style.textContent = `
.foo-sidebar { /* … */ }
.foo-sidebar__title { font-weight: 600; }
.foo-sidebar__list { list-style: none; padding: 0; }
.foo-sidebar__footer { font-size: 0.75rem; color: var(--muted); }
`;
document.head.appendChild(style);
}
// ── Builder ───────────────────────────────────────────────────────────────────
/**
* Create (or return the existing) sidebar element.
* Idempotent — safe to call multiple times; always returns the same node.
*/
export function createFooSidebar(): HTMLElement {
if (sidebarEl) return sidebarEl; // singleton guard
injectStyles();
const root = document.createElement('aside');
root.className = 'foo-sidebar';
const titleEl = document.createElement('h2');
titleEl.className = 'foo-sidebar__title';
root.appendChild(titleEl);
const listEl = document.createElement('ul');
listEl.className = 'foo-sidebar__list';
root.appendChild(listEl);
const footerEl = document.createElement('div');
footerEl.className = 'foo-sidebar__footer';
root.appendChild(footerEl);
// Cache references — avoids querySelector on every update
refs = { root, titleEl, listEl, footerEl };
sidebarEl = root;
return root;
}
// ── Updater ───────────────────────────────────────────────────────────────────
/**
* Push new data into the sidebar.
* Calls createFooSidebar() defensively if refs is not yet initialised.
*/
export function updateFoo(data: FooData): void {
if (!refs) createFooSidebar();
const r = refs!;
r.titleEl.textContent = data.title;
r.listEl.innerHTML = data.items
.map(item => `
<li class="foo-sidebar__item" data-id="${item.id}">
<span class="foo-item-label">${item.label}</span>
<span class="foo-item-value">${item.value}</span>
</li>`)
.join('');
r.footerEl.textContent =
`Updated ${data.lastUpdated.toLocaleTimeString()}`;
}
A sidebar that summarises the user's day — greeting, meeting countdown, inbox counts, stock alerts, CI failures, and an AI briefing with truncate/expand toggle. Data is pushed in from an external orchestrator.
// src/components/TodayFocusSidebar.ts
export interface FocusData {
userName: string;
nextMeeting: { title: string; startsInMinutes: number } | null;
inboxCounts: { email: number; slack: number; github: number };
stockAlerts: { symbol: string; changePercent: number }[]; // pre-filtered >= 2%
ciFailures: { repo: string; branch: string }[];
aiBriefing: string; // may be long — sidebar truncates with toggle
}
interface SectionRefs {
root: HTMLElement;
greetingEl: HTMLElement;
meetingEl: HTMLElement;
inboxEl: HTMLElement;
stocksEl: HTMLElement;
ciEl: HTMLElement;
briefingEl: HTMLElement;
}
let sidebarEl: HTMLElement | null = null;
let refs: SectionRefs | null = null;
let stylesInjected = false;
// ── Helpers ───────────────────────────────────────────────────────────────────
function greeting(name: string): string {
const h = new Date().getHours();
const salutation = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening';
return `${salutation}, ${name}`;
}
function formatCountdown(minutes: number): string {
if (minutes <= 0) return 'Now';
if (minutes < 60) return `in ${minutes}m`;
return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
// ── CSS ───────────────────────────────────────────────────────────────────────
function injectStyles(): void {
if (stylesInjected) return;
stylesInjected = true;
const s = document.createElement('style');
s.dataset.owner = 'today-focus-sidebar';
s.textContent = `
.tfs { display:flex; flex-direction:column; gap:12px; padding:16px;
background:var(--bg-surface, #1e1e2e); color:var(--fg, #cdd6f4);
border-radius:8px; font-family:inherit; }
.tfs__greeting { font-size:1.1rem; font-weight:600; }
.tfs__meeting { font-size:0.85rem; color:var(--yellow, #f9e2af); }
.tfs__section { font-size:0.85rem; }
.tfs__label { font-size:0.7rem; text-transform:uppercase;
letter-spacing:.06em; color:var(--muted, #6c7086);
margin-bottom:4px; }
.tfs__briefing { font-size:0.82rem; line-height:1.5; }
.tfs__toggle { background:none; border:none; color:var(--blue, #89b4fa);
cursor:pointer; font-size:0.8rem; padding:2px 0; }
.tfs__alert-pos { color:var(--green, #a6e3a1); }
.tfs__alert-neg { color:var(--red, #f38ba8); }
.tfs__ci-fail { color:var(--red, #f38ba8); }
`;
document.head.appendChild(s);
}
// ── Builder ───────────────────────────────────────────────────────────────────
export function createTodayFocusSidebar(): HTMLElement {
if (sidebarEl) return sidebarEl;
injectStyles();
const make = (tag: string, cls: string): HTMLElement => {
const el = document.createElement(tag);
el.className = cls;
return el as HTMLElement;
};
const root = make('aside', 'tfs');
const greetingEl = make('div', 'tfs__greeting');
const meetingEl = make('div', 'tfs__meeting');
const inboxEl = make('div', 'tfs__section');
const stocksEl = make('div', 'tfs__section');
const ciEl = make('div', 'tfs__section');
const briefingEl = make('div', 'tfs__section');
root.append(greetingEl, meetingEl, inboxEl, stocksEl, ciEl, briefingEl);
refs = { root, greetingEl, meetingEl, inboxEl, stocksEl, ciEl, briefingEl };
sidebarEl = root;
return root;
}
// ── Updater ───────────────────────────────────────────────────────────────────
const BRIEFING_LIMIT = 200;
export function updateTodayFocus(data: FocusData): void {
if (!refs) createTodayFocusSidebar();
const r = refs!;
// Greeting
r.greetingEl.textContent = greeting(data.userName);
// Next meeting
if (data.nextMeeting) {
r.meetingEl.textContent =
`Next: ${data.nextMeeting.title} — ${formatCountdown(data.nextMeeting.startsInMinutes)}`;
r.meetingEl.hidden = false;
} else {
r.meetingEl.hidden = true;
}
// Inbox counts
const { email, slack, github } = data.inboxCounts;
r.inboxEl.innerHTML = `
<div class="tfs__label">Inbox</div>
Email: ${email} Slack: ${slack} GitHub: ${github}`;
// Stock alerts (caller should pre-filter >= 2%, but we guard here too)
const alerts = data.stockAlerts.filter(s => Math.abs(s.changePercent) >= 2);
r.stocksEl.innerHTML = alerts.length === 0
? ''
: `<div class="tfs__label">Stock Alerts</div>` +
alerts.map(s => {
const cls = s.changePercent >= 0 ? 'tfs__alert-pos' : 'tfs__alert-neg';
const sign = s.changePercent >= 0 ? '+' : '';
return `<span class="${cls}">${s.symbol} ${sign}${s.changePercent.toFixed(1)}%</span>`;
}).join(' ');
// CI failures
r.ciEl.innerHTML = data.ciFailures.length === 0
? ''
: `<div class="tfs__label">CI Failures</div>` +
data.ciFailures
.map(f => `<div class="tfs__ci-fail">${f.repo} / ${f.branch}</div>`)
.join('');
// AI briefing with truncate/expand toggle
const text = data.aiBriefing;
if (text.length <= BRIEFING_LIMIT) {
r.briefingEl.innerHTML =
`<div class="tfs__label">Briefing</div><div class="tfs__briefing">${text}</div>`;
} else {
const short = text.slice(0, BRIEFING_LIMIT) + '…';
r.briefingEl.innerHTML = `
<div class="tfs__label">Briefing</div>
<div class="tfs__briefing"
data-full="${encodeURIComponent(text)}"
data-short="${encodeURIComponent(short)}"
data-expanded="false">${short}</div>
<button class="tfs__toggle" data-briefing-toggle>Show more</button>`;
r.briefingEl.querySelector('[data-briefing-toggle]')
?.addEventListener('click', function (this: HTMLButtonElement) {
const div = r.briefingEl.querySelector<HTMLElement>('[data-full]')!;
const expanded = div.dataset.expanded === 'true';
div.textContent = decodeURIComponent(
expanded ? div.dataset.short! : div.dataset.full!);
div.dataset.expanded = String(!expanded);
this.textContent = expanded ? 'Show more' : 'Show less';
});
}
}
interface XData (exported — callers need it)interface SectionRefs (internal — not exported)let sidebarEl, let refs, let stylesInjectedinjectStyles() inserts one <style> tag, guarded by stylesInjected; set style.dataset.owner for DevTools visibilitycreateXSidebar() is idempotent: returns existing sidebarEl if already builtcreateXSidebar() populates refs with live node referencesupdateX(data) calls createXSidebar() defensively if refs is nullupdateX(data) mutates only nodes in refs — never calls document.querySelectorcreateXSidebar, updateX, and XData interface; keep SectionRefs module-privateAfter creating a push-update sidebar, register it in both barrel files:
// src/components/index.ts
export { createTodayFocusSidebar, updateTodayFocus } from './TodayFocusSidebar';
export type { FocusData } from './TodayFocusSidebar';
// src/index.ts
export * from './components';
Used by both patterns for inline SVG sparklines:
// src/utils/sparkline.ts
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 `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
`<polyline points="${points}" fill="none" stroke="${color}" ` +
`stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
Need a new dashboard component?
│
├─ Will it fetch its own data (polling / one-shot)?
│ └─ YES → extend Panel (Part 1)
│ • constructor → super() → loadState() → fetchData() → setInterval
│ • fetchData() → resetRetry() → fetchWithRetry() → render() → saveState()
│ • destroy() → clearInterval → super.destroy()
│
└─ Will data be pushed from outside?
└─ YES → functional push-update module (Part 2)
• createXSidebar() — idempotent, builds DOM, fills SectionRefs
• updateX(data) — mutates only SectionRefs nodes
• injectStyles() — guarded, runs once
• export createX, updateX, XData; keep SectionRefs private