Compare commits

...

3 Commits

Author SHA1 Message Date
f64fc274a7 Adds Playwright E2E and Vitest test infrastructure
Some checks failed
Lint / Lint and Check (push) Failing after 48s
Integrates Playwright for end-to-end browser testing with automated web server setup, example smoke tests, and CI-compatible configuration. Introduces Vitest, Testing Library, and related utilities for fast component and unit testing.

Updates scripts, development dependencies, and lockfile to support both test suites. Establishes unified testing commands for local and CI workflows, laying groundwork for comprehensive automated UI and integration coverage.
2025-11-24 22:43:46 +01:00
65f1fcb7bb Adds habit edit, archive, and undo log features
Enables users to update or archive habits and to undo the latest habit log.
Adds PATCH/DELETE API endpoints for habit edit and soft deletion.
Introduces UI dialogs and controls for editing and archiving habits,
as well as for undoing the most recent log entry directly from the dashboard.
Improves log statistics handling by ordering and simplifies last log detection.
2025-11-24 22:12:25 +01:00
55950e9473 Plan 2025-11-24 22:02:40 +01:00
14 changed files with 1778 additions and 32 deletions

20
.cursorrules Normal file
View File

@@ -0,0 +1,20 @@
# Project Rules
## Testing Policy
- **Mandatory Tests**: All new features and bug fixes must be accompanied by tests.
- **Unit/Integration Tests**: Use **Vitest** for testing utilities, hooks, and components.
- **E2E Tests**: Use **Playwright** for critical user flows (auth, core features).
- **Coverage**: Aim for high coverage on business logic and critical paths.
## Tech Stack
- **Framework**: Next.js 15 (App Router)
- **Language**: TypeScript
- **Styling**: Tailwind CSS
- **Database**: PostgreSQL with Drizzle ORM
- **State Management**: React Query
## Code Style
- **Functional Components**: Use arrow functions for components.
- **Types**: strict TypeScript usage (avoid `any`).
- **Imports**: Use absolute imports (`@/...`).

4
.gitignore vendored
View File

@@ -36,3 +36,7 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
.turbo/ .turbo/
playwright-report/
test-results/

View File

@@ -4,6 +4,79 @@ https://trackevery.day/
A simple, privacy-focused habit tracking web app. Track anything, every day. A simple, privacy-focused habit tracking web app. Track anything, every day.
## 🎯 Vision & Goal
**Goal**: To provide the most frictionless, privacy-respecting tool for users to build consistency in their lives without the barrier of complex sign-ups or data tracking concerns.
**Vision**: A world where self-improvement is accessible to everyone without trading their privacy for it. `trackevery-day` aims to become the standard for "unaccounted" personal tracking, eventually expanding into a broader minimalist "life logger" platform.
## 💼 Business Model
This project operates on a sustainable Open Source model:
1. **Core Product (Free & Open Source)**: The full application is available for free. Users can self-host or use the public instance.
2. **Supporter Tier (Future)**: Optional premium features for power users who want to support development:
- Advanced Data Analysis & Trends
- Encrypted Cloud Backups
- API Access for integrations
3. **Donations**: Community support via GitHub Sponsors / Ko-fi to cover hosting costs.
---
## 🗺️ Roadmap & Tasks
We are building this out in phases. Below is the breakdown of problems into small, actionable tasks.
### Phase 1: Core Refinement (Current Focus)
_Goal: Polish the existing functionality to be feature-complete._
- [ ] **Habit Management**
- [ ] Add "Edit Habit" functionality (rename, change type/color).
- [ ] Add "Delete/Archive Habit" functionality (UI implementation).
- [ ] Implement "Undo Log" (remove accidental logs).
- [ ] **Visualization**
- [ ] Add a "Contribution Graph" (GitHub style) heatmap for each habit.
- [ ] Add a simple line chart for "Frequency over Time".
- [ ] **UX Improvements**
- [ ] specific mobile-responsive tweaks for the dashboard grid.
- [ ] Add a "Settings" page to manage the token (regenerate, view).
### Phase 2: Data Sovereignty
_Goal: Ensure users truly own their data._
- [ ] **Export/Import**
- [ ] Create JSON export handler.
- [ ] Create CSV export handler (for spreadsheet analysis).
- [ ] Build a "Restore from Backup" feature (JSON import).
- [ ] **Local-First Enhancements**
- [ ] Cache habit data in `localStorage` for faster load times.
- [ ] Implement offline queuing for logs when network is unavailable.
### Phase 3: Engagement & Growth
_Goal: Help users stay consistent._
- [ ] **PWA Implementation**
- [ ] Add `manifest.json` and service workers.
- [ ] Enable "Add to Home Screen" prompt.
- [ ] **Gamification (Subtle)**
- [ ] Visual rewards for hitting streaks (confetti, badges).
- [ ] "Levels" based on total consistency score.
- [ ] **Notifications**
- [ ] Browser-based push notifications for reminders (optional).
### Phase 4: Advanced Features (Supporter Tier)
_Goal: Power features for data nerds._
- [ ] **Public Profile** (Optional public shareable link for specific habits).
- [ ] **API Access** (Generate API keys to log via curl/scripts).
- [ ] **Webhooks** (Trigger events when a habit is logged).
---
## ✨ Features ## ✨ Features
- **Token-based authentication** - No email or password required - **Token-based authentication** - No email or password required

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users, habitLogs } from '@/lib/db'; import { db, habits, users, habitLogs } from '@/lib/db';
import { getTokenCookie } from '@/lib/auth/cookies'; import { getTokenCookie } from '@/lib/auth/cookies';
import { eq, and } from 'drizzle-orm'; import { eq, and, desc } from 'drizzle-orm';
async function getUserFromToken() { async function getUserFromToken() {
const token = await getTokenCookie(); const token = await getTokenCookie();
@@ -58,3 +58,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
} }
} }
export async function DELETE(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 });
}
// Find latest log
const latestLog = await db
.select()
.from(habitLogs)
.where(eq(habitLogs.habitId, habitId))
.orderBy(desc(habitLogs.loggedAt))
.limit(1);
if (latestLog.length === 0) {
return NextResponse.json({ error: 'No logs to undo' }, { status: 404 });
}
// Delete latest log
await db.delete(habitLogs).where(eq(habitLogs.id, latestLog[0].id));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Undo log error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, habits, users } 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 PATCH(
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 {
name?: string;
type?: string;
color?: string;
icon?: string;
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
};
const { name, type, color, icon, targetFrequency } = body;
// Validate type if provided
if (type && !['positive', 'neutral', 'negative'].includes(type)) {
return NextResponse.json(
{ error: 'Type must be one of: positive, neutral, negative' },
{ status: 400 }
);
}
const updatedHabitRows = await db
.update(habits)
.set({
...(name && { name }),
...(type && { type: type as 'positive' | 'neutral' | 'negative' }),
...(color && { color }),
...(icon && { icon }),
...(targetFrequency && { targetFrequency }),
})
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)))
.returning();
return NextResponse.json({ habit: updatedHabitRows[0] });
} catch (error) {
console.error('Update habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(
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 });
}
// Soft delete (archive)
await db
.update(habits)
.set({ isArchived: true, archivedAt: new Date() })
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete habit error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -33,26 +33,22 @@ export async function GET() {
// Then get aggregated log data for each habit // Then get aggregated log data for each habit
const habitsWithStats = await Promise.all( const habitsWithStats = await Promise.all(
userHabitsBase.map(async (habit) => { userHabitsBase.map(async (habit) => {
// Get all logs for this habit // Get all logs for this habit, ordered by date desc
const logs = await db const logs = await db
.select({ .select({
id: habitLogs.id,
loggedAt: habitLogs.loggedAt, loggedAt: habitLogs.loggedAt,
}) })
.from(habitLogs) .from(habitLogs)
.where(eq(habitLogs.habitId, habit.id)); .where(eq(habitLogs.habitId, habit.id))
.orderBy(desc(habitLogs.loggedAt));
// Calculate statistics // Calculate statistics
const totalLogs = logs.length; const totalLogs = logs.length;
const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length; const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length; const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
const lastLoggedAt = const lastLoggedAt = logs.length > 0 ? logs[0].loggedAt : null;
logs.length > 0
? logs.reduce(
(latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
logs[0].loggedAt,
)
: null;
return { return {
id: habit.id, id: habit.id,
name: habit.name, name: habit.name,

120
app/dashboard/page.test.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Dashboard from './page';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Mock fetch
global.fetch = vi.fn();
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
describe('Dashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock Auth Response
(global.fetch as any).mockImplementation((url: string) => {
if (url === '/api/auth') {
return Promise.resolve({
json: () => Promise.resolve({ authenticated: true, token: 'test-token' }),
});
}
if (url === '/api/habits') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
habits: [
{
id: 1,
name: 'Test Habit',
type: 'neutral',
totalLogs: 5,
logsLastWeek: 2,
logsLastMonth: 5,
createdAt: new Date().toISOString(),
lastLoggedAt: new Date().toISOString(),
}
]
}),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
});
});
it('renders habits correctly', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
});
it('opens edit dialog when edit button is clicked', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
const editBtn = screen.getByTestId('edit-habit-1');
fireEvent.click(editBtn);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Edit Habit')).toBeInTheDocument();
expect(screen.getByDisplayValue('Test Habit')).toBeInTheDocument();
});
});
it('opens archive confirmation when archive button is clicked', async () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<Dashboard />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Test Habit')).toBeInTheDocument();
});
// Open Edit Dialog
fireEvent.click(screen.getByTestId('edit-habit-1'));
await waitFor(() => screen.getByRole('dialog'));
// Click Archive
fireEvent.click(screen.getByText('Archive Habit'));
await waitFor(() => {
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
});
});
});

View File

@@ -16,6 +16,10 @@ import {
Check, Check,
Trophy, Trophy,
HeartCrack, HeartCrack,
MoreVertical,
Trash2,
Save,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -73,9 +77,17 @@ interface HabitResponse {
export default function Dashboard() { export default function Dashboard() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false); const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
const [newHabitName, setNewHabitName] = useState(''); const [newHabitName, setNewHabitName] = useState('');
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral'); const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [editingHabit, setEditingHabit] = useState<Habit | null>(null);
const [editHabitName, setEditHabitName] = useState('');
const [editHabitType, setEditHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [copiedToken, setCopiedToken] = useState(false); const [copiedToken, setCopiedToken] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now()); const [currentTime, setCurrentTime] = useState(() => Date.now());
@@ -92,14 +104,12 @@ export default function Dashboard() {
}, },
}); });
// Update current time periodically to avoid impure Date.now() calls during render // Update current time periodically
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentTime(Date.now()); setCurrentTime(Date.now());
}, 60000); // Update every minute }, 60000);
return () => { return () => clearInterval(interval);
clearInterval(interval);
};
}, []); }, []);
// Fetch habits // Fetch habits
@@ -129,6 +139,19 @@ export default function Dashboard() {
}, },
}); });
// Undo log mutation
const undoLogMutation = useMutation<void, Error, number>({
mutationFn: async (habitId: number): Promise<void> => {
const res = await fetch(`/api/habits/${habitId}/log`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to undo log');
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
},
});
// Create habit mutation // Create habit mutation
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({ const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => { mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
@@ -148,6 +171,38 @@ export default function Dashboard() {
}, },
}); });
// Update habit mutation
const updateHabitMutation = useMutation<HabitResponse, Error, { id: number; name: string; type: string }>({
mutationFn: async ({ id, name, type }): Promise<HabitResponse> => {
const res = await fetch(`/api/habits/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type }),
});
if (!res.ok) throw new Error('Failed to update habit');
return res.json() as Promise<HabitResponse>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setEditingHabit(null);
},
});
// Delete habit mutation
const deleteHabitMutation = useMutation<void, Error, number>({
mutationFn: async (id: number): Promise<void> => {
const res = await fetch(`/api/habits/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete habit');
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['habits'] });
setEditingHabit(null);
setShowDeleteConfirm(false);
},
});
const handleCreateHabit = () => { const handleCreateHabit = () => {
if (newHabitName.trim()) { if (newHabitName.trim()) {
createHabitMutation.mutate({ createHabitMutation.mutate({
@@ -157,6 +212,30 @@ export default function Dashboard() {
} }
}; };
const handleUpdateHabit = () => {
if (editingHabit && editHabitName.trim()) {
updateHabitMutation.mutate({
id: editingHabit.id,
name: editHabitName.trim(),
type: editHabitType,
});
}
};
const handleDeleteHabit = () => {
if (editingHabit) {
deleteHabitMutation.mutate(editingHabit.id);
}
};
const openEditDialog = (e: React.MouseEvent, habit: Habit) => {
e.stopPropagation();
setEditingHabit(habit);
setEditHabitName(habit.name);
setEditHabitType(habit.type);
setShowDeleteConfirm(false);
};
const copyToken = () => { const copyToken = () => {
if (authData?.token) { if (authData?.token) {
void navigator.clipboard.writeText(authData.token); void navigator.clipboard.writeText(authData.token);
@@ -355,7 +434,7 @@ export default function Dashboard() {
{habits.map((habit: Habit) => ( {habits.map((habit: Habit) => (
<Card <Card
key={habit.id} key={habit.id}
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass( className={`group relative transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
habit.type, habit.type,
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`} )} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
onClick={() => { onClick={() => {
@@ -365,19 +444,54 @@ export default function Dashboard() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<CardTitle className="text-lg">{habit.name}</CardTitle> <CardTitle className="text-lg">{habit.name}</CardTitle>
{getHabitIcon(habit.type)} <div className="flex items-center gap-2">
{getHabitIcon(habit.type)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/20"
onClick={(e) => openEditDialog(e, habit)}
data-testid={`edit-habit-${habit.id}`}
>
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{/* Last logged */} {/* Last logged */}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center justify-between text-sm">
<Clock className="h-4 w-4 text-zinc-500" /> <div className="flex items-center gap-2">
<span className="text-zinc-400"> <Clock className="h-4 w-4 text-zinc-500" />
{habit.lastLoggedAt <span className="text-zinc-400">
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true }) {habit.lastLoggedAt
: 'Never logged'} ? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
</span> : 'Never logged'}
</span>
</div>
{habit.lastLoggedAt && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-zinc-500 hover:text-red-400 hover:bg-red-950/30"
onClick={(e) => {
e.stopPropagation();
undoLogMutation.mutate(habit.id);
}}
>
<RotateCcw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Undo last log</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
<Separator className="bg-zinc-800" /> <Separator className="bg-zinc-800" />
@@ -439,6 +553,105 @@ export default function Dashboard() {
</Card> </Card>
)} )}
</div> </div>
{/* Edit Habit Dialog */}
<Dialog open={!!editingHabit} onOpenChange={(open) => !open && setEditingHabit(null)}>
<DialogContent className="border-zinc-800 bg-zinc-950">
<DialogHeader>
<DialogTitle>Edit Habit</DialogTitle>
<DialogDescription>
Modify your habit details or archive it if you no longer want to track it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Habit Name</Label>
<Input
id="edit-name"
value={editHabitName}
onChange={(e) => setEditHabitName(e.target.value)}
className="border-zinc-800 bg-zinc-900"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-type">Habit Type</Label>
<Select
value={editHabitType}
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
setEditHabitType(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 className="flex-col items-stretch gap-2 sm:flex-row sm:justify-between">
<div className="flex flex-1 justify-start">
{showDeleteConfirm ? (
<div className="flex items-center gap-2">
<Button
variant="destructive"
onClick={handleDeleteHabit}
disabled={deleteHabitMutation.isPending}
>
{deleteHabitMutation.isPending ? 'Deleting...' : 'Confirm Delete'}
</Button>
<Button variant="ghost" onClick={() => setShowDeleteConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button
variant="outline"
className="border-red-900 text-red-500 hover:bg-red-950 hover:text-red-400"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Archive Habit
</Button>
)}
</div>
<div className="flex items-center gap-2 justify-end">
<Button
variant="outline"
onClick={() => setEditingHabit(null)}
>
Cancel
</Button>
<Button
onClick={handleUpdateHabit}
disabled={!editHabitName.trim() || updateHabitMutation.isPending}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Save className="mr-2 h-4 w-4" />
{updateHabitMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
); );

View File

@@ -8,6 +8,9 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next typegen && eslint . && npx tsc --noEmit", "lint": "next typegen && eslint . && npx tsc --noEmit",
"test": "vitest",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
@@ -43,22 +46,29 @@
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.1",
"@tailwindcss/postcss": "4.1.17", "@tailwindcss/postcss": "4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/pg": "8.15.6", "@types/pg": "8.15.6",
"@types/react": "19.2.6", "@types/react": "19.2.6",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"drizzle-kit": "0.31.7", "drizzle-kit": "0.31.7",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-next": "16.0.3", "eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"jsdom": "^27.2.0",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1", "prettier-plugin-tailwindcss": "0.7.1",
"tailwindcss": "4.1.17", "tailwindcss": "4.1.17",
"turbo": "2.6.1", "turbo": "2.6.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.47.0" "typescript-eslint": "8.47.0",
"vitest": "^4.0.13"
}, },
"packageManager": "pnpm@10.23.0", "packageManager": "pnpm@10.23.0",
"pnpm": { "pnpm": {

26
playwright.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

1100
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

14
tests/e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test';
test('landing page loads and has create account button', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Track Every Day' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Start Tracking Now' })).toBeVisible();
});
test('can navigate to login input', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'I Have a Token' }).click();
await expect(page.getByLabel('Access Token')).toBeVisible();
});

17
vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
alias: {
'@': path.resolve(__dirname, './'),
},
// Exclude E2E tests from Vitest
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/tests/e2e/**'],
},
});

2
vitest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
import '@testing-library/jest-dom';