Compare commits
	
		
			172 Commits
		
	
	
		
			dev
			...
			9d7889e7e8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9d7889e7e8 | |||
| 51401e1e80 | |||
| 8ec8716300 | |||
| ed16749fc9 | |||
| 29a995deb9 | |||
| 4db001a1fb | |||
| 020645a1f8 | |||
| 5d61249bfc | |||
| 41777d6215 | |||
| f6c6caa67d | |||
| 853e882198 | |||
| 74ef49821e | |||
| 36f0fbd87d | |||
| 1ae54a78eb | |||
| 088afc042e | |||
| d2e7715a33 | |||
| 44df200838 | |||
| 3919f4dc6c | |||
| 0ce02d2f00 | |||
| 0c7816c8fd | |||
| a9173b6589 | |||
| 0d9a063ad4 | |||
| 68fe96f838 | |||
| 9ef3e83307 | |||
| c9e14aeecd | |||
| 608f6ae1af | |||
| 3ec58d99a7 | |||
| 200d4bde02 | |||
| 24260b0f72 | |||
| 30ed468ef3 | |||
| 2982995ce9 | |||
| 11071f3150 | |||
| 41f1ee4d0c | |||
| a0bfab2404 | |||
| 593d845969 | |||
| b76730c927 | |||
| 0f8cc9dfe6 | |||
| 2e06f6cdc1 | |||
| b5dd538a36 | |||
| 5e25753311 | |||
| ad1bf1425b | |||
| 1fa7ad48e8 | |||
| 18213aa2ec | |||
| 87e9f7df79 | |||
| 3867fa5b3c | |||
| 2411f4d862 | |||
| 2dea8867f5 | |||
| 6139cb4b1d | |||
| f687242557 | |||
| 9259c74101 | |||
| 66768abc3b | |||
| abe7a4af7e | |||
| 1c78d18327 | |||
| e9dbbe51bd | |||
| 58afc26526 | |||
| 83dc1de53c | |||
| d176451787 | |||
| ba560199cc | |||
| a1bfc8cf21 | |||
| e77f7686cf | |||
| eaf30ad45d | |||
| ed6790ae0f | |||
| 6944a240b2 | |||
| 9787a58d58 | |||
| 5bc2b7fa28 | |||
| b792b75ea1 | |||
| 87fc331e9b | |||
| ca7164e52b | |||
| e277d53b52 | |||
| 87d4d5dde4 | |||
| 4f78e6cfcd | |||
| a8c78d6a42 | |||
| 114cba348a | |||
| 501a784da9 | |||
| 6552451f39 | |||
| 8deb7d13e9 | |||
| 2de8e45e92 | |||
| 18d6cf77e5 | |||
| 30b28a7cb6 | |||
| e6b4bae90d | |||
| 295c884804 | |||
| 270b21dbf0 | |||
| 91cd975e51 | |||
| bedf8f7eab | |||
| b24096ff80 | |||
| 7e9754bca4 | |||
| d1495d29e9 | |||
| c69b164c23 | |||
| 26443ef169 | |||
| dcfd830afe | |||
| 6145d4549a | |||
| 9c99914c36 | |||
| 988535d452 | |||
| 2ece96f3ca | |||
| a0bdda25bc | |||
| 778626c3a2 | |||
| af3e2ceb6b | |||
| 1cfcd31fff | |||
| 9276e0f037 | |||
| c4bf5f1a35 | |||
| d480175dd0 | |||
| e62cfaa0d4 | |||
| 095f2c65be | |||
| 60ce3892d6 | |||
| 9dff61cbbc | |||
| 3c7eeb108b | |||
| af7690e66d | |||
| 64ef558818 | |||
| 6bd6192dd0 | |||
| 43de660f1b | |||
| 4bb1943244 | |||
| 0888cca45b | |||
| 5314c98508 | |||
| 484047a7bd | |||
| 80d95065e8 | |||
| 5386dad5c9 | |||
| 4e63d0ac42 | |||
| 485e75dc93 | |||
| 49c8389d9d | |||
| 92a27f544b | |||
| b02f5e6364 | |||
| f5fff9c52b | |||
| 5d9eb9217e | |||
| d69d9fc129 | |||
| 253111f741 | |||
| 835b73552b | |||
| b1e8fb3353 | |||
| 20c0bf2770 | |||
| 07475f7cb1 | |||
| 1f77670b1e | |||
| 7108ce3e8f | |||
| f28341781e | |||
| 1d3bf08a2c | |||
| e155256b65 | |||
| 71cffc17d6 | |||
| 922009dffe | |||
| 6665a8b735 | |||
| 9fe6e0d243 | |||
| e888cef0c1 | |||
| 192f2503e4 | |||
| a534a04082 | |||
| f7cac220a8 | |||
| 087859edb8 | |||
| 84a4cd6293 | |||
| 01f2a30ca0 | |||
| c05a4b40b6 | |||
| 65f3833ad4 | |||
| 8d7780e46d | |||
| b0b69a4352 | |||
| 0346f0c792 | |||
| b6b40a0b50 | |||
| 17071995dd | |||
| 4c9e984ab1 | |||
| ec9b659b7b | |||
| 48e03d3775 | |||
| 51bf896d37 | |||
| 789e389bcd | |||
| 7ac4a5261b | |||
| 8540764a99 | |||
| 8ab4c37676 | |||
| 9d6214ca8a | |||
| a4151c5736 | |||
| 762dd0a445 | |||
| 82a6a1ba96 | |||
| 776c9e5547 | |||
| 90e809c272 | |||
| 6c02bd8dc6 | |||
| 745bb0d7f8 | |||
| e927ab6a15 | |||
| df0c893137 | |||
| 2697e763c2 | |||
| ef969d2b6c | 
@@ -1,3 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "next/core-web-vitals"
 | 
			
		||||
}
 | 
			
		||||
@@ -7,21 +7,25 @@ on:
 | 
			
		||||
      - "**" # matches every branch
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  eslint:
 | 
			
		||||
    name: Lint
 | 
			
		||||
  lint_and_check:
 | 
			
		||||
    name: Lint and Check
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
 | 
			
		||||
      - name: Checkout code
 | 
			
		||||
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
 | 
			
		||||
 | 
			
		||||
      - name: Setup Node.js
 | 
			
		||||
        uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
          cache: "npm"
 | 
			
		||||
      - run: npm i
 | 
			
		||||
      - uses: sibiraj-s/action-eslint@bcf41bb9abce43cdbad51ab9b3da2eddaa17eab3 # v3.0.1
 | 
			
		||||
        with:
 | 
			
		||||
          eslint-args: "--ignore-path=.gitignore --quiet"
 | 
			
		||||
          extensions: "js,jsx,ts,tsx"
 | 
			
		||||
          annotations: true
 | 
			
		||||
          all-files: true
 | 
			
		||||
          node-version: 22
 | 
			
		||||
          cache: "pnpm"
 | 
			
		||||
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: pnpm install
 | 
			
		||||
 | 
			
		||||
      - name: Run check
 | 
			
		||||
        run: pnpm run check
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "singleQuote": true,
 | 
			
		||||
  "printWidth": 105,
 | 
			
		||||
  "plugins": ["prettier-plugin-tailwindcss"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										137
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										137
									
								
								README.md
									
									
									
									
									
								
							@@ -1,4 +1,137 @@
 | 
			
		||||
# trackevery-day
 | 
			
		||||
# 📅 Track Every Day
 | 
			
		||||
 | 
			
		||||
https://trackevery.day/
 | 
			
		||||
Track anything, every day.
 | 
			
		||||
 | 
			
		||||
A simple, privacy-focused habit tracking web app. Track anything, every day.
 | 
			
		||||
 | 
			
		||||
## ✨ Features
 | 
			
		||||
 | 
			
		||||
- **Token-based authentication** - No email or password required
 | 
			
		||||
- **Privacy-first** - Your data is tied to a unique token
 | 
			
		||||
- **Simple interface** - Click to log, see stats instantly
 | 
			
		||||
- **Habit types** - Track positive, neutral, or negative habits
 | 
			
		||||
- **Real-time statistics** - See averages, streaks, and time since last log
 | 
			
		||||
- **Cross-device sync** - Use your token to access data anywhere
 | 
			
		||||
 | 
			
		||||
## 🚀 Getting Started
 | 
			
		||||
 | 
			
		||||
### Prerequisites
 | 
			
		||||
 | 
			
		||||
- Node.js 18+
 | 
			
		||||
- PostgreSQL database (local or hosted)
 | 
			
		||||
- pnpm (or npm/yarn)
 | 
			
		||||
 | 
			
		||||
### Setup
 | 
			
		||||
 | 
			
		||||
1. Clone the repository:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
git clone https://git.schulze.network/schulze/trackevery-day.git
 | 
			
		||||
cd trackevery-day
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
2. Install dependencies:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
pnpm install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
3. Set up environment variables:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Create a .env.local file with:
 | 
			
		||||
POSTGRES_URL="your-postgres-connection-string"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
4. Set up the database:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Generate migrations
 | 
			
		||||
pnpm db:generate
 | 
			
		||||
 | 
			
		||||
# Push schema to database
 | 
			
		||||
pnpm db:push
 | 
			
		||||
 | 
			
		||||
# Or run migrations
 | 
			
		||||
pnpm db:migrate
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
5. Run the development server:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
pnpm dev
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Open [http://localhost:3000](http://localhost:3000) to start tracking!
 | 
			
		||||
 | 
			
		||||
## 🏗️ Tech Stack
 | 
			
		||||
 | 
			
		||||
- **Next.js 15** - React framework with App Router
 | 
			
		||||
- **Drizzle ORM** - Type-safe database queries
 | 
			
		||||
- **PostgreSQL** - Database (works with Vercel Postgres, Neon, Supabase, etc.)
 | 
			
		||||
- **React Query** - Data fetching and caching
 | 
			
		||||
- **Tailwind CSS** - Styling
 | 
			
		||||
- **TypeScript** - Type safety
 | 
			
		||||
 | 
			
		||||
## 📱 How It Works
 | 
			
		||||
 | 
			
		||||
1. **First Visit**: A unique token is generated (e.g., `happy-blue-cat-1234`)
 | 
			
		||||
2. **Save Your Token**: This is your key to access your data
 | 
			
		||||
3. **Track Habits**: Click habit cards to log executions
 | 
			
		||||
4. **View Stats**: See real-time statistics and progress
 | 
			
		||||
5. **Access Anywhere**: Use your token to login from any device
 | 
			
		||||
 | 
			
		||||
## 🔒 Privacy
 | 
			
		||||
 | 
			
		||||
- No personal information required
 | 
			
		||||
- No email or password needed
 | 
			
		||||
- Your token is your only identifier
 | 
			
		||||
- Data is only accessible with your token
 | 
			
		||||
 | 
			
		||||
## 📝 Database Schema
 | 
			
		||||
 | 
			
		||||
```sql
 | 
			
		||||
-- Users table
 | 
			
		||||
users (
 | 
			
		||||
  id SERIAL PRIMARY KEY,
 | 
			
		||||
  token TEXT UNIQUE NOT NULL,
 | 
			
		||||
  created_at TIMESTAMP DEFAULT NOW()
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
-- Habits table
 | 
			
		||||
habits (
 | 
			
		||||
  id SERIAL PRIMARY KEY,
 | 
			
		||||
  user_id INTEGER REFERENCES users(id),
 | 
			
		||||
  name TEXT NOT NULL,
 | 
			
		||||
  type TEXT CHECK (type IN ('positive', 'neutral', 'negative')),
 | 
			
		||||
  is_archived BOOLEAN DEFAULT FALSE,
 | 
			
		||||
  created_at TIMESTAMP DEFAULT NOW()
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
-- Habit logs table
 | 
			
		||||
habit_logs (
 | 
			
		||||
  id SERIAL PRIMARY KEY,
 | 
			
		||||
  habit_id INTEGER REFERENCES habits(id),
 | 
			
		||||
  logged_at TIMESTAMP DEFAULT NOW(),
 | 
			
		||||
  note TEXT
 | 
			
		||||
)
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 🛠️ Development
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Run development server
 | 
			
		||||
pnpm dev
 | 
			
		||||
 | 
			
		||||
# Type checking
 | 
			
		||||
pnpm check
 | 
			
		||||
 | 
			
		||||
# Database management
 | 
			
		||||
pnpm db:studio    # Open Drizzle Studio
 | 
			
		||||
pnpm db:generate  # Generate migrations
 | 
			
		||||
pnpm db:push      # Push schema changes
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 📄 License
 | 
			
		||||
 | 
			
		||||
GPL-3.0 License - see LICENSE file for details
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										115
									
								
								app/api/auth/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								app/api/auth/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
			
		||||
import { NextRequest, NextResponse } from 'next/server';
 | 
			
		||||
import { db, users } from '@/lib/db';
 | 
			
		||||
import { generateMemorableToken, isValidToken } from '@/lib/auth/tokens';
 | 
			
		||||
import { setTokenCookie, getTokenCookie } from '@/lib/auth/cookies';
 | 
			
		||||
import { eq } from 'drizzle-orm';
 | 
			
		||||
 | 
			
		||||
export async function GET() {
 | 
			
		||||
  try {
 | 
			
		||||
    // Check if user already has a token
 | 
			
		||||
    const existingToken = await getTokenCookie();
 | 
			
		||||
 | 
			
		||||
    if (existingToken) {
 | 
			
		||||
      // Verify token exists in database
 | 
			
		||||
      const userRows = await db.select().from(users).where(eq(users.token, existingToken));
 | 
			
		||||
 | 
			
		||||
      if (userRows.length > 0) {
 | 
			
		||||
        const user = userRows[0];
 | 
			
		||||
        return NextResponse.json({
 | 
			
		||||
          authenticated: true,
 | 
			
		||||
          token: existingToken,
 | 
			
		||||
          userId: user.id,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return NextResponse.json({ authenticated: false });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Auth check error:', error);
 | 
			
		||||
    return NextResponse.json({ authenticated: false }, { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function POST(request: NextRequest) {
 | 
			
		||||
  try {
 | 
			
		||||
    const body = (await request.json()) as { action: string; token?: string };
 | 
			
		||||
    const { action, token } = body;
 | 
			
		||||
 | 
			
		||||
    if (action === 'create') {
 | 
			
		||||
      // Generate new token and create user
 | 
			
		||||
      const newToken = generateMemorableToken();
 | 
			
		||||
 | 
			
		||||
      const newUserRows = await db
 | 
			
		||||
        .insert(users)
 | 
			
		||||
        .values({
 | 
			
		||||
          token: newToken,
 | 
			
		||||
        })
 | 
			
		||||
        .returning();
 | 
			
		||||
 | 
			
		||||
      if (newUserRows.length === 0) {
 | 
			
		||||
        throw new Error('Failed to create user');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const newUser = newUserRows[0];
 | 
			
		||||
      await setTokenCookie(newToken);
 | 
			
		||||
 | 
			
		||||
      return NextResponse.json({
 | 
			
		||||
        success: true,
 | 
			
		||||
        token: newToken,
 | 
			
		||||
        userId: newUser.id,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (action === 'login' && token) {
 | 
			
		||||
      // Validate token format
 | 
			
		||||
      if (!isValidToken(token)) {
 | 
			
		||||
        return NextResponse.json(
 | 
			
		||||
          {
 | 
			
		||||
            success: false,
 | 
			
		||||
            error: 'Invalid token format',
 | 
			
		||||
          },
 | 
			
		||||
          { status: 400 },
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check if token exists
 | 
			
		||||
      const userRows = await db.select().from(users).where(eq(users.token, token));
 | 
			
		||||
 | 
			
		||||
      if (userRows.length === 0) {
 | 
			
		||||
        return NextResponse.json(
 | 
			
		||||
          {
 | 
			
		||||
            success: false,
 | 
			
		||||
            error: 'Token not found',
 | 
			
		||||
          },
 | 
			
		||||
          { status: 404 },
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const user = userRows[0];
 | 
			
		||||
      await setTokenCookie(token);
 | 
			
		||||
 | 
			
		||||
      return NextResponse.json({
 | 
			
		||||
        success: true,
 | 
			
		||||
        token,
 | 
			
		||||
        userId: user.id,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return NextResponse.json(
 | 
			
		||||
      {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: 'Invalid action',
 | 
			
		||||
      },
 | 
			
		||||
      { status: 400 },
 | 
			
		||||
    );
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Auth error:', error);
 | 
			
		||||
    return NextResponse.json(
 | 
			
		||||
      {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: 'Internal server error',
 | 
			
		||||
      },
 | 
			
		||||
      { status: 500 },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										60
									
								
								app/api/habits/[id]/log/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/api/habits/[id]/log/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
import { NextRequest, NextResponse } from 'next/server';
 | 
			
		||||
import { db, habits, users, habitLogs } from '@/lib/db';
 | 
			
		||||
import { getTokenCookie } from '@/lib/auth/cookies';
 | 
			
		||||
import { eq, and } from 'drizzle-orm';
 | 
			
		||||
 | 
			
		||||
async function getUserFromToken() {
 | 
			
		||||
  const token = await getTokenCookie();
 | 
			
		||||
  if (!token) return null;
 | 
			
		||||
 | 
			
		||||
  const userRows = await db.select().from(users).where(eq(users.token, token));
 | 
			
		||||
  return userRows.length > 0 ? userRows[0] : null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { id } = await params;
 | 
			
		||||
    const habitId = parseInt(id);
 | 
			
		||||
 | 
			
		||||
    if (isNaN(habitId)) {
 | 
			
		||||
      return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const user = await getUserFromToken();
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Verify habit belongs to user
 | 
			
		||||
    const habitRows = await db
 | 
			
		||||
      .select()
 | 
			
		||||
      .from(habits)
 | 
			
		||||
      .where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
 | 
			
		||||
 | 
			
		||||
    if (habitRows.length === 0) {
 | 
			
		||||
      return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const body = (await request.json()) as { note?: string };
 | 
			
		||||
    const { note } = body;
 | 
			
		||||
 | 
			
		||||
    // Create log entry
 | 
			
		||||
    const logRows = await db
 | 
			
		||||
      .insert(habitLogs)
 | 
			
		||||
      .values({
 | 
			
		||||
        habitId,
 | 
			
		||||
        note,
 | 
			
		||||
      })
 | 
			
		||||
      .returning();
 | 
			
		||||
 | 
			
		||||
    if (logRows.length === 0) {
 | 
			
		||||
      throw new Error('Failed to create log entry');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const log = logRows[0];
 | 
			
		||||
    return NextResponse.json({ log });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Log habit error:', error);
 | 
			
		||||
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										136
									
								
								app/api/habits/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								app/api/habits/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,136 @@
 | 
			
		||||
import { NextRequest, NextResponse } from 'next/server';
 | 
			
		||||
import { db, habits, users, habitLogs } from '@/lib/db';
 | 
			
		||||
import { getTokenCookie } from '@/lib/auth/cookies';
 | 
			
		||||
import { eq, and, desc } from 'drizzle-orm';
 | 
			
		||||
 | 
			
		||||
async function getUserFromToken() {
 | 
			
		||||
  const token = await getTokenCookie();
 | 
			
		||||
  if (!token) return null;
 | 
			
		||||
 | 
			
		||||
  const userRows = await db.select().from(users).where(eq(users.token, token));
 | 
			
		||||
  return userRows.length > 0 ? userRows[0] : null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function GET() {
 | 
			
		||||
  try {
 | 
			
		||||
    const user = await getUserFromToken();
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get current timestamp for date calculations
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
    const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
 | 
			
		||||
    const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
 | 
			
		||||
 | 
			
		||||
    // First get all habits
 | 
			
		||||
    const userHabitsBase = await db
 | 
			
		||||
      .select()
 | 
			
		||||
      .from(habits)
 | 
			
		||||
      .where(and(eq(habits.userId, user.id), eq(habits.isArchived, false)))
 | 
			
		||||
      .orderBy(desc(habits.createdAt));
 | 
			
		||||
 | 
			
		||||
    // Then get aggregated log data for each habit
 | 
			
		||||
    const habitsWithStats = await Promise.all(
 | 
			
		||||
      userHabitsBase.map(async (habit) => {
 | 
			
		||||
        // Get all logs for this habit
 | 
			
		||||
        const logs = await db
 | 
			
		||||
          .select({
 | 
			
		||||
            loggedAt: habitLogs.loggedAt,
 | 
			
		||||
          })
 | 
			
		||||
          .from(habitLogs)
 | 
			
		||||
          .where(eq(habitLogs.habitId, habit.id));
 | 
			
		||||
 | 
			
		||||
        // Calculate statistics
 | 
			
		||||
        const totalLogs = logs.length;
 | 
			
		||||
        const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
 | 
			
		||||
        const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
 | 
			
		||||
        const lastLoggedAt =
 | 
			
		||||
          logs.length > 0
 | 
			
		||||
            ? logs.reduce(
 | 
			
		||||
                (latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
 | 
			
		||||
                logs[0].loggedAt,
 | 
			
		||||
              )
 | 
			
		||||
            : null;
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          id: habit.id,
 | 
			
		||||
          name: habit.name,
 | 
			
		||||
          type: habit.type,
 | 
			
		||||
          targetFrequency: habit.targetFrequency,
 | 
			
		||||
          color: habit.color,
 | 
			
		||||
          icon: habit.icon,
 | 
			
		||||
          createdAt: habit.createdAt,
 | 
			
		||||
          lastLoggedAt,
 | 
			
		||||
          totalLogs,
 | 
			
		||||
          logsLastWeek,
 | 
			
		||||
          logsLastMonth,
 | 
			
		||||
        };
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return NextResponse.json({ habits: habitsWithStats });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Get habits error:', error);
 | 
			
		||||
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function POST(request: NextRequest) {
 | 
			
		||||
  try {
 | 
			
		||||
    const user = await getUserFromToken();
 | 
			
		||||
    if (!user) {
 | 
			
		||||
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const body = (await request.json()) as {
 | 
			
		||||
      name: string;
 | 
			
		||||
      type: string;
 | 
			
		||||
      targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
 | 
			
		||||
      color?: string;
 | 
			
		||||
      icon?: string;
 | 
			
		||||
    };
 | 
			
		||||
    const { name, type, targetFrequency, color, icon } = body;
 | 
			
		||||
 | 
			
		||||
    if (!name || !type) {
 | 
			
		||||
      return NextResponse.json(
 | 
			
		||||
        {
 | 
			
		||||
          error: 'Name and type are required',
 | 
			
		||||
        },
 | 
			
		||||
        { status: 400 },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Validate type is one of the allowed enum values
 | 
			
		||||
    if (!['positive', 'neutral', 'negative'].includes(type)) {
 | 
			
		||||
      return NextResponse.json(
 | 
			
		||||
        {
 | 
			
		||||
          error: 'Type must be one of: positive, neutral, negative',
 | 
			
		||||
        },
 | 
			
		||||
        { status: 400 },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const newHabitRows = await db
 | 
			
		||||
      .insert(habits)
 | 
			
		||||
      .values({
 | 
			
		||||
        userId: user.id,
 | 
			
		||||
        name,
 | 
			
		||||
        type: type as 'positive' | 'neutral' | 'negative',
 | 
			
		||||
        targetFrequency,
 | 
			
		||||
        color,
 | 
			
		||||
        icon,
 | 
			
		||||
      })
 | 
			
		||||
      .returning();
 | 
			
		||||
 | 
			
		||||
    if (newHabitRows.length === 0) {
 | 
			
		||||
      throw new Error('Failed to create habit');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const newHabit = newHabitRows[0];
 | 
			
		||||
    return NextResponse.json({ habit: newHabit });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Create habit error:', error);
 | 
			
		||||
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										441
									
								
								app/dashboard/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										441
									
								
								app/dashboard/page.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,441 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import { useEffect, useState } from 'react';
 | 
			
		||||
import { useRouter } from 'next/navigation';
 | 
			
		||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 | 
			
		||||
import { formatDistanceToNow } from 'date-fns';
 | 
			
		||||
import {
 | 
			
		||||
  Plus,
 | 
			
		||||
  TrendingUp,
 | 
			
		||||
  TrendingDown,
 | 
			
		||||
  Activity,
 | 
			
		||||
  Clock,
 | 
			
		||||
  Calendar,
 | 
			
		||||
  Target,
 | 
			
		||||
  Copy,
 | 
			
		||||
  Check,
 | 
			
		||||
  Trophy,
 | 
			
		||||
  HeartCrack,
 | 
			
		||||
} from 'lucide-react';
 | 
			
		||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
 | 
			
		||||
import { Button } from '@/components/ui/button';
 | 
			
		||||
import { Input } from '@/components/ui/input';
 | 
			
		||||
import { Label } from '@/components/ui/label';
 | 
			
		||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogDescription,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogTrigger,
 | 
			
		||||
} from '@/components/ui/dialog';
 | 
			
		||||
import { Badge } from '@/components/ui/badge';
 | 
			
		||||
import { Alert, AlertDescription } from '@/components/ui/alert';
 | 
			
		||||
import { Separator } from '@/components/ui/separator';
 | 
			
		||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
 | 
			
		||||
 | 
			
		||||
interface AuthData {
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
  token?: string;
 | 
			
		||||
  userId?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface Habit {
 | 
			
		||||
  id: number;
 | 
			
		||||
  name: string;
 | 
			
		||||
  type: 'positive' | 'neutral' | 'negative';
 | 
			
		||||
  lastLoggedAt: string | null;
 | 
			
		||||
  totalLogs: number;
 | 
			
		||||
  logsLastWeek: number;
 | 
			
		||||
  logsLastMonth: number;
 | 
			
		||||
  createdAt: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface HabitsResponse {
 | 
			
		||||
  habits: Habit[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface LogResponse {
 | 
			
		||||
  log: {
 | 
			
		||||
    id: number;
 | 
			
		||||
    habitId: number;
 | 
			
		||||
    loggedAt: string;
 | 
			
		||||
    note?: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface HabitResponse {
 | 
			
		||||
  habit: Habit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Dashboard() {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
  const [userToken, setUserToken] = useState<string | null>(null);
 | 
			
		||||
  const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
 | 
			
		||||
  const [newHabitName, setNewHabitName] = useState('');
 | 
			
		||||
  const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
 | 
			
		||||
  const [copiedToken, setCopiedToken] = useState(false);
 | 
			
		||||
 | 
			
		||||
  // Check authentication
 | 
			
		||||
  const { data: authData, isLoading: authLoading } = useQuery<AuthData>({
 | 
			
		||||
    queryKey: ['auth'],
 | 
			
		||||
    queryFn: async (): Promise<AuthData> => {
 | 
			
		||||
      const res = await fetch('/api/auth');
 | 
			
		||||
      const data = (await res.json()) as AuthData;
 | 
			
		||||
      if (!data.authenticated) {
 | 
			
		||||
        router.push('/');
 | 
			
		||||
      }
 | 
			
		||||
      return data;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (authData?.token) {
 | 
			
		||||
      setUserToken(authData.token);
 | 
			
		||||
    }
 | 
			
		||||
  }, [authData]);
 | 
			
		||||
 | 
			
		||||
  // Fetch habits
 | 
			
		||||
  const { data: habitsData, isLoading: habitsLoading } = useQuery<HabitsResponse>({
 | 
			
		||||
    queryKey: ['habits'],
 | 
			
		||||
    queryFn: async (): Promise<HabitsResponse> => {
 | 
			
		||||
      const res = await fetch('/api/habits');
 | 
			
		||||
      if (!res.ok) throw new Error('Failed to fetch habits');
 | 
			
		||||
      return res.json() as Promise<HabitsResponse>;
 | 
			
		||||
    },
 | 
			
		||||
    enabled: !!authData?.authenticated,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Log habit mutation
 | 
			
		||||
  const logHabitMutation = useMutation<LogResponse, Error, number>({
 | 
			
		||||
    mutationFn: async (habitId: number): Promise<LogResponse> => {
 | 
			
		||||
      const res = await fetch(`/api/habits/${String(habitId)}/log`, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify({}),
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.ok) throw new Error('Failed to log habit');
 | 
			
		||||
      return res.json() as Promise<LogResponse>;
 | 
			
		||||
    },
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      void queryClient.invalidateQueries({ queryKey: ['habits'] });
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Create habit mutation
 | 
			
		||||
  const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
 | 
			
		||||
    mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
 | 
			
		||||
      const res = await fetch('/api/habits', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify(data),
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.ok) throw new Error('Failed to create habit');
 | 
			
		||||
      return res.json() as Promise<HabitResponse>;
 | 
			
		||||
    },
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      void queryClient.invalidateQueries({ queryKey: ['habits'] });
 | 
			
		||||
      setShowNewHabitDialog(false);
 | 
			
		||||
      setNewHabitName('');
 | 
			
		||||
      setNewHabitType('neutral');
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleCreateHabit = () => {
 | 
			
		||||
    if (newHabitName.trim()) {
 | 
			
		||||
      createHabitMutation.mutate({
 | 
			
		||||
        name: newHabitName.trim(),
 | 
			
		||||
        type: newHabitType,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const copyToken = () => {
 | 
			
		||||
    if (userToken) {
 | 
			
		||||
      void navigator.clipboard.writeText(userToken);
 | 
			
		||||
      setCopiedToken(true);
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        setCopiedToken(false);
 | 
			
		||||
      }, 2000);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getHabitCardClass = (type: string) => {
 | 
			
		||||
    switch (type) {
 | 
			
		||||
      case 'positive':
 | 
			
		||||
        return 'border-emerald-600 bg-emerald-950/50 hover:bg-emerald-900/50 hover:border-emerald-500';
 | 
			
		||||
      case 'negative':
 | 
			
		||||
        return 'border-red-600 bg-red-950/50 hover:bg-red-900/50 hover:border-red-500';
 | 
			
		||||
      default:
 | 
			
		||||
        return 'border-zinc-700 bg-zinc-950/50 hover:bg-zinc-900/50 hover:border-zinc-600';
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getHabitIcon = (type: string) => {
 | 
			
		||||
    switch (type) {
 | 
			
		||||
      case 'positive':
 | 
			
		||||
        return <Trophy className="h-5 w-5 text-emerald-500" />;
 | 
			
		||||
      case 'negative':
 | 
			
		||||
        return <HeartCrack className="h-5 w-5 text-red-500" />;
 | 
			
		||||
      default:
 | 
			
		||||
        return <Activity className="h-5 w-5 text-zinc-500" />;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getHabitBadgeVariant = (type: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
 | 
			
		||||
    switch (type) {
 | 
			
		||||
      case 'positive':
 | 
			
		||||
        return 'default';
 | 
			
		||||
      case 'negative':
 | 
			
		||||
        return 'destructive';
 | 
			
		||||
      default:
 | 
			
		||||
        return 'secondary';
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getAverageFrequency = (habit: Habit) => {
 | 
			
		||||
    const daysSinceCreation = Math.max(
 | 
			
		||||
      1,
 | 
			
		||||
      Math.floor((Date.now() - new Date(habit.createdAt).getTime()) / (1000 * 60 * 60 * 24)),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (daysSinceCreation <= 7) {
 | 
			
		||||
      const avg = habit.totalLogs / daysSinceCreation;
 | 
			
		||||
      return `${avg.toFixed(1)}/day`;
 | 
			
		||||
    } else if (daysSinceCreation <= 30) {
 | 
			
		||||
      const weeks = daysSinceCreation / 7;
 | 
			
		||||
      const avg = habit.totalLogs / weeks;
 | 
			
		||||
      return `${avg.toFixed(1)}/week`;
 | 
			
		||||
    } else {
 | 
			
		||||
      const months = daysSinceCreation / 30;
 | 
			
		||||
      const avg = habit.totalLogs / months;
 | 
			
		||||
      return `${avg.toFixed(1)}/month`;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (authLoading || habitsLoading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex min-h-screen items-center justify-center bg-black">
 | 
			
		||||
        <div className="text-zinc-400">Loading...</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const habits = habitsData?.habits ?? [];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="min-h-screen bg-black">
 | 
			
		||||
      <div className="mx-auto max-w-7xl p-4 md:p-8">
 | 
			
		||||
        {/* Header */}
 | 
			
		||||
        <div className="mb-8">
 | 
			
		||||
          <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
 | 
			
		||||
            <div>
 | 
			
		||||
              <h1 className="mb-2 text-4xl font-bold text-white">Track Every Day</h1>
 | 
			
		||||
              <p className="text-zinc-400">Build better habits, one day at a time.</p>
 | 
			
		||||
            </div>
 | 
			
		||||
            <Dialog open={showNewHabitDialog} onOpenChange={setShowNewHabitDialog}>
 | 
			
		||||
              <DialogTrigger asChild>
 | 
			
		||||
                <Button size="lg" className="bg-emerald-600 hover:bg-emerald-700">
 | 
			
		||||
                  <Plus className="mr-2 h-5 w-5" />
 | 
			
		||||
                  New Habit
 | 
			
		||||
                </Button>
 | 
			
		||||
              </DialogTrigger>
 | 
			
		||||
              <DialogContent className="border-zinc-800 bg-zinc-950">
 | 
			
		||||
                <DialogHeader>
 | 
			
		||||
                  <DialogTitle>Create New Habit</DialogTitle>
 | 
			
		||||
                  <DialogDescription>
 | 
			
		||||
                    Add a new habit to track. Choose whether it's something you want to do more,
 | 
			
		||||
                    less, or just monitor.
 | 
			
		||||
                  </DialogDescription>
 | 
			
		||||
                </DialogHeader>
 | 
			
		||||
                <div className="grid gap-4 py-4">
 | 
			
		||||
                  <div className="grid gap-2">
 | 
			
		||||
                    <Label htmlFor="name">Habit Name</Label>
 | 
			
		||||
                    <Input
 | 
			
		||||
                      id="name"
 | 
			
		||||
                      placeholder="e.g., Exercise, Read, Meditate..."
 | 
			
		||||
                      value={newHabitName}
 | 
			
		||||
                      onChange={(e) => {
 | 
			
		||||
                        setNewHabitName(e.target.value);
 | 
			
		||||
                      }}
 | 
			
		||||
                      className="border-zinc-800 bg-zinc-900"
 | 
			
		||||
                    />
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div className="grid gap-2">
 | 
			
		||||
                    <Label htmlFor="type">Habit Type</Label>
 | 
			
		||||
                    <Select
 | 
			
		||||
                      value={newHabitType}
 | 
			
		||||
                      onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
 | 
			
		||||
                        setNewHabitType(value);
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <SelectTrigger className="border-zinc-800 bg-zinc-900">
 | 
			
		||||
                        <SelectValue />
 | 
			
		||||
                      </SelectTrigger>
 | 
			
		||||
                      <SelectContent className="border-zinc-800 bg-zinc-900">
 | 
			
		||||
                        <SelectItem value="positive">
 | 
			
		||||
                          <div className="flex items-center gap-2">
 | 
			
		||||
                            <TrendingUp className="h-4 w-4 text-emerald-500" />
 | 
			
		||||
                            <span>Positive - Something to do more</span>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </SelectItem>
 | 
			
		||||
                        <SelectItem value="neutral">
 | 
			
		||||
                          <div className="flex items-center gap-2">
 | 
			
		||||
                            <Activity className="h-4 w-4 text-zinc-500" />
 | 
			
		||||
                            <span>Neutral - Just tracking</span>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </SelectItem>
 | 
			
		||||
                        <SelectItem value="negative">
 | 
			
		||||
                          <div className="flex items-center gap-2">
 | 
			
		||||
                            <TrendingDown className="h-4 w-4 text-red-500" />
 | 
			
		||||
                            <span>Negative - Something to reduce</span>
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </SelectItem>
 | 
			
		||||
                      </SelectContent>
 | 
			
		||||
                    </Select>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <DialogFooter>
 | 
			
		||||
                  <Button
 | 
			
		||||
                    variant="outline"
 | 
			
		||||
                    onClick={() => {
 | 
			
		||||
                      setShowNewHabitDialog(false);
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
                    Cancel
 | 
			
		||||
                  </Button>
 | 
			
		||||
                  <Button
 | 
			
		||||
                    onClick={handleCreateHabit}
 | 
			
		||||
                    disabled={!newHabitName.trim() || createHabitMutation.isPending}
 | 
			
		||||
                    className="bg-emerald-600 hover:bg-emerald-700"
 | 
			
		||||
                  >
 | 
			
		||||
                    {createHabitMutation.isPending ? 'Creating...' : 'Create Habit'}
 | 
			
		||||
                  </Button>
 | 
			
		||||
                </DialogFooter>
 | 
			
		||||
              </DialogContent>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {/* Token Alert */}
 | 
			
		||||
          {userToken && (
 | 
			
		||||
            <Alert className="border-zinc-800 bg-zinc-950">
 | 
			
		||||
              <AlertDescription className="flex items-center justify-between">
 | 
			
		||||
                <div className="space-y-1">
 | 
			
		||||
                  <p className="text-sm text-zinc-400">Your access token:</p>
 | 
			
		||||
                  <code className="rounded bg-zinc-900 px-2 py-1 font-mono text-sm text-white">
 | 
			
		||||
                    {userToken}
 | 
			
		||||
                  </code>
 | 
			
		||||
                </div>
 | 
			
		||||
                <TooltipProvider>
 | 
			
		||||
                  <Tooltip>
 | 
			
		||||
                    <TooltipTrigger asChild>
 | 
			
		||||
                      <Button variant="outline" size="sm" onClick={copyToken} className="ml-4">
 | 
			
		||||
                        {copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
 | 
			
		||||
                      </Button>
 | 
			
		||||
                    </TooltipTrigger>
 | 
			
		||||
                    <TooltipContent>
 | 
			
		||||
                      <p>{copiedToken ? 'Copied!' : 'Copy token'}</p>
 | 
			
		||||
                    </TooltipContent>
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                </TooltipProvider>
 | 
			
		||||
              </AlertDescription>
 | 
			
		||||
            </Alert>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {/* Habits Grid */}
 | 
			
		||||
        <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
          {habits.map((habit: Habit) => (
 | 
			
		||||
            <Card
 | 
			
		||||
              key={habit.id}
 | 
			
		||||
              className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
 | 
			
		||||
                habit.type,
 | 
			
		||||
              )} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                logHabitMutation.mutate(habit.id);
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <CardHeader className="pb-3">
 | 
			
		||||
                <div className="flex items-start justify-between">
 | 
			
		||||
                  <CardTitle className="text-lg">{habit.name}</CardTitle>
 | 
			
		||||
                  {getHabitIcon(habit.type)}
 | 
			
		||||
                </div>
 | 
			
		||||
              </CardHeader>
 | 
			
		||||
              <CardContent>
 | 
			
		||||
                <div className="space-y-3">
 | 
			
		||||
                  {/* Last logged */}
 | 
			
		||||
                  <div className="flex items-center gap-2 text-sm">
 | 
			
		||||
                    <Clock className="h-4 w-4 text-zinc-500" />
 | 
			
		||||
                    <span className="text-zinc-400">
 | 
			
		||||
                      {habit.lastLoggedAt
 | 
			
		||||
                        ? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
 | 
			
		||||
                        : 'Never logged'}
 | 
			
		||||
                    </span>
 | 
			
		||||
                  </div>
 | 
			
		||||
 | 
			
		||||
                  <Separator className="bg-zinc-800" />
 | 
			
		||||
 | 
			
		||||
                  {/* Stats */}
 | 
			
		||||
                  <div className="grid grid-cols-2 gap-2">
 | 
			
		||||
                    <div className="text-center">
 | 
			
		||||
                      <p className="text-2xl font-bold">{habit.totalLogs}</p>
 | 
			
		||||
                      <p className="text-xs text-zinc-500">Total</p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="text-center">
 | 
			
		||||
                      <p className="text-2xl font-bold">{habit.logsLastWeek}</p>
 | 
			
		||||
                      <p className="text-xs text-zinc-500">This week</p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
 | 
			
		||||
                  {/* Average frequency badge */}
 | 
			
		||||
                  <div className="flex justify-center pt-2">
 | 
			
		||||
                    <Badge variant={getHabitBadgeVariant(habit.type)} className="font-normal">
 | 
			
		||||
                      <Target className="mr-1 h-3 w-3" />
 | 
			
		||||
                      {getAverageFrequency(habit)}
 | 
			
		||||
                    </Badge>
 | 
			
		||||
                  </div>
 | 
			
		||||
 | 
			
		||||
                  {/* Motivational message */}
 | 
			
		||||
                  {habit.type === 'positive' && habit.totalLogs > 0 && (
 | 
			
		||||
                    <p className="pt-2 text-center text-xs text-emerald-400">
 | 
			
		||||
                      Keep up the great work! 💪
 | 
			
		||||
                    </p>
 | 
			
		||||
                  )}
 | 
			
		||||
 | 
			
		||||
                  {habit.type === 'negative' && habit.lastLoggedAt && (
 | 
			
		||||
                    <p className="pt-2 text-center text-xs text-red-400">
 | 
			
		||||
                      Stay mindful, you've got this! 🎯
 | 
			
		||||
                    </p>
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              </CardContent>
 | 
			
		||||
            </Card>
 | 
			
		||||
          ))}
 | 
			
		||||
 | 
			
		||||
          {/* Empty state */}
 | 
			
		||||
          {habits.length === 0 && (
 | 
			
		||||
            <Card className="border-dashed border-zinc-800 bg-zinc-950/50 md:col-span-2 lg:col-span-3 xl:col-span-4">
 | 
			
		||||
              <CardContent className="flex flex-col items-center justify-center py-12">
 | 
			
		||||
                <Calendar className="mb-4 h-12 w-12 text-zinc-600" />
 | 
			
		||||
                <h3 className="mb-2 text-lg font-semibold">No habits yet</h3>
 | 
			
		||||
                <p className="mb-4 text-sm text-zinc-500">Start building better habits today</p>
 | 
			
		||||
                <Button
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    setShowNewHabitDialog(true);
 | 
			
		||||
                  }}
 | 
			
		||||
                  className="bg-emerald-600 hover:bg-emerald-700"
 | 
			
		||||
                >
 | 
			
		||||
                  <Plus className="mr-2 h-4 w-4" />
 | 
			
		||||
                  Create Your First Habit
 | 
			
		||||
                </Button>
 | 
			
		||||
              </CardContent>
 | 
			
		||||
            </Card>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
@import 'tailwindcss';
 | 
			
		||||
@import "tailwindcss";
 | 
			
		||||
 | 
			
		||||
@custom-variant dark (&:is(.dark *));
 | 
			
		||||
 | 
			
		||||
@@ -6,10 +6,7 @@
 | 
			
		||||
  --font-sans: var(--font-sans);
 | 
			
		||||
 | 
			
		||||
  --background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
 | 
			
		||||
  --background-image-gradient-conic: conic-gradient(
 | 
			
		||||
    from 180deg at 50% 50%,
 | 
			
		||||
    var(--tw-gradient-stops)
 | 
			
		||||
  );
 | 
			
		||||
  --background-image-gradient-conic: conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops));
 | 
			
		||||
 | 
			
		||||
  --radius-lg: var(--radius);
 | 
			
		||||
  --radius-md: calc(var(--radius) - 2px);
 | 
			
		||||
@@ -90,41 +87,14 @@
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --background: 0 0% 100%;
 | 
			
		||||
    --foreground: 240 10% 3.9%;
 | 
			
		||||
    --card: 0 0% 100%;
 | 
			
		||||
    --card-foreground: 240 10% 3.9%;
 | 
			
		||||
    --popover: 0 0% 100%;
 | 
			
		||||
    --popover-foreground: 240 10% 3.9%;
 | 
			
		||||
    --primary: 240 5.9% 10%;
 | 
			
		||||
    --primary-foreground: 0 0% 98%;
 | 
			
		||||
    --secondary: 240 4.8% 95.9%;
 | 
			
		||||
    --secondary-foreground: 240 5.9% 10%;
 | 
			
		||||
    --muted: 240 4.8% 95.9%;
 | 
			
		||||
    --muted-foreground: 240 3.8% 46.1%;
 | 
			
		||||
    --accent: 240 4.8% 95.9%;
 | 
			
		||||
    --accent-foreground: 240 5.9% 10%;
 | 
			
		||||
    --destructive: 0 84.2% 60.2%;
 | 
			
		||||
    --destructive-foreground: 0 0% 98%;
 | 
			
		||||
    --border: 240 5.9% 90%;
 | 
			
		||||
    --input: 240 5.9% 90%;
 | 
			
		||||
    --ring: 240 10% 3.9%;
 | 
			
		||||
    --chart-1: 12 76% 61%;
 | 
			
		||||
    --chart-2: 173 58% 39%;
 | 
			
		||||
    --chart-3: 197 37% 24%;
 | 
			
		||||
    --chart-4: 43 74% 66%;
 | 
			
		||||
    --chart-5: 27 87% 67%;
 | 
			
		||||
    --radius: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
  .dark {
 | 
			
		||||
    --background: 240 10% 3.9%;
 | 
			
		||||
    --background: 0 0% 0%;
 | 
			
		||||
    --foreground: 0 0% 98%;
 | 
			
		||||
    --card: 240 10% 3.9%;
 | 
			
		||||
    --card-foreground: 0 0% 98%;
 | 
			
		||||
    --popover: 240 10% 3.9%;
 | 
			
		||||
    --popover-foreground: 0 0% 98%;
 | 
			
		||||
    --primary: 0 0% 98%;
 | 
			
		||||
    --primary-foreground: 240 5.9% 10%;
 | 
			
		||||
    --primary: 142.1 76.2% 36.3%;
 | 
			
		||||
    --primary-foreground: 355.7 100% 97.3%;
 | 
			
		||||
    --secondary: 240 3.7% 15.9%;
 | 
			
		||||
    --secondary-foreground: 0 0% 98%;
 | 
			
		||||
    --muted: 240 3.7% 15.9%;
 | 
			
		||||
@@ -135,12 +105,13 @@
 | 
			
		||||
    --destructive-foreground: 0 0% 98%;
 | 
			
		||||
    --border: 240 3.7% 15.9%;
 | 
			
		||||
    --input: 240 3.7% 15.9%;
 | 
			
		||||
    --ring: 240 4.9% 83.9%;
 | 
			
		||||
    --ring: 142.1 76.2% 36.3%;
 | 
			
		||||
    --chart-1: 220 70% 50%;
 | 
			
		||||
    --chart-2: 160 60% 45%;
 | 
			
		||||
    --chart-3: 30 80% 55%;
 | 
			
		||||
    --chart-4: 280 65% 60%;
 | 
			
		||||
    --chart-5: 340 75% 55%;
 | 
			
		||||
    --radius: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -148,6 +119,9 @@
 | 
			
		||||
  * {
 | 
			
		||||
    @apply border-border;
 | 
			
		||||
  }
 | 
			
		||||
  html {
 | 
			
		||||
    @apply bg-black;
 | 
			
		||||
  }
 | 
			
		||||
  body {
 | 
			
		||||
    @apply bg-background text-foreground;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,23 @@
 | 
			
		||||
import type { Metadata, Viewport } from "next";
 | 
			
		||||
import { Inter } from "next/font/google";
 | 
			
		||||
import PlausibleProvider from "next-plausible";
 | 
			
		||||
import "./globals.css";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import type { Metadata, Viewport } from 'next';
 | 
			
		||||
import { Inter } from 'next/font/google';
 | 
			
		||||
import PlausibleProvider from 'next-plausible';
 | 
			
		||||
import './globals.css';
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
import { Providers } from './providers';
 | 
			
		||||
 | 
			
		||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
 | 
			
		||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
 | 
			
		||||
 | 
			
		||||
export const viewport: Viewport = {
 | 
			
		||||
  colorScheme: "dark",
 | 
			
		||||
  colorScheme: 'dark',
 | 
			
		||||
  themeColor: [
 | 
			
		||||
    //{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" },
 | 
			
		||||
    //{ media: "(prefers-color-scheme: dark)", color: "#171717" },
 | 
			
		||||
    { color: "#052e16" },
 | 
			
		||||
    { color: '#052e16' },
 | 
			
		||||
  ],
 | 
			
		||||
};
 | 
			
		||||
export const metadata: Metadata = {
 | 
			
		||||
  title: "Track Every Day!",
 | 
			
		||||
  description: "A web app for tracking habits, activities and vices.",
 | 
			
		||||
  title: 'Track Every Day!',
 | 
			
		||||
  description: 'A web app for tracking habits, activities and vices.',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function RootLayout({
 | 
			
		||||
@@ -35,13 +36,8 @@ export default function RootLayout({
 | 
			
		||||
          trackOutboundLinks={true}
 | 
			
		||||
        />
 | 
			
		||||
      </head>
 | 
			
		||||
      <body
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "min-h-screen bg-background font-sans antialiased",
 | 
			
		||||
          inter.variable
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
      <body className={cn('bg-background min-h-screen font-sans antialiased', inter.variable)}>
 | 
			
		||||
        <Providers>{children}</Providers>
 | 
			
		||||
      </body>
 | 
			
		||||
    </html>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								app/page.tsx
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								app/page.tsx
									
									
									
									
									
								
							@@ -1,5 +1,14 @@
 | 
			
		||||
import { redirect } from "next/navigation";
 | 
			
		||||
import { cookies } from 'next/headers';
 | 
			
		||||
import { redirect } from 'next/navigation';
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
  redirect("/welcome");
 | 
			
		||||
export default async function Home() {
 | 
			
		||||
  const cookieStore = await cookies();
 | 
			
		||||
  const token = cookieStore.get('habit-tracker-token');
 | 
			
		||||
 | 
			
		||||
  // If user has a token, redirect to dashboard, otherwise to welcome
 | 
			
		||||
  if (token?.value) {
 | 
			
		||||
    redirect('/dashboard');
 | 
			
		||||
  } else {
 | 
			
		||||
    redirect('/welcome');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								app/providers.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/providers.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 | 
			
		||||
import { ReactNode, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
export function Providers({ children }: { children: ReactNode }) {
 | 
			
		||||
  const [queryClient] = useState(
 | 
			
		||||
    () =>
 | 
			
		||||
      new QueryClient({
 | 
			
		||||
        defaultOptions: {
 | 
			
		||||
          queries: {
 | 
			
		||||
            staleTime: 60 * 1000, // 1 minute
 | 
			
		||||
            refetchOnWindowFocus: false,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
 | 
			
		||||
}
 | 
			
		||||
@@ -3,9 +3,5 @@ export default function Layout({
 | 
			
		||||
}: Readonly<{
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col h-screen w-screen block bg-emerald-950 text-neutral-300">
 | 
			
		||||
      <div className="m-4 md:my-16 md:mx-auto max-w-96">{children}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
  return <div className="flex min-h-screen items-center justify-center bg-black p-4">{children}</div>;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,219 @@
 | 
			
		||||
export default function Home() {
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import { useState } from 'react';
 | 
			
		||||
import { useRouter } from 'next/navigation';
 | 
			
		||||
import { useMutation } from '@tanstack/react-query';
 | 
			
		||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
 | 
			
		||||
import { Button } from '@/components/ui/button';
 | 
			
		||||
import { Input } from '@/components/ui/input';
 | 
			
		||||
import { Label } from '@/components/ui/label';
 | 
			
		||||
import { Alert, AlertDescription } from '@/components/ui/alert';
 | 
			
		||||
import { Separator } from '@/components/ui/separator';
 | 
			
		||||
import { LogIn, Shield, Zap, ArrowLeft, Activity, CalendarCheck } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
interface AuthResponse {
 | 
			
		||||
  success?: boolean;
 | 
			
		||||
  token?: string;
 | 
			
		||||
  userId?: string;
 | 
			
		||||
  error?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Welcome() {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const [showTokenInput, setShowTokenInput] = useState(false);
 | 
			
		||||
  const [tokenInput, setTokenInput] = useState('');
 | 
			
		||||
  const [error, setError] = useState('');
 | 
			
		||||
 | 
			
		||||
  const createAccountMutation = useMutation({
 | 
			
		||||
    mutationFn: async (): Promise<AuthResponse> => {
 | 
			
		||||
      const res = await fetch('/api/auth', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify({ action: 'create' }),
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.ok) throw new Error('Failed to create account');
 | 
			
		||||
      return res.json() as Promise<AuthResponse>;
 | 
			
		||||
    },
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      router.push('/dashboard');
 | 
			
		||||
    },
 | 
			
		||||
    onError: () => {
 | 
			
		||||
      setError('Failed to create account. Please try again.');
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const loginMutation = useMutation({
 | 
			
		||||
    mutationFn: async (token: string): Promise<AuthResponse> => {
 | 
			
		||||
      const res = await fetch('/api/auth', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify({ action: 'login', token }),
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.ok) {
 | 
			
		||||
        const data = (await res.json()) as AuthResponse;
 | 
			
		||||
        throw new Error(data.error ?? 'Failed to login');
 | 
			
		||||
      }
 | 
			
		||||
      return res.json() as Promise<AuthResponse>;
 | 
			
		||||
    },
 | 
			
		||||
    onSuccess: () => {
 | 
			
		||||
      router.push('/dashboard');
 | 
			
		||||
    },
 | 
			
		||||
    onError: (error: Error) => {
 | 
			
		||||
      setError(error.message || 'Failed to login. Please check your token.');
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleTokenLogin = () => {
 | 
			
		||||
    if (tokenInput.trim()) {
 | 
			
		||||
      setError('');
 | 
			
		||||
      loginMutation.mutate(tokenInput.trim());
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown = (e: React.KeyboardEvent) => {
 | 
			
		||||
    if (e.key === 'Enter') {
 | 
			
		||||
      handleTokenLogin();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="shadow-xl rounded-lg w-full border px-6 py-12 bg-emerald-900 border-emerald-700 ">
 | 
			
		||||
      <div className="flex flex-col">
 | 
			
		||||
        <span className="text-4xl font-bold">📅 Track Every Day</span>
 | 
			
		||||
        <span className="mt-4 text-center">
 | 
			
		||||
          A web app for logging your habits, vices and activities.
 | 
			
		||||
        </span>
 | 
			
		||||
    <Card className="w-full max-w-md border-zinc-800 bg-zinc-950">
 | 
			
		||||
      <CardHeader className="space-y-2 text-center">
 | 
			
		||||
        <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-950">
 | 
			
		||||
          <span className="text-3xl">📅</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <CardTitle className="text-3xl font-bold">Track Every Day</CardTitle>
 | 
			
		||||
        <CardDescription className="text-base">
 | 
			
		||||
          Build better habits, one day at a time. No email or password required.
 | 
			
		||||
        </CardDescription>
 | 
			
		||||
      </CardHeader>
 | 
			
		||||
      <CardContent>
 | 
			
		||||
        {!showTokenInput ? (
 | 
			
		||||
          <div className="space-y-6">
 | 
			
		||||
            {/* Features */}
 | 
			
		||||
            <div className="grid gap-3 text-sm">
 | 
			
		||||
              <div className="flex items-center gap-3">
 | 
			
		||||
                <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
 | 
			
		||||
                  <Shield className="h-4 w-4 text-emerald-500" />
 | 
			
		||||
                </div>
 | 
			
		||||
                <p className="text-zinc-400">Privacy-first: No personal data required</p>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="flex items-center gap-3">
 | 
			
		||||
                <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
 | 
			
		||||
                  <Zap className="h-4 w-4 text-emerald-500" />
 | 
			
		||||
                </div>
 | 
			
		||||
                <p className="text-zinc-400">Instant access with a unique token</p>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="flex items-center gap-3">
 | 
			
		||||
                <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
 | 
			
		||||
                  <Activity className="h-4 w-4 text-emerald-500" />
 | 
			
		||||
                </div>
 | 
			
		||||
                <p className="text-zinc-400">Track positive, neutral, or negative habits</p>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <Separator className="bg-zinc-800" />
 | 
			
		||||
 | 
			
		||||
            {/* Actions */}
 | 
			
		||||
            <div className="space-y-3">
 | 
			
		||||
              <Button
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  createAccountMutation.mutate();
 | 
			
		||||
                }}
 | 
			
		||||
                disabled={createAccountMutation.isPending}
 | 
			
		||||
                className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
 | 
			
		||||
                size="lg"
 | 
			
		||||
              >
 | 
			
		||||
                {createAccountMutation.isPending ? (
 | 
			
		||||
                  <>Creating your account...</>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <>
 | 
			
		||||
                    <CalendarCheck className="mr-2 h-4 w-4" />
 | 
			
		||||
                    Start Tracking Now
 | 
			
		||||
                  </>
 | 
			
		||||
                )}
 | 
			
		||||
              </Button>
 | 
			
		||||
 | 
			
		||||
              <div className="relative">
 | 
			
		||||
                <div className="absolute inset-0 flex items-center">
 | 
			
		||||
                  <Separator className="w-full bg-zinc-800" />
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="relative flex justify-center text-xs uppercase">
 | 
			
		||||
                  <span className="bg-zinc-950 px-2 text-zinc-500">or</span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <Button
 | 
			
		||||
                variant="outline"
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  setShowTokenInput(true);
 | 
			
		||||
                }}
 | 
			
		||||
                className="w-full border-zinc-800 hover:bg-zinc-900"
 | 
			
		||||
                size="lg"
 | 
			
		||||
              >
 | 
			
		||||
                <LogIn className="mr-2 h-4 w-4" />I Have a Token
 | 
			
		||||
              </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <div className="space-y-4">
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="ghost"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setShowTokenInput(false);
 | 
			
		||||
                setTokenInput('');
 | 
			
		||||
                setError('');
 | 
			
		||||
              }}
 | 
			
		||||
              className="mb-2 -ml-2 text-zinc-500 hover:text-white"
 | 
			
		||||
              size="sm"
 | 
			
		||||
            >
 | 
			
		||||
              <ArrowLeft className="mr-1 h-4 w-4" />
 | 
			
		||||
              Back
 | 
			
		||||
            </Button>
 | 
			
		||||
 | 
			
		||||
            <div className="space-y-2">
 | 
			
		||||
              <Label htmlFor="token">Access Token</Label>
 | 
			
		||||
              <Input
 | 
			
		||||
                id="token"
 | 
			
		||||
                type="text"
 | 
			
		||||
                placeholder="e.g., happy-blue-cat-1234"
 | 
			
		||||
                value={tokenInput}
 | 
			
		||||
                onChange={(e) => {
 | 
			
		||||
                  setTokenInput(e.target.value);
 | 
			
		||||
                }}
 | 
			
		||||
                onKeyDown={handleKeyDown}
 | 
			
		||||
                className="border-zinc-800 bg-zinc-900 placeholder:text-zinc-600"
 | 
			
		||||
                autoFocus
 | 
			
		||||
              />
 | 
			
		||||
              <p className="text-xs text-zinc-500">
 | 
			
		||||
                Enter the token you saved from your previous session
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <Button
 | 
			
		||||
              onClick={handleTokenLogin}
 | 
			
		||||
              disabled={loginMutation.isPending || !tokenInput.trim()}
 | 
			
		||||
              className="w-full bg-emerald-600 hover:bg-emerald-700"
 | 
			
		||||
              size="lg"
 | 
			
		||||
            >
 | 
			
		||||
              {loginMutation.isPending ? 'Logging in...' : 'Access My Habits'}
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {error && (
 | 
			
		||||
          <Alert className="mt-4 border-red-900 bg-red-950">
 | 
			
		||||
            <AlertDescription className="text-sm text-red-400">{error}</AlertDescription>
 | 
			
		||||
          </Alert>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <div className="mt-6 border-t border-zinc-800 pt-6">
 | 
			
		||||
          <p className="text-center text-xs text-zinc-500">
 | 
			
		||||
            Your habits are tied to a unique token. Save it to access your data across devices. No
 | 
			
		||||
            account creation or personal information required.
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </CardContent>
 | 
			
		||||
    </Card>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										60
									
								
								components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import { cva, type VariantProps } from 'class-variance-authority';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
const alertVariants = cva(
 | 
			
		||||
  'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default: 'bg-card text-card-foreground',
 | 
			
		||||
        destructive:
 | 
			
		||||
          'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: 'default',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function Alert({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="alert"
 | 
			
		||||
      role="alert"
 | 
			
		||||
      className={cn(alertVariants({ variant }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="alert-title"
 | 
			
		||||
      className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="alert-description"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Alert, AlertTitle, AlertDescription };
 | 
			
		||||
							
								
								
									
										37
									
								
								components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import { Slot } from '@radix-ui/react-slot';
 | 
			
		||||
import { cva, type VariantProps } from 'class-variance-authority';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
const badgeVariants = cva(
 | 
			
		||||
  'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
 | 
			
		||||
        secondary:
 | 
			
		||||
          'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
 | 
			
		||||
        destructive:
 | 
			
		||||
          'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
 | 
			
		||||
        outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: 'default',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function Badge({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  asChild = false,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
 | 
			
		||||
  const Comp = asChild ? Slot : 'span';
 | 
			
		||||
 | 
			
		||||
  return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Badge, badgeVariants };
 | 
			
		||||
							
								
								
									
										52
									
								
								components/ui/button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								components/ui/button.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import { Slot } from '@radix-ui/react-slot';
 | 
			
		||||
import { cva, type VariantProps } from 'class-variance-authority';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
const buttonVariants = cva(
 | 
			
		||||
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
 | 
			
		||||
        destructive:
 | 
			
		||||
          'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
 | 
			
		||||
        outline:
 | 
			
		||||
          'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
 | 
			
		||||
        secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
 | 
			
		||||
        ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
 | 
			
		||||
        link: 'text-primary underline-offset-4 hover:underline',
 | 
			
		||||
      },
 | 
			
		||||
      size: {
 | 
			
		||||
        default: 'h-9 px-4 py-2 has-[>svg]:px-3',
 | 
			
		||||
        sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
 | 
			
		||||
        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
 | 
			
		||||
        icon: 'size-9',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: 'default',
 | 
			
		||||
      size: 'default',
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function Button({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  size,
 | 
			
		||||
  asChild = false,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<'button'> &
 | 
			
		||||
  VariantProps<typeof buttonVariants> & {
 | 
			
		||||
    asChild?: boolean;
 | 
			
		||||
  }) {
 | 
			
		||||
  const Comp = asChild ? Slot : 'button';
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Button, buttonVariants };
 | 
			
		||||
							
								
								
									
										71
									
								
								components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-header"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-description"
 | 
			
		||||
      className={cn('text-muted-foreground text-sm', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-action"
 | 
			
		||||
      className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-footer"
 | 
			
		||||
      className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
 | 
			
		||||
							
								
								
									
										126
									
								
								components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,126 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
 | 
			
		||||
import { XIcon } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
 | 
			
		||||
  return <DialogPrimitive.Root data-slot="dialog" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
 | 
			
		||||
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
 | 
			
		||||
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
 | 
			
		||||
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPrimitive.Overlay
 | 
			
		||||
      data-slot="dialog-overlay"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogContent({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  showCloseButton = true,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
 | 
			
		||||
  showCloseButton?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPortal data-slot="dialog-portal">
 | 
			
		||||
      <DialogOverlay />
 | 
			
		||||
      <DialogPrimitive.Content
 | 
			
		||||
        data-slot="dialog-content"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
 | 
			
		||||
          className,
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
        {showCloseButton && (
 | 
			
		||||
          <DialogPrimitive.Close
 | 
			
		||||
            data-slot="dialog-close"
 | 
			
		||||
            className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
 | 
			
		||||
          >
 | 
			
		||||
            <XIcon />
 | 
			
		||||
            <span className="sr-only">Close</span>
 | 
			
		||||
          </DialogPrimitive.Close>
 | 
			
		||||
        )}
 | 
			
		||||
      </DialogPrimitive.Content>
 | 
			
		||||
    </DialogPortal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="dialog-header"
 | 
			
		||||
      className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="dialog-footer"
 | 
			
		||||
      className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPrimitive.Title
 | 
			
		||||
      data-slot="dialog-title"
 | 
			
		||||
      className={cn('text-lg leading-none font-semibold', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogDescription({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPrimitive.Description
 | 
			
		||||
      data-slot="dialog-description"
 | 
			
		||||
      className={cn('text-muted-foreground text-sm', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogClose,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogDescription,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogOverlay,
 | 
			
		||||
  DialogPortal,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogTrigger,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										21
									
								
								components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <input
 | 
			
		||||
      type={type}
 | 
			
		||||
      data-slot="input"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
 | 
			
		||||
        'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
 | 
			
		||||
        'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Input };
 | 
			
		||||
							
								
								
									
										21
									
								
								components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import * as LabelPrimitive from '@radix-ui/react-label';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <LabelPrimitive.Root
 | 
			
		||||
      data-slot="label"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Label };
 | 
			
		||||
							
								
								
									
										170
									
								
								components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,170 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import * as SelectPrimitive from '@radix-ui/react-select';
 | 
			
		||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
 | 
			
		||||
  return <SelectPrimitive.Root data-slot="select" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
 | 
			
		||||
  return <SelectPrimitive.Group data-slot="select-group" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
 | 
			
		||||
  return <SelectPrimitive.Value data-slot="select-value" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectTrigger({
 | 
			
		||||
  className,
 | 
			
		||||
  size = 'default',
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
 | 
			
		||||
  size?: 'sm' | 'default';
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Trigger
 | 
			
		||||
      data-slot="select-trigger"
 | 
			
		||||
      data-size={size}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <SelectPrimitive.Icon asChild>
 | 
			
		||||
        <ChevronDownIcon className="size-4 opacity-50" />
 | 
			
		||||
      </SelectPrimitive.Icon>
 | 
			
		||||
    </SelectPrimitive.Trigger>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectContent({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  position = 'popper',
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Portal>
 | 
			
		||||
      <SelectPrimitive.Content
 | 
			
		||||
        data-slot="select-content"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
 | 
			
		||||
          position === 'popper' &&
 | 
			
		||||
            'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
 | 
			
		||||
          className,
 | 
			
		||||
        )}
 | 
			
		||||
        position={position}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        <SelectScrollUpButton />
 | 
			
		||||
        <SelectPrimitive.Viewport
 | 
			
		||||
          className={cn(
 | 
			
		||||
            'p-1',
 | 
			
		||||
            position === 'popper' &&
 | 
			
		||||
              'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {children}
 | 
			
		||||
        </SelectPrimitive.Viewport>
 | 
			
		||||
        <SelectScrollDownButton />
 | 
			
		||||
      </SelectPrimitive.Content>
 | 
			
		||||
    </SelectPrimitive.Portal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Label
 | 
			
		||||
      data-slot="select-label"
 | 
			
		||||
      className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Item
 | 
			
		||||
      data-slot="select-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="absolute right-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <SelectPrimitive.ItemIndicator>
 | 
			
		||||
          <CheckIcon className="size-4" />
 | 
			
		||||
        </SelectPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
 | 
			
		||||
    </SelectPrimitive.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Separator
 | 
			
		||||
      data-slot="select-separator"
 | 
			
		||||
      className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectScrollUpButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.ScrollUpButton
 | 
			
		||||
      data-slot="select-scroll-up-button"
 | 
			
		||||
      className={cn('flex cursor-default items-center justify-center py-1', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <ChevronUpIcon className="size-4" />
 | 
			
		||||
    </SelectPrimitive.ScrollUpButton>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectScrollDownButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.ScrollDownButton
 | 
			
		||||
      data-slot="select-scroll-down-button"
 | 
			
		||||
      className={cn('flex cursor-default items-center justify-center py-1', className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <ChevronDownIcon className="size-4" />
 | 
			
		||||
    </SelectPrimitive.ScrollDownButton>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectGroup,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectLabel,
 | 
			
		||||
  SelectScrollDownButton,
 | 
			
		||||
  SelectScrollUpButton,
 | 
			
		||||
  SelectSeparator,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										28
									
								
								components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function Separator({
 | 
			
		||||
  className,
 | 
			
		||||
  orientation = 'horizontal',
 | 
			
		||||
  decorative = true,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SeparatorPrimitive.Root
 | 
			
		||||
      data-slot="separator"
 | 
			
		||||
      decorative={decorative}
 | 
			
		||||
      orientation={orientation}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Separator };
 | 
			
		||||
							
								
								
									
										53
									
								
								components/ui/tooltip.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								components/ui/tooltip.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import * as React from 'react';
 | 
			
		||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
 | 
			
		||||
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
function TooltipProvider({
 | 
			
		||||
  delayDuration = 0,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipProvider>
 | 
			
		||||
      <TooltipPrimitive.Root data-slot="tooltip" {...props} />
 | 
			
		||||
    </TooltipProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
 | 
			
		||||
  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function TooltipContent({
 | 
			
		||||
  className,
 | 
			
		||||
  sideOffset = 0,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipPrimitive.Portal>
 | 
			
		||||
      <TooltipPrimitive.Content
 | 
			
		||||
        data-slot="tooltip-content"
 | 
			
		||||
        sideOffset={sideOffset}
 | 
			
		||||
        className={cn(
 | 
			
		||||
          'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
 | 
			
		||||
          className,
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
        <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
 | 
			
		||||
      </TooltipPrimitive.Content>
 | 
			
		||||
    </TooltipPrimitive.Portal>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
 | 
			
		||||
							
								
								
									
										17
									
								
								drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
import { defineConfig } from 'drizzle-kit';
 | 
			
		||||
import dotenv from 'dotenv';
 | 
			
		||||
dotenv.config({ path: ['.env.local', '.env'] });
 | 
			
		||||
 | 
			
		||||
const DATABASE_URL = process.env.POSTGRES_URL;
 | 
			
		||||
if (!DATABASE_URL) {
 | 
			
		||||
  throw new Error('POSTGRES_URL environment variable is required');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  schema: './lib/db/schema.ts',
 | 
			
		||||
  dialect: 'postgresql',
 | 
			
		||||
  dbCredentials: {
 | 
			
		||||
    url: DATABASE_URL,
 | 
			
		||||
  },
 | 
			
		||||
  out: './drizzle',
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										32
									
								
								drizzle/0000_lazy_prism.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								drizzle/0000_lazy_prism.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
CREATE TABLE "habit_logs" (
 | 
			
		||||
	"id" serial PRIMARY KEY NOT NULL,
 | 
			
		||||
	"habit_id" integer NOT NULL,
 | 
			
		||||
	"logged_at" timestamp DEFAULT now() NOT NULL,
 | 
			
		||||
	"note" text
 | 
			
		||||
);
 | 
			
		||||
--> statement-breakpoint
 | 
			
		||||
CREATE TABLE "habits" (
 | 
			
		||||
	"id" serial PRIMARY KEY NOT NULL,
 | 
			
		||||
	"user_id" integer NOT NULL,
 | 
			
		||||
	"name" text NOT NULL,
 | 
			
		||||
	"type" text DEFAULT 'neutral' NOT NULL,
 | 
			
		||||
	"target_frequency" jsonb,
 | 
			
		||||
	"color" text,
 | 
			
		||||
	"icon" text,
 | 
			
		||||
	"is_archived" boolean DEFAULT false NOT NULL,
 | 
			
		||||
	"created_at" timestamp DEFAULT now() NOT NULL,
 | 
			
		||||
	"archived_at" timestamp
 | 
			
		||||
);
 | 
			
		||||
--> statement-breakpoint
 | 
			
		||||
CREATE TABLE "users" (
 | 
			
		||||
	"id" serial PRIMARY KEY NOT NULL,
 | 
			
		||||
	"token" text NOT NULL,
 | 
			
		||||
	"created_at" timestamp DEFAULT now() NOT NULL,
 | 
			
		||||
	CONSTRAINT "users_token_unique" UNIQUE("token")
 | 
			
		||||
);
 | 
			
		||||
--> statement-breakpoint
 | 
			
		||||
ALTER TABLE "habit_logs" ADD CONSTRAINT "habit_logs_habit_id_habits_id_fk" FOREIGN KEY ("habit_id") REFERENCES "public"."habits"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
 | 
			
		||||
ALTER TABLE "habits" ADD CONSTRAINT "habits_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
 | 
			
		||||
CREATE INDEX "habit_logs_habit_id_idx" ON "habit_logs" USING btree ("habit_id");--> statement-breakpoint
 | 
			
		||||
CREATE INDEX "habit_logs_logged_at_idx" ON "habit_logs" USING btree ("logged_at");--> statement-breakpoint
 | 
			
		||||
CREATE INDEX "habits_user_id_idx" ON "habits" USING btree ("user_id");
 | 
			
		||||
							
								
								
									
										248
									
								
								drizzle/meta/0000_snapshot.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								drizzle/meta/0000_snapshot.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,248 @@
 | 
			
		||||
{
 | 
			
		||||
  "id": "1b2a9dc5-79f6-4172-ba68-f1b9c0e67708",
 | 
			
		||||
  "prevId": "00000000-0000-0000-0000-000000000000",
 | 
			
		||||
  "version": "7",
 | 
			
		||||
  "dialect": "postgresql",
 | 
			
		||||
  "tables": {
 | 
			
		||||
    "public.habit_logs": {
 | 
			
		||||
      "name": "habit_logs",
 | 
			
		||||
      "schema": "",
 | 
			
		||||
      "columns": {
 | 
			
		||||
        "id": {
 | 
			
		||||
          "name": "id",
 | 
			
		||||
          "type": "serial",
 | 
			
		||||
          "primaryKey": true,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "habit_id": {
 | 
			
		||||
          "name": "habit_id",
 | 
			
		||||
          "type": "integer",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "logged_at": {
 | 
			
		||||
          "name": "logged_at",
 | 
			
		||||
          "type": "timestamp",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true,
 | 
			
		||||
          "default": "now()"
 | 
			
		||||
        },
 | 
			
		||||
        "note": {
 | 
			
		||||
          "name": "note",
 | 
			
		||||
          "type": "text",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": false
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "indexes": {
 | 
			
		||||
        "habit_logs_habit_id_idx": {
 | 
			
		||||
          "name": "habit_logs_habit_id_idx",
 | 
			
		||||
          "columns": [
 | 
			
		||||
            {
 | 
			
		||||
              "expression": "habit_id",
 | 
			
		||||
              "isExpression": false,
 | 
			
		||||
              "asc": true,
 | 
			
		||||
              "nulls": "last"
 | 
			
		||||
            }
 | 
			
		||||
          ],
 | 
			
		||||
          "isUnique": false,
 | 
			
		||||
          "concurrently": false,
 | 
			
		||||
          "method": "btree",
 | 
			
		||||
          "with": {}
 | 
			
		||||
        },
 | 
			
		||||
        "habit_logs_logged_at_idx": {
 | 
			
		||||
          "name": "habit_logs_logged_at_idx",
 | 
			
		||||
          "columns": [
 | 
			
		||||
            {
 | 
			
		||||
              "expression": "logged_at",
 | 
			
		||||
              "isExpression": false,
 | 
			
		||||
              "asc": true,
 | 
			
		||||
              "nulls": "last"
 | 
			
		||||
            }
 | 
			
		||||
          ],
 | 
			
		||||
          "isUnique": false,
 | 
			
		||||
          "concurrently": false,
 | 
			
		||||
          "method": "btree",
 | 
			
		||||
          "with": {}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "foreignKeys": {
 | 
			
		||||
        "habit_logs_habit_id_habits_id_fk": {
 | 
			
		||||
          "name": "habit_logs_habit_id_habits_id_fk",
 | 
			
		||||
          "tableFrom": "habit_logs",
 | 
			
		||||
          "tableTo": "habits",
 | 
			
		||||
          "columnsFrom": [
 | 
			
		||||
            "habit_id"
 | 
			
		||||
          ],
 | 
			
		||||
          "columnsTo": [
 | 
			
		||||
            "id"
 | 
			
		||||
          ],
 | 
			
		||||
          "onDelete": "no action",
 | 
			
		||||
          "onUpdate": "no action"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "compositePrimaryKeys": {},
 | 
			
		||||
      "uniqueConstraints": {},
 | 
			
		||||
      "policies": {},
 | 
			
		||||
      "checkConstraints": {},
 | 
			
		||||
      "isRLSEnabled": false
 | 
			
		||||
    },
 | 
			
		||||
    "public.habits": {
 | 
			
		||||
      "name": "habits",
 | 
			
		||||
      "schema": "",
 | 
			
		||||
      "columns": {
 | 
			
		||||
        "id": {
 | 
			
		||||
          "name": "id",
 | 
			
		||||
          "type": "serial",
 | 
			
		||||
          "primaryKey": true,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "user_id": {
 | 
			
		||||
          "name": "user_id",
 | 
			
		||||
          "type": "integer",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "name": {
 | 
			
		||||
          "name": "name",
 | 
			
		||||
          "type": "text",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "type": {
 | 
			
		||||
          "name": "type",
 | 
			
		||||
          "type": "text",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true,
 | 
			
		||||
          "default": "'neutral'"
 | 
			
		||||
        },
 | 
			
		||||
        "target_frequency": {
 | 
			
		||||
          "name": "target_frequency",
 | 
			
		||||
          "type": "jsonb",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": false
 | 
			
		||||
        },
 | 
			
		||||
        "color": {
 | 
			
		||||
          "name": "color",
 | 
			
		||||
          "type": "text",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": false
 | 
			
		||||
        },
 | 
			
		||||
        "icon": {
 | 
			
		||||
          "name": "icon",
 | 
			
		||||
          "type": "text",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": false
 | 
			
		||||
        },
 | 
			
		||||
        "is_archived": {
 | 
			
		||||
          "name": "is_archived",
 | 
			
		||||
          "type": "boolean",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true,
 | 
			
		||||
          "default": false
 | 
			
		||||
        },
 | 
			
		||||
        "created_at": {
 | 
			
		||||
          "name": "created_at",
 | 
			
		||||
          "type": "timestamp",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true,
 | 
			
		||||
          "default": "now()"
 | 
			
		||||
        },
 | 
			
		||||
        "archived_at": {
 | 
			
		||||
          "name": "archived_at",
 | 
			
		||||
          "type": "timestamp",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": false
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "indexes": {
 | 
			
		||||
        "habits_user_id_idx": {
 | 
			
		||||
          "name": "habits_user_id_idx",
 | 
			
		||||
          "columns": [
 | 
			
		||||
            {
 | 
			
		||||
              "expression": "user_id",
 | 
			
		||||
              "isExpression": false,
 | 
			
		||||
              "asc": true,
 | 
			
		||||
              "nulls": "last"
 | 
			
		||||
            }
 | 
			
		||||
          ],
 | 
			
		||||
          "isUnique": false,
 | 
			
		||||
          "concurrently": false,
 | 
			
		||||
          "method": "btree",
 | 
			
		||||
          "with": {}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "foreignKeys": {
 | 
			
		||||
        "habits_user_id_users_id_fk": {
 | 
			
		||||
          "name": "habits_user_id_users_id_fk",
 | 
			
		||||
          "tableFrom": "habits",
 | 
			
		||||
          "tableTo": "users",
 | 
			
		||||
          "columnsFrom": [
 | 
			
		||||
            "user_id"
 | 
			
		||||
          ],
 | 
			
		||||
          "columnsTo": [
 | 
			
		||||
            "id"
 | 
			
		||||
          ],
 | 
			
		||||
          "onDelete": "no action",
 | 
			
		||||
          "onUpdate": "no action"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "compositePrimaryKeys": {},
 | 
			
		||||
      "uniqueConstraints": {},
 | 
			
		||||
      "policies": {},
 | 
			
		||||
      "checkConstraints": {},
 | 
			
		||||
      "isRLSEnabled": false
 | 
			
		||||
    },
 | 
			
		||||
    "public.users": {
 | 
			
		||||
      "name": "users",
 | 
			
		||||
      "schema": "",
 | 
			
		||||
      "columns": {
 | 
			
		||||
        "id": {
 | 
			
		||||
          "name": "id",
 | 
			
		||||
          "type": "serial",
 | 
			
		||||
          "primaryKey": true,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "token": {
 | 
			
		||||
          "name": "token",
 | 
			
		||||
          "type": "text",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true
 | 
			
		||||
        },
 | 
			
		||||
        "created_at": {
 | 
			
		||||
          "name": "created_at",
 | 
			
		||||
          "type": "timestamp",
 | 
			
		||||
          "primaryKey": false,
 | 
			
		||||
          "notNull": true,
 | 
			
		||||
          "default": "now()"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "indexes": {},
 | 
			
		||||
      "foreignKeys": {},
 | 
			
		||||
      "compositePrimaryKeys": {},
 | 
			
		||||
      "uniqueConstraints": {
 | 
			
		||||
        "users_token_unique": {
 | 
			
		||||
          "name": "users_token_unique",
 | 
			
		||||
          "nullsNotDistinct": false,
 | 
			
		||||
          "columns": [
 | 
			
		||||
            "token"
 | 
			
		||||
          ]
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "policies": {},
 | 
			
		||||
      "checkConstraints": {},
 | 
			
		||||
      "isRLSEnabled": false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "enums": {},
 | 
			
		||||
  "schemas": {},
 | 
			
		||||
  "sequences": {},
 | 
			
		||||
  "roles": {},
 | 
			
		||||
  "policies": {},
 | 
			
		||||
  "views": {},
 | 
			
		||||
  "_meta": {
 | 
			
		||||
    "columns": {},
 | 
			
		||||
    "schemas": {},
 | 
			
		||||
    "tables": {}
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								drizzle/meta/_journal.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								drizzle/meta/_journal.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
{
 | 
			
		||||
  "version": "7",
 | 
			
		||||
  "dialect": "postgresql",
 | 
			
		||||
  "entries": [
 | 
			
		||||
    {
 | 
			
		||||
      "idx": 0,
 | 
			
		||||
      "version": "7",
 | 
			
		||||
      "when": 1752596363693,
 | 
			
		||||
      "tag": "0000_lazy_prism",
 | 
			
		||||
      "breakpoints": true
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										60
									
								
								eslint.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								eslint.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
import js from '@eslint/js';
 | 
			
		||||
import tseslint from 'typescript-eslint';
 | 
			
		||||
import nextPlugin from '@next/eslint-plugin-next';
 | 
			
		||||
 | 
			
		||||
export default tseslint.config(
 | 
			
		||||
  // Base recommended configs
 | 
			
		||||
  js.configs.recommended,
 | 
			
		||||
 | 
			
		||||
  // Next.js recommended configs
 | 
			
		||||
  {
 | 
			
		||||
    plugins: {
 | 
			
		||||
      '@next/next': nextPlugin,
 | 
			
		||||
    },
 | 
			
		||||
    rules: {
 | 
			
		||||
      ...nextPlugin.configs.recommended.rules,
 | 
			
		||||
      ...nextPlugin.configs['core-web-vitals'].rules,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // TypeScript configs
 | 
			
		||||
  ...tseslint.configs.recommended,
 | 
			
		||||
  ...tseslint.configs.strictTypeChecked,
 | 
			
		||||
  ...tseslint.configs.stylisticTypeChecked,
 | 
			
		||||
 | 
			
		||||
  // Project-specific configuration
 | 
			
		||||
  {
 | 
			
		||||
    languageOptions: {
 | 
			
		||||
      parserOptions: {
 | 
			
		||||
        projectService: true,
 | 
			
		||||
        tsconfigRootDir: import.meta.dirname,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Next.js specific overrides
 | 
			
		||||
  {
 | 
			
		||||
    files: ['**/*.{js,jsx,ts,tsx}'],
 | 
			
		||||
    rules: {
 | 
			
		||||
      // Next.js already handles React imports
 | 
			
		||||
      '@typescript-eslint/no-unused-vars': [
 | 
			
		||||
        'error',
 | 
			
		||||
        {
 | 
			
		||||
          argsIgnorePattern: '^_',
 | 
			
		||||
          varsIgnorePattern: '^_',
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Config files don't need strict type checking
 | 
			
		||||
  {
 | 
			
		||||
    files: ['**/*.config.{js,ts,mjs}', 'tailwind.config.{js,ts}'],
 | 
			
		||||
    ...tseslint.configs.disableTypeChecked,
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Ignore build outputs and dependencies
 | 
			
		||||
  {
 | 
			
		||||
    ignores: ['.next/**', 'node_modules/**', 'dist/**', 'build/**', 'drizzle/**/*.sql'],
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										26
									
								
								lib/auth/cookies.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/auth/cookies.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import { cookies } from 'next/headers';
 | 
			
		||||
 | 
			
		||||
const TOKEN_COOKIE_NAME = 'habit-tracker-token';
 | 
			
		||||
const TOKEN_COOKIE_OPTIONS = {
 | 
			
		||||
  httpOnly: true,
 | 
			
		||||
  secure: process.env.NODE_ENV === 'production',
 | 
			
		||||
  sameSite: 'lax' as const,
 | 
			
		||||
  maxAge: 60 * 60 * 24 * 365, // 1 year
 | 
			
		||||
  path: '/',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export async function setTokenCookie(token: string) {
 | 
			
		||||
  const cookieStore = await cookies();
 | 
			
		||||
  cookieStore.set(TOKEN_COOKIE_NAME, token, TOKEN_COOKIE_OPTIONS);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getTokenCookie(): Promise<string | undefined> {
 | 
			
		||||
  const cookieStore = await cookies();
 | 
			
		||||
  const cookie = cookieStore.get(TOKEN_COOKIE_NAME);
 | 
			
		||||
  return cookie?.value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deleteTokenCookie() {
 | 
			
		||||
  const cookieStore = await cookies();
 | 
			
		||||
  cookieStore.delete(TOKEN_COOKIE_NAME);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										167
									
								
								lib/auth/tokens.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								lib/auth/tokens.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,167 @@
 | 
			
		||||
import { customAlphabet } from 'nanoid';
 | 
			
		||||
 | 
			
		||||
// Word lists for generating memorable tokens
 | 
			
		||||
const adjectives = [
 | 
			
		||||
  'quick',
 | 
			
		||||
  'lazy',
 | 
			
		||||
  'happy',
 | 
			
		||||
  'brave',
 | 
			
		||||
  'bright',
 | 
			
		||||
  'calm',
 | 
			
		||||
  'clever',
 | 
			
		||||
  'eager',
 | 
			
		||||
  'gentle',
 | 
			
		||||
  'kind',
 | 
			
		||||
  'lively',
 | 
			
		||||
  'proud',
 | 
			
		||||
  'silly',
 | 
			
		||||
  'witty',
 | 
			
		||||
  'bold',
 | 
			
		||||
  'cool',
 | 
			
		||||
  'fair',
 | 
			
		||||
  'fine',
 | 
			
		||||
  'glad',
 | 
			
		||||
  'good',
 | 
			
		||||
  'neat',
 | 
			
		||||
  'nice',
 | 
			
		||||
  'rare',
 | 
			
		||||
  'safe',
 | 
			
		||||
  'warm',
 | 
			
		||||
  'wise',
 | 
			
		||||
  'fresh',
 | 
			
		||||
  'clean',
 | 
			
		||||
  'clear',
 | 
			
		||||
  'crisp',
 | 
			
		||||
  'sweet',
 | 
			
		||||
  'smooth',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const colors = [
 | 
			
		||||
  'red',
 | 
			
		||||
  'blue',
 | 
			
		||||
  'green',
 | 
			
		||||
  'yellow',
 | 
			
		||||
  'purple',
 | 
			
		||||
  'orange',
 | 
			
		||||
  'pink',
 | 
			
		||||
  'black',
 | 
			
		||||
  'white',
 | 
			
		||||
  'gray',
 | 
			
		||||
  'brown',
 | 
			
		||||
  'cyan',
 | 
			
		||||
  'lime',
 | 
			
		||||
  'navy',
 | 
			
		||||
  'teal',
 | 
			
		||||
  'gold',
 | 
			
		||||
  'silver',
 | 
			
		||||
  'coral',
 | 
			
		||||
  'salmon',
 | 
			
		||||
  'indigo',
 | 
			
		||||
  'violet',
 | 
			
		||||
  'crimson',
 | 
			
		||||
  'azure',
 | 
			
		||||
  'jade',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const animals = [
 | 
			
		||||
  'cat',
 | 
			
		||||
  'dog',
 | 
			
		||||
  'bird',
 | 
			
		||||
  'fish',
 | 
			
		||||
  'bear',
 | 
			
		||||
  'lion',
 | 
			
		||||
  'wolf',
 | 
			
		||||
  'fox',
 | 
			
		||||
  'deer',
 | 
			
		||||
  'owl',
 | 
			
		||||
  'hawk',
 | 
			
		||||
  'duck',
 | 
			
		||||
  'goat',
 | 
			
		||||
  'seal',
 | 
			
		||||
  'crab',
 | 
			
		||||
  'moth',
 | 
			
		||||
  'bee',
 | 
			
		||||
  'ant',
 | 
			
		||||
  'bat',
 | 
			
		||||
  'cow',
 | 
			
		||||
  'pig',
 | 
			
		||||
  'hen',
 | 
			
		||||
  'ram',
 | 
			
		||||
  'rat',
 | 
			
		||||
  'eel',
 | 
			
		||||
  'cod',
 | 
			
		||||
  'jay',
 | 
			
		||||
  'yak',
 | 
			
		||||
  'ox',
 | 
			
		||||
  'pug',
 | 
			
		||||
  'doe',
 | 
			
		||||
  'hog',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const nouns = [
 | 
			
		||||
  'moon',
 | 
			
		||||
  'star',
 | 
			
		||||
  'cloud',
 | 
			
		||||
  'river',
 | 
			
		||||
  'mountain',
 | 
			
		||||
  'ocean',
 | 
			
		||||
  'forest',
 | 
			
		||||
  'desert',
 | 
			
		||||
  'island',
 | 
			
		||||
  'valley',
 | 
			
		||||
  'meadow',
 | 
			
		||||
  'garden',
 | 
			
		||||
  'bridge',
 | 
			
		||||
  'castle',
 | 
			
		||||
  'tower',
 | 
			
		||||
  'light',
 | 
			
		||||
  'shadow',
 | 
			
		||||
  'dream',
 | 
			
		||||
  'hope',
 | 
			
		||||
  'wish',
 | 
			
		||||
  'song',
 | 
			
		||||
  'dance',
 | 
			
		||||
  'smile',
 | 
			
		||||
  'laugh',
 | 
			
		||||
  'gift',
 | 
			
		||||
  'pearl',
 | 
			
		||||
  'jewel',
 | 
			
		||||
  'crown',
 | 
			
		||||
  'shield',
 | 
			
		||||
  'sword',
 | 
			
		||||
  'arrow',
 | 
			
		||||
  'bow',
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
// Generate a 4-digit number suffix for uniqueness
 | 
			
		||||
const generateNumber = customAlphabet('0123456789', 4);
 | 
			
		||||
 | 
			
		||||
function getRandomElement<T>(array: T[]): T {
 | 
			
		||||
  return array[Math.floor(Math.random() * array.length)];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateMemorableToken(): string {
 | 
			
		||||
  const parts = [
 | 
			
		||||
    getRandomElement(adjectives),
 | 
			
		||||
    getRandomElement(colors),
 | 
			
		||||
    getRandomElement(animals),
 | 
			
		||||
    generateNumber(),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return parts.join('-');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateShortToken(): string {
 | 
			
		||||
  const parts = [getRandomElement(colors), getRandomElement(nouns), generateNumber()];
 | 
			
		||||
 | 
			
		||||
  return parts.join('-');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate token format
 | 
			
		||||
export function isValidToken(token: string): boolean {
 | 
			
		||||
  // Check if token matches our format (word-word-word-4digits or word-word-4digits)
 | 
			
		||||
  const longFormat = /^[a-z]+-[a-z]+-[a-z]+-\d{4}$/;
 | 
			
		||||
  const shortFormat = /^[a-z]+-[a-z]+-\d{4}$/;
 | 
			
		||||
 | 
			
		||||
  return longFormat.test(token) || shortFormat.test(token);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								lib/db/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/db/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import { drizzle } from 'drizzle-orm/node-postgres';
 | 
			
		||||
import * as schema from './schema';
 | 
			
		||||
import dotenv from 'dotenv';
 | 
			
		||||
dotenv.config({ path: ['.env.local', '.env'] });
 | 
			
		||||
 | 
			
		||||
const DATABASE_URL = process.env.POSTGRES_URL;
 | 
			
		||||
if (!DATABASE_URL) {
 | 
			
		||||
  throw new Error('POSTGRES_URL environment variable is required');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const db = drizzle(DATABASE_URL, { schema });
 | 
			
		||||
 | 
			
		||||
// Re-export schema types for convenience
 | 
			
		||||
export * from './schema';
 | 
			
		||||
							
								
								
									
										76
									
								
								lib/db/schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								lib/db/schema.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
			
		||||
import { pgTable, serial, text, timestamp, integer, jsonb, boolean, index } from 'drizzle-orm/pg-core';
 | 
			
		||||
import { relations } from 'drizzle-orm';
 | 
			
		||||
 | 
			
		||||
export const users = pgTable('users', {
 | 
			
		||||
  id: serial('id').primaryKey(),
 | 
			
		||||
  token: text('token').notNull().unique(),
 | 
			
		||||
  createdAt: timestamp('created_at').defaultNow().notNull(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const habits = pgTable(
 | 
			
		||||
  'habits',
 | 
			
		||||
  {
 | 
			
		||||
    id: serial('id').primaryKey(),
 | 
			
		||||
    userId: integer('user_id')
 | 
			
		||||
      .references(() => users.id)
 | 
			
		||||
      .notNull(),
 | 
			
		||||
    name: text('name').notNull(),
 | 
			
		||||
    type: text('type', { enum: ['positive', 'neutral', 'negative'] })
 | 
			
		||||
      .notNull()
 | 
			
		||||
      .default('neutral'),
 | 
			
		||||
    targetFrequency: jsonb('target_frequency').$type<{
 | 
			
		||||
      value: number;
 | 
			
		||||
      period: 'day' | 'week' | 'month';
 | 
			
		||||
    }>(),
 | 
			
		||||
    color: text('color'),
 | 
			
		||||
    icon: text('icon'),
 | 
			
		||||
    isArchived: boolean('is_archived').default(false).notNull(),
 | 
			
		||||
    createdAt: timestamp('created_at').defaultNow().notNull(),
 | 
			
		||||
    archivedAt: timestamp('archived_at'),
 | 
			
		||||
  },
 | 
			
		||||
  (table) => [index('habits_user_id_idx').on(table.userId)],
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const habitLogs = pgTable(
 | 
			
		||||
  'habit_logs',
 | 
			
		||||
  {
 | 
			
		||||
    id: serial('id').primaryKey(),
 | 
			
		||||
    habitId: integer('habit_id')
 | 
			
		||||
      .references(() => habits.id)
 | 
			
		||||
      .notNull(),
 | 
			
		||||
    loggedAt: timestamp('logged_at').defaultNow().notNull(),
 | 
			
		||||
    note: text('note'),
 | 
			
		||||
  },
 | 
			
		||||
  (table) => [
 | 
			
		||||
    index('habit_logs_habit_id_idx').on(table.habitId),
 | 
			
		||||
    index('habit_logs_logged_at_idx').on(table.loggedAt),
 | 
			
		||||
  ],
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Relations
 | 
			
		||||
export const usersRelations = relations(users, ({ many }) => ({
 | 
			
		||||
  habits: many(habits),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export const habitsRelations = relations(habits, ({ one, many }) => ({
 | 
			
		||||
  user: one(users, {
 | 
			
		||||
    fields: [habits.userId],
 | 
			
		||||
    references: [users.id],
 | 
			
		||||
  }),
 | 
			
		||||
  logs: many(habitLogs),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export const habitLogsRelations = relations(habitLogs, ({ one }) => ({
 | 
			
		||||
  habit: one(habits, {
 | 
			
		||||
    fields: [habitLogs.habitId],
 | 
			
		||||
    references: [habits.id],
 | 
			
		||||
  }),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
// Types
 | 
			
		||||
export type User = typeof users.$inferSelect;
 | 
			
		||||
export type NewUser = typeof users.$inferInsert;
 | 
			
		||||
export type Habit = typeof habits.$inferSelect;
 | 
			
		||||
export type NewHabit = typeof habits.$inferInsert;
 | 
			
		||||
export type HabitLog = typeof habitLogs.$inferSelect;
 | 
			
		||||
export type NewHabitLog = typeof habitLogs.$inferInsert;
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import { clsx, type ClassValue } from "clsx"
 | 
			
		||||
import { twMerge } from "tailwind-merge"
 | 
			
		||||
import { clsx, type ClassValue } from 'clsx';
 | 
			
		||||
import { twMerge } from 'tailwind-merge';
 | 
			
		||||
 | 
			
		||||
export function cn(...inputs: ClassValue[]) {
 | 
			
		||||
  return twMerge(clsx(inputs))
 | 
			
		||||
  return twMerge(clsx(inputs));
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								middleware.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								middleware.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
import { NextResponse } from 'next/server';
 | 
			
		||||
import type { NextRequest } from 'next/server';
 | 
			
		||||
 | 
			
		||||
export function middleware(request: NextRequest) {
 | 
			
		||||
  const token = request.cookies.get('habit-tracker-token');
 | 
			
		||||
  const isAuthPage = request.nextUrl.pathname === '/welcome';
 | 
			
		||||
  const isDashboard = request.nextUrl.pathname === '/dashboard';
 | 
			
		||||
 | 
			
		||||
  // If trying to access dashboard without token, redirect to welcome
 | 
			
		||||
  if (isDashboard && !token) {
 | 
			
		||||
    return NextResponse.redirect(new URL('/welcome', request.url));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // If trying to access welcome page with token, redirect to dashboard
 | 
			
		||||
  if (isAuthPage && token) {
 | 
			
		||||
    return NextResponse.redirect(new URL('/dashboard', request.url));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return NextResponse.next();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const config = {
 | 
			
		||||
  matcher: ['/dashboard', '/welcome'],
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										8596
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8596
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										72
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										72
									
								
								package.json
									
									
									
									
									
								
							@@ -2,38 +2,66 @@
 | 
			
		||||
  "name": "trackeveryday",
 | 
			
		||||
  "version": "0.1.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "next dev --turbopack",
 | 
			
		||||
    "build": "next build",
 | 
			
		||||
    "start": "next start",
 | 
			
		||||
    "lint": "next lint && npx tsc --noEmit"
 | 
			
		||||
    "check": "next lint && npx tsc --noEmit",
 | 
			
		||||
    "db:generate": "drizzle-kit generate",
 | 
			
		||||
    "db:migrate": "drizzle-kit migrate",
 | 
			
		||||
    "db:push": "drizzle-kit push",
 | 
			
		||||
    "db:studio": "drizzle-kit studio",
 | 
			
		||||
    "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
 | 
			
		||||
    "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
    "@radix-ui/react-select": "^2.2.5",
 | 
			
		||||
    "@radix-ui/react-separator": "^1.1.7",
 | 
			
		||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "@radix-ui/react-tooltip": "^1.2.7",
 | 
			
		||||
    "@tanstack/react-query": "^5.83.0",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "cssnano": "^7.0.1",
 | 
			
		||||
    "lucide-react": "^0.509.0",
 | 
			
		||||
    "next": "15.3.2",
 | 
			
		||||
    "next-plausible": "^3.12.0",
 | 
			
		||||
    "cssnano": "^7.1.0",
 | 
			
		||||
    "date-fns": "^4.1.0",
 | 
			
		||||
    "dotenv": "^17.2.0",
 | 
			
		||||
    "drizzle-orm": "^0.44.3",
 | 
			
		||||
    "lucide-react": "^0.552.0",
 | 
			
		||||
    "nanoid": "^5.1.5",
 | 
			
		||||
    "next": "15.5.6",
 | 
			
		||||
    "next-plausible": "^3.12.4",
 | 
			
		||||
    "pg": "^8.16.3",
 | 
			
		||||
    "pg-native": "^3.5.2",
 | 
			
		||||
    "postcss-flexbugs-fixes": "^5.0.2",
 | 
			
		||||
    "postcss-preset-env": "^10.0.0",
 | 
			
		||||
    "react": "19.1.0",
 | 
			
		||||
    "react-dom": "19.1.0",
 | 
			
		||||
    "sharp": "^0.34.0",
 | 
			
		||||
    "tailwind-merge": "^3.0.0",
 | 
			
		||||
    "postcss-preset-env": "^10.2.4",
 | 
			
		||||
    "react": "19.2.0",
 | 
			
		||||
    "react-dom": "19.2.0",
 | 
			
		||||
    "tailwind-merge": "^3.3.1",
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@tailwindcss/postcss": "^4.1.6",
 | 
			
		||||
    "@types/node": "22.15.17",
 | 
			
		||||
    "@types/react": "19.1.3",
 | 
			
		||||
    "@types/react-dom": "19.1.3",
 | 
			
		||||
    "eslint": "8.57.1",
 | 
			
		||||
    "eslint-config-next": "15.3.2",
 | 
			
		||||
    "postcss": "8.5.3",
 | 
			
		||||
    "tailwindcss": "^4.1.6",
 | 
			
		||||
    "turbo": "2.5.3",
 | 
			
		||||
    "typescript": "5.8.3"
 | 
			
		||||
    "@eslint/js": "9.39.0",
 | 
			
		||||
    "@next/eslint-plugin-next": "15.5.6",
 | 
			
		||||
    "@tailwindcss/postcss": "4.1.16",
 | 
			
		||||
    "@types/node": "22.18.13",
 | 
			
		||||
    "@types/pg": "8.15.6",
 | 
			
		||||
    "@types/react": "19.2.2",
 | 
			
		||||
    "@types/react-dom": "19.2.2",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "8.46.2",
 | 
			
		||||
    "@typescript-eslint/parser": "8.46.2",
 | 
			
		||||
    "drizzle-kit": "0.31.6",
 | 
			
		||||
    "eslint": "9.39.0",
 | 
			
		||||
    "eslint-config-next": "15.5.6",
 | 
			
		||||
    "postcss": "8.5.6",
 | 
			
		||||
    "prettier": "3.6.2",
 | 
			
		||||
    "prettier-plugin-tailwindcss": "0.7.1",
 | 
			
		||||
    "tailwindcss": "4.1.16",
 | 
			
		||||
    "turbo": "2.6.0",
 | 
			
		||||
    "typescript": "5.9.3",
 | 
			
		||||
    "typescript-eslint": "8.46.2"
 | 
			
		||||
  },
 | 
			
		||||
  "packageManager": "npm@11.3.0"
 | 
			
		||||
  "packageManager": "pnpm@10.20.0"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7418
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										7418
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										3
									
								
								pnpm-workspace.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								pnpm-workspace.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
onlyBuiltDependencies:
 | 
			
		||||
  - '@tailwindcss/oxide'
 | 
			
		||||
  - sharp
 | 
			
		||||
		Reference in New Issue
	
	Block a user