diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000..af1fbe6
--- /dev/null
+++ b/.cursorrules
@@ -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 (`@/...`).
+
diff --git a/.gitignore b/.gitignore
index dce92ca..eaed81d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,7 @@ yarn-error.log*
next-env.d.ts
.turbo/
+
+playwright-report/
+
+test-results/
diff --git a/app/dashboard/page.test.tsx b/app/dashboard/page.test.tsx
new file mode 100644
index 0000000..9bce6a0
--- /dev/null
+++ b/app/dashboard/page.test.tsx
@@ -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(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Habit')).toBeInTheDocument();
+ });
+ });
+
+ it('opens edit dialog when edit button is clicked', async () => {
+ render(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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();
+ });
+ });
+});
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index d66ad68..af42ef5 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -451,6 +451,7 @@ export default function Dashboard() {
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}`}
>
diff --git a/package.json b/package.json
index 53c7b2f..b77e76f 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,9 @@
"build": "next build",
"start": "next start",
"lint": "next typegen && eslint . && npx tsc --noEmit",
+ "test": "vitest",
+ "test:e2e": "playwright test",
+ "test:coverage": "vitest run --coverage",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..9e5c8d0
--- /dev/null
+++ b/playwright.config.ts
@@ -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,
+ },
+});
+
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
new file mode 100644
index 0000000..8daf6ff
--- /dev/null
+++ b/tests/e2e/smoke.spec.ts
@@ -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();
+});
+
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..deef3bc
--- /dev/null
+++ b/vitest.config.ts
@@ -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/**'],
+ },
+});
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 0000000..adee3c8
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1,2 @@
+import '@testing-library/jest-dom';
+