diff --git a/apps/web/__tests__/api/habits.test.ts b/apps/web/__tests__/api/habits.test.ts new file mode 100644 index 0000000..14c5b84 --- /dev/null +++ b/apps/web/__tests__/api/habits.test.ts @@ -0,0 +1,243 @@ +/** + * API tests for habits routes. + * These tests verify the habit CRUD API logic using mocked Drizzle. + * Run with: npm test -- --testPathPattern=habits + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; + +// Mock the database module +jest.mock('@project-e/db', () => ({ + db: { + select: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + sql: { unsafe: jest.fn() }, + habits: {}, + habitCompletions: {}, + habitTags: {}, + tags: {}, + activityFeed: {}, +})); + +jest.mock('@/lib/auth', () => ({ + withAuth: (handler: any) => { + return (request: any, context: any) => { + const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' }; + return handler(request, mockUser, context); + }; + }, + requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined), + createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({ + code, + message, + status, + details, + }), + ApiError: class ApiError extends Error { + constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') { + super(message); + } + }, +})); + +jest.mock('@/lib/activity', () => ({ + recordActivity: jest.fn().mockResolvedValue(undefined), +})); + +// Build a Drizzle-like chain that resolves to the given value when awaited +function chain(resolvedValue: any) { + const c: any = {}; + c.from = jest.fn().mockReturnValue(c); + c.where = jest.fn().mockReturnValue(c); + c.orderBy = jest.fn().mockReturnValue(c); + c.limit = jest.fn().mockReturnValue(c); + c.offset = jest.fn().mockReturnValue(c); + c.innerJoin = jest.fn().mockReturnValue(c); + c.having = jest.fn().mockReturnValue(c); + c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled); + c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected); + return c; +} + +function insertChain(resolvedValue: any) { + const c: any = {}; + c.values = jest.fn().mockReturnValue(c); + c.returning = jest.fn().mockReturnValue(c); + c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled); + c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected); + return c; +} + +function updateChain(resolvedValue?: any) { + const c: any = {}; + c.set = jest.fn().mockReturnValue(c); + c.where = jest.fn().mockReturnValue(c); + c.returning = jest.fn().mockReturnValue(c); + c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled); + c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected); + return c; +} + +describe('Habits API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/domains/[domainId]/habits', () => { + it('should list habits with default pagination', async () => { + const { GET } = await import('@/app/api/domains/[domainId]/habits/route'); + const { db } = require('@project-e/db'); + + const mockHabits = [ + { id: '1', name: 'Habit 1', frequency: 'daily', difficulty: 'medium', domainId: 'domain-1', streakCount: 5, bestStreak: 10 }, + { id: '2', name: 'Habit 2', frequency: 'weekly', difficulty: 'hard', domainId: 'domain-1', streakCount: 0, bestStreak: 3 }, + ]; + + db.select + .mockReturnValueOnce(chain(mockHabits)) // main query + .mockReturnValueOnce(chain([{ count: 2 }])) // count query + .mockReturnValueOnce(chain([])); // tags query + + const request = new Request('http://localhost:3000/api/domains/domain-1/habits'); + const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + + expect(response).toBeDefined(); + }); + + it('should filter by active status', async () => { + const { GET } = await import('@/app/api/domains/[domainId]/habits/route'); + const { db } = require('@project-e/db'); + + db.select + .mockReturnValueOnce(chain([{ id: '1', name: 'Habit 1', domainId: 'domain-1' }])) + .mockReturnValueOnce(chain([{ count: 1 }])) + .mockReturnValueOnce(chain([])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/habits?active=true'); + const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('POST /api/domains/[domainId]/habits', () => { + it('should create a habit with required fields', async () => { + const { POST } = await import('@/app/api/domains/[domainId]/habits/route'); + const { db } = require('@project-e/db'); + + const mockHabit = { + id: 'new-habit-1', + name: 'Test Habit', + frequency: 'daily', + difficulty: 'medium', + domainId: 'domain-1', + }; + + db.insert.mockReturnValue(insertChain([mockHabit])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/habits', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test Habit' }), + }); + + const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + expect(response).toBeDefined(); + }); + + it('should reject empty name', async () => { + const { POST } = await import('@/app/api/domains/[domainId]/habits/route'); + const request = new Request('http://localhost:3000/api/domains/domain-1/habits', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '' }), + }); + + const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('POST /api/domains/[domainId]/habits/[id]/complete', () => { + it('should complete a habit and return streak info', async () => { + const { POST } = await import('@/app/api/domains/[domainId]/habits/[id]/complete/route'); + const { db } = require('@project-e/db'); + + const mockHabit = { + id: 'habit-1', + name: 'Test Habit', + domainId: 'domain-1', + skipDays: [0, 6], + streakCount: 3, + bestStreak: 10, + }; + + db.select + .mockReturnValueOnce(chain([mockHabit])) // verify habit exists + .mockReturnValueOnce(chain([])); // completions for streak calc + + db.insert.mockReturnValue(insertChain([{ id: 'comp-1', habitId: 'habit-1', date: new Date(), value: 1 }])); + + db.update.mockReturnValue(updateChain()); + + const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 1 }), + }); + + const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('PATCH /api/domains/[domainId]/habits/[id]', () => { + it('should update a habit', async () => { + const { PATCH } = await import('@/app/api/domains/[domainId]/habits/[id]/route'); + const { db } = require('@project-e/db'); + + const existingHabit = { + id: 'habit-1', + name: 'Test Habit', + domainId: 'domain-1', + }; + + db.select.mockReturnValue(chain([existingHabit])); + db.update.mockReturnValue(updateChain([{ ...existingHabit, name: 'Updated Habit' }])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Updated Habit' }), + }); + + const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('DELETE /api/domains/[domainId]/habits/[id]', () => { + it('should soft delete a habit', async () => { + const { DELETE } = await import('@/app/api/domains/[domainId]/habits/[id]/route'); + const { db } = require('@project-e/db'); + + const existingHabit = { + id: 'habit-1', + name: 'Test Habit', + domainId: 'domain-1', + }; + + db.select.mockReturnValue(chain([existingHabit])); + db.update.mockReturnValue(updateChain()); + + const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', { + method: 'DELETE', + }); + + const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) }); + expect(response.status).toBe(204); + }); + }); +}); diff --git a/apps/web/__tests__/api/projects.test.ts b/apps/web/__tests__/api/projects.test.ts new file mode 100644 index 0000000..b62b3fc --- /dev/null +++ b/apps/web/__tests__/api/projects.test.ts @@ -0,0 +1,273 @@ +/** + * API tests for projects and sections routes. + * These tests verify the project CRUD and section API logic using mocked Drizzle. + * Run with: npm test -- --testPathPattern=projects + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; + +// Mock the database module +jest.mock('@project-e/db', () => ({ + db: { + select: jest.fn(), + insert: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + sql: { unsafe: jest.fn() }, + projects: {}, + sections: {}, + tasks: {}, + projectTags: {}, + tags: {}, + activityFeed: {}, +})); + +jest.mock('@/lib/auth', () => ({ + withAuth: (handler: any) => { + return (request: any, context: any) => { + const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' }; + return handler(request, mockUser, context); + }; + }, + requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined), + createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({ + code, + message, + status, + details, + }), + ApiError: class ApiError extends Error { + constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') { + super(message); + } + }, +})); + +jest.mock('@/lib/activity', () => ({ + recordActivity: jest.fn().mockResolvedValue(undefined), +})); + +// Build a Drizzle-like chain that resolves to the given value when awaited +function chain(resolvedValue: any) { + const c: any = {}; + c.from = jest.fn().mockReturnValue(c); + c.where = jest.fn().mockReturnValue(c); + c.orderBy = jest.fn().mockReturnValue(c); + c.limit = jest.fn().mockReturnValue(c); + c.offset = jest.fn().mockReturnValue(c); + c.innerJoin = jest.fn().mockReturnValue(c); + c.having = jest.fn().mockReturnValue(c); + c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled); + c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected); + return c; +} + +function insertChain(resolvedValue: any) { + const c: any = {}; + c.values = jest.fn().mockReturnValue(c); + c.returning = jest.fn().mockReturnValue(c); + c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled); + c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected); + return c; +} + +function updateChain(resolvedValue?: any) { + const c: any = {}; + c.set = jest.fn().mockReturnValue(c); + c.where = jest.fn().mockReturnValue(c); + c.returning = jest.fn().mockReturnValue(c); + c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled); + c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected); + return c; +} + +describe('Projects API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/domains/[domainId]/projects', () => { + it('should list projects with default pagination', async () => { + const { GET } = await import('@/app/api/domains/[domainId]/projects/route'); + const { db } = require('@project-e/db'); + + const mockProjects = [ + { id: '1', name: 'Project 1', status: 'active', domainId: 'domain-1' }, + { id: '2', name: 'Project 2', status: 'paused', domainId: 'domain-1' }, + ]; + + db.select + .mockReturnValueOnce(chain(mockProjects)) // main query + .mockReturnValueOnce(chain([{ count: 2 }])) // count query + .mockReturnValueOnce(chain([])) // tags query + .mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 1 + .mockReturnValueOnce(chain([{ count: 0 }])) // completed count for project 1 + .mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 2 + .mockReturnValueOnce(chain([{ count: 0 }])); // completed count for project 2 + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects'); + const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + + expect(response).toBeDefined(); + }); + + it('should filter by status', async () => { + const { GET } = await import('@/app/api/domains/[domainId]/projects/route'); + const { db } = require('@project-e/db'); + + db.select + .mockReturnValueOnce(chain([{ id: '1', name: 'Project 1', domainId: 'domain-1' }])) + .mockReturnValueOnce(chain([{ count: 1 }])) + .mockReturnValueOnce(chain([])) + .mockReturnValueOnce(chain([{ count: 0 }])) + .mockReturnValueOnce(chain([{ count: 0 }])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects?status=active'); + const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('POST /api/domains/[domainId]/projects', () => { + it('should create a project with required fields', async () => { + const { POST } = await import('@/app/api/domains/[domainId]/projects/route'); + const { db } = require('@project-e/db'); + + const mockProject = { + id: 'new-project-1', + name: 'Test Project', + status: 'active', + domainId: 'domain-1', + }; + + db.insert.mockReturnValue(insertChain([mockProject])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test Project' }), + }); + + const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + expect(response).toBeDefined(); + }); + + it('should reject empty name', async () => { + const { POST } = await import('@/app/api/domains/[domainId]/projects/route'); + const request = new Request('http://localhost:3000/api/domains/domain-1/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '' }), + }); + + const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('PATCH /api/domains/[domainId]/projects/[id]', () => { + it('should update a project', async () => { + const { PATCH } = await import('@/app/api/domains/[domainId]/projects/[id]/route'); + const { db } = require('@project-e/db'); + + const existingProject = { + id: 'project-1', + name: 'Test Project', + domainId: 'domain-1', + }; + + db.select.mockReturnValue(chain([existingProject])); + db.update.mockReturnValue(updateChain([{ ...existingProject, name: 'Updated Project' }])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Updated Project' }), + }); + + const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('DELETE /api/domains/[domainId]/projects/[id]', () => { + it('should soft delete a project', async () => { + const { DELETE } = await import('@/app/api/domains/[domainId]/projects/[id]/route'); + const { db } = require('@project-e/db'); + + const existingProject = { + id: 'project-1', + name: 'Test Project', + domainId: 'domain-1', + }; + + db.select.mockReturnValue(chain([existingProject])); + db.update.mockReturnValue(updateChain()); + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', { + method: 'DELETE', + }); + + const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) }); + expect(response.status).toBe(204); + }); + }); +}); + +describe('Sections API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/domains/[domainId]/projects/[projectId]/sections', () => { + it('should list sections for a project', async () => { + const { GET } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route'); + const { db } = require('@project-e/db'); + + const mockSections = [ + { id: '1', name: 'Section 1', projectId: 'project-1', kind: 'section', sortOrder: 0 }, + { id: '2', name: 'Milestone 1', projectId: 'project-1', kind: 'milestone', sortOrder: 1 }, + ]; + + db.select + .mockReturnValueOnce(chain([{ id: 'project-1' }])) // verify project + .mockReturnValueOnce(chain(mockSections)); // list sections + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections'); + const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) }); + expect(response).toBeDefined(); + }); + }); + + describe('POST /api/domains/[domainId]/projects/[projectId]/sections', () => { + it('should create a section', async () => { + const { POST } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route'); + const { db } = require('@project-e/db'); + + const mockProject = { id: 'project-1', name: 'Test Project' }; + const mockSection = { + id: 'new-section-1', + name: 'Test Section', + projectId: 'project-1', + kind: 'section', + sortOrder: 0, + }; + + db.select + .mockReturnValueOnce(chain([mockProject])) // verify project + .mockReturnValueOnce(chain([{ max: -1 }])); // max sort order + + db.insert.mockReturnValue(insertChain([mockSection])); + + const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Test Section' }), + }); + + const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) }); + expect(response).toBeDefined(); + }); + }); +}); diff --git a/apps/web/app/(dashboard)/habits/page.tsx b/apps/web/app/(dashboard)/habits/page.tsx index cb85c61..198ee8b 100644 --- a/apps/web/app/(dashboard)/habits/page.tsx +++ b/apps/web/app/(dashboard)/habits/page.tsx @@ -1,29 +1,237 @@ "use client"; -import { useState } from "react"; -import { Plus } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; +import { Plus, CheckCircle2, Circle, MoreHorizontal, Flame, Filter } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { HabitCard } from "@/components/habits/habit-card"; -import { CreateItemDialog } from "@/components/create-item-dialog"; -import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; +import { Badge } from "@/components/ui/badge"; +import { HabitCreateDialog } from "@/components/habits/habit-create-dialog"; +import { HabitCompletionDialog } from "@/components/habits/habit-completion-dialog"; +import { HabitCalendarHeatmap } from "@/components/habits/habit-calendar-heatmap"; +import { toast } from "sonner"; + +interface Habit { + id: string; + name: string; + description: string | null; + domainId: string; + frequency: 'daily' | 'weekly' | 'custom'; + difficulty: 'easy' | 'medium' | 'hard'; + goalPerPeriod: number; + unit: string | null; + streakCount: number; + bestStreak: number; + active: boolean; + moodTracking: boolean; + tags: { id: string; name: string; color: string | null }[]; +} + +const difficultyColors: Record = { + easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; export default function HabitsPage() { - const [refreshKey, setRefreshKey] = useState(0); - const { open, openCreate, closeCreate } = useCreateDialogStore(); + const [habits, setHabits] = useState([]); + const [domainId, setDomainId] = useState(null); + const [domains, setDomains] = useState<{ id: string; name: string }[]>([]); + const [createOpen, setCreateOpen] = useState(false); + const [completionHabit, setCompletionHabit] = useState(null); + const [expandedHabit, setExpandedHabit] = useState(null); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(true); + + // Fetch domains + useEffect(() => { + fetch('/api/domains?sort=sort_order') + .then((res) => res.json()) + .then((data) => { + const items = data.items || []; + setDomains(items); + if (items.length > 0 && !domainId) { + setDomainId(items[0].id); + } + }) + .catch(() => {}); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Fetch habits + const fetchHabits = useCallback(async () => { + if (!domainId) return; + setLoading(true); + try { + const params = new URLSearchParams(); + if (filter === 'active') params.set('active', 'true'); + const res = await fetch(`/api/domains/${domainId}/habits?${params}`); + const data = await res.json(); + setHabits(data.items || []); + } catch { + toast.error('Failed to load habits'); + } finally { + setLoading(false); + } + }, [domainId, filter]); + + useEffect(() => { + fetchHabits(); + }, [fetchHabits]); + + // Complete a habit + const handleComplete = async (habit: Habit, value?: number, mood?: number, notes?: string) => { + try { + const res = await fetch(`/api/domains/${domainId}/habits/${habit.id}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: value ?? 1, mood, notes }), + }); + if (!res.ok) throw new Error('Failed to complete'); + toast.success(`"${habit.name}" logged!`); + fetchHabits(); + } catch { + toast.error('Failed to complete habit'); + } + }; + + // Listen for custom event to open create dialog + useEffect(() => { + const handler = () => setCreateOpen(true); + document.addEventListener('open-create-habit', handler); + return () => document.removeEventListener('open-create-habit', handler); + }, []); return (

Habits

-

Build consistency, one day at a time.

+

Build streaks, track progress, stay consistent.

+
+
+ {domains.length > 1 && ( + + )} +
+ + +
+
-
- - (o ? openCreate("habit") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} /> + + {loading ? ( +
Loading habits...
+ ) : habits.length === 0 ? ( +
+
+ ) : ( +
+ {habits.map((habit) => ( +
+
+ +
+
+ {habit.name} + + {habit.difficulty} + + {habit.unit && ( + per {habit.unit} + )} +
+ {habit.tags.length > 0 && ( +
+ {habit.tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} +
+
+
+
+ + +
+
+ {expandedHabit === habit.id && ( +
+ +
+ )} +
+ ))} +
+ )} + + + + {completionHabit && ( + { if (!open) setCompletionHabit(null); }} + habit={completionHabit} + onComplete={(value, mood, notes) => { + handleComplete(completionHabit, value, mood, notes); + setCompletionHabit(null); + }} + /> + )}
); } diff --git a/apps/web/app/(dashboard)/projects/[id]/page.tsx b/apps/web/app/(dashboard)/projects/[id]/page.tsx index de3cfe2..73e7f95 100644 --- a/apps/web/app/(dashboard)/projects/[id]/page.tsx +++ b/apps/web/app/(dashboard)/projects/[id]/page.tsx @@ -1,411 +1,302 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { ArrowLeft, Calendar, CheckCircle2, Circle, Flag } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Progress } from '@/components/ui/progress'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import Link from 'next/link'; +import { useState, useEffect, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { Plus, ArrowLeft, GripVertical, MoreHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { SectionDialog } from "@/components/projects/section-dialog"; +import Link from "next/link"; +import { toast } from "sonner"; -interface Project { +interface Section { id: string; name: string; - description?: string; - status: 'active' | 'paused' | 'archived'; - domain: string; - progress: number; - task_count: number; - completed_count: number; - due_date?: string; + projectId: string; + kind: 'section' | 'milestone'; + status: 'planned' | 'in_progress' | 'complete'; + targetDate: string | null; + sortOrder: number; } interface Task { id: string; title: string; - status: 'todo' | 'in_progress' | 'done'; - priority: 'low' | 'medium' | 'high' | 'urgent'; - due_date?: string; + status: string; + priority: string; + sectionId: string | null; + order: number; } -interface Milestone { +interface ProjectDetail { id: string; name: string; - description?: string; - due_date?: string; - status: 'planned' | 'in_progress' | 'completed'; - completed_tasks: number; - total_tasks: number; + description: string | null; + status: string; + color: string | null; + icon: string | null; + targetDate: string | null; + sections: Section[]; + tasks: Task[]; + taskCount: number; + completedCount: number; + progress: number; } +const statusColors: Record = { + active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + completed: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + archived: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200", +}; + +const taskStatusColors: Record = { + todo: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", + in_progress: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", + done: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200", + cancelled: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200", +}; + export default function ProjectDetailPage() { const params = useParams(); const projectId = params.id as string; - - const [project, setProject] = useState(null); - const [tasks, setTasks] = useState([]); - const [milestones, setMilestones] = useState([]); + const [project, setProject] = useState(null); + const [domainId, setDomainId] = useState(null); const [loading, setLoading] = useState(true); - const [domainMap, setDomainMap] = useState>(new Map()); + const [sectionDialogOpen, setSectionDialogOpen] = useState(false); + const [draggedTaskId, setDraggedTaskId] = useState(null); - useEffect(() => { - if (projectId) { - fetchDomains(); - fetchProject(); - fetchTasks(); - fetchMilestones(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); - - async function fetchDomains() { + // Extract domainId from the project data + const fetchProject = useCallback(async () => { + setLoading(true); try { - const res = await fetch('/api/domains?sort=sort_order'); - if (res.ok) { - const data = await res.json(); - const map = new Map(); - for (const d of data.items || []) map.set(d.id, d.name); - setDomainMap(map); + // We need to find the domain first — use the first domain + const domainsRes = await fetch('/api/domains?sort=sort_order'); + const domainsData = await domainsRes.json(); + const firstDomain = domainsData.items?.[0]; + if (!firstDomain) { + setLoading(false); + return; } - } catch {} - } + setDomainId(firstDomain.id); - async function fetchProject() { - try { - const response = await fetch(`/api/projects/${projectId}`); - if (response.ok) { - const data = await response.json(); - setProject(data); - } - } catch (error) { - console.error('Failed to fetch project:', error); - } - } - - async function fetchTasks() { - try { - const response = await fetch( - `/api/tasks?filter=project_id%3D%22${projectId}%22&sort=-created` - ); - if (response.ok) { - const data = await response.json(); - setTasks(data.items || []); - } - } catch (error) { - console.error('Failed to fetch tasks:', error); + const res = await fetch(`/api/domains/${firstDomain.id}/projects/${projectId}`); + if (!res.ok) throw new Error('Not found'); + const data = await res.json(); + setProject(data); + } catch { + toast.error('Failed to load project'); } finally { setLoading(false); } - } + }, [projectId]); - async function fetchMilestones() { - try { - const response = await fetch( - `/api/milestones?filter=project_id%3D%22${projectId}%22&sort=due_date` - ); - if (response.ok) { - const data = await response.json(); - setMilestones(data.items || []); - } - } catch (error) { - console.error('Failed to fetch milestones:', error); - } - } + useEffect(() => { + fetchProject(); + }, [fetchProject]); - async function toggleTaskComplete(taskId: string, currentStatus: string) { - const newStatus = currentStatus === 'done' ? 'todo' : 'done'; + const handleMoveTask = async (taskId: string, sectionId: string | null) => { try { - await fetch(`/api/tasks/${taskId}`, { + const res = await fetch(`/api/domains/${domainId}/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: newStatus }), + body: JSON.stringify({ sectionId }), }); - fetchTasks(); + if (!res.ok) throw new Error('Failed to move task'); + toast.success('Task moved'); fetchProject(); - } catch (error) { - console.error('Failed to toggle task:', error); + } catch { + toast.error('Failed to move task'); } + }; + + // Listen for custom event to open section dialog + useEffect(() => { + const handler = () => setSectionDialogOpen(true); + document.addEventListener('open-create-section', handler); + return () => document.removeEventListener('open-create-section', handler); + }, []); + + if (loading) { + return
Loading project...
; } - if (loading || !project) { - return

Loading project...

; + if (!project) { + return ( +
+

Project not found.

+ + ← Back to projects + +
+ ); + } + + // Group tasks by section + const tasksBySection = new Map(); + tasksBySection.set('unsectioned', []); + for (const section of project.sections) { + tasksBySection.set(section.id, []); + } + for (const task of project.tasks) { + const key = task.sectionId || 'unsectioned'; + if (!tasksBySection.has(key)) tasksBySection.set(key, []); + tasksBySection.get(key)!.push(task); } return (
- {/* Back button */} - - - - - {/* Project header */} + {/* Header */}
+ +