Unified Full-Stack Panel Authoring
One guide for the complete workflow: backend route → service → server wiring → Panel component with resilience → registration. Eliminates the need to consult separate skills for what is always a single cohesive task.
When to Use
- Adding a new panel type to a vanilla-TypeScript dashboard
- Creating a feature that spans all layers: server route, service, component, registration
- Ensuring pattern-consistency across the stack
- Projects using a manual
http.createServerdispatch loop or Express
Quick Reference — File Checklist
| # | File | Action |
|---|---|---|
| 1 | server/routes/{feature}-route.ts |
Create |
| 2 | src/services/{feature}.ts |
Create |
| 3 | src/components/{Feature}Panel.ts |
Create |
| 4 | src/components/index.ts |
Update — add export |
| 5 | server/index.ts |
Update — import + dispatch case |
| 6 | src/utils/circuit-breaker.ts |
Create if absent |
| 7 | src/utils/sparkline.ts |
Create if absent |
Step 1 — Explore Project Structure
Run these commands before writing a single line of code. Reading existing patterns prevents style drift and reveals the exact dispatch mechanism.
# Locate server entry point (critical for wiring)
find . -name "index.ts" -o -name "index.js" -o -name "server.ts" -o -name "server.js" \
| grep -v node_modules | head -10
# Find existing route handlers to use as pattern files
find server/routes -name "*.ts" -o -name "*.js" 2>/dev/null | head -10
# Find existing service files
find src/services -name "*.ts" -o -name "*.js" 2>/dev/null | head -10
# Find existing Panel components
find src/components -name "*Panel.ts" 2>/dev/null | head -10
# Find the Panel base class
find src -name "Panel.ts" 2>/dev/null | head -5
# Find component registration index
find src/components -name "index.ts" 2>/dev/null | head -3
# Check for CircuitBreaker utility
find src/utils -name "circuit-breaker.ts" 2>/dev/null
# Check framework (should NOT find React in vanilla TS projects)
grep -s '"react"' package.json | head -3
Read 1-2 examples from each layer before generating any code:
cat server/routes/<existing>-route.ts
cat src/services/<existing>.ts
cat src/components/<existing>Panel.ts
cat src/components/index.ts
cat server/index.ts # most important: reveals dispatch pattern
Step 2 — Detect Server Dispatch Pattern
Read server/index.ts (or server/index.js, src/server.ts) and classify:
# Express signals
grep -n "express\|app\.use\|router\." server/index.ts 2>/dev/null
# Manual http.createServer signals
grep -n "createServer\|switch.*url\|switch.*pathname\|req\.url\|case '/" server/index.ts 2>/dev/null
| Pattern in file | Wiring method (Step 5) |
|---|---|
app.use('/api/x', xRouter) |
5A - Express |
switch(pathname) { case '/api/x': |
5B - Manual switch/case |
if (url.startsWith('/api/x')) |
5C - Manual if/else chain |
const handlers = { '/api/x': fn } |
5D - Handler map |
NOTE: Misidentifying the dispatch pattern is the most common cause of silent 404s. If unsure, read the full server entry point before proceeding.
Step 3 — Create the Server Route Handler
Use the manual dispatch template for http.createServer projects (most common in this codebase). Use the Express template only when Express is confirmed.
Manual dispatch (TypeScript) — preferred template
// server/routes/{feature}-route.ts
import { IncomingMessage, ServerResponse } from 'http';
import { fetch{Feature}Data } from '../../src/services/{feature}';
export async function handle{Feature}Request(
req: IncomingMessage,
res: ServerResponse,
action?: string
): Promise<void> {
try {
const data = await fetch{Feature}Data(action);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: err instanceof Error ? err.message : String(err)
}));
}
}
Express template (only when confirmed)
// server/routes/{feature}-route.ts
import { Router } from 'express';
import { fetch{Feature}Data } from '../../src/services/{feature}';
const router = Router();
router.get('/', async (req, res) => {
try {
const data = await fetch{Feature}Data(req.query.action as string | undefined);
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
export default router;
Step 4 — Create the Service Layer
The service owns data fetching and transformation. The CircuitBreaker (Step 6) wraps the service call at the component level — keep the service itself simple.
// src/services/{feature}.ts
export interface {Feature}Item {
id: string;
// define your data shape matching the API response
}
/**
* Fetch {feature} data from the server API.
* @param action Optional sub-action / filter parameter.
*/
export async function fetch{Feature}Data(action?: string): Promise<{Feature}Item[]> {
const url = `/api/panels/{feature}${action ? `?action=${encodeURIComponent(action)}` : ''}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${localStorage.getItem('token') ?? ''}` }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json() as { data: {Feature}Item[] };
return data;
}
Step 5 — Wire the Route Handler into the Server
This step is the most frequently missed and causes features to silently return 404.
After creating the route file, open server/index.ts and add both an import and a dispatch entry.
5A - Express
// server/index.ts -- excerpt
import {feature}Router from './routes/{feature}-route';
// Add alongside other app.use() calls:
app.use('/api/panels/{feature}', {feature}Router);
5B - Manual switch/case (most common pattern)
// server/index.ts -- excerpt showing the additions
// 1. Add import near the top with other route imports
import { handle{Feature}Request } from './routes/{feature}-route';
// 2. Inside the switch / if-else dispatch block, add a new case
// Find the block that looks like:
// switch (pathname) {
// case '/api/panels/health': ...
// case '/api/panels/email': ...
// Add:
case '/api/panels/{feature}':
await handle{Feature}Request(req, res, action);
break;
5C - Manual if/else chain
// server/index.ts -- excerpt
import { handle{Feature}Request } from './routes/{feature}-route';
// Add a new else-if branch:
} else if (url.startsWith('/api/panels/{feature}')) {
await handle{Feature}Request(req, res, action);
}
5D - Handler map
// server/index.ts -- excerpt
import { handle{Feature}Request } from './routes/{feature}-route';
const handlers: Record<string, HandlerFn> = {
// ... existing entries ...
'/api/panels/{feature}': handle{Feature}Request,
};
Verification: After wiring, start the server and curl /api/panels/{feature} — you should receive JSON, not a 404.
Step 6 — Create the Panel Component
Architecture overview
Panel (base class)
+-- element: HTMLElement (.panel)
+-- header (.panel-header)
| +-- headerLeft (.panel-header-left)
| | +-- title (.panel-title)
| +-- countEl (.panel-count) [optional]
+-- content (.panel-content)
Resilience layers
| Layer | When to use |
|---|---|
isFetching guard |
Always -- prevents duplicate concurrent requests |
fetchWithRetry() |
When you own the raw fetch and want per-request retry |
CircuitBreaker.execute() |
When a service may fail repeatedly; serves stale cache during cooldown |
saveState() / loadState() |
When collapsed/size state should survive page reloads |
Complete Panel component template
// src/components/{Feature}Panel.ts
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetch{Feature}Data, {Feature}Item } from '../services/{feature}';
// Module-level breaker -- persists cache and failure state between re-renders
const breaker = createCircuitBreaker<{Feature}Item[]>({
name: '{Feature}',
cacheTtlMs: 60_000, // serve cache for 1 min
cooldownMs: 5 * 60_000, // open circuit for 5 min after maxFailures
maxFailures: 2,
});
export class {Feature}Panel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: '{feature}', title: '{Feature Display Name}', showCount: true });
this.loadState(); // restore collapsed/size
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
}
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
try {
// One-liner: wraps existing service call with caching + circuit-break
const items = await breaker.execute(() => fetch{Feature}Data(), []);
this.setCount(items.length);
this.render(items);
} catch (err) {
this.showError(
err instanceof Error ? err.message : 'Failed to load',
() => {
this.retryAttempts = 0;
this.retryDelay = 1000;
this.fetchData();
}
);
} finally {
this.setFetching(false);
}
}
private render(items: {Feature}Item[]): void {
if (items.length === 0) {
this.setContent('<div class="panel-empty">No items found.</div>');
return;
}
const container = document.createElement('div');
container.className = '{feature}-panel';
for (const item of items) {
const row = document.createElement('div');
row.className = '{feature}-panel__row';
// Build DOM from item fields -- no JSX, no innerHTML with untrusted data:
const label = document.createElement('span');
label.className = '{feature}-panel__label';
label.textContent = item.id; // replace with real fields
row.appendChild(label);
container.appendChild(row);
}
this.content.innerHTML = '';
this.content.appendChild(container);
this.saveState();
}
destroy(): void {
if (this.refreshTimer !== null) clearInterval(this.refreshTimer);
super.destroy();
}
}
Key rules for vanilla TS panels (no React):
- Extend
Panelbase class; usesetContent()/showError()/setCount()/showLoading()instead of React state. - Build DOM via
document.createElement-- not JSX, notinnerHTMLwith untrusted strings. - Lifecycle:
constructor->fetchData()->render()->destroy(). NouseEffect. - File extension is
.ts, not.jsx/.tsx.
Step 7 — Register the Component in index.ts
Export the new panel from the components barrel file so the dashboard can discover it.
// src/components/index.ts -- add alongside existing exports
export { {Feature}Panel } from './{Feature}Panel';
Then instantiate and mount in the dashboard bootstrap (follow the existing pattern):
// src/index.ts or src/dashboard.ts -- excerpt
import { {Feature}Panel } from './components';
const {feature}Panel = new {Feature}Panel();
document.getElementById('{feature}-slot')?.appendChild({feature}Panel.getElement());
Step 8 — Add Utility Files (if absent)
Only create these if find src/utils -name "circuit-breaker.ts" returns nothing.
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; // default 2
cooldownMs?: number; // default 5 min
cacheTtlMs?: number; // default 10 min
}
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 cacheTtlMs: number;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.maxFailures = options.maxFailures ?? 2;
this.cooldownMs = options.cooldownMs ?? 5 * 60 * 1000;
this.cacheTtlMs = options.cacheTtlMs ?? 10 * 60 * 1000;
}
isOnCooldown(): boolean {
if (Date.now() < this.state.cooldownUntil) return true;
if (this.state.cooldownUntil > 0) this.state = { failures: 0, cooldownUntil: 0 };
return false;
}
getCached(): T | null {
if (this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) return this.cache.data;
return null;
}
recordSuccess(data: T): void {
this.state = { failures: 0, cooldownUntil: 0 };
this.cache = { data, timestamp: Date.now() };
}
recordFailure(): void {
this.state.failures++;
if (this.state.failures >= this.maxFailures) {
this.state.cooldownUntil = Date.now() + this.cooldownMs;
console.warn(`[${this.name}] Circuit open -- cooldown ${this.cooldownMs / 1000}s`);
}
}
async execute<R extends T>(fn: () => Promise<R>, defaultValue: R): Promise<R> {
if (this.isOnCooldown()) return (this.getCached() as R) ?? defaultValue;
const cached = this.getCached();
if (cached !== null) return cached as R;
try {
const result = await fn();
this.recordSuccess(result);
return result;
} catch (e) {
console.error(`[${this.name}] Request failed:`, e);
this.recordFailure();
return defaultValue;
}
}
}
export function createCircuitBreaker<T>(options: CircuitBreakerOptions): CircuitBreaker<T> {
return new CircuitBreaker<T>(options);
}
src/utils/sparkline.ts (optional — for numeric trend data)
/**
* Returns an inline SVG sparkline string, or '' if data is too short.
* Embed directly in HTML template strings.
*/
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>`;
}
Step 9 — Panel Base Class (if absent)
Only create src/components/Panel.ts if no existing base class is found.
// 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 (used by fetchWithRetry)
protected retryAttempts = 0;
protected retryDelay = 1000;
private maxRetries = 3;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel ${options.className ?? ''}`;
this.element.dataset.panel = options.id;
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);
if (options.showCount) {
this.countEl = document.createElement('span');
this.countEl.className = 'panel-count';
this.countEl.textContent = '0';
this.header.appendChild(this.countEl);
}
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.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 setFetching(v: boolean): void { this._fetching = v; }
protected get isFetching(): boolean { return this._fetching; }
protected async fetchWithRetry(url: string): Promise<unknown> {
while (this.retryAttempts < this.maxRetries) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
this.retryAttempts++;
if (this.retryAttempts >= this.maxRetries) {
throw new Error(`Failed after ${this.maxRetries} attempts: ${(error as Error).message}`);
}
await new Promise(resolve => setTimeout(resolve, this.retryDelay));
this.retryDelay *= 2;
}
}
}
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;
}
}
Step 10 — Safe File Writing
Known tool issue: write_file (and some shell tools) fail silently or with "unknown error" when file content contains multi-byte Unicode characters such as emoji (e.g., document, chart, checkmark emoji).
Rules for safe content
Never use emoji in generated source code. Replace with plain-text equivalents:
- Document emoji ->
[doc]or(file) - Chart emoji ->
[chart] - Checkmark emoji ->
[ok]or(done) - Warning emoji ->
[warn]orWARNING:
- Document emoji ->
Use ASCII substitutes in comments instead of Unicode box-drawing characters.
Test-write suspect content to a throwaway file first if unsure:
echo "test content" > /tmp/write_test.txt && cat /tmp/write_test.txtRecovery pattern -- if a write_file call fails:
- Strip all non-ASCII characters from the content.
- Retry the write.
- If still failing, split the file into smaller chunks and concatenate with
cat.
Validation Checklist
Before considering the feature complete, verify each layer:
Server route
[ ] File created at server/routes/{feature}-route.ts
[ ] Exports handle{Feature}Request function
[ ] Returns { success: true, data } on success
[ ] Returns { success: false, error } on failure with correct HTTP status
Service
[ ] File created at src/services/{feature}.ts
[ ] Exports fetch{Feature}Data function
[ ] Throws on non-ok HTTP status (so CircuitBreaker can record failures)
Server wiring
[ ] Import added to server/index.ts
[ ] Dispatch case/route added (matching the detected pattern)
[ ] curl /api/panels/{feature} returns JSON (not 404)
Panel component
[ ] Extends Panel base class
[ ] Uses isFetching guard
[ ] CircuitBreaker wraps the service call
[ ] showError() called with retry callback on failure
[ ] destroy() clears interval and calls super.destroy()
Registration
[ ] Export added to src/components/index.ts
[ ] Panel instantiated and mounted in dashboard bootstrap
File safety
[ ] No emoji in any generated source file
[ ] All write_file calls succeeded (check file size > 0)
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Skipping Step 5 (server wiring) | Feature returns 404 | Add import + dispatch entry to server/index.ts |
| Wrong dispatch pattern | 404 or runtime error | Re-read server/index.ts; use grep from Step 2 |
| Emoji in source files | write_file silent failure | Replace all emoji with ASCII equivalents |
| React template in vanilla TS project | Compile errors | Confirm no React in package.json; use Panel base class template |
| Creating Panel.ts when one exists | Overwrites base class | Check with find src -name "Panel.ts" first |
Not calling super.destroy() |
Memory leak | Always chain super.destroy() in subclass destroy() |
| CircuitBreaker declared inside class | Cache lost between fetches | Declare const breaker = ... at module level outside the class |