| name | panel-base-advanced |
| description | Unified guide for authoring dashboard panel components in vanilla TypeScript — covers the authoritative Panel base class (grid-span persistence, dual resize handles, radar loading animation, exponential-backoff error countdown, data/new badge helpers, full destroy() cleanup), localStorage state via loadMap/saveMap, exponential-backoff retry, and CircuitBreaker integration. Shows how to wrap any existing fetchX() service call with CircuitBreaker.execute() as a one-liner and clarifies when each resilience layer is appropriate. |
Resilient Panel — Unified Pattern (v2)
Create dashboard panel components using vanilla TypeScript (no framework, no JSX).
Each panel is a class extending a Panel base class and optionally wraps its data source with a CircuitBreaker.
Architecture Overview
Panel (base class)
├── element: HTMLElement (.panel)
│ ├── header: HTMLElement (.panel-header)
│ │ ├── headerLeft (.panel-header-left)
│ │ │ ├── title (.panel-title)
│ │ │ └── newBadge (.panel-new-badge) [optional, via setNewBadge()]
│ │ ├── statusBadge (.panel-data-badge) [optional, via setDataBadge()]
│ │ └── countEl (.panel-count) [optional]
│ ├── content: HTMLElement (.panel-content)
│ ├── resizeHandleRow (.panel-resize-handle-row) [bottom edge]
│ └── resizeHandleCol (.panel-resize-handle-col) [right edge]
Resilience layers (choose what you need)
| Layer | File | When to use |
|---|
| isFetching guard | Panel base class | Always — prevents concurrent duplicate requests |
| Exponential-backoff retry | Panel.fetchWithRetry() | When you own the fetch call and want per-request retry before surfacing an error |
| Error countdown in showError() | Panel base class | Automatic retry countdown rendered inside the error state UI |
| CircuitBreaker | src/utils/circuit-breaker.ts | When a service may fail repeatedly; stops hammering the API and serves stale cache instead |
| localStorage persistence | Panel.saveMap() / loadMap() | When you want grid-span (not pixel size) state to survive page reloads |
Wrapping an existing service call with CircuitBreaker is a one-liner:
const data = await fetchStockQuotes();
const data = await breaker.execute(() => fetchStockQuotes(), []);
File 1 — src/components/Panel.ts
Authoritative implementation. Use this verbatim; do not revert to
the older saveState()/loadState() width/height approach.
export interface PanelOptions {
id: string;
title: string;
showCount?: boolean;
className?: string;
}
export class Panel {
protected element: HTMLElement;
protected content: HTMLElement;
protected header: HTMLElement;
protected headerLeft: HTMLElement;
protected countEl: HTMLElement | null = null;
protected statusBadge: HTMLElement | null = null;
protected newBadge: HTMLElement | null = null;
protected panelId: string;
private _fetching = false;
private _countdownInterval: ReturnType<typeof setInterval> | null = null;
private : <{ : ; : ; : }> = [];
retryAttempts = ;
retryDelay = ;
maxRetries = ;
() {
. = options.;
. = .();
.. = ;
... = options.;
. = .();
.. = ;
. = .();
.. = ;
title = .();
title. = ;
title. = options.;
..(title);
..(.);
(options.) {
. = .();
.. = ;
.. = ;
..(.);
}
. = .();
.. = ;
.. = ;
resizeHandleRow = .();
resizeHandleRow. = ;
resizeHandleCol = .();
resizeHandleCol. = ;
..(.);
..(.);
..(resizeHandleRow);
..(resizeHandleCol);
.(resizeHandleRow, resizeHandleCol);
.();
}
(): { .; }
(message = ): {
.();
.. = ;
}
(
message = ,
?: ,
?:
): {
.();
countdownId = ;
.. = ;
(onRetry) {
..()
?.(, { .(); (); });
}
(autoRetryMs != && onRetry) {
remaining = autoRetryMs;
= () => ..<>();
. = ( {
remaining -= ;
(remaining <= ) {
.();
();
} {
node = ();
(node) node. = ;
}
}, );
}
}
(: ): {
.();
.. = html;
}
(: ): {
(.) .. = count.();
}
(: , variant = ): {
(!.) {
. = .();
.. = ;
..(.);
}
.. = text;
... = variant;
}
(): {
.?.();
. = ;
}
(text = ): {
(!.) {
. = .();
.. = ;
..(.);
}
.. = text;
}
(): {
.?.();
. = ;
}
(): { ...(); }
(): { ...(); }
(): {
.();
( { target, , fn } .) {
target.(, fn);
}
. = [];
..();
}
(: ): { . = v; }
(): { .; }
(: ): <> {
(. < .) {
{
response = (url);
(!response.) ();
response.();
} (error) {
.++;
(. >= .) {
(
);
}
( (resolve, .));
. *= ;
}
}
}
(: <, >): {
.(, .(data));
}
(): <, > | {
raw = .();
(!raw) ;
{
.(raw) <, >;
} {
;
}
}
(): {
(. !== ) {
(.);
. = ;
}
}
(
: ,
:
): {
= () => {
target.(, fn);
..({ target, , fn });
};
rowDragging = ;
rowStartY = ;
rowStartSpan = ;
= () => {
me = e ;
rowDragging = ;
rowStartY = me.;
m = (...() ?? [])[];
rowStartSpan = m ? (m, ) : ;
me.();
};
= () => {
(!rowDragging) ;
me = e ;
delta = .((me. - rowStartY) / );
newSpan = .(, .(, rowStartSpan + delta));
.. = ..
.(, )
.();
(!.(..)) {
...();
}
};
= () => { rowDragging = ; };
(rowHandle, , onRowMouseDown);
(, , onRowMouseMove);
(, , onRowMouseUp);
colDragging = ;
colStartX = ;
colStartSpan = ;
= () => {
me = e ;
colDragging = ;
colStartX = me.;
m = (...() ?? [])[];
colStartSpan = m ? (m, ) : ;
me.();
};
= () => {
(!colDragging) ;
me = e ;
delta = .((me. - colStartX) / );
newSpan = .(, .(, colStartSpan + delta));
.. = ..
.(, )
.();
(!.(..)) {
...();
}
};
= () => { colDragging = ; };
(colHandle, , onColMouseDown);
(, , onColMouseMove);
(, , onColMouseUp);
}
}
File 2 — src/utils/circuit-breaker.ts
interface CircuitState {
failures: number;
cooldownUntil: number;
}
interface CacheEntry<T> {
data: T;
timestamp: number;
}
export interface CircuitBreakerOptions {
name: string;
maxFailures?: number;
cooldownMs?: number;
cacheTtlMs?: number;
}
export class CircuitBreaker<T> {
private state: CircuitState = { failures: 0, cooldownUntil: 0 };
private cache: CacheEntry<T> | null = null;
private readonly name: string;
private readonly maxFailures: number;
private readonly cooldownMs: number;
private readonly : ;
() {
. = options.;
. = options. ?? ;
. = options. ?? * * ;
. = options. ?? * * ;
}
(): {
(.() < ..) ;
(.. > ) {
. = { : , : };
}
;
}
(): T | {
(. && .() - .. < .) {
..;
}
;
}
(: T): {
. = { : , : };
. = { data, : .() };
}
(): {
..++;
(.. >= .) {
.. = .() + .;
.();
}
}
execute<R T>(: <R>, : R): <R> {
(.()) {
cached = .();
(cached R) ?? defaultValue;
}
cached = .();
(cached !== ) cached R;
{
result = ();
.(result);
result;
} (e) {
.(, e);
.();
defaultValue;
}
}
}
createCircuitBreaker<T>(
:
): <T> {
<T>(options);
}
File 3 — 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="" height="" viewBox="0 0 ">` +
+
;
}
Key differences from the old Panel pattern
| Feature | Old pattern | New (authoritative) pattern |
|---|
| Persistence | saveState()/loadState() with inline width/height strings | saveMap()/loadMap() with arbitrary key/value map; store grid-span class numbers |
| Resize | Single .panel-resize-handle | Dual handles: .panel-resize-handle-row (vertical) + .panel-resize-handle-col (horizontal) |
| Loading UI | Spinner div | Radar-ring animation (3 nested .radar-ring divs) |
| Error UI | Static message + optional retry button | Message + optional countdown timer (autoRetryMs) + retry button; countdown clears on manual retry |
| Badges | None | setDataBadge(text, variant) / clearDataBadge() and setNewBadge(text) / clearNewBadge() |
destroy() | element.remove() only | Clears countdown interval + removes all resize document listeners + element.remove() |
Creating a Concrete Panel
Option A — Wrapping an existing project service with CircuitBreaker (preferred)
Use this when the project already has a fetchStockQuotes() (or similar) function.
The circuit breaker is a one-liner wrapper — no refactoring of the service needed.
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetchStockQuotes } from '../services/stocks';
interface StockQuote {
symbol: string;
name: string;
price: number | null;
change: number | null;
sparkline?: number[];
}
const breaker = createCircuitBreaker<StockQuote[]>({
name: 'StockMarket',
cacheTtlMs: 60_000,
cooldownMs: 5 * 60_000,
});
export class StockPanel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: , : , : });
state = .();
(state) {
(state.) ...();
(state.) ...();
(state. === ) ...();
}
.();
. = ( .(), );
btn = .();
btn. = ;
btn. = ;
btn.(, .());
..(btn);
.();
}
(): <> {
(.) ;
.();
.();
{
quotes = breaker.( (), []);
.(.(quotes));
.(quotes.);
.(, );
colM = ...();
rowM = ...();
.({
: colM ? colM[] : ,
: rowM ? rowM[] : ,
});
} (err) {
.(, );
.(, .(), );
} {
.();
}
}
(: []): {
(!quotes.) ;
quotes.( ).();
}
(): {
(.) (.);
.();
}
}
Option B — Direct fetch with fetchWithRetry (no external service)
Use this when there is no existing service function and you want full control over
the HTTP call with per-request retry baked in.
export class NewsPanel extends Panel {
constructor() {
super({ id: 'news', title: 'Latest News' });
const state = this.loadMap();
if (state?.collapsed === 'true') this.element.classList.add('collapsed');
this.load();
}
private async load(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
this.retryAttempts = 0;
this.retryDelay = 1000;
try {
const data = await this.fetchWithRetry('/api/news') as NewsItem[];
this.(.(data));
.(, );
} (err) {
.(, );
.(, .(), );
} {
.();
}
}
(: []): {
items.( ).();
}
}
CSS reference for new features
.panel-loading-radar {
position: relative;
width: 40px;
height: 40px;
margin: 0 auto 8px;
}
.radar-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--accent, #4af);
opacity: 0;
animation: radar-pulse 1.8s ease-out infinite;
}
.radar-ring-2 { animation-delay: 0.6s; }
.radar-ring-3 { animation-delay: 1.2s; }
@keyframes radar-pulse {
0% { transform: scale(0.3); opacity: 0.8; }
100% { transform: scale(1.4); opacity: 0; }
}
.panel-data-badge[data-variant="live"] { background: var(--green, #2a2); color: #fff; }
{ : (--yellow, ); : ; }
{ : (--red, ); : ; }
{
: absolute;
: ;
: ;
: ;
: ;
: row-resize;
}
{
: absolute;
: ;
: ;
: ;
: ;
: col-resize;
}
{
: ;
: ;
: ;
}