chore(deps): update nextjs monorepo to v15 (major) - autoclosed #7

Closed
renovate-bot wants to merge 0 commits from renovate/major-nextjs-monorepo into main
44 changed files with 8586 additions and 10007 deletions

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -7,25 +7,21 @@ on:
- "**" # matches every branch - "**" # matches every branch
jobs: jobs:
lint_and_check: eslint:
name: Lint and Check name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 20
cache: "pnpm" cache: "npm"
- run: npm i
- name: Install dependencies - uses: sibiraj-s/action-eslint@bcf41bb9abce43cdbad51ab9b3da2eddaa17eab3 # v3.0.1
run: pnpm install with:
eslint-args: "--ignore-path=.gitignore --quiet"
- name: Run check extensions: "js,jsx,ts,tsx"
run: pnpm run check annotations: true
all-files: true

View File

@@ -0,0 +1,33 @@
on:
push:
branches:
- main
- dev
- "!renovate/**"
pull_request:
types: [opened, synchronize, reopened]
name: SonarQube Scan
jobs:
sonarqube:
name: SonarQube Trigger
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
# Disabling shallow clone is recommended for improving relevancy of reporting
fetch-depth: 0
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@1b442ee39ac3fa7c2acdd410208dcb2bcfaae6c4 # v4.1.0
env:
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST }}
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
# Check the Quality Gate status.
- name: SonarQube Quality Gate check
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 2
env:
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST }}
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}

2
.gitignore vendored
View File

@@ -34,5 +34,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.turbo/

View File

@@ -1,6 +0,0 @@
{
"tabWidth": 2,
"singleQuote": true,
"printWidth": 105,
"plugins": ["prettier-plugin-tailwindcss"]
}

137
README.md
View File

@@ -1,137 +1,4 @@
# 📅 Track Every Day # trackevery-day
https://trackevery.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

View File

@@ -1,115 +0,0 @@
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 },
);
}
}

View File

@@ -1,60 +0,0 @@
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 });
}
}

View File

@@ -1,136 +0,0 @@
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 });
}
}

View File

@@ -1,441 +0,0 @@
'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&apos;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&apos;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>
);
}

View File

@@ -1,75 +1,7 @@
@import "tailwindcss"; @tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant dark (&:is(.dark *));
@theme {
--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));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility text-balance {
text-wrap: balance;
}
@layer utilities {
:root { :root {
--foreground-rgb: 0, 0, 0; --foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220; --background-start-rgb: 214, 219, 220;
@@ -83,18 +15,50 @@
--background-end-rgb: 0, 0, 0; --background-end-rgb: 0, 0, 0;
} }
} }
@layer utilities {
.text-balance {
text-wrap: balance;
}
} }
@layer base { @layer base {
:root { :root {
--background: 0 0% 0%; --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%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 142.1 76.2% 36.3%; --primary: 0 0% 98%;
--primary-foreground: 355.7 100% 97.3%; --primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%; --secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
@@ -105,13 +69,12 @@
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%; --input: 240 3.7% 15.9%;
--ring: 142.1 76.2% 36.3%; --ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
--radius: 0.5rem;
} }
} }
@@ -119,9 +82,6 @@
* { * {
@apply border-border; @apply border-border;
} }
html {
@apply bg-black;
}
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

View File

@@ -1,23 +1,22 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from "next";
import { Inter } from 'next/font/google'; import { Inter } from "next/font/google";
import PlausibleProvider from 'next-plausible'; import PlausibleProvider from "next-plausible";
import './globals.css'; import "./globals.css";
import { cn } from '@/lib/utils'; 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 = { export const viewport: Viewport = {
colorScheme: 'dark', colorScheme: "dark",
themeColor: [ themeColor: [
//{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" }, //{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" },
//{ media: "(prefers-color-scheme: dark)", color: "#171717" }, //{ media: "(prefers-color-scheme: dark)", color: "#171717" },
{ color: '#052e16' }, { color: "#052e16" },
], ],
}; };
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Track Every Day!', title: "Track Every Day!",
description: 'A web app for tracking habits, activities and vices.', description: "A web app for tracking habits, activities and vices.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -36,8 +35,13 @@ export default function RootLayout({
trackOutboundLinks={true} trackOutboundLinks={true}
/> />
</head> </head>
<body className={cn('bg-background min-h-screen font-sans antialiased', inter.variable)}> <body
<Providers>{children}</Providers> className={cn(
"min-h-screen bg-background font-sans antialiased",
inter.variable
)}
>
{children}
</body> </body>
</html> </html>
); );

View File

@@ -1,14 +1,5 @@
import { cookies } from 'next/headers'; import { redirect } from "next/navigation";
import { redirect } from 'next/navigation';
export default async function Home() { export default function Home() {
const cookieStore = await cookies(); redirect("/welcome");
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');
}
} }

View File

@@ -1,20 +0,0 @@
'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>;
}

View File

@@ -3,5 +3,9 @@ export default function Layout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return <div className="flex min-h-screen items-center justify-center bg-black p-4">{children}</div>; 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>
);
} }

View File

@@ -1,219 +1,12 @@
'use client'; export default function Home() {
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 ( return (
<Card className="w-full max-w-md border-zinc-800 bg-zinc-950"> <div className="shadow-xl rounded-lg w-full border px-6 py-12 bg-emerald-900 border-emerald-700 ">
<CardHeader className="space-y-2 text-center"> <div className="flex flex-col">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-950"> <span className="text-4xl font-bold">📅 Track Every Day</span>
<span className="text-3xl">📅</span> <span className="mt-4 text-center">
</div> A web app for logging your habits, vices and activities.
<CardTitle className="text-3xl font-bold">Track Every Day</CardTitle> </span>
<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>
</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>
); );
} }

View File

@@ -1,60 +0,0 @@
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 };

View File

@@ -1,37 +0,0 @@
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 };

View File

@@ -1,52 +0,0 @@
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 };

View File

@@ -1,71 +0,0 @@
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 };

View File

@@ -1,126 +0,0 @@
'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,
};

View File

@@ -1,21 +0,0 @@
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 };

View File

@@ -1,21 +0,0 @@
'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 };

View File

@@ -1,170 +0,0 @@
'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,
};

View File

@@ -1,28 +0,0 @@
'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 };

View File

@@ -1,53 +0,0 @@
'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 };

View File

@@ -1,17 +0,0 @@
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',
});

View File

@@ -1,32 +0,0 @@
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");

View File

@@ -1,248 +0,0 @@
{
"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": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752596363693,
"tag": "0000_lazy_prism",
"breakpoints": true
}
]
}

View File

@@ -1,60 +0,0 @@
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'],
},
);

View File

@@ -1,26 +0,0 @@
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);
}

View File

@@ -1,167 +0,0 @@
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);
}

View File

@@ -1,14 +0,0 @@
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';

View File

@@ -1,76 +0,0 @@
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;

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from "clsx"
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs))
} }

View File

@@ -1,24 +0,0 @@
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'],
};

8356
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,66 +2,37 @@
"name": "trackeveryday", "name": "trackeveryday",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbo",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"check": "next lint && npx tsc --noEmit", "lint": "next lint"
"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": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14", "autoprefixer": "^10.4.19",
"@radix-ui/react-label": "^2.1.7", "class-variance-authority": "^0.7.0",
"@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", "clsx": "^2.1.1",
"cssnano": "^7.1.0", "cssnano": "^7.0.1",
"date-fns": "^4.1.0", "lucide-react": "^0.468.0",
"dotenv": "^17.2.0", "next": "15.0.4",
"drizzle-orm": "^0.44.3", "next-plausible": "^3.12.0",
"lucide-react": "^0.526.0",
"nanoid": "^5.1.5",
"next": "15.4.4",
"next-plausible": "^3.12.4",
"pg": "^8.16.3",
"pg-native": "^3.5.2",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-preset-env": "^10.2.4", "postcss-preset-env": "^10.0.0",
"react": "19.1.0", "react": "^18",
"react-dom": "19.1.0", "react-dom": "^18",
"tailwind-merge": "^3.3.1", "sharp": "^0.33.4",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.32.0", "@types/node": "22.10.1",
"@next/eslint-plugin-next": "15.4.4", "@types/react": "18.3.14",
"@tailwindcss/postcss": "4.1.11", "@types/react-dom": "18.3.2",
"@types/node": "22.16.5", "eslint": "8.57.1",
"@types/pg": "8.15.4", "eslint-config-next": "15.0.4",
"@types/react": "19.1.8", "postcss": "8.4.49",
"@types/react-dom": "19.1.6", "tailwindcss": "3.4.16",
"@typescript-eslint/eslint-plugin": "8.38.0", "turbo": "2.3.3",
"@typescript-eslint/parser": "8.38.0", "typescript": "5.7.2"
"drizzle-kit": "0.31.4", }
"eslint": "9.32.0",
"eslint-config-next": "15.4.4",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.11",
"turbo": "2.5.5",
"typescript": "5.8.3",
"typescript-eslint": "8.38.0"
},
"packageManager": "pnpm@10.13.1"
} }

7370
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- sharp

View File

@@ -1,8 +1,9 @@
/** @type {import('postcss-load-config').Config} */ /** @type {import('postcss-load-config').Config} */
const config = { const config = {
plugins: { plugins: {
'@tailwindcss/postcss': {}, tailwindcss: {},
autoprefixer: {},
"postcss-flexbugs-fixes": { "postcss-flexbugs-fixes": {
"postcss-preset-env": { "postcss-preset-env": {
autoprefixer: { autoprefixer: {

70
tailwind.config.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./lib/**/*.{ts,tsx,js,jsx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)"]
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

View File

@@ -19,8 +19,7 @@
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
}, }
"target": "ES2022"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]