chore(deps): update dependency eslint to v9 #4

Closed
renovate-bot wants to merge 0 commits from renovate/major-eslint-monorepo into main
44 changed files with 8329 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
with:
# Disabling shallow clone is recommended for improving relevancy of reporting
fetch-depth: 0
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@0c0f3958d90fc466625f1d1af1f47bddd4cc6bd1 # v3.0.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"]
}

139
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,100 +1,64 @@
@import "tailwindcss"; @tailwind base;
@tailwind components;
@tailwind utilities;
@custom-variant dark (&:is(.dark *)); :root {
--foreground-rgb: 0, 0, 0;
@theme { --background-start-rgb: 214, 219, 220;
--font-sans: var(--font-sans); --background-end-rgb: 255, 255, 255;
--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));
} }
/* @media (prefers-color-scheme: dark) {
The default border color has changed to `currentcolor` in Tailwind CSS v4, :root {
so we've added these compatibility styles to make sure everything still --foreground-rgb: 255, 255, 255;
looks the same as it did with Tailwind CSS v3. --background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
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 { @layer utilities {
:root { .text-balance {
--foreground-rgb: 0, 0, 0; text-wrap: balance;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
} }
} }
@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"> </div>
Build better habits, one day at a time. No email or password required. </div>
</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>
); );
} }

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'],
};

8099
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.439.0",
"dotenv": "^17.2.0", "next": "14.2.8",
"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": "20.16.5",
"@next/eslint-plugin-next": "15.4.4", "@types/react": "18.3.5",
"@tailwindcss/postcss": "4.1.11", "@types/react-dom": "18.3.0",
"@types/node": "22.16.5", "eslint": "9.10.0",
"@types/pg": "8.15.4", "eslint-config-next": "14.2.8",
"@types/react": "19.1.8", "postcss": "8.4.45",
"@types/react-dom": "19.1.6", "tailwindcss": "3.4.10",
"@typescript-eslint/eslint-plugin": "8.38.0", "turbo": "2.1.1",
"@typescript-eslint/parser": "8.38.0", "typescript": "5.5.4"
"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"]