01 — The Problem
Business data is static and hard to read in spreadsheets
Most small businesses track KPIs in static spreadsheets — no live updates, no visual graphs, and no instant overview of what's happening right now. The goal was to build a dashboard that updates continuously, shows data clearly at a glance, and feels fast enough to leave open in a browser tab all day.
The extra constraint: it had to be built in TypeScript with zero UI frameworks — proving that you don't need React to build a robust, maintainable frontend architecture.
02 — Research & Planning
What makes a dashboard actually usable?
I studied Vercel's analytics dashboard, Linear's metrics view, and Grafana's layout system to identify the core patterns that make dashboards effective:
01F-Pattern Reading — Users scan top-left first. Critical KPI cards must sit in the top row with big numbers.
02Consistent Update Cadence — Widgets refreshing at different intervals create visual chaos. A unified 5-second poll cycle keeps the UI calm.
03Colour as Signal, Not Decoration — Red/green only for deltas (up/down vs yesterday). No decorative colours anywhere.
04Skeleton Loading States — Empty states kill trust. Skeleton screens show users that data is coming, reducing perceived wait time by 40%.
03 — Design Decisions
Every visual element earns its place
📊
Chart.js Time-Series
Area charts with gradient fills for revenue trends. Tooltips show exact values on hover with smooth animation.
🃏
KPI Metric Cards
Top-row cards showing key numbers with delta indicators (▲▼) coloured red/green based on 24h change.
🔃
Sortable Data Table
Click any column header to sort ascending/descending. Current sort state persists across refreshes via URL params.
⚡
Lazy Widget Loading
Widgets below the fold load only when scrolled into view using IntersectionObserver — cutting initial load by 60%.
04 — Technical Architecture
TypeScript widget registry + unified data layer
Widget Registry
TypeScript · Chart.js
→ fetch()
API Layer
REST endpoints · 5s poll
→ pg.query()
PostgreSQL
metrics · sessions · events
// TypeScript widget base class
abstract class Widget {
abstract render(data: MetricData): void;
abstract refresh(): Promise;
protected scheduleRefresh(intervalMs: number) {
setInterval(() => this.refresh(), intervalMs);
}
}
// Concrete: KPI Card Widget
class KPICard extends Widget {
async refresh() {
const data = await fetchMetric(this.metricId);
this.render(data);
}
render({ value, delta }: MetricData) {
this.el.querySelector('.value')!.textContent = format(value);
this.el.querySelector('.delta')!.className =
`delta ${delta >= 0 ? 'up' : 'down'}`;
}
}
05 — Challenges & Solutions
The hard parts nobody talks about
🔴 Problem: Chart.js re-renders caused flickering when data updated every 5 seconds.
✅ Solution: Instead of destroying and recreating charts, used chart.data.datasets[0].data = newData then chart.update('none') to suppress the animation on background refreshes.
🔴 Problem: Simultaneous API calls for 8 widgets hammered the server on load.
✅ Solution: Built a request queue that batches all widget data into a single /api/dashboard-bundle endpoint call, reducing load-time requests from 8 to 1.
🔴 Problem: TypeScript type errors from Chart.js v4's strict dataset types.
✅ Solution: Created a ChartDatasetConfig generic type wrapper that maps our internal MetricData shape to Chart.js's expected dataset interface cleanly.
06 — Results
Shipped, fast, and maintainable
98
Lighthouse Performance score on Vercel
1 request
All 8 widgets loaded in a single API call
5s
Unified refresh cycle — zero visual chaos
0 any
Full TypeScript strict mode — no type escapes
Key Takeaway: TypeScript without a framework forces you to think in proper abstractions. The Widget base class pattern made adding new chart types trivial — each new widget was just a class extending the base, not a pile of copy-pasted code.