Skip to content

Build a Habit Tracker with Vue.js

DodaTech Updated 2026-06-21 7 min read

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

How do I sync habit data across devices?

Add user authentication with JWT, then store all data server-side instead of SQLite. Deploy the Express API to a cloud host and connect the Vue app to the remote endpoint.

Can I add notifications to remind me of habits?

Yes. Use the WebSocket or the Notification API in the browser. Schedule a service worker to check uncompleted habits daily and push a browser notification.

How do I export habit data?

Add a /api/export endpoint that returns CSV. Use res.setHeader('Content-Type', 'text/csv') and send rows as comma-separated values. The browser will prompt a file download.

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