Build a Habit Tracker with Vue.js
In this tutorial, you'll learn about Build a Habit Tracker with Vue.js. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a habit tracker application with Vue.js and Express that tracks daily completions, calculates consecutive day streaks, and displays progress on a visual calendar heatmap.
What You'll Build
You'll build a habit tracking dashboard where you define habits (like "Read 20 minutes" or "Code daily"), check them off each day, and watch your streak grow. The calendar heatmap shows which days you succeeded or missed — the same visual pattern used by GitHub for contribution graphs.
Why Streak Tracking Matters
Habit tracking works because it makes progress visible. A streak is a powerful motivator — once you've gone 7 days straight, you don't want to break it. The same streak calculation logic powers user retention analytics in products like DodaZIP, where daily active usage is tracked to identify power users.
Prerequisites
- Vue.js basics (components, reactive data, directives)
- Node.js and Express fundamentals
- Familiarity with SQLite or similar databases
Step 1: Scaffold the Project
mkdir habit-tracker
cd habit-tracker
mkdir server client
cd server && npm init -y
npm install express cors better-sqlite3
cd ../client && npm create vue@latest . -- --default
npm install axios
Step 2: Database and API Setup
// server/database.js
const Database = require('better-sqlite3');
const db = new Database('habits.db');
db.exec(`
CREATE TABLE IF NOT EXISTS habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at TEXT DEFAULT (date('now'))
);
CREATE TABLE IF NOT EXISTS completions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habit_id INTEGER NOT NULL,
date TEXT NOT NULL,
FOREIGN KEY (habit_id) REFERENCES habits(id),
UNIQUE(habit_id, date)
);
`);
module.exports = db;
We use two tables: habits stores the habit definitions, and completions tracks each daily check-in. The UNIQUE(habit_id, date) constraint prevents double-counting the same habit on the same day.
// server/server.js
const express = require('express');
const cors = require('cors');
const db = require('./database');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/habits', (req, res) => {
const habits = db.prepare('SELECT * FROM habits ORDER BY created_at DESC').all();
res.json(habits);
});
app.post('/api/habits', (req, res) => {
const { name, description } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
const result = db.prepare('INSERT INTO habits (name, description) VALUES (?, ?)').run(name, description || '');
res.json({ id: result.lastInsertRowid });
});
app.post('/api/completions', (req, res) => {
const { habit_id, date } = req.body;
if (!habit_id || !date) return res.status(400).json({ error: 'habit_id and date required' });
try {
db.prepare('INSERT INTO completions (habit_id, date) VALUES (?, ?)').run(habit_id, date);
res.json({ success: true });
} catch (e) {
res.status(409).json({ error: 'Already completed for this date' });
}
});
app.get('/api/habits/:id/streak', (req, res) => {
const rows = db.prepare(
'SELECT date FROM completions WHERE habit_id = ? ORDER BY date DESC'
).all(req.params.id);
const dates = rows.map(r => r.date);
let streak = 0;
const today = new Date().toISOString().slice(0, 10);
for (let i = 0; i < dates.length; i++) {
const expected = new Date();
expected.setDate(expected.getDate() - i);
const expectedStr = expected.toISOString().slice(0, 10);
if (dates[i] === expectedStr) streak++;
else break;
}
res.json({ streak, total: dates.length });
});
app.listen(4000, () => console.log('Server running on port 4000'));
The streak endpoint checks if the habit was completed on consecutive days going backward from today. If you completed it yesterday and today, streak is 2. Miss a day and it resets.
Step 3: Vue.js Frontend with Heatmap
<!-- client/src/App.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const API = 'http://localhost:4000/api'
const habits = ref([])
const newName = ref('')
const newDesc = ref('')
const today = new Date().toISOString().slice(0, 10)
async function loadHabits() {
const r = await axios.get(`${API}/habits`)
habits.value = r.data
for (const h of habits.value) {
const s = await axios.get(`${API}/habits/${h.id}/streak`)
h.streak = s.data.streak
h.total = s.data.total
}
}
async function addHabit() {
if (!newName.value.trim()) return
await axios.post(`${API}/habits`, { name: newName.value, description: newDesc.value })
newName.value = ''
newDesc.value = ''
loadHabits()
}
async function complete(habitId) {
try {
await axios.post(`${API}/completions`, { habit_id: habitId, date: today })
loadHabits()
} catch (e) {
alert('Already completed today!')
}
}
function heatmap(total) {
if (total >= 20) return '#4caf50'
if (total >= 10) return '#8bc34a'
if (total >= 5) return '#cddc39'
return '#e0e0e0'
}
onMounted(loadHabits)
</script>
<template>
<div style="max-width: 700px; margin: 0 auto; padding: 20px; font-family: system-ui">
<h1>Habit Tracker</h1>
<div style="display: flex; gap: 8px; margin-bottom: 20px">
<input v-model="newName" placeholder="Habit name" style="flex: 1; padding: 8px" />
<input v-model="newDesc" placeholder="Description" style="flex: 1; padding: 8px" />
<button @click="addHabit" style="padding: 8px 16px">Add Habit</button>
</div>
<div v-for="habit in habits" :key="habit.id"
style="border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 12px">
<div style="display: flex; justify-content: space-between; align-items: center">
<div>
<strong>{{ habit.name }}</strong>
<span v-if="habit.description" style="color: #666; margin-left: 8px">{{ habit.description }}</span>
</div>
<button @click="complete(habit.id)" style="padding: 6px 12px; background: #4caf50; color: white; border: none; border-radius: 4px">
Complete Today
</button>
</div>
<div style="margin-top: 12px; display: flex; gap: 16px">
<span>Streak: <strong>{{ habit.streak }}</strong> days</span>
<span>Total: <strong>{{ habit.total }}</strong> days</span>
</div>
<div style="margin-top: 8px; display: flex; gap: 3px; flex-wrap: wrap">
<div v-for="d in 30" :key="d"
:style="{
width: 14, height: 14, borderRadius: 2,
background: d <= (habit.total > 30 ? 30 : habit.total) ? heatmap(d) : '#eee'
}"
:title="`Day ${d}`">
</div>
</div>
</div>
<div v-if="habits.length === 0" style="color: #888; text-align: center; padding: 40px">
No habits yet. Add your first one above.
</div>
</div>
</template>
Expected output: The page shows a form to add habits and a list of habits each with a "Complete Today" button, streak counter, and a 30-day color-coded heatmap. Green blocks represent consistency, gray blocks are misses.
Architecture
flowchart TD
A[Vue.js Frontend] -->|REST API| B[Express Server :4000]
B --> C[SQLite Database]
B --> D[Streak Calculator]
D --> E[Consecutive Day Logic]
E --> A
Common Errors
1. CORS policy blocks requests
The Vue dev server runs on port 5173 and Express on 4000. Install and use the cors middleware on Express. Without it, browsers block all cross-origin requests.
2. Streak incorrectly resets
The streak logic checks dates consecutively backward from today. If your local system time differs from UTC, the date comparison fails. Always create dates with new Date().toISOString().slice(0, 10) for consistency.
3. Duplicate completion not rejected
If the user clicks "Complete Today" twice, the UNIQUE constraint throws an error. Our server catches it and returns a 409 status. The frontend shows an alert, but you could also disable the button after completion.
4. Database locked with multiple users
SQLite handles one writer at a time. For a multi-user app, switch to PostgreSQL or enable WAL mode: db.pragma('journal_mode = WAL').
Practice Questions
1. How does the streak calculation work? It fetches all completion dates sorted descending, then iterates checking if each date matches today, yesterday, day-before, etc. The moment a date doesn't match the expected consecutive day, the streak breaks and counting stops.
2. Why use a UNIQUE constraint on (habit_id, date)? It prevents the same habit from being completed twice on the same day. The database enforces this at the storage level — even if the frontend has a bug, the data stays clean.
3. What does the heatmap color gradient represent?
The heatmap uses total completions across all time. More completions shift the color from gray (0-4) through yellow (5-9) to green (20+). This gives an at-a-glance view of consistency.
4. Challenge: Weekly goal tracking
Add a goal_per_week column to habits. Show a progress bar each week: "3/5 completed this week". Calculate it by counting completions where the date falls within the current ISO week.
FAQ
Next Steps
- Add push notifications to remind you of uncompleted habits
- Explore Vue.js state management with Pinia for complex apps
- Try building the Personal Finance Tracker for another full-stack project
- Learn about Docker deployment to containerize both frontend and backend
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro