diff --git a/.gitignore b/.gitignore index 5ef6a52..b831ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # next.js /.next/ /out/ +/dist/ # production /build diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 0000000..8a8444d --- /dev/null +++ b/app/api/tasks/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server"; +import { getData, saveData, type DataStore, type Task } from "@/lib/server/taskDb"; +import { getAuthenticatedUser } from "@/lib/server/auth"; + +export const runtime = "nodejs"; + +// GET - fetch all tasks, projects, and sprints +export async function GET() { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const data = getData(); + return NextResponse.json(data); + } catch (error) { + console.error(">>> API GET: database error:", error); + return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 }); + } +} + +// POST - create or update tasks, projects, or sprints +export async function POST(request: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { task, tasks, projects, sprints } = body as { + task?: Task; + tasks?: Task[]; + projects?: DataStore["projects"]; + sprints?: DataStore["sprints"]; + }; + + const data = getData(); + + if (projects) data.projects = projects; + if (sprints) data.sprints = sprints; + + if (task) { + const existingIndex = data.tasks.findIndex((t) => t.id === task.id); + if (existingIndex >= 0) { + const existingTask = data.tasks[existingIndex]; + data.tasks[existingIndex] = { + ...existingTask, + ...task, + updatedAt: new Date().toISOString(), + updatedById: user.id, + updatedByName: user.name, + updatedByAvatarUrl: user.avatarUrl, + }; + } else { + data.tasks.push({ + ...task, + id: task.id || Date.now().toString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdById: task.createdById || user.id, + createdByName: task.createdByName || user.name, + createdByAvatarUrl: task.createdByAvatarUrl || user.avatarUrl, + updatedById: user.id, + updatedByName: user.name, + updatedByAvatarUrl: user.avatarUrl, + assigneeId: task.assigneeId || user.id, + assigneeName: task.assigneeName || user.name, + assigneeEmail: task.assigneeEmail || user.email, + }); + } + } + + if (tasks && Array.isArray(tasks)) { + data.tasks = tasks.map((entry) => ({ + ...entry, + createdById: entry.createdById || user.id, + createdByName: entry.createdByName || user.name, + createdByAvatarUrl: entry.createdByAvatarUrl || (entry.createdById === user.id ? user.avatarUrl : undefined), + updatedById: entry.updatedById || user.id, + updatedByName: entry.updatedByName || user.name, + updatedByAvatarUrl: entry.updatedByAvatarUrl || (entry.updatedById === user.id ? user.avatarUrl : undefined), + assigneeId: entry.assigneeId || undefined, + assigneeName: entry.assigneeName || undefined, + assigneeEmail: entry.assigneeEmail || undefined, + assigneeAvatarUrl: undefined, + })); + } + + const saved = saveData(data); + return NextResponse.json({ success: true, data: saved }); + } catch (error) { + console.error(">>> API POST: database error:", error); + return NextResponse.json({ error: "Failed to save" }, { status: 500 }); + } +} + +// DELETE - remove a task +export async function DELETE(request: Request) { + try { + const user = await getAuthenticatedUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = (await request.json()) as { id: string }; + const data = getData(); + data.tasks = data.tasks.filter((t) => t.id !== id); + saveData(data); + return NextResponse.json({ success: true }); + } catch (error) { + console.error(">>> API DELETE: database error:", error); + return NextResponse.json({ error: "Failed to delete" }, { status: 500 }); + } +} diff --git a/app/projects/page.tsx b/app/projects/page.tsx new file mode 100644 index 0000000..ea1b4ba --- /dev/null +++ b/app/projects/page.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useState, useEffect } from "react" +import { DashboardLayout } from "@/components/layout/sidebar" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { SprintBoard } from "@/components/gantt/SprintBoard" +import { BacklogView } from "@/components/gantt/BacklogView" +import { useTaskStore } from "@/stores/useTaskStore" +import { + Plus, + LayoutGrid, + ListTodo, + Calendar, + FolderKanban, + Target, + CheckCircle2, + Clock +} from "lucide-react" +import Link from "next/link" + +export default function ProjectsPage() { + const [activeTab, setActiveTab] = useState("board") + const [mounted, setMounted] = useState(false) + + const { + tasks, + sprints, + projects, + selectedProjectId, + selectedSprintId, + currentUser, + syncFromServer, + isLoading + } = useTaskStore() + + useEffect(() => { + setMounted(true) + syncFromServer() + }, [syncFromServer]) + + // Calculate stats + const activeSprint = sprints.find(s => s.status === "active") + const activeSprintTasks = activeSprint + ? tasks.filter(t => t.sprintId === activeSprint.id) + : [] + const completedTasks = activeSprintTasks.filter(t => t.status === "done").length + const totalTasks = activeSprintTasks.length + const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0 + + if (!mounted) { + return ( + +
+
+
+ + ) + } + + return ( + +
+ {/* Header */} +
+
+

Projects

+

+ Sprint planning and project management +

+
+
+ + + + +
+
+ + {/* Stats Cards */} +
+ + + + Active Sprint + + + + +
+ {activeSprint?.name || "No Active Sprint"} +
+

+ {activeSprint + ? `${new Date(activeSprint.startDate).toLocaleDateString()} - ${new Date(activeSprint.endDate).toLocaleDateString()}` + : "Create a sprint to get started" + } +

+
+
+ + + + + Sprint Progress + + + + +
{completionRate}%
+
+
+
+

+ {completedTasks} of {totalTasks} tasks completed +

+ + + + + + + Total Projects + + + + +
{projects.length}
+

+ {tasks.length} total tasks across all projects +

+
+
+ + + + + Sprints + + + + +
{sprints.length}
+

+ {sprints.filter(s => s.status === "active").length} active, {sprints.filter(s => s.status === "planning").length} planning +

+
+
+
+ + {/* Main Content Tabs */} + + + + + Sprint Board + Board + + + + Backlog & Sprints + Backlog + + + + + {isLoading ? ( +
+
+
+ ) : ( + + )} + + + + {isLoading ? ( +
+
+
+ ) : ( + + )} + + + + {/* Quick Links */} + + + Quick Links + + +
+ + + + + + + + + +
+
+
+
+ + ) +} diff --git a/components/gantt/BacklogView.tsx b/components/gantt/BacklogView.tsx new file mode 100644 index 0000000..4b7242a --- /dev/null +++ b/components/gantt/BacklogView.tsx @@ -0,0 +1,498 @@ +"use client" + +import { useEffect, useState, type ReactNode } from "react" +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useDroppable, + useSensor, + useSensors, + closestCorners, +} from "@dnd-kit/core" +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { useRouter } from "next/navigation" +import { useTaskStore, Task } from "@/stores/useTaskStore" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react" +import { format, isValid, parseISO } from "date-fns" +import { generateAvatarDataUrl } from "@/lib/avatar" + +const priorityColors: Record = { + low: "bg-slate-600", + medium: "bg-blue-600", + high: "bg-orange-600", + urgent: "bg-red-600", +} + +const typeLabels: Record = { + idea: "💡", + task: "📋", + bug: "🐛", + research: "🔬", + plan: "📐", +} + +interface AssignableUser { + id: string + name: string + email?: string + avatarUrl?: string +} + +function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) { + const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned") + return ( + {name + ) +} + +// Sortable Task Row +function SortableTaskRow({ + task, + assigneeAvatarUrl, + onClick, +}: { + task: Task + assigneeAvatarUrl?: string + onClick: () => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: task.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+
+ +
+ {typeLabels[task.type]} +
+

{task.title}

+
+ + {task.priority} + + {task.comments && task.comments.length > 0 && ( + 💬 {task.comments.length} + )} + +
+ ) +} + +// Drag Overlay Item +function DragOverlayItem({ task, assigneeAvatarUrl }: { task: Task; assigneeAvatarUrl?: string }) { + return ( +
+ + {typeLabels[task.type]} +
+

{task.title}

+
+ + {task.priority} + + +
+ ) +} + +function SectionDropZone({ id, children }: { id: string; children: ReactNode }) { + const { isOver, setNodeRef } = useDroppable({ id }) + + return ( +
+ {children} +
+ ) +} + +// Collapsible Section +function TaskSection({ + title, + tasks, + isOpen, + onToggle, + onTaskClick, + resolveAssigneeAvatar, + sprintInfo, +}: { + title: string + tasks: Task[] + isOpen: boolean + onToggle: () => void + onTaskClick: (task: Task) => void + resolveAssigneeAvatar: (task: Task) => string | undefined + sprintInfo?: { name: string; date: string; status: string } +}) { + return ( +
+ + + {isOpen && ( +
+ t.id)} + strategy={verticalListSortingStrategy} + > +
+ {tasks.length === 0 ? ( +

No tasks

+ ) : ( + tasks.map((task) => ( + onTaskClick(task)} + /> + )) + )} +
+
+
+ )} +
+ ) +} + +export function BacklogView() { + const router = useRouter() + const [assignableUsers, setAssignableUsers] = useState([]) + const { + tasks, + sprints, + selectedProjectId, + updateTask, + addSprint, + } = useTaskStore() + + useEffect(() => { + let active = true + const loadUsers = async () => { + try { + const response = await fetch("/api/auth/users", { cache: "no-store" }) + if (!response.ok) return + const data = await response.json() + if (!active || !Array.isArray(data?.users)) return + setAssignableUsers( + data.users.map((entry: { id: string; name: string; email?: string; avatarUrl?: string }) => ({ + id: entry.id, + name: entry.name, + email: entry.email, + avatarUrl: entry.avatarUrl, + })), + ) + } catch { + // Keep backlog usable if users lookup fails. + } + } + void loadUsers() + return () => { + active = false + } + }, []) + + const resolveAssigneeAvatar = (task: Task) => { + if (!task.assigneeId) return task.assigneeAvatarUrl + return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl + } + + const [activeId, setActiveId] = useState(null) + const [openSections, setOpenSections] = useState>({ + current: true, + backlog: true, + }) + const [isCreatingSprint, setIsCreatingSprint] = useState(false) + const [newSprint, setNewSprint] = useState({ + name: "", + goal: "", + startDate: "", + endDate: "", + }) + + // Sensors for drag detection + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + + // Get current active sprint + const now = new Date() + const currentSprint = sprints.find( + (s) => + s.status === "active" && + new Date(s.startDate) <= now && + new Date(s.endDate) >= now + ) + + // Get other sprints (not current) + const otherSprints = sprints.filter((s) => s.id !== currentSprint?.id) + + // Get tasks by section + const currentSprintTasks = currentSprint + ? tasks.filter((t) => t.sprintId === currentSprint.id) + : [] + + const backlogTasks = tasks.filter((t) => !t.sprintId) + + // Get active task for drag overlay + const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + + if (!over) return + + const taskId = active.id as string + const overId = over.id as string + const overTask = tasks.find((t) => t.id === overId) + + const destinationId = overTask + ? overTask.sprintId + ? currentSprint && overTask.sprintId === currentSprint.id + ? "current" + : `sprint-${overTask.sprintId}` + : "backlog" + : overId + + // If dropped over a section header, move task to that section's sprint + if (destinationId === "backlog") { + updateTask(taskId, { sprintId: undefined, status: "open" }) + } else if (destinationId === "current" && currentSprint) { + updateTask(taskId, { sprintId: currentSprint.id, status: "open" }) + } else if (destinationId.startsWith("sprint-")) { + const sprintId = destinationId.replace("sprint-", "") + updateTask(taskId, { sprintId, status: "open" }) + } + } + + const toggleSection = (section: string) => { + setOpenSections((prev) => ({ ...prev, [section]: !prev[section] })) + } + + const handleCreateSprint = () => { + if (!newSprint.name) return + + addSprint({ + name: newSprint.name, + goal: newSprint.goal, + startDate: newSprint.startDate || new Date().toISOString(), + endDate: newSprint.endDate || new Date().toISOString(), + status: "planning", + projectId: selectedProjectId || "2", + }) + + setIsCreatingSprint(false) + setNewSprint({ name: "", goal: "", startDate: "", endDate: "" }) + } + + return ( + +
+ {/* Current Sprint Section */} + + toggleSection("current")} + onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)} + resolveAssigneeAvatar={resolveAssigneeAvatar} + sprintInfo={ + currentSprint + ? { + name: currentSprint.name, + date: `${(() => { + const start = parseISO(currentSprint.startDate) + const end = parseISO(currentSprint.endDate) + if (!isValid(start) || !isValid(end)) return "Invalid dates" + return `${format(start, "MMM d")} - ${format(end, "MMM d")}` + })()}`, + status: currentSprint.status, + } + : undefined + } + /> + + + {/* Other Sprints Sections - ordered by start date */} + {otherSprints + .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()) + .map((sprint) => { + const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id) + console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title)) + return ( + + toggleSection(sprint.id)} + onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)} + resolveAssigneeAvatar={resolveAssigneeAvatar} + sprintInfo={{ + name: sprint.name, + date: (() => { + const start = parseISO(sprint.startDate) + const end = parseISO(sprint.endDate) + if (!isValid(start) || !isValid(end)) return "Invalid dates" + return `${format(start, "MMM d")} - ${format(end, "MMM d")}` + })(), + status: sprint.status, + }} + /> + + ) + })} + + {/* Create Sprint Button */} + {!isCreatingSprint ? ( + + ) : ( + + +

Create New Sprint

+ setNewSprint({ ...newSprint, name: e.target.value })} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-sm text-white" + /> +