10 · Project — A Habit Tracker¶
Time to put Level 1 together. You'll build a habit tracker: add habits, tick them off for
each of the last seven days, see your current streak, and keep everything across page
reloads. Every file on this page was compiled with vue-tsc, and the tests at the end were
run with Vitest — all six pass.
What you'll use from this level:
| Feature | Where |
|---|---|
ref, computed, deep watch |
App.vue state, summary, persistence |
Props, emits, defineModel |
HabitRow, DayCell, HabitForm |
v-for with stable keys, v-if/v-else |
the habit list and empty state |
Form handling with v-model and .prevent |
HabitForm |
Scoped CSS, v-bind() in CSS |
per-habit colours in DayCell |
<TransitionGroup> |
rows animate in and out |
1. Create the project¶
No router or Pinia this time — one screen, and state that lives in the root component.
Delete the example files in src/components/ and src/components/__tests__/, and empty
src/assets/main.css (or keep its reset — your choice).
A Vitest fix for Node 25 and later¶
If you are on Node.js 25 or newer, add one line to vitest.config.ts before writing any
test that touches localStorage:
test: {
environment: 'jsdom',
// Node 25+ ships its own localStorage global, which hides jsdom's in tests
execArgv: ['--no-experimental-webstorage'],
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
We found this the hard way. On Node 26 our tests first failed with:
Newer Node versions define a built-in localStorage global that is undefined unless
Node is started with --localstorage-file, and it shadows the one jsdom provides. Plain
node -e "console.log(typeof localStorage)" prints undefined with an
ExperimentalWarning. Turning Node's web-storage off for the test workers restores jsdom's
implementation. On Node 22 or 24 the flag is harmless.
2. Pure logic first¶
Keep date and streak logic out of components. Plain functions are easier to test, and components stay focused on rendering.
export interface Habit {
id: string
name: string
color: string
/** ISO dates (YYYY-MM-DD) on which the habit was completed */
done: string[]
}
export function toISODate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** The last `n` days, oldest first, ending with `today`. */
export function lastDays(n: number, today = new Date()): string[] {
return Array.from({ length: n }, (_, i) => {
const d = new Date(today)
d.setDate(today.getDate() - (n - 1 - i))
return toISODate(d)
})
}
/** Consecutive completed days ending today (or yesterday, if today isn't done yet). */
export function currentStreak(done: string[], today = new Date()): number {
const set = new Set(done)
const cursor = new Date(today)
if (!set.has(toISODate(cursor))) cursor.setDate(cursor.getDate() - 1)
let streak = 0
while (set.has(toISODate(cursor))) {
streak++
cursor.setDate(cursor.getDate() - 1)
}
return streak
}
const STORAGE_KEY = 'habit-tracker:v1'
export function loadHabits(): Habit[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
const parsed: unknown = raw ? JSON.parse(raw) : []
return Array.isArray(parsed) ? (parsed as Habit[]) : []
} catch {
return []
}
}
export function saveHabits(habits: Habit[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits))
}
Design decisions:
- Dates are stored as
YYYY-MM-DDstrings in local time, built by hand rather than withtoISOString().toISOString()converts to UTC, so for someone in India at 2 a.m. it would report yesterday's date. doneis an array rather than aSetbecause it has to go throughJSON.stringify.- The streak forgives "not yet today": if today isn't ticked, it counts back from yesterday, so your streak doesn't show 0 every morning.
loadHabitsnever throws — corrupted storage just gives an empty list.
3. The form¶
<script setup lang="ts">
import { ref, computed } from 'vue'
const { existingNames } = defineProps<{ existingNames: string[] }>()
const emit = defineEmits<{ add: [name: string, color: string] }>()
const name = ref('')
const color = ref('#16a34a')
const trimmed = computed(() => name.value.trim())
const duplicate = computed(() =>
existingNames.some((n) => n.toLowerCase() === trimmed.value.toLowerCase()),
)
const canAdd = computed(() => trimmed.value.length > 0 && !duplicate.value)
function submit() {
if (!canAdd.value) return
emit('add', trimmed.value, color.value)
name.value = ''
}
</script>
<template>
<form class="habit-form" @submit.prevent="submit">
<label for="habit-name">New habit</label>
<input id="habit-name" v-model="name" maxlength="40" placeholder="e.g. Read 20 pages" />
<input v-model="color" type="color" aria-label="Habit colour" />
<button type="submit" :disabled="!canAdd">Add</button>
<p v-if="duplicate" class="error" role="alert">You already track “{{ trimmed }}”.</p>
</form>
</template>
<style scoped>
.habit-form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.habit-form label { width: 100%; font-weight: 600; }
.error { width: 100%; color: #b91c1c; margin: 0; }
</style>
The form doesn't know about the habit list. It receives the existing names (so it can
reject duplicates, case-insensitively and ignoring surrounding spaces) and emits add.
The parent decides what adding means. The error uses role="alert", so screen readers
announce it as soon as it appears.
4. One day cell¶
<script setup lang="ts">
const { date, color } = defineProps<{ date: string; color: string }>()
const done = defineModel<boolean>({ required: true })
const label = new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
weekday: 'short',
day: 'numeric',
})
</script>
<template>
<button
type="button"
class="cell"
:class="{ done }"
:aria-pressed="done"
:aria-label="`${label}: ${done ? 'done' : 'not done'}`"
@click="done = !done"
>
{{ done ? '✓' : '' }}
</button>
</template>
<style scoped>
.cell {
width: 2rem; height: 2rem; border: 2px solid v-bind(color); border-radius: 6px;
background: transparent; color: white; font-weight: 700; cursor: pointer;
}
.cell.done { background: v-bind(color); }
</style>
DayCell supports v-model through defineModel. The habit colour flows into CSS with
v-bind(color), so each row gets its own colour without generating class names. The
visible content is only a checkmark, so the button has an aria-label with the day and
state, plus aria-pressed to expose it as a toggle.
5. One habit row¶
<script setup lang="ts">
import { computed } from 'vue'
import DayCell from './DayCell.vue'
import { currentStreak, type Habit } from '../habits'
const { habit, days } = defineProps<{ habit: Habit; days: string[] }>()
const emit = defineEmits<{
toggle: [id: string, date: string]
remove: [id: string]
}>()
const streak = computed(() => currentStreak(habit.done))
</script>
<template>
<li class="row">
<span class="name">{{ habit.name }}</span>
<DayCell
v-for="day in days"
:key="day"
:date="day"
:color="habit.color"
:model-value="habit.done.includes(day)"
@update:model-value="emit('toggle', habit.id, day)"
/>
<span class="streak" :title="`${streak}-day streak`">🔥 {{ streak }}</span>
<button type="button" class="remove" :aria-label="`Remove ${habit.name}`" @click="emit('remove', habit.id)">×</button>
</li>
</template>
<style scoped>
.row { display: grid; grid-template-columns: 10rem repeat(7, 2rem) 4rem 2rem; gap: 0.4rem; align-items: center; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.remove { border: none; background: none; font-size: 1.25rem; cursor: pointer; }
</style>
Look at how HabitRow uses DayCell. It doesn't use v-model, because a DayCell's
value is derived (habit.done.includes(day)) rather than a variable the row owns. So it
binds the two halves of v-model by hand: :model-value down, @update:model-value up —
and turns the update into a toggle event for the parent. The row never mutates
habit, which is a prop.
6. The root component¶
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import HabitForm from './components/HabitForm.vue'
import HabitRow from './components/HabitRow.vue'
import { lastDays, loadHabits, saveHabits, toISODate, type Habit } from './habits'
const habits = ref<Habit[]>(loadHabits())
const days = lastDays(7)
const today = toISODate(new Date())
watch(habits, (value) => saveHabits(value), { deep: true })
const doneToday = computed(() => habits.value.filter((h) => h.done.includes(today)).length)
const names = computed(() => habits.value.map((h) => h.name))
function addHabit(name: string, color: string) {
habits.value.push({ id: crypto.randomUUID(), name, color, done: [] })
}
function toggle(id: string, date: string) {
const habit = habits.value.find((h) => h.id === id)
if (!habit) return
const i = habit.done.indexOf(date)
if (i === -1) habit.done.push(date)
else habit.done.splice(i, 1)
}
function remove(id: string) {
habits.value = habits.value.filter((h) => h.id !== id)
}
</script>
<template>
<main class="app">
<h1>Habits</h1>
<p class="summary" aria-live="polite">
{{ doneToday }} of {{ habits.length }} done today
</p>
<HabitForm :existing-names="names" @add="addHabit" />
<TransitionGroup v-if="habits.length" name="list" tag="ul" class="list">
<HabitRow
v-for="habit in habits"
:key="habit.id"
:habit="habit"
:days="days"
@toggle="toggle"
@remove="remove"
/>
</TransitionGroup>
<p v-else class="empty">No habits yet — add your first one above.</p>
</main>
</template>
<style scoped>
.app { max-width: 42rem; margin: 2rem auto; font-family: system-ui, sans-serif; }
.list { list-style: none; padding: 0; display: grid; gap: 0.5rem; }
.list-enter-active, .list-leave-active { transition: opacity 200ms, transform 200ms; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-1rem); }
@media (prefers-reduced-motion: reduce) {
.list-enter-active, .list-leave-active { transition: none; }
}
</style>
All the state lives here: habits is the single source of truth, and every change goes
through one of three functions. Because toggle mutates an array inside a habit, the
persistence watcher needs { deep: true } — a shallow watcher on habits would only
notice when the whole array is replaced (as remove does).
<TransitionGroup tag="ul"> renders a real <ul> and animates HabitRow items as they're
added or removed, keyed by habit.id.
Replace src/main.ts's contents with the three-line version:
Run npm run dev, add a couple of habits and tick some days, then reload the page — your
data should still be there.
7. Tests¶
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import App from '../App.vue'
import { currentStreak, lastDays } from '../habits'
describe('streaks', () => {
const today = new Date('2026-09-29T12:00:00')
it('counts consecutive days ending today', () => {
expect(currentStreak(['2026-09-27', '2026-09-28', '2026-09-29'], today)).toBe(3)
})
it('still counts yesterday when today is not done yet', () => {
expect(currentStreak(['2026-09-27', '2026-09-28'], today)).toBe(2)
})
it('breaks on a gap', () => {
expect(currentStreak(['2026-09-25', '2026-09-28', '2026-09-29'], today)).toBe(2)
})
it('lists the last 7 days oldest first', () => {
expect(lastDays(7, today)).toEqual([
'2026-09-23', '2026-09-24', '2026-09-25', '2026-09-26', '2026-09-27', '2026-09-28', '2026-09-29',
])
})
})
describe('App', () => {
beforeEach(() => localStorage.clear())
it('adds a habit, toggles today and persists', async () => {
const wrapper = mount(App)
await wrapper.find('#habit-name').setValue('Read')
await wrapper.find('form').trigger('submit')
expect(wrapper.findAll('li.row')).toHaveLength(1)
const cells = wrapper.findAll('button.cell')
await cells[cells.length - 1]!.trigger('click') // today is the last cell
expect(wrapper.find('.summary').text()).toBe('1 of 1 done today')
await nextTick()
const saved = JSON.parse(localStorage.getItem('habit-tracker:v1')!)
expect(saved[0].name).toBe('Read')
expect(saved[0].done).toHaveLength(1)
})
it('rejects duplicate names', async () => {
const wrapper = mount(App)
await wrapper.find('#habit-name').setValue('Read')
await wrapper.find('form').trigger('submit')
await wrapper.find('#habit-name').setValue('read ')
expect(wrapper.find('[role="alert"]').text()).toContain('You already track')
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
})
})
The streak tests pass a fixed "today" so they don't depend on when you run them. The component tests drive the app the way a user would: type into the input, submit the form, click a cell, and check what's on screen and in storage. Our run:
✓ src/habit/__tests__/habits.spec.ts > streaks > counts consecutive days ending today 1ms
✓ src/habit/__tests__/habits.spec.ts > streaks > still counts yesterday when today is not done yet 0ms
✓ src/habit/__tests__/habits.spec.ts > streaks > breaks on a gap 0ms
✓ src/habit/__tests__/habits.spec.ts > streaks > lists the last 7 days oldest first 0ms
✓ src/habit/__tests__/habits.spec.ts > HabitApp > adds a habit, toggles today and persists 58ms
✓ src/habit/__tests__/habits.spec.ts > HabitApp > rejects duplicate names 5ms
Test Files 1 passed (1)
Tests 6 passed (6)
(We built the project inside a larger scratch app, which is why the paths and the
HabitApp name differ slightly from yours.) Run yours with npx vitest run, and
type-check with npx vue-tsc --build.
How It Actually Works¶
Follow a single click on today's cell through the system:
- The native
clickrunsdone = !doneinsideDayCell.doneis thedefineModelref; its setter emitsupdate:modelValuewithtrue. HabitRowlistens with@update:model-valueand emitstoggle(id, date).App.vue'stogglefinds the habit andpushes the date intohabit.done. That array lives insidehabits.value, so it is a reactive proxy:pushtriggers every effect that readhabit.done— which includes thedoneTodaycomputed,HabitRow's render andstreakcomputed (they readhabit.donethrough the prop), and the deep watcher.- Vue batches all of those. On the next microtask the scheduler runs the
prejobs (the deep watcher saves to storage), then re-renders the components whose render effects were triggered —App(the summary text) and that oneHabitRow. Other rows were not triggered, so they don't re-render at all. - The
HabitRowre-render produces a newmodelValueoftruefor thatDayCell, which re-renders with thedoneclass and a checkmark.
Notice that step 3 mutates a habit object that is also a prop of HabitRow. That's fine,
because the mutation happens in the owner (App), not in the child — the one-way data flow
rule is about who writes, not about object identity.
Common mistakes¶
- Using
toISOString().slice(0, 10)for "today" — it's UTC, not the user's day. - A shallow watcher for persistence — ticking a day would not save.
:key="index"on the rows — removing a habit would animate the wrong row out and shift everyone's colours for a frame.- Letting child components mutate
habit.donedirectly — it works, but you lose the single place where state changes, which is exactly what you'll want when this moves into a Pinia store in Level 2. - Testing with the real date — tests that pass today and fail at midnight.
Exercise¶
Extend the tracker:
- Rename in place. Double-clicking a habit name turns it into an input (use
v-ifanduseTemplateRefto focus it). Enter saves, Escape cancels. Reject duplicates using the same rule asHabitForm. - Longest streak. Add a
longestStreak(done)function tohabits.ts, with tests for an empty list, a single day, and two separate runs. - Weekly goal. Add an optional
goal(1–7 days per week) to each habit and show "3/5 this week" with aProgressBarfrom Lesson 09. - Export/import. Add buttons to download the habits as JSON and to import a JSON file, validating that the file has the right shape before replacing state.
- Storage versioning. The storage key ends in
:v1. Add agoalfield in a way that still loads v1 data correctly, and write a test for it.