Every tutorial on "add charts to your app" starts the same way: npm install chart.js. Or D3. Or Recharts. Then a backend to store the data. Then an API to fetch it. Then a database.
I built 23 analytics dashboards — mood trackers, symptom diaries, habit logs — with zero charting libraries, zero frameworks, and zero backend. Each dashboard computes live insights from user-entered data and renders a bar chart, all in a single HTML file.
This is the pattern. It's 150 lines and it runs forever.
See it live: CBT Mood Tracker — log a mood, watch the chart update instantly.
The problem
Each of my CBT tools lets users log entries — a mood rating, a panic attack, a burnout episode. The therapeutically valuable part isn't the individual entry; it's the pattern over time. Is the mood trend going up or down? What's the recovery rate? Which trigger is most common?
So every tool needs:
- A way to store entries (localStorage — covered in my last article)
- A way to compute aggregate insights from those entries
- A way to render a chart + stats panel
Steps 2 and 3 are where everyone reaches for a library. Here's why you don't need one.
The data
Entries are stored as a JSON array in localStorage. Each entry is a plain object:
// One mood log entry
{
date: "2026-08-20T14:30:00.000Z",
mood: 3, // 1-5 scale
emotion: "anxious", // tag
note: "Big presentation tomorrow"
}
Loading them is one line:
const entries = JSON.parse(localStorage.getItem("mood_entries") || "[]");
That's your entire dataset. No fetch, no async, no loading spinner.
Computing insights — the computeInsights() function
This is the core. It takes the raw entries and returns the numbers a user actually cares about:
function computeInsights(entries) {
if (entries.length === 0) return null;
// Basic counts
const count = entries.length;
const moods = entries.map(e => e.mood);
const avgMood = moods.reduce((a, b) => a + b, 0) / count;
// Most common emotion (frequency table)
const emotionCounts = {};
for (const e of entries) {
emotionCounts[e.emotion] = (emotionCounts[e.emotion] || 0) + 1;
}
const topEmotion = Object.entries(emotionCounts)
.sort((a, b) => b[1] - a[1])[0][0];
// Trend: compare last 7 entries vs previous 7
const recent = moods.slice(-7);
const prior = moods.slice(-14, -7);
const recentAvg = avg(recent);
const priorAvg = prior.length ? avg(prior) : recentAvg;
const trend = recentAvg > priorAvg ? "improving"
: recentAvg < priorAvg ? "declining"
: "stable";
// Rate: entries per day over the logging period
const days = (new Date(entries[count-1].date) - new Date(entries[0].date))
/ 86400000 + 1;
const dailyRate = count / days;
return { count, avgMood, topEmotion, trend, dailyRate };
}
function avg(arr) {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
That's 30 lines. It gives you: total entries, average mood, most common emotion, the trend direction, and the logging rate. These are the exact insights a therapist would look at — and they're computed instantly from data that never leaves the browser.
The "rate" pattern — the most useful insight
The single most valuable number in any self-tracking tool is a behavioral rate: what percentage of entries included an adaptive vs. maladaptive response?
In the burnout diary, I compute a "recovery rate" — did the user take a break, set a boundary, or do a recovery activity?
function recoveryRate(entries) {
const adaptive = ["took break", "set boundary", "recovery activity"];
let recoveryCount = 0;
for (const e of entries) {
if (adaptive.includes(e.whatDid)) recoveryCount++;
}
return Math.round((recoveryCount / entries.length) * 100);
}
This one number tells the user if they're improving. A rising recovery rate over weeks is the signal CBT is working. No chart library gives you this — it's domain logic, and it's 8 lines.
Rendering the bar chart — no Canvas, no SVG, no library
Here's the part everyone overthinks. A bar chart is just rectangles with different heights. I render it with div elements and CSS flexbox:
function renderBarChart(entries, container) {
container.innerHTML = "";
// Group by day, average mood per day
const byDay = {};
for (const e of entries) {
const day = e.date.slice(0, 10);
if (!byDay[day]) byDay[day] = [];
byDay[day].push(e.mood);
}
const days = Object.keys(byDay).sort();
const maxMood = 5; // our scale
for (const day of days) {
const dayAvg = avg(byDay[day]);
const heightPct = (dayAvg / maxMood) * 100;
const bar = document.createElement("div");
bar.className = "bar";
bar.style.height = heightPct + "%";
bar.title = day + ": mood " + dayAvg.toFixed(1);
const label = document.createElement("span");
label.className = "bar-label";
label.textContent = day.slice(5); // MM-DD
const col = document.createElement("div");
col.className = "bar-col";
col.appendChild(bar);
col.appendChild(label);
container.appendChild(col);
}
}
And the CSS — 8 lines:
#chart { display: flex; align-items: flex-end; gap: 4px; height: 150px; }
.bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; }
.bar { width: 100%; background: #4a9d4a; border-radius: 3px 3px 0 0;
min-height: 2px; transition: height 0.3s; }
.bar-label { font-size: 10px; color: #888; margin-top: 2px; }
That's a complete, animated bar chart in ~40 lines of JS + CSS. It updates instantly when a new entry is logged. No 200KB library. No canvas API. No SVG path math.
Why div and not canvas? Canvas requires a redraw loop, doesn't scale with device pixel ratio without extra work, and the bars aren't selectable or accessible. div elements get hover tooltips for free (title attribute), work with screen readers, and print cleanly. For a bar chart, the DOM is the better rendering surface.
The insights panel
The stats render into a simple panel. One function, pure DOM:
function renderInsights(insights, container) {
const trendIcon = { improving: "📈", declining: "📉", stable: "➡️" };
container.innerHTML = `
<div class="stat">
<span class="stat-value">${insights.count}</span>
<span class="stat-label">entries logged</span>
</div>
<div class="stat">
<span class="stat-value">${insights.avgMood.toFixed(1)}</span>
<span class="stat-label">avg mood (1-5)</span>
</div>
<div class="stat">
<span class="stat-value">${insights.topEmotion}</span>
<span class="stat-label">most common</span>
</div>
<div class="stat">
<span class="stat-value">${trendIcon[insights.trend]} ${insights.trend}</span>
<span class="stat-label">7-day trend</span>
</div>
`;
}
The adaptive guidance — the part that makes it a tool, not a chart
A dashboard that shows numbers is a toy. A dashboard that interprets them and suggests a next step is a tool. This is the final piece:
function nextStep(insights, recoveryRate) {
if (insights.count < 3)
return "Keep logging — patterns emerge after 3+ entries.";
if (recoveryRate < 30)
return "Recovery rate is low. Try scheduling one recovery "
+ "activity (walk, social, sleep) as a non-negotiable block.";
if (insights.trend === "declining")
return "Mood is declining. Review recent entries for a pattern "
+ "— what changed? Consider a thought record for the trigger.";
if (recoveryRate > 60 && insights.trend !== "declining")
return "Strong recovery rate + stable/improving mood. "
+ "Keep doing what's working.";
return "You're building awareness. The act of logging itself "
+ "is the intervention — keep going.";
}
This is domain logic encoded as a function. It's the reason the tool is useful: it doesn't just show data, it responds to it. And it's 15 lines.
Putting it together
The full flow, triggered on every new entry:
function logMood(mood, emotion, note) {
// 1. Store
const entries = JSON.parse(localStorage.getItem("mood_entries") || "[]");
entries.push({ date: new Date().toISOString(), mood, emotion, note });
localStorage.setItem("mood_entries", JSON.stringify(entries));
// 2. Compute
const insights = computeInsights(entries);
const rate = recoveryRate(entries);
// 3. Render
renderBarChart(entries, document.getElementById("chart"));
renderInsights(insights, document.getElementById("stats"));
document.getElementById("guidance").textContent = nextStep(insights, rate);
}
Three steps: store, compute, render. No state management library. No reactivity framework. No data fetching. The UI updates because I called the render functions directly. Imperative code is not a code smell when your app is this simple.
What I'd do differently at scale
This pattern works because each dashboard has one chart type and one data shape. If you need:
- Multiple chart types (line, scatter, pie, heatmap) — write each as a 40-line function. You'll hit ~5 charts before a library pays off.
- Real-time streaming data (WebSocket updates 10x/sec) — the DOM updates will thrash. Use canvas or requestAnimationFrame batching.
- Interactive zoom/pan/brush — this is where D3 actually earns its size. If you need brushable timelines, reach for a library.
- Server-side rendering / shared dashboards — you need a backend. But for personal tracking tools, the data is yours and lives in your browser.
The key question: does your chart need to be interactive, or does it need to be informative? Most analytics dashboards just need to show the data clearly. A div bar chart with a hover tooltip does that.
The full architecture
┌──────────────────────────────────────────────┐
│ Single HTML file │
│ ├── <style> (8 lines of chart CSS) │
│ ├── <script> │
│ │ ├── logMood() (store + trigger) │
│ │ ├── computeInsights() (30 lines) │
│ │ ├── recoveryRate() (8 lines) │
│ │ ├── renderBarChart() (25 lines) │
│ │ ├── renderInsights() (15 lines) │
│ │ └── nextStep() (15 lines) │
│ └── localStorage (the "database") │
└──────────────────────────────────────────────┘
Total: ~150 lines. Dependencies: 0.
The results
I've used this exact pattern across 23 tools. The mood tracker renders a 30-day bar chart. The burnout diary computes recovery rate + overwork rate + disconnection rate. The panic diary shows attack frequency trends. Each is a single HTML file, each works offline, each loads in under 50ms.
Chart.js: 200 KB minified, 1 dependency, 1 build step
D3.js: 280 KB minified, steep learning curve
My chart: 40 lines of JS + 8 lines of CSS, 0 dependencies
For a bar chart, the library is 5,000x the size of the code it replaces.
Takeaways for builders
-
A bar chart is rectangles. That's it.
div+height+flexbox. The 200KB library is for the 5% of cases that need interactivity, not the 95% that need to show data. - Compute insights where the data lives. If your data is in the browser, your analytics are in the browser. No round-trip to a server, no loading state, no API to maintain.
-
Domain logic > generic charts. "Recovery rate is 45%" is a number. "Your recovery rate is low — try scheduling a recovery activity" is a tool. The 15-line
nextStep()function is worth more than any chart. -
Imperative is fine. Not every app needs reactivity.
store → compute → renderin a single function call is the simplest thing that works. Adding a framework here would be ceremony without benefit.
If you want to see the full code or use the tools:
- CBT Mood Tracker — the tool this article is about
- CBT Toolkit Hub — all 23 tools, each self-contained
- Burnout Diary — recovery rate + overwork rate dashboard
If you prefer tracking in a structured Notion template (the "paper notebook" version), I made one: CBT Thought Record for Notion ($7).
What's a chart you're rendering with a 200KB library that could be 40 lines of divs?
Top comments (0)