Build a habit tracker with streaks in Replit in 30-60 minutes. Users log daily completions, see their current streak and longest streak, and visualize 90-day progress on a GitHub-style contribution heatmap. Streak calculations run via a PostgreSQL function that updates a cache table on every completion toggle. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.
What you're building
Habit trackers work because streaks create accountability. Seeing your 30-day streak is strong motivation not to miss a day. This project builds the core mechanic — daily completion with streak calculation — in under an hour using Replit Agent.
Replit Agent generates the Express backend from a single prompt: habits table, completions table, and a streak_cache table for pre-calculated stats. The streak calculation runs as a PostgreSQL function called on every completion toggle, updating the cache atomically. This means the dashboard always shows accurate streaks without recalculating on every page load.
The 90-day heatmap endpoint returns completion counts grouped by date — perfect for rendering a GitHub-style contribution graph in the frontend. No complex queries needed: a simple GROUP BY date with LEFT JOIN generates all the data. Deploy on Autoscale — habit trackers have brief daily check-in sessions, and scale-to-zero keeps costs at zero between uses.
Final result
A habit tracker with streak gamification, a 90-day heatmap, and today's completion dashboard — all running in your Replit account at zero monthly cost.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient)
- No external API keys required — everything runs on Replit's built-in PostgreSQL
- No coding experience needed — Replit Agent generates all the code
Build steps
Generate the project with Replit Agent
The streak_cache table is the key design insight. Without it, every dashboard load would recalculate streaks from the entire completion history. With it, streak stats are always one SELECT away.
1// Prompt to type into Replit Agent:2// Build a habit tracker app with Express and PostgreSQL using Drizzle ORM.3// Create these tables in shared/schema.ts:4// - habits: id serial pk, user_id text not null, name text not null,5// description text, color text not null default '#3B82F6',6// frequency text default 'daily' (daily/weekdays/weekends/weekly),7// target_count integer default 1,8// reminder_time text (format 'HH:MM', nullable),9// is_archived boolean default false, created_at timestamp10// - completions: id serial pk, habit_id integer references habits not null,11// completed_date date not null, count integer default 1,12// notes text, created_at timestamp,13// UNIQUE constraint on (habit_id, completed_date)14// - streak_cache: id serial pk, habit_id integer references habits not null unique,15// current_streak integer default 0, longest_streak integer default 0,16// last_completed date, total_completions integer default 0,17// updated_at timestamp18// Create a PostgreSQL function calculate_streak(p_habit_id integer)19// that computes current_streak from completions table and upserts into streak_cache.20// Set up Replit Auth. Bind server to 0.0.0.0.Pro tip: The streak calculation is the hardest part for Agent. If the generated function doesn't look correct, paste the function body into the next prompt and ask Agent to fix the streak logic specifically.
Expected result: Agent creates shared/schema.ts with three tables and a PostgreSQL function. Verify in Drizzle Studio (database icon in sidebar) that the streak_cache and completions tables exist.
Build the habit CRUD and completion toggle routes
The completion toggle is the most-used route. It checks if today's completion exists, inserts it if not (marking as complete), or deletes it if it does (un-marking). After toggling, it calls the streak calculation function.
1const { db } = require('../db');2const { habits, completions, streakCache } = require('../../shared/schema');3const { eq, and, sql } = require('drizzle-orm');45router.get('/api/habits', async (req, res) => {6 if (!req.user) return res.status(401).json({ error: 'Auth required' });78 const today = new Date().toISOString().split('T')[0];910 const result = await db.execute(11 sql`SELECT h.id, h.name, h.description, h.color, h.frequency, h.target_count, h.reminder_time,12 sc.current_streak, sc.longest_streak, sc.total_completions, sc.last_completed,13 CASE WHEN c.habit_id IS NOT NULL THEN true ELSE false END AS completed_today14 FROM habits h15 LEFT JOIN streak_cache sc ON sc.habit_id = h.id16 LEFT JOIN completions c ON c.habit_id = h.id AND c.completed_date = ${today}17 WHERE h.user_id = ${req.user.id} AND h.is_archived = false18 ORDER BY h.created_at ASC`19 );2021 res.json(result.rows);22});2324router.post('/api/habits/:id/complete', async (req, res) => {25 if (!req.user) return res.status(401).json({ error: 'Auth required' });2627 const habitId = Number(req.params.id);28 const dateStr = req.body.date || new Date().toISOString().split('T')[0];2930 // Verify ownership31 const habit = await db.query.habits.findFirst({32 where: and(eq(habits.id, habitId), eq(habits.userId, req.user.id))33 });34 if (!habit) return res.status(404).json({ error: 'Habit not found' });3536 const existing = await db.query.completions.findFirst({37 where: and(eq(completions.habitId, habitId), eq(completions.completedDate, dateStr))38 });3940 let completed;41 if (existing) {42 await db.delete(completions).where(eq(completions.id, existing.id));43 completed = false;44 } else {45 await db.insert(completions).values({46 habitId, completedDate: dateStr, notes: req.body.notes || null47 });48 completed = true;49 }5051 // Recalculate streak and update cache52 await db.execute(sql`SELECT calculate_streak(${habitId})`);5354 const [cache] = await db.select().from(streakCache).where(eq(streakCache.habitId, habitId));5556 res.json({ completed, currentStreak: cache?.currentStreak || 0, longestStreak: cache?.longestStreak || 0 });57});Pro tip: The completion toggle pattern (check → insert/delete) avoids needing a separate PUT endpoint. Two taps on the same habit in the same day first completes it, then un-completes it — intuitive for mobile use.
Expected result: Tapping a habit's complete button returns {completed: true, currentStreak: 1}. Tapping again returns {completed: false, currentStreak: 0}. The streak_cache table updates each time.
Build the streak calculation PostgreSQL function
The streak function is the core algorithm. It counts consecutive completed dates backward from today (or the most recent completion). The frequency setting determines whether weekends count for daily habits.
1// Prompt to type into Replit Agent:2// Add this PostgreSQL function to the database migration.3// Create or replace the calculate_streak function:4//5// CREATE OR REPLACE FUNCTION calculate_streak(p_habit_id INTEGER)6// RETURNS VOID AS $$7// DECLARE8// v_current_streak INTEGER := 0;9// v_longest_streak INTEGER := 0;10// v_temp_streak INTEGER := 0;11// v_last_date DATE := NULL;12// v_check_date DATE;13// v_total INTEGER;14// v_row RECORD;15// BEGIN16// -- Count total completions17// SELECT COUNT(*) INTO v_total FROM completions WHERE habit_id = p_habit_id;18//19// -- Calculate current streak (consecutive days ending today or yesterday)20// v_check_date := CURRENT_DATE;21// LOOP22// IF EXISTS (SELECT 1 FROM completions WHERE habit_id = p_habit_id AND completed_date = v_check_date) THEN23// v_current_streak := v_current_streak + 1;24// v_check_date := v_check_date - INTERVAL '1 day';25// ELSE26// EXIT;27// END IF;28// END LOOP;29//30// -- If today not completed, check if streak continues from yesterday31// IF v_current_streak = 0 THEN32// v_check_date := CURRENT_DATE - INTERVAL '1 day';33// LOOP34// IF EXISTS (SELECT 1 FROM completions WHERE habit_id = p_habit_id AND completed_date = v_check_date) THEN35// v_current_streak := v_current_streak + 1;36// v_check_date := v_check_date - INTERVAL '1 day';37// ELSE38// EXIT;39// END IF;40// END LOOP;41// END IF;42//43// -- Calculate longest streak from all completions44// v_temp_streak := 0;45// v_last_date := NULL;46// FOR v_row IN SELECT completed_date FROM completions WHERE habit_id = p_habit_id ORDER BY completed_date ASC47// LOOP48// IF v_last_date IS NULL OR v_row.completed_date = v_last_date + INTERVAL '1 day' THEN49// v_temp_streak := v_temp_streak + 1;50// v_longest_streak := GREATEST(v_longest_streak, v_temp_streak);51// ELSE52// v_temp_streak := 1;53// END IF;54// v_last_date := v_row.completed_date;55// END LOOP;56//57// -- Upsert streak_cache58// INSERT INTO streak_cache (habit_id, current_streak, longest_streak, total_completions, last_completed, updated_at)59// VALUES (p_habit_id, v_current_streak, v_longest_streak, v_total,60// (SELECT MAX(completed_date) FROM completions WHERE habit_id = p_habit_id), NOW())61// ON CONFLICT (habit_id) DO UPDATE SET62// current_streak = EXCLUDED.current_streak,63// longest_streak = EXCLUDED.longest_streak,64// total_completions = EXCLUDED.total_completions,65// last_completed = EXCLUDED.last_completed,66// updated_at = NOW();67// END;68// $$ LANGUAGE plpgsql;69//70// Run this function in the database migration script.Expected result: After running the migration, test the function in Drizzle Studio SQL editor: SELECT calculate_streak(1). Then add completions for several consecutive dates and verify current_streak increments correctly.
Build the heatmap data endpoint and React frontend
The heatmap endpoint returns 90 days of completion counts grouped by date. The frontend renders this as a grid of colored squares — darker color means more completions. This is the most satisfying visual feature.
1// Prompt to type into Replit Agent:2// Add these routes to server/routes/habits.js:3//4// GET /api/habits/:id/history — heatmap data for last 90 days5// Generate a series of 90 dates (today going back) and LEFT JOIN with completions6// Return array of {date: 'YYYY-MM-DD', count: number} for all 90 days7// Use PostgreSQL: SELECT d.date, COALESCE(c.count, 0) AS count8// FROM generate_series(CURRENT_DATE - 89, CURRENT_DATE, '1 day') AS d(date)9// LEFT JOIN completions c ON c.completed_date = d.date AND c.habit_id = :id10// ORDER BY d.date ASC11//12// GET /api/habits/:id/stats — detailed stats13// Return: current_streak, longest_streak, total_completions, last_completed,14// completion_rate_30d (completions in last 30 days / 30 * 100),15// completion_rate_90d (same for 90 days)16//17// GET /api/dashboard — summary for homepage18// Return: total_habits (non-archived), completed_today (habits with completion today),19// longest_active_streak (max current_streak across all user's habits)20//21// React frontend components:22// 1. HabitCard: shows habit name, colored circle complete button (filled when done today),23// streak fire icon with number, target_count progress (e.g. '1/1 today')24// Animate the complete button: scale-up + color fill on click25// 2. HeatmapGrid: 90 squares (10 rows x 9 cols or 13 weeks x 7 days)26// Color intensity based on count: 0=gray, 1=light, 2+=dark (using habit.color)27// Tooltip on hover showing date and count28// 3. Dashboard: greeting, summary stats cards, list of all HabitCardsPro tip: PostgreSQL's generate_series function is perfect for heatmaps — it generates a row for every date in the range even if no completions exist for that date, making the LEFT JOIN return 0 for empty days.
Expected result: GET /api/habits/1/history returns an array of 90 objects like [{date: '2026-01-01', count: 0}, {date: '2026-01-02', count: 1}]. The frontend renders these as a color-graded grid.
Deploy on Autoscale
Habit trackers need almost no infrastructure. Deploy on Autoscale for zero cost when idle. Add a simple notification reminder using browser push notifications as an enhancement — no server-side infrastructure needed.
1// Prompt to type into Replit Agent:2// Finalize the app for deployment:3// 1. Add SESSION_SECRET to Replit Secrets (lock icon in sidebar)4// Value: any 32-character random string5// 2. Ensure server/index.js binds to 0.0.0.0:6// app.listen(process.env.PORT || 3000, '0.0.0.0', ...)7// 3. Add a PostgreSQL retry wrapper to server/db.js:8// const pool = new Pool({ connectionString: process.env.DATABASE_URL,9// connectionTimeoutMillis: 5000, idleTimeoutMillis: 30000 })10// 4. Add a POST /api/habits route for creating habits:11// Body: {name, description, color, frequency, targetCount, reminderTime}12// INSERT into habits with user_id = req.user.id13// Also create an empty streak_cache row: INSERT INTO streak_cache (habit_id) VALUES (:id) ON CONFLICT DO NOTHING14// 5. Add PATCH /api/habits/:id/archive for soft-archiving a habit15// 6. Deploy → Autoscale (button in top-right Deploy menu)Pro tip: After deploying, share the URL with yourself on mobile. The completion button should work well on a phone screen. Consider adding a PWA manifest so users can add it to their home screen for a native-app feel.
Expected result: The app is live at your Replit deployment URL. Creating a habit, completing it for several days, and viewing the heatmap shows colored squares growing darker with each day's completion.
Complete code
1const { Router } = require('express');2const { db } = require('../db');3const { habits, completions, streakCache } = require('../../shared/schema');4const { eq, and, sql } = require('drizzle-orm');56const router = Router();78router.get('/api/habits', async (req, res) => {9 if (!req.user) return res.status(401).json({ error: 'Auth required' });10 const today = new Date().toISOString().split('T')[0];11 const result = await db.execute(12 sql`SELECT h.id, h.name, h.description, h.color, h.frequency, h.target_count,13 sc.current_streak, sc.longest_streak, sc.total_completions,14 CASE WHEN c.habit_id IS NOT NULL THEN true ELSE false END AS completed_today15 FROM habits h16 LEFT JOIN streak_cache sc ON sc.habit_id = h.id17 LEFT JOIN completions c ON c.habit_id = h.id AND c.completed_date = ${today}18 WHERE h.user_id = ${req.user.id} AND h.is_archived = false19 ORDER BY h.created_at ASC`20 );21 res.json(result.rows);22});2324router.post('/api/habits', async (req, res) => {25 if (!req.user) return res.status(401).json({ error: 'Auth required' });26 const { name, description, color, frequency, targetCount, reminderTime } = req.body;27 const [habit] = await db.insert(habits).values({28 userId: req.user.id, name, description, color: color || '#3B82F6',29 frequency: frequency || 'daily', targetCount: targetCount || 1,30 reminderTime: reminderTime || null31 }).returning();32 await db.insert(streakCache).values({ habitId: habit.id })33 .onConflictDoNothing();34 res.json(habit);35});3637router.post('/api/habits/:id/complete', async (req, res) => {38 if (!req.user) return res.status(401).json({ error: 'Auth required' });39 const habitId = Number(req.params.id);40 const dateStr = req.body.date || new Date().toISOString().split('T')[0];41 const habit = await db.query.habits.findFirst({42 where: and(eq(habits.id, habitId), eq(habits.userId, req.user.id))43 });44 if (!habit) return res.status(404).json({ error: 'Habit not found' });45 const existing = await db.query.completions.findFirst({46 where: and(eq(completions.habitId, habitId), eq(completions.completedDate, dateStr))47 });48 const completed = !existing;49 if (existing) {50 await db.delete(completions).where(eq(completions.id, existing.id));51 } else {52 await db.insert(completions).values({ habitId, completedDate: dateStr });53 }54 await db.execute(sql`SELECT calculate_streak(${habitId})`);55 const [cache] = await db.select().from(streakCache).where(eq(streakCache.habitId, habitId));56 res.json({ completed, currentStreak: cache?.currentStreak || 0, longestStreak: cache?.longestStreak || 0 });57});5859router.get('/api/habits/:id/history', async (req, res) => {60 if (!req.user) return res.status(401).json({ error: 'Auth required' });Customization ideas
Streak milestone celebrations
After each completion, check if the new current_streak is a milestone (7, 30, 100 days). If so, return a milestone flag in the completion response. The frontend triggers a confetti animation (using the canvas-confetti npm package) when a milestone is hit.
Habit stacking
Add a stack_after_habit_id column to habits. When completing a habit, the UI automatically focuses the next habit in the stack. This implements the 'habit stacking' productivity technique where new habits attach to existing ones.
Public habit profile
Add a is_public column to habits. A public heatmap page at /profile/:userId shows all public habits and their heatmaps, no login required. Share your progress page with friends for accountability.
Common pitfalls
Pitfall: Recalculating streaks by summing completions on every dashboard load
How to avoid: Use the streak_cache table as shown. Recalculate only when a completion is toggled. Dashboard loads read the cached values — sub-millisecond regardless of history size.
Pitfall: Not handling the case where today's completion doesn't exist but yesterday's does
How to avoid: The streak function checks from today backward first. If today has no completion, it then checks from yesterday backward. This way the streak stays alive until a full day is missed.
Pitfall: Using JavaScript Date() arithmetic for streak calculations instead of PostgreSQL
How to avoid: Run streak calculations in PostgreSQL using CURRENT_DATE (server's local date) and INTERVAL '1 day' arithmetic. All date comparisons happen in the database timezone consistently.
Best practices
- Use the streak_cache table to store pre-calculated streak data. Recalculate on every completion toggle, not on every read. This makes dashboard loads instant regardless of history size.
- Store reminder times as 'HH:MM' strings, not full timestamps. This makes it easy to check 'did 15:30 already pass today?' without timezone arithmetic.
- Use Replit Auth for user isolation — every habits and completions query must include WHERE user_id = req.user.id. One user should never see another's habits.
- Use Drizzle Studio (database icon in sidebar) to test the calculate_streak PostgreSQL function directly with SELECT calculate_streak(1) before connecting it to the API.
- Handle the unique constraint on completions (habit_id, completed_date) gracefully — if two rapid taps fire simultaneously, the second insert fails silently with ON CONFLICT DO NOTHING.
- Deploy on Autoscale — habit trackers have brief daily check-in sessions. The free tier is sufficient for personal use.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a habit tracker with PostgreSQL and Node.js. I have a completions table with columns habit_id and completed_date (date type). I need a PostgreSQL PL/pgSQL function calculate_streak(p_habit_id INTEGER) that: (1) calculates the current streak as consecutive completed dates from today backward (if today is not completed, start from yesterday), (2) calculates the longest streak ever as consecutive dates in the full history, (3) upserts these values into a streak_cache table (habit_id, current_streak, longest_streak, total_completions, last_completed). Help me write the complete function.
Add a weekly check-in email to the habit tracker. Create scripts/weeklyDigest.js that runs every Sunday via Scheduled Deployment. It queries each user's habits and streak_cache, calculates overall completion rate for the past week, and sends a personalized summary email via SendGrid: 'You completed X of Y habits this week. Longest streak: Z days.' Store SENDGRID_API_KEY in Replit Secrets.
Frequently asked questions
How do I mark a habit as done for a past date?
The POST /api/habits/:id/complete endpoint accepts a date parameter in the body (format: YYYY-MM-DD). Send {date: '2026-04-20'} to toggle the completion for April 20. The streak recalculates from the new completion history.
What does 'weekdays' frequency mean?
A habit with frequency='weekdays' is expected every Monday through Friday. The streak logic skips Saturdays and Sundays — not completing a weekday habit on the weekend doesn't break the streak. The current streak function should check only weekday dates when frequency='weekdays'.
Can multiple people share a habit tracker?
In the current design, each Replit Auth user has their own habits. For shared accountability, you could add a feature where a habit's heatmap page has a public share URL (/share/:habitId) showing the completion grid without login.
How do I add push notification reminders?
Browser push notifications use the Web Push API. The frontend requests notification permission, and the service worker receives push events. The server uses the web-push npm package to send notifications. Store the VAPID keys in Replit Secrets. This doesn't require any server-side scheduled infrastructure — it's purely browser-based.
Do I need a paid Replit plan?
No. The free plan is sufficient for a personal habit tracker. The built-in PostgreSQL, Autoscale deployment, and Replit Auth are all included at no cost. The only limitation is the database sleeping after 5 minutes of inactivity, adding a brief delay on the first daily login.
What happens to the streak if I miss a day?
The streak resets to zero. The calculate_streak function counts consecutive days backward from today or yesterday — a single missed day breaks the chain. The longest_streak in streak_cache preserves your all-time best so the missed day doesn't erase your achievement entirely.
Can RapidDev build a custom wellness or accountability app?
Yes. RapidDev has built 600+ apps and can add features like team accountability groups, progress photos, coach dashboards, and integration with fitness APIs. Book a free consultation at rapidevelopers.com.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation