├── public ├── robots.txt ├── icon.png ├── bg │ └── bg1.jpg ├── favicon.ico └── social-card.png ├── server ├── tsconfig.json ├── database │ ├── migrations │ │ ├── meta │ │ │ ├── _journal.json │ │ │ └── 0000_snapshot.json │ │ └── 0000_overrated_tempest.sql │ └── schema.ts ├── api │ ├── habits │ │ ├── index.get.ts │ │ ├── [id].delete.ts │ │ ├── index.post.ts │ │ └── [id].patch.ts │ ├── users │ │ ├── [login] │ │ │ ├── index.get.ts │ │ │ └── habits.get.ts │ │ ├── index.delete.ts │ │ └── index.patch.ts │ └── auth │ │ └── github.get.ts └── utils │ └── db.ts ├── types ├── index.d.ts └── auth.d.ts ├── .env.example ├── tsconfig.json ├── app ├── app.config.ts ├── components │ ├── ContentBox.vue │ ├── Container.vue │ ├── AppFooter.vue │ ├── Card.vue │ ├── PrivateAccount.vue │ ├── AppHeader.vue │ ├── ProfileHeader.vue │ ├── HabitHeatmap.vue │ ├── EmptyHabits.vue │ ├── Dropdown.vue │ ├── HabitForm.vue │ ├── ProfileForm.vue │ └── HabitCard.vue ├── composables │ ├── useHeatmap.ts │ ├── useConfetti.ts │ └── useHabits.ts ├── layouts │ └── default.vue ├── pages │ ├── index.vue │ └── [user].vue └── app.vue ├── drizzle.config.ts ├── .prettierrc ├── .gitignore ├── nuxt.config.ts ├── .github └── workflows │ └── release.yml ├── LICENSE ├── README.md └── package.json /public/robots.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackha/habit/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/bg/bg1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackha/habit/HEAD/public/bg/bg1.jpg -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackha/habit/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackha/habit/HEAD/public/social-card.png -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Day { 2 | date: string; 3 | } 4 | 5 | type Week = Day[]; 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NUXT_OAUTH_GITHUB_CLIENT_ID= 2 | NUXT_OAUTH_GITHUB_CLIENT_SECRET= 3 | NUXT_SESSION_PASSWORD= -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: 'green', 4 | gray: 'neutral', 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit'; 2 | 3 | export default defineConfig({ 4 | dialect: 'sqlite', 5 | schema: './server/database/schema.ts', 6 | out: './server/database/migrations', 7 | }); 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "singleQuote": true, 4 | "printWidth": 180, 5 | "htmlWhitespaceSensitivity": "ignore", 6 | "bracketSameLine": true, 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /server/database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1738839277466, 9 | "tag": "0000_overrated_tempest", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /server/api/habits/index.get.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | 3 | export default eventHandler(async event => { 4 | const { user } = await requireUserSession(event); 5 | 6 | const habits = await useDB().select().from(tables.habits).where(eq(tables.habits.userId, user.id)).all(); 7 | 8 | return habits as Habit[]; 9 | }); 10 | -------------------------------------------------------------------------------- /app/components/ContentBox.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /server/utils/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1'; 2 | import * as schema from '../database/schema'; 3 | 4 | export { sql, eq, and, or } from 'drizzle-orm'; 5 | 6 | export const tables = schema; 7 | 8 | export function useDB() { 9 | return drizzle(hubDatabase(), { schema }); 10 | } 11 | 12 | export type Habit = typeof tables.habits.$inferSelect; 13 | export type User = typeof tables.users.$inferSelect; 14 | -------------------------------------------------------------------------------- /server/api/users/[login]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | import { useValidatedParams, z } from 'h3-zod'; 3 | 4 | export default eventHandler(async event => { 5 | const { login } = await useValidatedParams(event, { 6 | login: z.string().toLowerCase(), 7 | }); 8 | 9 | const user = await useDB().select().from(tables.users).where(eq(tables.users.login, login)).limit(1).get(); 10 | 11 | return user; 12 | }); 13 | -------------------------------------------------------------------------------- /server/api/users/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | 3 | export default eventHandler(async event => { 4 | const { user } = await requireUserSession(event); 5 | 6 | await useDB().delete(tables.habits).where(eq(tables.habits.userId, user.id)); 7 | await useDB().delete(tables.users).where(eq(tables.users.id, user.id)); 8 | 9 | return { message: 'Account and all related habits have been successfully deleted.' }; 10 | }); 11 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import pkg from './package.json'; 2 | export default defineNuxtConfig({ 3 | compatibilityDate: '2024-11-01', 4 | devtools: { enabled: true }, 5 | future: { compatibilityVersion: 4 }, 6 | modules: ['@nuxthub/core', 'nuxt-auth-utils', '@pinia/nuxt', '@pinia/colada-nuxt', '@nuxt/ui'], 7 | hub: { 8 | database: true, 9 | }, 10 | colorMode: { 11 | preference: 'dark', 12 | }, 13 | runtimeConfig: { 14 | public: { 15 | version: pkg.version, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | 23 | - run: npx changelogithub 24 | env: 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /server/api/users/index.patch.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm'; 2 | import { useValidatedBody, z } from 'h3-zod'; 3 | 4 | export default eventHandler(async event => { 5 | const { userView } = await useValidatedBody(event, { 6 | userView: z.boolean().optional(), 7 | }); 8 | 9 | const { user } = await requireUserSession(event); 10 | 11 | const updatedFields = { userView }; 12 | 13 | const updatedUser = await useDB().update(tables.users).set(updatedFields).where(eq(tables.users.id, user.id)).returning().get(); 14 | 15 | return updatedUser; 16 | }); 17 | -------------------------------------------------------------------------------- /server/api/habits/[id].delete.ts: -------------------------------------------------------------------------------- 1 | import { eq, and } from 'drizzle-orm'; 2 | import { useValidatedParams, zh } from 'h3-zod'; 3 | 4 | export default eventHandler(async event => { 5 | const { id } = await useValidatedParams(event, { 6 | id: zh.intAsString, 7 | }); 8 | 9 | const { user } = await requireUserSession(event); 10 | 11 | const deletedHabit = await useDB() 12 | .delete(tables.habits) 13 | .where(and(eq(tables.habits.id, id), eq(tables.habits.userId, user.id))) 14 | .returning() 15 | .get(); 16 | 17 | return deletedHabit; 18 | }); 19 | -------------------------------------------------------------------------------- /app/components/Container.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /app/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /app/composables/useHeatmap.ts: -------------------------------------------------------------------------------- 1 | import { format, subDays, isThisYear } from 'date-fns'; 2 | 3 | export const generateWeeks = (daysCount = 49): Week[] => { 4 | const days: Day[] = Array.from({ length: daysCount }, (_, i) => { 5 | const date = subDays(new Date(), daysCount - 1 - i); 6 | return { date: format(date, 'yyyy-MM-dd') }; 7 | }); 8 | 9 | const weeks: Week[] = []; 10 | for (let i = 0; i < days.length; i += 7) { 11 | weeks.push(days.slice(i, i + 7)); 12 | } 13 | 14 | return weeks; 15 | }; 16 | 17 | export const formatDate = (date: string): string => { 18 | return isThisYear(new Date(date)) ? format(new Date(date), 'MMMM d') : format(new Date(date), 'MMMM d, yyyy'); 19 | }; 20 | -------------------------------------------------------------------------------- /app/composables/useConfetti.ts: -------------------------------------------------------------------------------- 1 | import confetti from 'canvas-confetti'; 2 | 3 | export const startConfettiAnimation = (duration: number = 1000, colors: string[] = ['#01a535', '#7affc7']) => { 4 | const end = Date.now() + duration; 5 | 6 | const frame = () => { 7 | confetti({ 8 | particleCount: 2, 9 | angle: 60, 10 | spread: 55, 11 | origin: { x: 0 }, 12 | colors: colors, 13 | }); 14 | confetti({ 15 | particleCount: 2, 16 | angle: 120, 17 | spread: 55, 18 | origin: { x: 1 }, 19 | colors: colors, 20 | }); 21 | 22 | if (Date.now() < end) { 23 | requestAnimationFrame(frame); 24 | } 25 | }; 26 | 27 | frame(); 28 | }; 29 | -------------------------------------------------------------------------------- /server/api/users/[login]/habits.get.ts: -------------------------------------------------------------------------------- 1 | import { eq, and } from 'drizzle-orm'; 2 | import { useValidatedParams, z } from 'h3-zod'; 3 | 4 | export default eventHandler(async event => { 5 | const { login } = await useValidatedParams(event, { 6 | login: z.string().toLowerCase(), 7 | }); 8 | 9 | const user = await useDB().select().from(tables.users).where(eq(tables.users.login, login)).limit(1).get(); 10 | 11 | if (!user || !user.userView) { 12 | return []; 13 | } 14 | 15 | const habits = await useDB() 16 | .select() 17 | .from(tables.habits) 18 | .where(and(eq(tables.habits.userId, user.id), eq(tables.habits.habitView, true))) 19 | .all(); 20 | 21 | return habits; 22 | }); 23 | -------------------------------------------------------------------------------- /server/database/migrations/0000_overrated_tempest.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `habits` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `user_id` integer NOT NULL, 4 | `title` text NOT NULL, 5 | `description` text, 6 | `complete_days` text DEFAULT '[]' NOT NULL, 7 | `created_at` integer NOT NULL, 8 | `habit_view` integer DEFAULT false NOT NULL 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE `users` ( 12 | `id` integer PRIMARY KEY NOT NULL, 13 | `login` text NOT NULL, 14 | `name` text, 15 | `bio` text, 16 | `avatar_url` text NOT NULL, 17 | `created_at` integer NOT NULL, 18 | `user_view` integer DEFAULT false NOT NULL 19 | ); 20 | --> statement-breakpoint 21 | CREATE UNIQUE INDEX `users_login_unique` ON `users` (`login`); -------------------------------------------------------------------------------- /server/api/habits/index.post.ts: -------------------------------------------------------------------------------- 1 | import { useValidatedBody, z } from 'h3-zod'; 2 | 3 | export default eventHandler(async event => { 4 | const { title, description, habitView } = await useValidatedBody(event, { 5 | title: z.string().min(1, 'Title is required').trim(), 6 | description: z.string().min(1, 'Description is required').trim(), 7 | habitView: z.boolean(), 8 | }); 9 | 10 | const { user } = await requireUserSession(event); 11 | 12 | const habit = await useDB() 13 | .insert(tables.habits) 14 | .values({ 15 | userId: user.id, 16 | title, 17 | description, 18 | createdAt: new Date(), 19 | habitView, 20 | }) 21 | .returning() 22 | .get(); 23 | 24 | return habit; 25 | }); 26 | -------------------------------------------------------------------------------- /server/api/auth/github.get.ts: -------------------------------------------------------------------------------- 1 | export default defineOAuthGitHubEventHandler({ 2 | async onSuccess(event, { user }) { 3 | await useDB() 4 | .insert(tables.users) 5 | .values({ 6 | id: user.id, 7 | login: user.login.toLowerCase(), 8 | name: user.name || user.login, 9 | bio: user.bio || '', 10 | avatarUrl: user.avatar_url, 11 | createdAt: new Date(), 12 | }) 13 | .onConflictDoUpdate({ 14 | target: tables.users.id, 15 | set: { 16 | name: user.name || user.login, 17 | bio: user.bio || '', 18 | avatarUrl: user.avatar_url, 19 | }, 20 | }) 21 | .returning() 22 | .get(); 23 | await setUserSession(event, { user }); 24 | return sendRedirect(event, `/${user.login}`); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /app/components/Card.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /app/components/PrivateAccount.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 39 | -------------------------------------------------------------------------------- /server/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; 2 | 3 | export const habits = sqliteTable('habits', { 4 | id: integer('id').primaryKey(), 5 | userId: integer('user_id').notNull(), 6 | title: text('title').notNull(), 7 | description: text('description'), 8 | completeDays: text('complete_days', { mode: 'json' }).$type().notNull().default([]), 9 | createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), 10 | habitView: integer('habit_view', { mode: 'boolean' }).notNull().default(false), 11 | }); 12 | 13 | export const users = sqliteTable('users', { 14 | id: integer('id').primaryKey(), 15 | login: text('login').notNull().unique(), 16 | name: text('name'), 17 | bio: text('bio'), 18 | avatarUrl: text('avatar_url').notNull(), 19 | createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), 20 | userView: integer('user_view', { mode: 'boolean' }).notNull().default(false), 21 | }); 22 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | -------------------------------------------------------------------------------- /types/auth.d.ts: -------------------------------------------------------------------------------- 1 | declare module '#auth-utils' { 2 | interface User { 3 | login: string; 4 | id: number; 5 | node_id: string; 6 | avatar_url: string; 7 | gravatar_id: string; 8 | url: string; 9 | html_url: string; 10 | followers_url: string; 11 | following_url: string; 12 | gists_url: string; 13 | starred_url: string; 14 | subscriptions_url: string; 15 | organizations_url: string; 16 | repos_url: string; 17 | events_url: string; 18 | received_events_url: string; 19 | type: string; 20 | user_view_type: string; 21 | site_admin: boolean; 22 | name: string; 23 | company: string | null; 24 | blog: string; 25 | location: string; 26 | email: string; 27 | hireable: boolean; 28 | bio: string; 29 | twitter_username: string; 30 | notification_email: string; 31 | public_repos: number; 32 | public_gists: number; 33 | followers: number; 34 | following: number; 35 | created_at: string; 36 | updated_at: string; 37 | } 38 | } 39 | export {}; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sefa Bulak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /app/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /server/api/habits/[id].patch.ts: -------------------------------------------------------------------------------- 1 | import { eq, and } from 'drizzle-orm'; 2 | import { useValidatedParams, useValidatedBody, z, zh } from 'h3-zod'; 3 | 4 | export default eventHandler(async event => { 5 | const { id } = await useValidatedParams(event, { 6 | id: zh.intAsString, 7 | }); 8 | 9 | const { title, description, completeDays, habitView } = await useValidatedBody(event, { 10 | title: z.string().optional(), 11 | description: z.string().optional(), 12 | completeDays: z.array(z.string()).optional(), 13 | habitView: z.boolean().optional(), 14 | }); 15 | 16 | const { user } = await requireUserSession(event); 17 | 18 | const updatedFields: Partial<{ title: string; description: string; completeDays: string[]; habitView: boolean }> = {}; 19 | if (title) updatedFields.title = title; 20 | if (description) updatedFields.description = description; 21 | if (completeDays) updatedFields.completeDays = completeDays; 22 | if (habitView !== undefined) updatedFields.habitView = habitView; 23 | 24 | const habit = await useDB() 25 | .update(tables.habits) 26 | .set(updatedFields) 27 | .where(and(eq(tables.habits.id, id), eq(tables.habits.userId, user.id))) 28 | .returning() 29 | .get(); 30 | 31 | return habit; 32 | }); 33 | -------------------------------------------------------------------------------- /app/components/ProfileHeader.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Habit Tracker 🚀 2 | 3 | A habit-tracking application built with **Nuxt 3**, **Drizzle ORM**, and **SQLite**. Designed to help you set and achieve your daily goals while providing a clean and intuitive user experience. 4 | 5 | ![habit](https://raw.githubusercontent.com/zackha/habit/refs/heads/master/public/social-card.png) 6 | 7 | ## We Love Our Stars ⭐⭐⭐⭐⭐ 8 | 9 | Thanks to the following people who have given us a star on our repo: 10 | [![Stargazers repo roster for @zackha/habit](https://reporoster.com/stars/dark/zackha/habit#gh-dark-mode-only)](https://github.com/zackha/habit/stargazers) 11 | 12 | --- 13 | 14 | ### Features 15 | 16 | - ✨ **Server-Side Rendering (SSR)** for optimized performance. 17 | - 🔒 **GitHub Authentication** using [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils). 18 | - 🗂️ **Drizzle ORM** integrated with **SQLite** for efficient database management. 19 | - 📅 **Calendar Heatmap** to visualize your progress. 20 | - 📊 **Progress Tracker** with dynamic completion rates. 21 | - 🌗 **Dark/Light Mode Toggle** with built-in support from [Nuxt UI](https://ui.nuxt.com). 22 | - 🖊️ **Markdown Support** for habit descriptions. 23 | - 🧩 Modular and scalable **component-based architecture**. 24 | - 🚀 Seamless deployment with [NuxtHub](https://hub.nuxt.com). 25 | - 🔄 **State Management** with [Pinia](https://pinia.vuejs.org). 26 | - 🛠️ Enhanced developer experience with [Nuxt DevTools](https://devtools.nuxt.com). 27 | 28 | --- 29 | 30 | ### Demo 31 | 32 | Check out the live demo: [https://habit.nuxt.dev](https://habit.nuxt.dev) 33 | 34 | ### License 35 | 36 | This project is licensed under the [MIT License](./LICENSE). 37 | -------------------------------------------------------------------------------- /app/components/HabitHeatmap.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | 22 | 42 | -------------------------------------------------------------------------------- /app/components/EmptyHabits.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "habit", 3 | "version": "6.1.22", 4 | "description": "A minimalistic habit tracker application to track and manage your daily habits with ease.", 5 | "author": { 6 | "name": "Sefa Bulak", 7 | "url": "https://github.com/zackha" 8 | }, 9 | "license": "MIT", 10 | "homepage": "https://github.com/zackha/habit", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/zackha/habit.git" 14 | }, 15 | "keywords": [ 16 | "habit tracker", 17 | "daily habits", 18 | "task manager", 19 | "vue 3", 20 | "nuxt", 21 | "drizzle orm", 22 | "sqlite", 23 | "confetti animation", 24 | "tailwindcss", 25 | "markdown support", 26 | "typescript", 27 | "habit tracking app", 28 | "nuxthub", 29 | "todo" 30 | ], 31 | "scripts": { 32 | "build": "nuxt build", 33 | "dev": "nuxt dev", 34 | "preview": "nuxt preview", 35 | "postinstall": "nuxt prepare", 36 | "db:generate": "drizzle-kit generate", 37 | "deploy": "bumpp" 38 | }, 39 | "dependencies": { 40 | "@nuxt/ui": "^2.22.3", 41 | "@nuxthub/core": "0.9.0", 42 | "@pinia/colada": "^0.17.6", 43 | "@pinia/colada-nuxt": "0.2.2", 44 | "@pinia/nuxt": "^0.11.2", 45 | "canvas-confetti": "^1.9.3", 46 | "date-fns": "^4.1.0", 47 | "drizzle-kit": "^0.31.5", 48 | "drizzle-orm": "^0.44.6", 49 | "h3-zod": "^0.5.3", 50 | "marked": "^16.4.1", 51 | "nuxt": "^4.1.3", 52 | "nuxt-auth-utils": "0.5.25", 53 | "pinia": "^3.0.3", 54 | "vue": "latest", 55 | "vue-router": "latest", 56 | "zod": "^3.25.76" 57 | }, 58 | "packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d", 59 | "devDependencies": { 60 | "@iconify-json/heroicons": "^1.2.3", 61 | "@iconify-json/simple-icons": "^1.2.55", 62 | "@types/canvas-confetti": "^1.9.0", 63 | "prettier": "^3.6.2", 64 | "prettier-plugin-tailwindcss": "^0.6.14", 65 | "wrangler": "^4.43.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/pages/[user].vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 54 | -------------------------------------------------------------------------------- /app/components/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 53 | 54 | 65 | -------------------------------------------------------------------------------- /app/composables/useHabits.ts: -------------------------------------------------------------------------------- 1 | import { isSameDay, parseISO, differenceInDays, format, compareAsc } from 'date-fns'; 2 | 3 | const habits = ref([ 4 | { 5 | id: 1, 6 | title: 'Morning Exercise', 7 | description: '**Daily** 30 minutes of exercise to stay fit.', 8 | complete_days: ['2025-01-12'], 9 | target_days: 40, 10 | }, 11 | { 12 | id: 2, 13 | title: 'Reading', 14 | description: 'Read *at least* 20 pages every day.', 15 | complete_days: ['2025-01-15', '2025-01-14', '2025-01-13', '2025-01-12'], 16 | target_days: 40, 17 | }, 18 | ]); 19 | 20 | const today = format(new Date(), 'yyyy-MM-dd'); 21 | 22 | const resetIfStreakBroken = (habit: Habit): void => { 23 | if (habit.complete_days.length === 0) return; 24 | 25 | const sortedDays = habit.complete_days.slice().sort((a, b) => compareAsc(parseISO(a), parseISO(b))); 26 | const hasGap = sortedDays.some((day, index) => index > 0 && differenceInDays(parseISO(day), parseISO(sortedDays[index - 1])) > 1); 27 | 28 | const lastCompletedDate = parseISO(sortedDays[sortedDays.length - 1]); 29 | const diffToToday = differenceInDays(parseISO(today), lastCompletedDate); 30 | 31 | if (hasGap || diffToToday > 1) habit.complete_days = []; 32 | }; 33 | 34 | const checkAllHabitsForStreak = (): void => habits.value.forEach(resetIfStreakBroken); 35 | 36 | const addHabit = (title: string, description: string): void => { 37 | if (!title.trim() || !description.trim()) return; 38 | 39 | habits.value.push({ 40 | id: Date.now(), 41 | title, 42 | description, 43 | complete_days: [], 44 | target_days: 40, 45 | }); 46 | }; 47 | 48 | const deleteHabit = (id: number): void => { 49 | habits.value = habits.value.filter(habit => habit.id !== id); 50 | }; 51 | 52 | const toggleTodayCompletion = (habit: Habit): void => { 53 | const isCompletedToday = habit.complete_days.some(day => isSameDay(parseISO(day), parseISO(today))); 54 | 55 | if (isCompletedToday) { 56 | habit.complete_days = habit.complete_days.filter(day => !isSameDay(parseISO(day), parseISO(today))); 57 | if (habit.target_days === 90 && habit.complete_days.length < 40) { 58 | habit.target_days = 40; 59 | } 60 | } else { 61 | habit.complete_days.push(today); 62 | if (habit.complete_days.length === habit.target_days && habit.target_days === 40) { 63 | habit.target_days = 90; 64 | } 65 | } 66 | }; 67 | 68 | const isTodayCompleted = (habit: Habit): boolean => habit.complete_days.some(day => isSameDay(parseISO(day), parseISO(today))); 69 | 70 | const getCompletionRate = (habit: Habit): number => Math.round((habit.complete_days.length / habit.target_days) * 100); 71 | 72 | checkAllHabitsForStreak(); 73 | 74 | export function useHabits() { 75 | return { 76 | habits, 77 | addHabit, 78 | deleteHabit, 79 | toggleTodayCompletion, 80 | isTodayCompleted, 81 | getCompletionRate, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /app/components/HabitForm.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 80 | 81 | 99 | -------------------------------------------------------------------------------- /server/database/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "ecbd96a5-780d-4c1e-9f19-a17c642c97dc", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "habits": { 8 | "name": "habits", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "user_id": { 18 | "name": "user_id", 19 | "type": "integer", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "title": { 25 | "name": "title", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "description": { 32 | "name": "description", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "complete_days": { 39 | "name": "complete_days", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false, 44 | "default": "'[]'" 45 | }, 46 | "created_at": { 47 | "name": "created_at", 48 | "type": "integer", 49 | "primaryKey": false, 50 | "notNull": true, 51 | "autoincrement": false 52 | }, 53 | "habit_view": { 54 | "name": "habit_view", 55 | "type": "integer", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": false 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {}, 65 | "uniqueConstraints": {}, 66 | "checkConstraints": {} 67 | }, 68 | "users": { 69 | "name": "users", 70 | "columns": { 71 | "id": { 72 | "name": "id", 73 | "type": "integer", 74 | "primaryKey": true, 75 | "notNull": true, 76 | "autoincrement": false 77 | }, 78 | "login": { 79 | "name": "login", 80 | "type": "text", 81 | "primaryKey": false, 82 | "notNull": true, 83 | "autoincrement": false 84 | }, 85 | "name": { 86 | "name": "name", 87 | "type": "text", 88 | "primaryKey": false, 89 | "notNull": false, 90 | "autoincrement": false 91 | }, 92 | "bio": { 93 | "name": "bio", 94 | "type": "text", 95 | "primaryKey": false, 96 | "notNull": false, 97 | "autoincrement": false 98 | }, 99 | "avatar_url": { 100 | "name": "avatar_url", 101 | "type": "text", 102 | "primaryKey": false, 103 | "notNull": true, 104 | "autoincrement": false 105 | }, 106 | "created_at": { 107 | "name": "created_at", 108 | "type": "integer", 109 | "primaryKey": false, 110 | "notNull": true, 111 | "autoincrement": false 112 | }, 113 | "user_view": { 114 | "name": "user_view", 115 | "type": "integer", 116 | "primaryKey": false, 117 | "notNull": true, 118 | "autoincrement": false, 119 | "default": false 120 | } 121 | }, 122 | "indexes": { 123 | "users_login_unique": { 124 | "name": "users_login_unique", 125 | "columns": [ 126 | "login" 127 | ], 128 | "isUnique": true 129 | } 130 | }, 131 | "foreignKeys": {}, 132 | "compositePrimaryKeys": {}, 133 | "uniqueConstraints": {}, 134 | "checkConstraints": {} 135 | } 136 | }, 137 | "views": {}, 138 | "enums": {}, 139 | "_meta": { 140 | "schemas": {}, 141 | "tables": {}, 142 | "columns": {} 143 | }, 144 | "internal": { 145 | "indexes": {} 146 | } 147 | } -------------------------------------------------------------------------------- /app/components/ProfileForm.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 112 | -------------------------------------------------------------------------------- /app/components/HabitCard.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 232 | --------------------------------------------------------------------------------