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:
@@ -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 => ({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user