From a3e4d3c868897aabbcdcd200ef0193a0870e76c5 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 10 Aug 2026 21:59:10 +0000 Subject: [PATCH] feat: add threaded comments, activity feeds, and task dependencies --- apps/api/src/index.ts | 4 + apps/api/src/routes/activity.ts | 104 + apps/api/src/routes/comments.ts | 222 + apps/api/src/routes/habits.ts | 12 +- apps/api/src/routes/tasks.ts | 210 +- apps/web/src/components/charts/BarChart.tsx | 28 + .../src/components/charts/CalendarHeatmap.tsx | 23 + .../src/components/charts/HorizontalBar.tsx | 21 + apps/web/src/components/charts/LineChart.tsx | 24 + .../src/components/charts/PieChartSimple.tsx | 42 + apps/web/src/components/charts/index.ts | 12 + .../src/components/entities/detail-page.tsx | 87 + .../components/entities/entity-activity.tsx | 130 + .../components/entities/entity-comments.tsx | 277 + .../src/components/entities/inline-edit.tsx | 403 ++ .../src/components/entities/note-editor.tsx | 98 + apps/web/src/hooks/use-optimistic-patch.ts | 65 + apps/web/src/hooks/use-realtime.ts | 9 +- apps/web/src/lib/types/index.ts | 25 + apps/web/src/routes/_app/analytics.tsx | 124 +- apps/web/src/routes/_app/canvas/$id.tsx | 169 +- apps/web/src/routes/_app/habits/$id.tsx | 539 +- apps/web/src/routes/_app/notes.tsx | 90 +- apps/web/src/routes/_app/notes/$id.tsx | 345 +- apps/web/src/routes/_app/projects/$id.tsx | 737 ++- apps/web/src/routes/_app/tasks/$id.tsx | 652 ++- bun.lock | 1 + drizzle/0006_minor_doctor_octopus.sql | 18 + drizzle/meta/0002_snapshot.json | 6 +- drizzle/meta/0003_snapshot.json | 1625 +++++- drizzle/meta/0006_snapshot.json | 4641 +++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 1 + packages/db/src/schema.ts | 28 + script/backfill-comments.ts | 82 + 35 files changed, 10283 insertions(+), 578 deletions(-) create mode 100644 apps/api/src/routes/activity.ts create mode 100644 apps/api/src/routes/comments.ts create mode 100644 apps/web/src/components/charts/BarChart.tsx create mode 100644 apps/web/src/components/charts/CalendarHeatmap.tsx create mode 100644 apps/web/src/components/charts/HorizontalBar.tsx create mode 100644 apps/web/src/components/charts/LineChart.tsx create mode 100644 apps/web/src/components/charts/PieChartSimple.tsx create mode 100644 apps/web/src/components/charts/index.ts create mode 100644 apps/web/src/components/entities/detail-page.tsx create mode 100644 apps/web/src/components/entities/entity-activity.tsx create mode 100644 apps/web/src/components/entities/entity-comments.tsx create mode 100644 apps/web/src/components/entities/inline-edit.tsx create mode 100644 apps/web/src/components/entities/note-editor.tsx create mode 100644 apps/web/src/hooks/use-optimistic-patch.ts create mode 100644 drizzle/0006_minor_doctor_octopus.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 script/backfill-comments.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 75907fe..4576057 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,11 +18,13 @@ import { dashboardRoutes } from "./routes/dashboard"; import { agentRoutes } from "./routes/agents"; import { webhookRoutes } from "./routes/webhooks"; import { canvasRoutes } from "./routes/canvas"; +import { commentRoutes } from "./routes/comments"; import { dailyNoteRoutes } from "./routes/daily-notes"; import { tagRoutes } from "./routes/tags"; import { customFieldRoutes } from "./routes/custom-fields"; import { errorLogRoutes } from "./routes/error-log"; import { analyticsRoutes } from "./routes/analytics"; +import { activityRoutes } from "./routes/activity"; import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; import { healthHandler } from "./routes/health"; @@ -54,11 +56,13 @@ app.route("/api/dashboard", dashboardRoutes); app.route("/api/agents", agentRoutes); app.route("/api/webhooks", webhookRoutes); app.route("/api/canvas", canvasRoutes); +app.route("/api/comments", commentRoutes); app.route("/api/daily-notes", dailyNoteRoutes); app.route("/api/tags", tagRoutes); app.route("/api/custom-fields", customFieldRoutes); app.route("/api/error-log", errorLogRoutes); app.route("/api/analytics", analyticsRoutes); +app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); diff --git a/apps/api/src/routes/activity.ts b/apps/api/src/routes/activity.ts new file mode 100644 index 0000000..bd5c5b5 --- /dev/null +++ b/apps/api/src/routes/activity.ts @@ -0,0 +1,104 @@ +import { Hono } from "hono"; +import { + db, + activityFeed, + tasks, + projects, + habits, + notes, + canvases, + dailyNotes, + calendarEvents, +} from "@project-e/db"; +import { and, desc, eq, sql } from "drizzle-orm"; +import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"; +import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth"; +import { z } from "zod"; + +export const activityRoutes = new Hono(); + +// Entity types that participate in the generic activity feed / comments API. +// The comments table stores the same entityType strings, so keep this enum and +// the entityWorkspaceLookups map below in sync. +const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]); + +interface EntityWorkspaceLookup { + table: AnyPgTable; + idColumn: AnyPgColumn; + workspaceColumn: AnyPgColumn; +} + +// Maps an entityType (as recorded in activity_feed) to the table + column that +// holds its owning workspace/domain. All of these tables use domain_id. +const entityWorkspaceLookups: Record = { + task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId }, + project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId }, + habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, + note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId }, + canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId }, + daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId }, + calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId }, +}; + +async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise { + const lookup = entityWorkspaceLookups[entityType]; + if (!lookup) return null; + + const [row] = await db + .select({ workspaceId: lookup.workspaceColumn }) + .from(lookup.table) + .where(eq(lookup.idColumn, entityId)) + .limit(1); + + // AnyPgColumn erases the concrete column type, so the selected value is unknown. + return (row?.workspaceId as string | undefined) ?? null; +} + +// GET /api/activity?entityType=&entityId=&limit= — Activity feed for one entity. +activityRoutes.get("/", async (c) => { + try { + await requireAuth(c); + const entityType = c.req.query("entityType") || ""; + const entityId = c.req.query("entityId") || ""; + + const entityTypeResult = entityTypeEnum.safeParse(entityType); + if (!entityTypeResult.success) { + return c.json(createErrorResponse("VALIDATION_ERROR", "Invalid entityType"), 400); + } + if (!isUuid(entityId)) { + return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404); + } + + const domainId = await resolveEntityWorkspaceId(entityType, entityId); + if (!domainId) { + return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404); + } + await requireWorkspaceAccess(c, domainId); + + const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "50", 10) || 50, 1), 200); + const conditions = [ + eq(activityFeed.entityType, entityType), + eq(activityFeed.entityId, entityId), + eq(activityFeed.workspaceId, domainId), + ]; + + const [items, countResult] = await Promise.all([ + db.select() + .from(activityFeed) + .where(and(...conditions)) + .orderBy(desc(activityFeed.createdAt)) + .limit(limit), + db.select({ count: sql`count(*)` }) + .from(activityFeed) + .where(and(...conditions)), + ]); + + return c.json({ items, totalItems: Number(countResult[0]?.count || 0) }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[activity] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get activity" } }, 500); + } +}); diff --git a/apps/api/src/routes/comments.ts b/apps/api/src/routes/comments.ts new file mode 100644 index 0000000..b9461f5 --- /dev/null +++ b/apps/api/src/routes/comments.ts @@ -0,0 +1,222 @@ +import { Hono } from "hono"; +import { + db, + comments, + tasks, + projects, + habits, + notes, + canvases, + dailyNotes, + calendarEvents, +} from "@project-e/db"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; +import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"; +import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const commentRoutes = new Hono(); + +// Entity types that support comments. Keep in sync with entityWorkspaceLookups. +const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]); + +interface EntityWorkspaceLookup { + table: AnyPgTable; + idColumn: AnyPgColumn; + workspaceColumn: AnyPgColumn; +} + +// Maps an entityType (as recorded in comments.entityType) to the table + column +// that holds its owning workspace/domain. All of these tables use domain_id. +const entityWorkspaceLookups: Record = { + task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId }, + project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId }, + habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, + note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId }, + canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId }, + daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId }, + calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId }, +}; + +async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise { + const lookup = entityWorkspaceLookups[entityType]; + if (!lookup) return null; + + const [row] = await db + .select({ workspaceId: lookup.workspaceColumn }) + .from(lookup.table) + .where(eq(lookup.idColumn, entityId)) + .limit(1); + + // AnyPgColumn erases the concrete column type, so the selected value is unknown. + return (row?.workspaceId as string | undefined) ?? null; +} + +const createCommentSchema = z.object({ + entityType: entityTypeEnum, + entityId: z.string().uuid(), + content: z.string().min(1, "Content is required"), + parentId: z.string().uuid().optional(), +}); + +// GET /api/comments?entityType=&entityId= — Flat list of non-deleted comments +// for an entity, ordered oldest-first. The frontend assembles the reply tree +// from parentId. +commentRoutes.get("/", async (c) => { + try { + await requireAuth(c); + const entityType = c.req.query("entityType") || ""; + const entityId = c.req.query("entityId") || ""; + + const entityTypeResult = entityTypeEnum.safeParse(entityType); + if (!entityTypeResult.success) { + return c.json(createErrorResponse("VALIDATION_ERROR", "Invalid entityType"), 400); + } + if (!isUuid(entityId)) { + return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404); + } + + const domainId = await resolveEntityWorkspaceId(entityType, entityId); + if (!domainId) { + return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404); + } + await requireWorkspaceAccess(c, domainId); + + const items = await db.select() + .from(comments) + .where(and( + eq(comments.entityType, entityType), + eq(comments.entityId, entityId), + eq(comments.workspaceId, domainId), + isNull(comments.deletedAt), + )) + .orderBy(asc(comments.createdAt)); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[comments] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get comments" } }, 500); + } +}); + +// POST /api/comments — Create a comment (or a reply via parentId). +commentRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createCommentSchema.parse(body); + + const domainId = await resolveEntityWorkspaceId(data.entityType, data.entityId); + if (!domainId) { + return c.json(createErrorResponse("NOT_FOUND", "Entity not found"), 404); + } + await requireWorkspaceAccess(c, domainId); + + if (data.parentId) { + const [parent] = await db.select({ + id: comments.id, + entityType: comments.entityType, + entityId: comments.entityId, + deletedAt: comments.deletedAt, + }) + .from(comments) + .where(eq(comments.id, data.parentId)) + .limit(1); + + if (!parent || parent.deletedAt !== null || parent.entityType !== data.entityType || parent.entityId !== data.entityId) { + return c.json(createErrorResponse("VALIDATION_ERROR", "Parent comment not found or does not belong to this entity"), 400); + } + } + + const [newComment] = await db.insert(comments).values({ + entityType: data.entityType, + entityId: data.entityId, + workspaceId: domainId, + parentId: data.parentId ?? null, + author: user.name, + content: data.content.trim(), + }).returning(); + + await recordActivity({ + actor: user.name, + action: "commented", + entityType: data.entityType, + entityId: data.entityId, + changes: { commentId: newComment.id }, + workspaceId: domainId, + }); + + return c.json(newComment, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[comments] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create comment" } }, 500); + } +}); + +// DELETE /api/comments/:id — Soft-delete a comment and all of its descendants. +commentRoutes.delete("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404); + } + + const [comment] = await db.select() + .from(comments) + .where(eq(comments.id, id)) + .limit(1); + + if (!comment) { + return c.json(createErrorResponse("NOT_FOUND", "Comment not found"), 404); + } + + await requireWorkspaceAccess(c, comment.workspaceId); + + // Soft-delete the comment and every descendant. Comment volume is low, so a + // loop over parentId is fine. + const deletedAt = new Date(); + const idsToDelete = [id]; + let frontier: string[] = [id]; + while (frontier.length > 0) { + const children = await db.select({ id: comments.id }) + .from(comments) + .where(inArray(comments.parentId, frontier)); + frontier = children + .map(child => child.id) + .filter(childId => !idsToDelete.includes(childId)); + idsToDelete.push(...frontier); + } + + await db.update(comments) + .set({ deletedAt }) + .where(inArray(comments.id, idsToDelete)); + + await recordActivity({ + actor: user.name, + action: "deleted_comment", + entityType: comment.entityType, + entityId: comment.entityId, + changes: { commentId: id }, + workspaceId: comment.workspaceId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[comments] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete comment" } }, 500); + } +}); diff --git a/apps/api/src/routes/habits.ts b/apps/api/src/routes/habits.ts index 2509415..329603e 100644 --- a/apps/api/src/routes/habits.ts +++ b/apps/api/src/routes/habits.ts @@ -311,15 +311,19 @@ habitRoutes.get("/:id", async (c) => { await requireWorkspaceAccess(c, habit.domainId); - // Fetch recent completions (last 30 days) - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + // Fetch recent completions (default last 30 days; ?days= extends up to a + // year so the frontend heatmap can show longer history) + const url = new URL(c.req.url); + const daysParam = url.searchParams.get("days"); + const days = Math.min(365, Math.max(1, parseInt(daysParam || "30") || 30)); + const since = new Date(); + since.setDate(since.getDate() - days); const recentCompletions = await db.select() .from(habitCompletions) .where(and( eq(habitCompletions.habitId, id), - gte(habitCompletions.date, thirtyDaysAgo), + gte(habitCompletions.date, since), )) .orderBy(desc(habitCompletions.date)); diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index d7652fe..87f0c1c 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -714,6 +714,130 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => { } }); +// POST /api/tasks/:id/dependencies — Make this task depend on another task +taskRoutes.post("/:id/dependencies", 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 body = await c.req.json(); + const { dependsOnTaskId } = z.object({ + dependsOnTaskId: z.string().uuid("Invalid task id"), + }).parse(body); + + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + + // A task cannot depend on itself + if (dependsOnTaskId === id) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400); + } + + const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt))) + .limit(1); + if (!depTask) { + return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404); + } + if (depTask.domainId !== task.domainId) { + return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403); + } + + // Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from + // dependsOnTaskId; reaching id means adding this edge would create a cycle. + let currentId: string | null = dependsOnTaskId; + const visited = new Set([id]); + while (currentId) { + if (visited.has(currentId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400); + } + visited.add(currentId); + const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId }) + .from(taskDependencies) + .where(eq(taskDependencies.taskId, currentId)) + .limit(1); + currentId = next?.dependsOnTaskId ?? null; + } + + // Junction table has a composite PK — ignore duplicate edges + await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing(); + + await recordActivity({ + actor: user.name, + action: "dependency_added", + entityType: "task", + entityId: id, + changes: { dependsOnTaskId }, + workspaceId: task.domainId, + }); + + await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[tasks] POST /:id/dependencies error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500); + } +}); + +// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency +taskRoutes.delete("/:id/dependencies/:depId", 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 depId = c.req.param("depId"); + + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + + // Junction table has no deleted_at — hard delete is correct here + await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId))); + + await recordActivity({ + actor: user.name, + action: "dependency_removed", + entityType: "task", + entityId: id, + changes: { removedDependsOnTaskId: depId }, + workspaceId: task.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[tasks] DELETE /:id/dependencies/:depId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500); + } +}); + // POST /api/tasks/:id/status — Change task status (Kanban drag) taskRoutes.post("/:id/status", async (c) => { try { @@ -813,92 +937,6 @@ taskRoutes.get("/:id/history", async (c) => { } }); -// GET /api/tasks/:id/comments — Comment thread (stored in activity feed as entityType=comment) -taskRoutes.get("/:id/comments", 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 [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) - .from(tasks) - .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) - .limit(1); - - if (!task) { - return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); - } - - await requireWorkspaceAccess(c, task.domainId); - - const comments = await db.select() - .from(activityFeed) - .where(and( - eq(activityFeed.entityId, id), - eq(activityFeed.entityType, "comment"), - eq(activityFeed.workspaceId, task.domainId), - )) - .orderBy(asc(activityFeed.createdAt)); - - return c.json({ items: comments, totalItems: comments.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 /:id/comments error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get comments" } }, 500); - } -}); - -// POST /api/tasks/:id/comments — Add a comment -taskRoutes.post("/:id/comments", 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 body = await c.req.json(); - const { content } = z.object({ - content: z.string().min(1, "Content is required"), - }).parse(body); - - // Verify task exists - const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) - .from(tasks) - .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) - .limit(1); - - if (!task) { - return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); - } - - await requireWorkspaceAccess(c, task.domainId); - - await recordActivity({ - actor: user.name, - action: "commented", - entityType: "comment", - entityId: id, - changes: { content }, - workspaceId: task.domainId, - }); - - return c.json({ success: true }, 201); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - if (error instanceof z.ZodError) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); - } - console.error("[tasks] POST /:id/comments error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add comment" } }, 500); - } -}); - // GET /api/tasks/:id/attachments — File attachments metadata taskRoutes.get("/:id/attachments", async (c) => { try { diff --git a/apps/web/src/components/charts/BarChart.tsx b/apps/web/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..7211f68 --- /dev/null +++ b/apps/web/src/components/charts/BarChart.tsx @@ -0,0 +1,28 @@ +/** + * Lightweight SVG bar chart — part of the shared chart set intentionally + * implemented as plain SVG instead of pulling in the recharts dependency. + */ +export function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) { + if (!data.length) return

No data

; + const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0); + const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1); + const series = yKey2 ? 2 : 1; + const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series)); + const width = Math.max(data.length * (barWidth * series + 4) + 40, 200); + return ( + + {data.map((d, i) => { + const barH = (valOf(d, yKey) / maxVal) * (height - 30); + const x = i * (barWidth * series + 4) + 20; + const y = height - 20 - barH; + return ; + })} + {yKey2 && data.map((d, i) => { + const barH = (valOf(d, yKey2) / maxVal) * (height - 30); + const x = i * (barWidth * series + 4) + 20 + barWidth; + const y = height - 20 - barH; + return ; + })} + + ); +} diff --git a/apps/web/src/components/charts/CalendarHeatmap.tsx b/apps/web/src/components/charts/CalendarHeatmap.tsx new file mode 100644 index 0000000..ea4b2fd --- /dev/null +++ b/apps/web/src/components/charts/CalendarHeatmap.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; +import { format, subDays } from "date-fns"; + +/** + * Lightweight calendar heatmap — part of the shared chart set intentionally + * implemented as plain divs instead of pulling in the recharts dependency. + */ +export function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) { + const today = new Date(); + const dateMap = new Map(data.map((d) => [d.date?.slice(0, 10), d.count || 0])); + const cells = []; + for (let i = days - 1; i >= 0; i--) { + const d = subDays(today, i); + const key = format(d, "yyyy-MM-dd"); + const count = dateMap.get(key) || 0; + const intensity = count > 0 ? Math.min(count / 5, 1) : 0; + const color = intensity > 0.75 ? "bg-green-600" : intensity > 0.5 ? "bg-green-500" : intensity > 0.25 ? "bg-green-400" : intensity > 0 ? "bg-green-200" : "bg-muted"; + cells.push( +
+ ); + } + return
{cells}
; +} diff --git a/apps/web/src/components/charts/HorizontalBar.tsx b/apps/web/src/components/charts/HorizontalBar.tsx new file mode 100644 index 0000000..4565346 --- /dev/null +++ b/apps/web/src/components/charts/HorizontalBar.tsx @@ -0,0 +1,21 @@ +/** + * Lightweight horizontal bar chart — part of the shared chart set intentionally + * implemented as plain SVG/divs instead of pulling in the recharts dependency. + */ +export function HorizontalBar({ data, xKey, yKey, height = 100 }: { data: any[]; xKey: string; yKey: string; height?: number }) { + if (!data.length) return

No data

; + const maxVal = Math.max(...data.map((d) => d[yKey]), 1); + return ( +
+ {data.map((d, i) => ( +
+ {d[xKey]} +
+
+
+ {d[yKey]} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/charts/LineChart.tsx b/apps/web/src/components/charts/LineChart.tsx new file mode 100644 index 0000000..5594143 --- /dev/null +++ b/apps/web/src/components/charts/LineChart.tsx @@ -0,0 +1,24 @@ +/** + * Lightweight SVG line chart — part of the shared chart set intentionally + * implemented as plain SVG instead of pulling in the recharts dependency. + */ +export function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) { + if (!data.length) return

No data

; + const maxVal = Math.max(...data.map((d) => d[yKey]), 1); + const width = Math.max(data.length * 30, 200); + const points = data.map((d, i) => { + const x = (i / (data.length - 1 || 1)) * (width - 40) + 20; + const y = height - 20 - ((d[yKey] / maxVal) * (height - 40)); + return `${x},${y}`; + }).join(" "); + return ( + + + {data.map((d, i) => { + const x = (i / (data.length - 1 || 1)) * (width - 40) + 20; + const y = height - 20 - ((d[yKey] / maxVal) * (height - 40)); + return ; + })} + + ); +} diff --git a/apps/web/src/components/charts/PieChartSimple.tsx b/apps/web/src/components/charts/PieChartSimple.tsx new file mode 100644 index 0000000..349ca56 --- /dev/null +++ b/apps/web/src/components/charts/PieChartSimple.tsx @@ -0,0 +1,42 @@ +/** + * Lightweight SVG pie/donut chart — part of the shared chart set intentionally + * implemented as plain SVG instead of pulling in the recharts dependency. + */ +export function PieChartSimple({ data, labelKey, valueKey, size = 120 }: { data: any[]; labelKey: string; valueKey: string; size?: number }) { + if (!data.length) return

No data

; + const total = data.reduce((s, d) => s + d[valueKey], 0) || 1; + const colors = ["#3b82f6", "#22c55e", "#f97316", "#a855f7", "#e11d48", "#14b8a6"]; + let cumulative = 0; + const slices = data.map((d, i) => { + const pct = d[valueKey] / total; + const startAngle = cumulative * 360; + cumulative += pct; + const endAngle = cumulative * 360; + const startRad = (startAngle - 90) * Math.PI / 180; + const endRad = (endAngle - 90) * Math.PI / 180; + const r = size / 2 - 4; + const cx = size / 2; + const cy = size / 2; + const x1 = cx + r * Math.cos(startRad); + const y1 = cy + r * Math.sin(startRad); + const x2 = cx + r * Math.cos(endRad); + const y2 = cy + r * Math.sin(endRad); + const largeArc = pct > 0.5 ? 1 : 0; + return { path: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`, color: colors[i % colors.length], label: d[labelKey], pct: Math.round(pct * 100) }; + }); + return ( +
+ + {slices.map((s, i) => )} + +
+ {slices.map((s, i) => ( +
+
+ {s.label} ({s.pct}%) +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/charts/index.ts b/apps/web/src/components/charts/index.ts new file mode 100644 index 0000000..3a0998a --- /dev/null +++ b/apps/web/src/components/charts/index.ts @@ -0,0 +1,12 @@ +/** + * Shared lightweight SVG chart components. + * + * These are intentionally implemented as plain SVG/divs rather than pulling in + * the recharts dependency — the visualizations needed here are simple enough + * that a full charting library is overkill. + */ +export { LineChart } from "./LineChart"; +export { BarChart } from "./BarChart"; +export { HorizontalBar } from "./HorizontalBar"; +export { PieChartSimple } from "./PieChartSimple"; +export { CalendarHeatmap } from "./CalendarHeatmap"; diff --git a/apps/web/src/components/entities/detail-page.tsx b/apps/web/src/components/entities/detail-page.tsx new file mode 100644 index 0000000..603af2b --- /dev/null +++ b/apps/web/src/components/entities/detail-page.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +export interface DetailTab { + value: string; + label: string; + content: ReactNode; +} + +export interface EntityDetailPageProps { + /** Back navigation. `to` is a TanStack Router route path (e.g. "/tasks"), label the button text. */ + backTo: { to: string; label: string }; + /** Title node — pages usually pass an InlineText-wrapped title or plain heading. */ + title: ReactNode; + /** Optional leading icon next to the title (lucide icon component). */ + icon?: ReactNode; + /** Badges row (status/priority/etc) under or beside the title. */ + badges?: ReactNode; + /** Right-aligned action buttons (quick actions: complete, log, pin, delete...). */ + actions?: ReactNode; + /** Tabs. Each renders its content in a TabsContent. */ + tabs: DetailTab[]; + /** Initial active tab. Defaults to the first tab. */ + defaultTab?: string; + /** Sticky-header container class override (default "max-w-4xl"). */ + containerClassName?: string; +} + +export function EntityDetailPage({ + backTo, + title, + icon, + badges, + actions, + tabs, + defaultTab, + containerClassName, +}: EntityDetailPageProps) { + const navigate = useNavigate(); + + return ( +
+
+ +
+
+ {icon &&
{icon}
} +
+

{title}

+ {badges && ( +
{badges}
+ )} +
+
+ {actions &&
{actions}
} +
+
+ + + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + {tabs.map((tab) => ( + +
{tab.content}
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/entities/entity-activity.tsx b/apps/web/src/components/entities/entity-activity.tsx new file mode 100644 index 0000000..4765dc8 --- /dev/null +++ b/apps/web/src/components/entities/entity-activity.tsx @@ -0,0 +1,130 @@ +import { + CheckCircle2, + CirclePlus, + Dot, + Link2, + ListOrdered, + MessageSquare, + Pencil, + Tag, + Trash2, + Unlink, + type LucideIcon, +} from "lucide-react"; +import { formatDistanceToNow, parseISO } from "date-fns"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ErrorState, LoadingState } from "@/components/state"; +import { useApiQuery } from "@/lib/api"; +import type { ActivityItem } from "@/lib/types"; + +interface EntityActivityProps { + entityType: string; + entityId: string; + limit?: number; +} + +interface ActivityResponse { + items: ActivityItem[]; + totalItems: number; +} + +interface ActionMeta { + icon: LucideIcon; + label: string; +} + +const ACTION_META: Record = { + created: { icon: CirclePlus, label: "created" }, + completed: { icon: CheckCircle2, label: "completed" }, + updated: { icon: Pencil, label: "updated" }, + commented: { icon: MessageSquare, label: "commented" }, + tagged: { icon: Tag, label: "tagged" }, + untagged: { icon: Tag, label: "untagged" }, + reordered: { icon: ListOrdered, label: "reordered" }, + dependency_added: { icon: Link2, label: "added a dependency" }, + dependency_removed: { icon: Unlink, label: "removed a dependency" }, + deleted: { icon: Trash2, label: "deleted" }, + deleted_comment: { icon: Trash2, label: "deleted a comment" }, +}; + +// Keys recorded alongside "updated" that aren't user-facing field changes. +const UPDATED_META_KEYS = new Set(["updatedAt", "createdAt", "previousStatus"]); + +/** Compact, plain-text summary of what changed. Only meaningful for updates. */ +function changesSummary( + action: string, + changes: Record | null +): string | null { + if (action !== "updated" || !changes) return null; + const fields = Object.keys(changes).filter((key) => !UPDATED_META_KEYS.has(key)); + return fields.length > 0 ? fields.join(", ") : null; +} + +function ActivityRow({ item }: { item: ActivityItem }) { + const meta: ActionMeta = ACTION_META[item.action] ?? { icon: Dot, label: item.action }; + const Icon = meta.icon; + const summary = changesSummary(item.action, item.changes); + + return ( +
+
+ +
+
+

+ {item.actor} + {meta.label} + + {formatDistanceToNow(parseISO(item.createdAt), { addSuffix: true })} + +

+ {summary ?

{summary}

: null} +
+
+ ); +} + +/** + * Activity feed for a single entity. The API returns newest-first, so rows are + * rendered in the order received. `limit` caps how many entries are fetched + * (the API clamps it to 1–200). + */ +export function EntityActivity({ entityType, entityId, limit }: EntityActivityProps) { + const path = + `/activity?entityType=${entityType}&entityId=${entityId}` + + (limit ? `&limit=${limit}` : ""); + + const { data, isLoading, isError, error, refetch } = useApiQuery( + ["activity", entityType, entityId], + path + ); + + const items = data?.items ?? []; + + return ( + + + Activity + + + {isLoading ? : null} + {isError ? ( + refetch()} + /> + ) : null} + {!isLoading && !isError && items.length === 0 ? ( +

No activity yet

+ ) : null} + {!isLoading && !isError && items.length > 0 ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/entities/entity-comments.tsx b/apps/web/src/components/entities/entity-comments.tsx new file mode 100644 index 0000000..b9bdc3f --- /dev/null +++ b/apps/web/src/components/entities/entity-comments.tsx @@ -0,0 +1,277 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { formatDistanceToNow, parseISO } from "date-fns"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { ErrorState, LoadingState } from "@/components/state"; +import { api, useApiMutation, useApiQuery } from "@/lib/api"; +import type { Comment } from "@/lib/types"; + +interface EntityCommentsProps { + entityType: string; + entityId: string; +} + +interface CommentsResponse { + items: Comment[]; +} + +interface CreateCommentVariables { + entityType: string; + entityId: string; + content: string; + parentId?: string | null; +} + +interface CommentNode { + comment: Comment; + children: CommentNode[]; +} + +/** Assemble the flat, oldest-first API list into a reply tree via parentId. */ +function buildCommentTree(items: Comment[]): CommentNode[] { + const childrenByParent = new Map(); + for (const item of items) { + const key = item.parentId ?? "__root__"; + const siblings = childrenByParent.get(key); + if (siblings) siblings.push(item); + else childrenByParent.set(key, [item]); + } + const build = (parentId: string): CommentNode[] => + (childrenByParent.get(parentId) ?? []).map((comment) => ({ + comment, + children: build(comment.id), + })); + return build("__root__"); +} + +function initials(name: string): string { + return ( + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0].toUpperCase()) + .join("") || "?" + ); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : "Something went wrong"; +} + +interface CommentComposerProps { + entityType: string; + entityId: string; + parentId?: string | null; + submitLabel?: string; + placeholder?: string; + autoFocus?: boolean; + onSubmitted?: () => void; +} + +/** + * Inline comment/reply composer. Each instance owns its text + mutation state, + * so the top composer and any open reply composer are independent and neither + * can double-submit. + */ +function CommentComposer({ + entityType, + entityId, + parentId = null, + submitLabel = "Comment", + placeholder = "Write a comment...", + autoFocus = false, + onSubmitted, +}: CommentComposerProps) { + const queryClient = useQueryClient(); + const [content, setContent] = useState(""); + + const createMutation = useApiMutation( + "post", + "/comments", + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] }); + toast.success("Comment added"); + setContent(""); + onSubmitted?.(); + }, + onError: (err) => toast.error(errorMessage(err)), + } + ); + + const canSubmit = content.trim().length > 0 && !createMutation.isPending; + + const submit = () => { + const trimmed = content.trim(); + if (!trimmed || createMutation.isPending) return; + createMutation.mutate({ + entityType, + entityId, + content: trimmed, + parentId, + }); + }; + + return ( +
+