diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index dfb4cd5..e28ac4c 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -237,9 +237,12 @@ calendarRoutes.get("/unified", async (c) => { await requireWorkspaceAccess(c, domainId); const from = fromStr ? new Date(fromStr) : new Date(new Date().setDate(new Date().getDate() - 7)); const to = toStr ? new Date(toStr) : new Date(new Date().setDate(new Date().getDate() + 30)); + const projectId = url.searchParams.get("project_id"); + const taskConds: any[] = [eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to)]; + if (projectId) taskConds.push(eq(tasks.projectId, projectId)); const [events, domainTasks, domainHabits] = await Promise.all([ db.select().from(calendarEvents).where(and(eq(calendarEvents.domainId, domainId), gte(calendarEvents.startTime, from), lte(calendarEvents.startTime, to))).orderBy(asc(calendarEvents.startTime)), - db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to))).orderBy(asc(tasks.dueDate)), + db.select().from(tasks).where(and(...taskConds)).orderBy(asc(tasks.dueDate)), db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))), ]); const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({ diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 62770e5..ef3042f 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -316,6 +316,60 @@ projectRoutes.get("/:id", async (c) => { } }); +// GET /api/projects/:id/stats — Task counts by state group for board headers/progress +projectRoutes.get("/:id/stats", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const [project] = await db.select().from(projects) + .where(and(eq(projects.id, id), isNull(projects.deletedAt))).limit(1); + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + await requireWorkspaceAccess(c, project.domainId); + + const projectStates = await db.select().from(states) + .where(and(eq(states.projectId, id), isNull(states.deletedAt))) + .orderBy(asc(states.sortOrder)); + const projectTasks = await db.select({ id: tasks.id, stateId: tasks.stateId, completedAt: tasks.completedAt, dueDate: tasks.dueDate }) + .from(tasks).where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt))); + + const stateById = new Map(projectStates.map((s) => [s.id, s])); + const byGroup: Record = { backlog: 0, unstarted: 0, started: 0, completed: 0, cancelled: 0 }; + const byState: Record = {}; + for (const t of projectTasks) { + byState[t.stateId || "__none__"] = (byState[t.stateId || "__none__"] || 0) + 1; + const group = t.stateId ? stateById.get(t.stateId)?.group || "unstarted" : "unstarted"; + byGroup[group] = (byGroup[group] || 0) + 1; + } + const now = new Date(); + const overdue = projectTasks.filter((t) => t.dueDate && new Date(t.dueDate) < now && !t.completedAt).length; + const dueSoon = projectTasks.filter((t) => { + if (!t.dueDate || t.completedAt) return false; + const d = new Date(t.dueDate).getTime() - now.getTime(); + return d >= 0 && d <= 7 * 24 * 60 * 60 * 1000; + }).length; + const total = projectTasks.length; + const completed = byGroup.completed || 0; + + return c.json({ + total, completed, overdue, dueSoon, + progress: total > 0 ? Math.round((completed / total) * 100) : 0, + byGroup, byState, + states: projectStates.map((s) => ({ id: s.id, name: s.name, color: s.color, group: s.group, count: byState[s.id] || 0 })), + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[projects] GET /:id/stats error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project stats" } }, 500); + } +}); + // PATCH /api/projects/:id — Update a project projectRoutes.patch("/:id", async (c) => { try { diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 8e1a652..3cf2c05 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, tasks, states as statesTable, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db"; -import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { enqueueWebhooks } from "../middleware/webhook-queue"; @@ -95,6 +95,8 @@ taskRoutes.get("/", async (c) => { const parentId = url.searchParams.get("parent_id"); const projectId = url.searchParams.get("project_id"); const sectionId = url.searchParams.get("section_id"); + const dueAfter = url.searchParams.get("due_after"); + const dueBefore = url.searchParams.get("due_before"); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); const offset = parseInt(url.searchParams.get("offset") || "0"); const order = url.searchParams.get("order") || "asc"; @@ -139,6 +141,14 @@ taskRoutes.get("/", async (c) => { if (sectionId) { conditions.push(eq(tasks.sectionId, sectionId)); } + if (dueAfter) { + const d = new Date(dueAfter); + if (!Number.isNaN(d.getTime())) conditions.push(gte(tasks.dueDate, d)); + } + if (dueBefore) { + const d = new Date(dueBefore); + if (!Number.isNaN(d.getTime())) conditions.push(lte(tasks.dueDate, d)); + } if (stateId) { conditions.push(eq(tasks.stateId, stateId)); } @@ -249,6 +259,114 @@ taskRoutes.get("/", async (c) => { } }); +// GET /api/tasks/grouped — Board-ready grouping (by state, project, or priority) +taskRoutes.get("/grouped", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const groupBy = url.searchParams.get("group_by") || "state"; + const projectId = url.searchParams.get("project_id"); + const priority = url.searchParams.get("priority"); + const search = url.searchParams.get("search"); + const dueAfter = url.searchParams.get("due_after"); + const dueBefore = url.searchParams.get("due_before"); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + await requireWorkspaceAccess(c, domainId); + + const conditions: any[] = [eq(tasks.domainId, domainId), isNull(tasks.deletedAt)]; + if (projectId) conditions.push(eq(tasks.projectId, projectId)); + if (priority) conditions.push(inArray(tasks.priority, priority.split(",") as any)); + if (search) conditions.push(ilike(tasks.title, `%${search}%`)); + if (dueAfter) { + const d = new Date(dueAfter); + if (!Number.isNaN(d.getTime())) conditions.push(gte(tasks.dueDate, d)); + } + if (dueBefore) { + const d = new Date(dueBefore); + if (!Number.isNaN(d.getTime())) conditions.push(lte(tasks.dueDate, d)); + } + + const items = await db.select().from(tasks) + .where(and(...conditions)) + .orderBy(asc(tasks.order)) + .limit(500); + + let taskTagMap = new Map(); + if (items.length > 0) { + const tagRows = await db.select({ + taskId: taskTags.taskId, id: tagsTable.id, name: tagsTable.name, color: tagsTable.color, + }).from(taskTags).innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id)) + .where(inArray(taskTags.taskId, items.map((t) => t.id))); + for (const row of tagRows) { + if (!taskTagMap.has(row.taskId)) taskTagMap.set(row.taskId, []); + taskTagMap.get(row.taskId)!.push({ id: row.id, name: row.name, color: row.color }); + } + } + const withTags = items.map((t) => ({ ...t, tags: taskTagMap.get(t.id) || [] })); + + if (groupBy === "state") { + const stateIds = [...new Set(withTags.map((t) => t.stateId).filter(Boolean))] as string[]; + let stateList: typeof statesTable.$inferSelect[] = []; + if (projectId) { + stateList = await db.select().from(statesTable) + .where(and(eq(statesTable.projectId, projectId), isNull(statesTable.deletedAt))) + .orderBy(asc(statesTable.sortOrder)); + } else if (stateIds.length > 0) { + stateList = await db.select().from(statesTable).where(inArray(statesTable.id, stateIds)); + } + const byState = new Map(); + for (const t of withTags) { + const key = t.stateId ?? null; + if (!byState.has(key)) byState.set(key, []); + byState.get(key)!.push(t); + } + const groups = stateList.map((s) => ({ + id: s.id, name: s.name, color: s.color, group: s.group, + tasks: (byState.get(s.id) || []).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), + })); + const unassigned = (byState.get(null) || []).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + if (unassigned.length > 0) groups.unshift({ id: "__none__", name: "No state", color: null, group: "unstarted", tasks: unassigned } as any); + return c.json({ groups, totalItems: withTags.length }); + } + + if (groupBy === "priority") { + const order = ["urgent", "high", "medium", "low"] as const; + return c.json({ + groups: order.map((p) => ({ id: p, name: p[0].toUpperCase() + p.slice(1), tasks: withTags.filter((t) => t.priority === p) })), + totalItems: withTags.length, + }); + } + + // group_by=project + const projectIds = [...new Set(withTags.map((t) => t.projectId).filter(Boolean))] as string[]; + let projectList: { id: string; name: string; color: string | null }[] = []; + if (projectIds.length > 0) { + projectList = await db.select({ id: projects.id, name: projects.name, color: projects.color }) + .from(projects).where(inArray(projects.id, projectIds)); + } + const byProject = new Map(); + for (const t of withTags) { + const key = t.projectId ?? null; + if (!byProject.has(key)) byProject.set(key, []); + byProject.get(key)!.push(t); + } + const groups = projectList.map((p) => ({ id: p.id, name: p.name, color: p.color, tasks: byProject.get(p.id) || [] })); + const inbox = byProject.get(null) || []; + if (inbox.length > 0) groups.unshift({ id: "__none__", name: "No project", color: null, tasks: inbox }); + return c.json({ groups, totalItems: withTags.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[tasks] GET /grouped error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to group tasks" } }, 500); + } +}); + // POST /api/tasks — Create a task taskRoutes.post("/", async (c) => { try { diff --git a/apps/web/node_modules b/apps/web/node_modules deleted file mode 120000 index 79c9c91..0000000 --- a/apps/web/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/user/projects/dev/ProjectE/apps/web/node_modules \ No newline at end of file diff --git a/apps/web/src/components/filters/filter-bar.tsx b/apps/web/src/components/filters/filter-bar.tsx new file mode 100644 index 0000000..f9dc50f --- /dev/null +++ b/apps/web/src/components/filters/filter-bar.tsx @@ -0,0 +1,190 @@ +import { CalendarClock, Flag, FolderKanban, Search, SlidersHorizontal, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { State } from "@/lib/types"; + +export type DueFilter = "all" | "overdue" | "today" | "week" | "none"; + +export const DUE_FILTER_LABELS: Record = { + all: "Any due date", + overdue: "Overdue", + today: "Due today", + week: "Due this week", + none: "No due date", +}; + +interface FilterBarProps { + search: string; + onSearchChange: (v: string) => void; + projects: { id: string; name: string; color?: string | null }[]; + projectId: string; + onProjectChange: (v: string) => void; + states: State[]; + stateId: string; + onStateChange: (v: string) => void; + priority: string; + onPriorityChange: (v: string) => void; + dueFilter: DueFilter; + onDueChange: (v: DueFilter) => void; + onClearAll: () => void; +} + +function Chip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + {label} + + + ); +} + +export function FilterBar({ + search, + onSearchChange, + projects, + projectId, + onProjectChange, + states, + stateId, + onStateChange, + priority, + onPriorityChange, + dueFilter, + onDueChange, + onClearAll, +}: FilterBarProps) { + const projectName = projects.find((p) => p.id === projectId)?.name; + const stateName = states.find((s) => s.id === stateId)?.name; + const activeCount = + (projectId ? 1 : 0) + (stateId ? 1 : 0) + (priority ? 1 : 0) + (dueFilter !== "all" ? 1 : 0) + (search ? 1 : 0); + + return ( +
+
+
+ + onSearchChange(e.target.value)} + className="h-8 pl-8" + aria-label="Search tasks" + /> +
+ + + + {states.length > 0 && ( + + )} + + + + + + + + + Due date + + {(Object.keys(DUE_FILTER_LABELS) as DueFilter[]).map((d) => ( + onDueChange(d)}> + {DUE_FILTER_LABELS[d]} + + ))} + + + + {activeCount > 0 && ( + + )} +
+ + {activeCount > 0 && ( +
+ + Active: + + {projectName && onProjectChange("")} />} + {stateName && onStateChange("")} />} + {priority && onPriorityChange("")} />} + {dueFilter !== "all" && onDueChange("all")} />} + {search && onSearchChange("")} />} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/shell/sidebar.tsx b/apps/web/src/components/shell/sidebar.tsx index 4562d16..72083a6 100644 --- a/apps/web/src/components/shell/sidebar.tsx +++ b/apps/web/src/components/shell/sidebar.tsx @@ -16,6 +16,7 @@ import { Bot, PenLine, Settings, + ChevronDown, ChevronLeft, ChevronRight, LogOut, @@ -24,6 +25,9 @@ import { FileBarChart, FileStack, } from "lucide-react"; +import { useApiQuery } from "@/lib/api"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import type { PaginatedResponse, Project } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; @@ -115,6 +119,13 @@ export function Sidebar() { ? "right" : "left" ); + const [projectsExpanded, setProjectsExpanded] = useState(true); + const activeDomainId = useApiDomain(); + const { data: sidebarProjectsData } = useApiQuery>( + ["projects", activeDomainId, "sidebar"], + "/projects?limit=50&status=active" + (activeDomainId ? "&domain=" + activeDomainId : "") + ); + const sidebarProjects = sidebarProjectsData?.items || []; useEffect(() => { const onSidebarPositionChange = (event: Event) => { @@ -182,7 +193,64 @@ export function Sidebar() { const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (