| name | panel-component-aggregator |
| description | Dashboard panel component pattern for aggregator/summary panels that accept pushed partial data updates via updateData(), defer rendering until first data arrives, and incrementally re-render metrics without re-fetching — ideal for panels that synthesize data already fetched by other panels. |
Aggregator Panel Component Pattern
Create aggregator dashboard panel components using vanilla TypeScript (no framework, no JSX).
An aggregator panel does not fetch data itself. Instead, it receives partial data pushes from sibling panels (or a coordinator) via updateData(partial), defers its first render until enough data has arrived, and re-renders incrementally on each subsequent push.
This skill is self-contained. It covers the base Panel class, the AggregatorPanel generic base class, and a concrete example (SummaryPanel).
When to Use This Pattern
| Use case | Fetching Panel | Aggregator Panel |
|---|
| Panel owns its own API endpoint | yes | no |
| Panel summarises data from multiple sources | no | yes |
| Data arrives asynchronously from siblings | no | yes |
| Re-renders on each push without extra network calls | no | yes |
Architecture Overview
Panel (base class)
└── AggregatorPanel<TData> (generic aggregator base)
├── partialData: Partial<TData> — accumulated pushed fields
├── receivedKeys: Set<keyof TData> — tracks which keys have arrived
├── requiredKeys: (keyof TData)[] — keys needed before first render
├── lastUpdated: Map<keyof TData, number> — staleness timestamps
└── SummaryPanel (concrete example)
Data flow:
StockPanel ──pushes──► coordinator.push('stocks', quotes)
EmailPanel ──pushes──► coordinator.push('emails', messages)
│
AggregatorPanel.updateData({ stocks, emails })
│
┌──────────▼──────────┐
│ enough data yet? │
│ no → showPending │
│ yes → render() │
└─────────────────────┘
Base Panel Class
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;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel ${options.className || ''}`.trim();
this... = options.;
. = .();
.. = ;
headerLeft = .();
headerLeft. = ;
title = .();
title. = ;
title. = options.;
headerLeft.(title);
..(headerLeft);
(options.) {
. = .();
.. = ;
.. = ;
..(.);
}
. = .();
.. = ;
.. = ;
..(.);
..(.);
}
(): { .; }
(message = ): {
.. = ;
}
(message = ): {
.. = ;
}
(message = , ?: ): {
.. = ;
(onRetry) {
..()
?.(, onRetry);
}
}
(: ): { .. = html; }
(: ): {
(.) .. = count.();
}
(): { ...(); }
(): { ...(); }
(: ): { . = v; }
(): { .; }
(): { ..(); }
}
AggregatorPanel Base Class
Create src/components/AggregatorPanel.ts:
import { Panel, PanelOptions } from './Panel';
export interface AggregatorPanelOptions<TData> extends PanelOptions {
requiredKeys: (keyof TData)[];
staleThresholdMs?: number;
}
export abstract class AggregatorPanel<TData extends Record<string, unknown>>
extends Panel {
private partialData: Partial<TData> = {};
private receivedKeys = new Set<keyof TData>();
private readonly requiredKeys: (keyof TData)[];
private : ;
lastUpdated = <keyof , >();
hasRenderedOnce = ;
() {
(options);
. = options.;
. = options. ?? * * ;
.(.());
}
(: <>): {
( key .(partial) (keyof )[]) {
.[key] = partial[key];
..(key);
..(key, .());
}
(!.()) {
.(.());
;
}
{
.(. );
. = ;
.();
} (err) {
message = err ? err. : (err);
.(, {
.({});
});
}
}
(): {
..( ..(k));
}
(): (keyof )[] {
..( !..(k));
}
(: keyof ): {
ts = ..(key);
(ts == ) ;
.() - ts > .;
}
(): {
. = {};
..();
..();
. = ;
.(.());
}
(: ): ;
(): <> {
{ .... };
}
(): {
!.;
}
(): {
missing = .() [];
(missing. === ) ;
;
}
(): {
staleKeys = ..( .(k));
indicator = ..();
(staleKeys. > ) {
msg = ;
(indicator) {
indicator. = msg;
} {
el = .();
el. = ;
el. = msg;
..(el);
}
} {
indicator?.();
}
}
}
Concrete Example: SummaryPanel
Create src/components/SummaryPanel.ts:
import { AggregatorPanel } from './AggregatorPanel';
import { StockQuote } from '../services/stock-service';
import { EmailMessage } from '../services/email-service';
import { CalendarEvent } from '../services/calendar-service';
interface SummaryData {
stocks: StockQuote[];
emails: EmailMessage[];
events: CalendarEvent[];
}
export class SummaryPanel extends AggregatorPanel<SummaryData> {
constructor() {
super({
id: 'summary',
title: 'Daily Summary',
className: 'panel-wide',
showCount: true,
requiredKeys: ['stocks', 'emails'],
staleThresholdMs: 3 * 60 * 1000,
});
}
protected (: ): {
snapshot = .();
unread = data..( !e.).;
gainers = data..( (q. ?? ) > ).;
losers = data..( (q. ?? ) < ).;
upcomingCount = snapshot.
? snapshot..( (ev.) > ()).
: ;
eventsHtml = upcomingCount !=
?
: ;
(.) {
.();
} {
.(, unread);
.(, gainers);
.(, losers);
(upcomingCount != ) .(, upcomingCount);
}
.(data.. + data..);
}
(: , : ): {
el = ..(
);
(el) el. = (value);
}
}
Coordinator / Push Pattern
Create src/services/panel-coordinator.ts:
import { SummaryPanel } from '../components/SummaryPanel';
export class PanelCoordinator {
private summaryPanel: SummaryPanel;
constructor(summaryPanel: SummaryPanel) {
this.summaryPanel = summaryPanel;
}
push<K extends 'stocks' | 'emails' | 'events'>(
key: K,
value: unknown
): void {
this.summaryPanel.updateData({ [key]: value } as any);
}
}
In your fetching panels, call coordinator.push() after each successful render:
coordinator.push('stocks', quotes);
coordinator.push('emails', messages);
Key Patterns
- Constructor calls
super() with requiredKeys — the minimum fields needed before the first render.
- updateData(partial) is the only public ingestion point. It merges, timestamps, and either shows a pending placeholder or triggers
render().
- render(data) receives a fully-typed snapshot; use
this.getSnapshot() for optional keys.
- isFirstRender lets you choose between a full DOM replace (first time) vs. incremental in-place patch (subsequent pushes) to avoid unnecessary reflow.
- Staleness indicators are automatically injected into the header when a key's last-update timestamp exceeds
staleThresholdMs.
- reset() clears all state and reverts to pending — call it on session logout or data source disconnect.
- Aggregator panels never call
fetch() or showLoading() — they call showPending() until required data arrives.
- The coordinator is a thin pub/sub shim; replace with EventEmitter, RxJS Subject, or your app's store if available.
Aggregator vs Fetching Panel Checklist
| Concern | Fetching Panel | Aggregator Panel |
|---|
| Network call | fetchWithRetry(url) | none |
| Initial state | showLoading() in constructor | showPending() in constructor |
| Data ingestion | internal async fetch | updateData(partial) |
| First render gate | none | hasRequiredData() |
| Re-render strategy | full replace | incremental patch preferred |
| Staleness | timer-based refetch | automatic header indicator |
destroy() | clear interval + super | super only (no timers to clear) |
TypeScript Strict-Mode Notes
TData extends Record<string, unknown> ensures key iteration is safe.
Object.keys(partial) as (keyof TData)[] is safe because partial is Partial<TData>.
- The
as TData cast in this.render(this.partialData as TData) is guarded by hasRequiredData() — a runtime guarantee backing the compile-time assertion.
- If your project enables
exactOptionalPropertyTypes, declare optional aggregated sources as key?: T | undefined in TData.