feat(tasks): project-centric task management redesign

- Task board uses per-project workflow state columns with status
  fallback when no project is selected; adds calendar view tab
- Chip-based filter bar (search, project, state, priority, due date)
  with quick-add bar and slide-over task detail panel
- Project detail switches to left-nav layout with progress summary
  and per-section counts; projects list gains search, status
  filter, and richer cards
- Sidebar gains expandable active-projects sub-nav; calendar
  unified view gains project filter
- API: task due_after/due_before filters, GET /tasks/grouped,
  GET /projects/:id/stats, calendar unified project_id filter
- Apply Buzzbee design tokens; remove accidentally committed
  apps/web/node_modules self-symlink
This commit is contained in:
2026-09-10 10:49:41 +00:00
parent f3b1fd709a
commit ddcb707190
15 changed files with 1394 additions and 317 deletions
+4 -1
View File
@@ -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 => ({
+54
View File
@@ -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<string, number> = { backlog: 0, unstarted: 0, started: 0, completed: 0, cancelled: 0 };
const byState: Record<string, number> = {};
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 {
+119 -1
View File
@@ -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<string, { id: string; name: string; color: string | null }[]>();
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<string | null, typeof withTags>();
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<string | null, typeof withTags>();
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 {