From ffc50091b1c37ee534dfe7ba02e0ca6241e62607 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Sat, 22 Aug 2026 18:00:41 +0000 Subject: [PATCH] refactor: remove canvas, automations, and custom statuses; simplify notification and status model --- apps/api/src/index.ts | 8 - apps/api/src/lib/automation-engine.ts | 291 ---------- apps/api/src/lib/notify.ts | 65 --- apps/api/src/middleware/activity.ts | 2 - apps/api/src/routes/activity.ts | 6 +- apps/api/src/routes/agents.ts | 32 ++ apps/api/src/routes/analytics.ts | 133 ++++- apps/api/src/routes/automations.ts | 309 ---------- apps/api/src/routes/calendar.ts | 53 +- apps/api/src/routes/canvas.ts | 423 -------------- apps/api/src/routes/comments.ts | 22 +- apps/api/src/routes/mcp.ts | 78 +-- apps/api/src/routes/notifications.ts | 182 +----- apps/api/src/routes/projects.ts | 60 +- apps/api/src/routes/statuses.ts | 389 ------------- apps/api/src/routes/tasks.ts | 275 ++------- apps/api/src/routes/timeline.ts | 118 ---- apps/web/package.json | 2 + .../components/automation-rule-builder.tsx | 528 ------------------ .../components/entities/entity-comments.tsx | 15 +- apps/web/src/components/gantt/gantt-chart.tsx | 267 --------- .../gantt/gantt-dependency-arrow.tsx | 50 -- .../src/components/gantt/gantt-milestone.tsx | 24 - .../src/components/gantt/gantt-task-bar.tsx | 105 ---- .../gantt/gantt-timeline-header.tsx | 101 ---- apps/web/src/components/gantt/gantt-utils.ts | 82 --- .../src/components/notification-center.tsx | 253 --------- apps/web/src/components/quick-add-bar.tsx | 234 -------- apps/web/src/components/shell/sidebar.tsx | 2 - apps/web/src/components/shell/topbar.tsx | 95 +++- .../components/tasks/recurrence-picker.tsx | 42 ++ apps/web/src/hooks/use-realtime.ts | 7 - apps/web/src/lib/nlp-parser.test.ts | 257 --------- apps/web/src/lib/nlp-parser.ts | 458 --------------- apps/web/src/lib/nlp.ts | 133 +++++ apps/web/src/lib/status-colors.ts | 34 -- apps/web/src/lib/types/index.ts | 138 +---- apps/web/src/routeTree.ts | 4 - apps/web/src/routes/_app.tsx | 2 - apps/web/src/routes/_app/analytics.tsx | 29 +- apps/web/src/routes/_app/calendar.tsx | 50 +- apps/web/src/routes/_app/canvas.tsx | 481 ---------------- apps/web/src/routes/_app/canvas/$id.tsx | 181 ------ apps/web/src/routes/_app/daily.tsx | 25 +- apps/web/src/routes/_app/index.tsx | 2 +- apps/web/src/routes/_app/projects/$id.tsx | 194 +------ apps/web/src/routes/_app/search.tsx | 28 +- apps/web/src/routes/_app/tasks.tsx | 132 ++--- apps/web/src/routes/_app/tasks/$id.tsx | 139 ++--- apps/web/tsconfig.json | 1 - apps/worker/src/index.ts | 126 +---- bun.lock | 4 + drizzle/0007_custom_task_statuses.sql | 121 ---- drizzle/0007_drop_canvas_reports.sql | 4 + drizzle/0008_automation_rules.sql | 14 - drizzle/0009_notifications.sql | 20 - drizzle/meta/_journal.json | 18 +- e2e/canvas.spec.ts | 37 -- e2e/navigation.spec.ts | 1 - package.json | 1 - packages/db/src/schema.ts | 193 +------ packages/shared/src/constants/index.ts | 3 - packages/shared/src/index.ts | 2 - packages/shared/src/schemas/canvas.ts | 81 --- packages/shared/src/schemas/report.ts | 72 --- packages/shared/src/types/index.ts | 8 - script/deploy.sh | 2 - script/migrate-task-statuses.ts | 22 - tsconfig.tsbuildinfo | 2 +- 69 files changed, 834 insertions(+), 6438 deletions(-) delete mode 100644 apps/api/src/lib/automation-engine.ts delete mode 100644 apps/api/src/lib/notify.ts delete mode 100644 apps/api/src/routes/automations.ts delete mode 100644 apps/api/src/routes/canvas.ts delete mode 100644 apps/api/src/routes/statuses.ts delete mode 100644 apps/api/src/routes/timeline.ts delete mode 100644 apps/web/src/components/automation-rule-builder.tsx delete mode 100644 apps/web/src/components/gantt/gantt-chart.tsx delete mode 100644 apps/web/src/components/gantt/gantt-dependency-arrow.tsx delete mode 100644 apps/web/src/components/gantt/gantt-milestone.tsx delete mode 100644 apps/web/src/components/gantt/gantt-task-bar.tsx delete mode 100644 apps/web/src/components/gantt/gantt-timeline-header.tsx delete mode 100644 apps/web/src/components/gantt/gantt-utils.ts delete mode 100644 apps/web/src/components/notification-center.tsx delete mode 100644 apps/web/src/components/quick-add-bar.tsx create mode 100644 apps/web/src/components/tasks/recurrence-picker.tsx delete mode 100644 apps/web/src/lib/nlp-parser.test.ts delete mode 100644 apps/web/src/lib/nlp-parser.ts create mode 100644 apps/web/src/lib/nlp.ts delete mode 100644 apps/web/src/routes/_app/canvas.tsx delete mode 100644 apps/web/src/routes/_app/canvas/$id.tsx delete mode 100644 drizzle/0007_custom_task_statuses.sql create mode 100644 drizzle/0007_drop_canvas_reports.sql delete mode 100644 drizzle/0008_automation_rules.sql delete mode 100644 drizzle/0009_notifications.sql delete mode 100644 e2e/canvas.spec.ts delete mode 100644 packages/shared/src/schemas/canvas.ts delete mode 100644 packages/shared/src/schemas/report.ts delete mode 100644 script/migrate-task-statuses.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 708491e..a5fac02 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,8 +10,6 @@ import { domainRoutes } from "./routes/domains"; import { taskRoutes } from "./routes/tasks"; import { habitRoutes } from "./routes/habits"; import { projectRoutes } from "./routes/projects"; -import { statusRoutes } from "./routes/statuses"; -import { automationRoutes } from "./routes/automations"; import { noteRoutes } from "./routes/notes"; import { searchRoutes } from "./routes/search"; import { calendarRoutes } from "./routes/calendar"; @@ -19,7 +17,6 @@ import { graphRoutes } from "./routes/graph"; 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"; @@ -29,7 +26,6 @@ import { analyticsRoutes } from "./routes/analytics"; import { activityRoutes } from "./routes/activity"; import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; -import { timelineRoutes } from "./routes/timeline"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -51,8 +47,6 @@ app.route("/api/domains", domainRoutes); app.route("/api/tasks", taskRoutes); app.route("/api/habits", habitRoutes); app.route("/api/projects", projectRoutes); -app.route("/api/projects", statusRoutes); -app.route("/api/projects", automationRoutes); app.route("/api/notes", noteRoutes); app.route("/api/search", searchRoutes); app.route("/api/calendar", calendarRoutes); @@ -60,7 +54,6 @@ app.route("/api/graph", graphRoutes); 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); @@ -71,7 +64,6 @@ app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); -app.route("/api", timelineRoutes); app.route("/api/mcp", mcpRoutes); // Persist uncaught server errors so the Settings → Error Log tab shows real diff --git a/apps/api/src/lib/automation-engine.ts b/apps/api/src/lib/automation-engine.ts deleted file mode 100644 index b8ee784..0000000 --- a/apps/api/src/lib/automation-engine.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { db, automationRules, tasks, taskTags, tags as tagsTable, statusDefinitions } from "@project-e/db"; -import { and, eq, isNull } from "drizzle-orm"; -import { recordActivity } from "../middleware/activity"; -import { enqueueWebhooks } from "../middleware/webhook-queue"; -import { notifyWorkspaceOwner } from "./notify"; - -export const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const; -export const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const; - -// Safety limit: cap the number of actions executed per trigger event so a -// misconfigured rule can never cascade into runaway writes. -const MAX_ACTIONS_PER_EVENT = 10; - -export interface EvaluateAutomationsParams { - projectId: string | null | undefined; - triggerType: string; - /** The affected entity row (e.g. the task after its mutation). */ - entity: Record; - /** Diff of the mutation, e.g. { previousStatusId } for status changes. */ - changes?: Record; - /** Display name of the user who triggered the event. Defaults to "Automation". */ - actor?: string; -} - -type AutomationRuleRow = typeof automationRules.$inferSelect; -type StatusRow = typeof statusDefinitions.$inferSelect; - -interface RuleContext { - projectId: string; - statusKey: string | null; - previousStatusKey: string | null; - priority: string | null; - labels: string[]; - /** The affected entity row (e.g. the task after its mutation). */ - entity: Record; - statusById: Map; - idByKey: Map; - categoryById: Map; -} - -function matchesCondition( - condition: { field: string; op: string; value: any }, - ctx: RuleContext -): boolean { - const value = condition.value; - switch (condition.field) { - case "project": - return ctx.projectId === value; - case "status": { - // `to`/`from` are status-change semantics; everything else compares the - // task's current status key. - if (condition.op === "to") return ctx.statusKey === value; - if (condition.op === "from") return ctx.previousStatusKey === value; - if (condition.op === "neq") return ctx.statusKey !== value; - return ctx.statusKey === value; - } - case "priority": - if (condition.op === "in") { - return Array.isArray(value) && value.includes(ctx.priority); - } - if (condition.op === "neq") return ctx.priority !== value; - return ctx.priority === value; - case "label": - if (condition.op === "not_has") return !ctx.labels.includes(value); - return ctx.labels.includes(value); - default: - return true; - } -} - -/** - * Execute a single automation action immediately. Returns true when the action - * actually ran (i.e. it should count against the per-event limit), false when it - * was a no-op (e.g. status key no longer exists in the project). - */ -async function executeAction( - action: { type: string; params: Record }, - ctx: RuleContext, - actor: string -): Promise { - const params = action.params ?? {}; - const entity = ctx.entity; - - switch (action.type) { - case "set_status": { - const statusId = ctx.idByKey.get(params.statusKey as string); - if (!statusId) return false; - const category = ctx.categoryById.get(statusId) ?? "todo"; - await db.update(tasks) - .set({ - statusId, - completedAt: category === "done" ? new Date() : null, - updatedAt: new Date(), - }) - .where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt))); - - await recordActivity({ - actor, - action: "updated", - entityType: "task", - entityId: entity.id, - changes: { - previousStatusId: entity.statusId, - newStatusId: statusId, - viaAutomation: true, - automation: params.statusKey, - }, - workspaceId: entity.domainId, - }); - await enqueueWebhooks({ - workspaceId: entity.domainId, - event: "task.updated", - entityType: "task", - entityId: entity.id, - data: { previousStatusId: entity.statusId, newStatusId: statusId, viaAutomation: true }, - }); - return true; - } - - case "set_priority": { - const priority = params.priority as string; - await db.update(tasks) - .set({ priority: priority as any, updatedAt: new Date() }) - .where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt))); - - await recordActivity({ - actor, - action: "updated", - entityType: "task", - entityId: entity.id, - changes: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true }, - workspaceId: entity.domainId, - }); - await enqueueWebhooks({ - workspaceId: entity.domainId, - event: "task.updated", - entityType: "task", - entityId: entity.id, - data: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true }, - }); - return true; - } - - case "add_label": { - const label = (params.label as string).trim(); - if (!label) return false; - - // Find-or-create a tag so rules can attach labels that don't exist yet. - let tagId: string | null = null; - const [existingTag] = await db.select({ id: tagsTable.id }) - .from(tagsTable) - .where(eq(tagsTable.name, label)) - .limit(1); - if (existingTag) { - tagId = existingTag.id; - } else { - const [created] = await db.insert(tagsTable).values({ name: label, scope: "tasks" }).returning({ id: tagsTable.id }); - tagId = created.id; - } - - await db.insert(taskTags).values({ taskId: entity.id, tagId }).onConflictDoNothing(); - - await recordActivity({ - actor, - action: "tagged", - entityType: "task", - entityId: entity.id, - changes: { tagId, tagName: label, viaAutomation: true }, - workspaceId: entity.domainId, - }); - return true; - } - - case "create_notification": { - const message = (params.message as string).trim(); - if (!message) return false; - - // Create a real in-app notification so it lands in the bell's unread - // badge and the notification sheet (single-user MVP: goes to the - // workspace owner). The activity row is kept for the audit trail. - await notifyWorkspaceOwner({ - workspaceId: entity.domainId, - type: "automation", - title: message, - body: `Automation fired on "${entity.title ?? "task"}"`, - entityType: "task", - entityId: entity.id, - }); - - await recordActivity({ - actor, - action: "notified", - entityType: "task", - entityId: entity.id, - changes: { message, viaAutomation: true }, - workspaceId: entity.domainId, - }); - return true; - } - - default: - return false; - } -} - -/** - * Find every active rule for a project whose trigger matches `triggerType`, - * check their conditions, and run the matching rules' actions immediately. - * - * Never throws: automation failures must not fail the user's request. All errors - * are logged. The total number of actions executed per event is capped at - * `MAX_ACTIONS_PER_EVENT`. - */ -export async function evaluateAutomations({ - projectId, - triggerType, - entity, - changes = {}, - actor = "Automation", -}: EvaluateAutomationsParams): Promise { - if (!projectId || !entity?.id) return; - - try { - const rules = await db.select() - .from(automationRules) - .where(and( - eq(automationRules.projectId, projectId), - eq(automationRules.active, true), - )); - - const matching = rules.filter((rule: AutomationRuleRow) => rule.trigger.type === triggerType); - if (matching.length === 0) return; - - // Build the context maps once per event: status definitions for the project - // (key <-> id) and the task's current labels for condition matching. - const statuses = await db.select() - .from(statusDefinitions) - .where(eq(statusDefinitions.projectId, projectId)); - - const statusById = new Map(statuses.map((s) => [s.id, s])); - const idByKey = new Map(); - const categoryById = new Map(); - for (const status of statuses) { - idByKey.set(status.key, status.id); - categoryById.set(status.id, status.category); - } - - const tagRows = await db.select({ name: tagsTable.name }) - .from(taskTags) - .innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id)) - .where(eq(taskTags.taskId, entity.id)); - - const statusKey = entity.statusId ? (statusById.get(entity.statusId)?.key ?? null) : null; - const previousStatusKey = changes.previousStatusId - ? (statusById.get(changes.previousStatusId)?.key ?? null) - : null; - - const ctx: RuleContext = { - projectId, - statusKey, - previousStatusKey, - priority: entity.priority ?? null, - labels: tagRows.map((r) => r.name), - entity, - statusById, - idByKey, - categoryById, - }; - - let executedActions = 0; - for (const rule of matching) { - if (executedActions >= MAX_ACTIONS_PER_EVENT) break; - - const conditionsMatch = (rule.conditions ?? []).every((condition) => - matchesCondition(condition, ctx) - ); - if (!conditionsMatch) continue; - - for (const action of rule.actions ?? []) { - if (executedActions >= MAX_ACTIONS_PER_EVENT) { - console.warn(`[automations] Hit safety limit of ${MAX_ACTIONS_PER_EVENT} actions for event ${triggerType} in project ${projectId}`); - break; - } - const ran = await executeAction(action, ctx, actor); - if (ran) executedActions += 1; - } - } - } catch (error) { - console.error(`[automations] evaluateAutomations failed for ${triggerType} in project ${projectId}:`, error); - } -} diff --git a/apps/api/src/lib/notify.ts b/apps/api/src/lib/notify.ts deleted file mode 100644 index 523950b..0000000 --- a/apps/api/src/lib/notify.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { db, sql, notifications, domains } from "@project-e/db"; -import { eq } from "drizzle-orm"; - -export type NotificationType = - | "mention" - | "status_change" - | "due_soon" - | "automation" - | "assignment"; - -export interface CreateNotificationParams { - userId: string; - workspaceId?: string | null; - type: NotificationType | string; - title: string; - body?: string | null; - entityType?: string | null; - entityId?: string | null; -} - -/** - * Insert an in-app notification and fan it out over the realtime SSE stream so - * open clients refresh their bell count and list without polling. The event - * mirrors the pg_notify shape used by recordActivity: `{ type, action, id, - * workspace_id }` with `type: "notification"`. - */ -export async function createNotification(params: CreateNotificationParams): Promise { - const { userId, workspaceId, type, title, body, entityType, entityId } = params; - - const [notification] = await db.insert(notifications).values({ - userId, - workspaceId: workspaceId ?? null, - type, - title, - body: body ?? null, - entityType: entityType ?? null, - entityId: entityId ?? null, - }).returning(); - - if (workspaceId) { - const payload = JSON.stringify({ type: "notification", action: "created", id: notification.id, workspace_id: workspaceId }); - await sql`SELECT pg_notify('project_e_events', ${payload}::text)`; - } - - return notification; -} - -/** - * MVP convenience for the single-user app: resolve the workspace owner and send - * them the notification. Returns null when the workspace has no owner, so - * callers can rely on this never throwing for missing ownership. - */ -export async function notifyWorkspaceOwner(params: Omit): Promise { - const { workspaceId, ...rest } = params; - if (!workspaceId) return null; - - const [domain] = await db - .select({ ownerId: domains.ownerId }) - .from(domains) - .where(eq(domains.id, workspaceId)) - .limit(1); - - if (!domain?.ownerId) return null; - return createNotification({ ...rest, workspaceId, userId: domain.ownerId }); -} diff --git a/apps/api/src/middleware/activity.ts b/apps/api/src/middleware/activity.ts index 35acec3..4cb8aa9 100644 --- a/apps/api/src/middleware/activity.ts +++ b/apps/api/src/middleware/activity.ts @@ -6,7 +6,6 @@ import { habits, projects, notes, - canvases, dailyNotes, calendarEvents, webhooks, @@ -69,7 +68,6 @@ const entityWorkspaceLookups: Record = { habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, project: { table: projects, idColumn: projects.id, workspaceColumn: projects.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 }, webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId }, diff --git a/apps/api/src/routes/activity.ts b/apps/api/src/routes/activity.ts index bd5c5b5..51f177b 100644 --- a/apps/api/src/routes/activity.ts +++ b/apps/api/src/routes/activity.ts @@ -6,7 +6,6 @@ import { projects, habits, notes, - canvases, dailyNotes, calendarEvents, } from "@project-e/db"; @@ -20,7 +19,7 @@ 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"]); +const entityTypeEnum = z.enum(["task", "project", "habit", "note", "daily_note", "calendar_event"]); interface EntityWorkspaceLookup { table: AnyPgTable; @@ -28,14 +27,11 @@ interface EntityWorkspaceLookup { 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 }, }; diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts index 346843d..7adfb61 100644 --- a/apps/api/src/routes/agents.ts +++ b/apps/api/src/routes/agents.ts @@ -329,6 +329,38 @@ agentRoutes.get("/:id/permissions", async (c) => { } }); +// POST /api/agents/dispatch — Stub for @mention dispatch (enqueues worker job) +agentRoutes.post("/dispatch", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const domainId = body.workspaceId || body.domain || (await resolveActiveDomain(user)).id; + await requireWorkspaceAccess(c, domainId); + const agentsList = await db.select().from(agents).where(and(eq(agents.domainId, domainId), eq(agents.status, "active"))).limit(1); + if (agentsList.length === 0) return c.json({ success: false, message: "No active agents" }, 200); + const agent = agentsList[0]; + await db.insert(agentTasks).values({ + agentId: agent.id, + taskType: "mention", + input: { commentId: body.commentId, entityType: body.entityType, entityId: body.entityId, author: user.name }, + status: "pending", + }); + await db.insert(agentActivity).values({ + agentId: agent.id, + action: "mentioned", + entityType: body.entityType || "comment", + entityId: body.entityId, + details: { commentId: body.commentId }, + success: true, + }); + return c.json({ success: true, agentId: agent.id }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[agents] POST /dispatch error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Dispatch failed" } }, 500); + } +}); + // GET /api/agents/:id/activity — Agent activity log (or all if id=_all) agentRoutes.get("/:id/activity", async (c) => { try { diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index ba26bca..e0c5b66 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { db, tasks, habits, habitCompletions, projects, statusDefinitions } from "@project-e/db"; -import { and, eq, gte, getTableColumns, inArray, isNull, or } from "drizzle-orm"; +import { db, tasks, habits, habitCompletions, projects } from "@project-e/db"; +import { and, eq, gte, inArray, isNull, or } from "drizzle-orm"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; export const analyticsRoutes = new Hono(); @@ -22,18 +22,15 @@ analyticsRoutes.get("/productivity", async (c) => { const startDate = new Date(); startDate.setDate(startDate.getDate() - range); - const taskColumns = getTableColumns(tasks); - const allTasks = await db.select({ ...taskColumns, statusCategory: statusDefinitions.category }) + const allTasks = await db.select() .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and( eq(tasks.domainId, domainId), gte(tasks.createdAt, startDate), isNull(tasks.deletedAt), )); - // "Done" is a status category; a status marked category='done' completes a task. - const completedTasks = allTasks.filter(t => t.statusCategory === "done"); + const completedTasks = allTasks.filter(t => t.status === "done"); const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0; return c.json({ @@ -51,7 +48,7 @@ analyticsRoutes.get("/productivity", async (c) => { } }); -// GET /api/analytics/habits?range=... — Habit completion rate +// GET /api/analytics/habits?range=... — Habit completion rate (fixed per-habit expected) analyticsRoutes.get("/habits", async (c) => { try { const user = await requireAuth(c); @@ -74,7 +71,6 @@ analyticsRoutes.get("/habits", async (c) => { const habitIds = allHabits.map((h) => h.id); - // Only count completions belonging to habits in this domain (not all completions globally) const allLogs = habitIds.length > 0 ? await db.select() .from(habitCompletions) @@ -84,8 +80,45 @@ analyticsRoutes.get("/habits", async (c) => { )) : []; - const habitConsistency = allHabits.length > 0 - ? Math.round((allLogs.length / (allHabits.length * range)) * 100) + const logsByHabit = new Map(); + for (const lg of allLogs) logsByHabit.set(lg.habitId, (logsByHabit.get(lg.habitId) || 0) + 1); + + const expectedForHabit = (h: typeof allHabits[number]) => { + if (!h.active) return 0; + const skipSet = new Set(h.skipDays || []); + if (h.frequency === "daily") { + let expected = 0; + const cursor = new Date(startDate); + for (let i = 0; i < range; i++) { + if (!skipSet.has(cursor.getUTCDay())) expected += h.goalPerPeriod || 1; + cursor.setDate(cursor.getDate() + 1); + } + return expected; + } + if (h.frequency === "weekly") { + const weeks = Math.ceil(range / 7); + return weeks * (h.goalPerPeriod || 1); + } + return range * (h.goalPerPeriod || 1); + }; + + let totalExpected = 0; + const perHabit: Array<{ id: string; name: string; completed: number; expected: number; consistency: number }> = []; + for (const h of allHabits) { + const expected = expectedForHabit(h); + const completed = logsByHabit.get(h.id) || 0; + totalExpected += expected; + perHabit.push({ + id: h.id, + name: h.name, + completed, + expected, + consistency: expected > 0 ? Math.min(100, Math.round((completed / expected) * 100)) : 0, + }); + } + + const habitConsistency = totalExpected > 0 + ? Math.min(100, Math.round((allLogs.length / totalExpected) * 100)) : 0; const activeStreaks = allHabits.filter(h => (h.streakCount || 0) > 0); @@ -95,6 +128,8 @@ analyticsRoutes.get("/habits", async (c) => { habitConsistency, totalHabits: allHabits.length, totalLogs: allLogs.length, + totalExpected, + perHabit, activeStreaks: activeStreaks.length, bestStreak, period: range, @@ -130,9 +165,8 @@ analyticsRoutes.get("/projects", async (c) => { // Count tasks per project (any status, including non-done) for the domain const taskRows = projectIds.length > 0 - ? await db.select({ projectId: tasks.projectId, statusCategory: statusDefinitions.category }) + ? await db.select({ projectId: tasks.projectId, status: tasks.status }) .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and( isNull(tasks.deletedAt), inArray(tasks.projectId, projectIds), @@ -144,7 +178,7 @@ analyticsRoutes.get("/projects", async (c) => { if (!t.projectId) continue; const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 }; entry.totalTasks += 1; - if (t.statusCategory === "done") entry.completedTasks += 1; + if (t.status === "done") entry.completedTasks += 1; counts.set(t.projectId, entry); } @@ -176,6 +210,71 @@ analyticsRoutes.get("/projects", async (c) => { } }); +// GET /api/analytics/velocity?range=... — Tasks completed per day (used by new dashboard) +analyticsRoutes.get("/velocity", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const range = parseInt(url.searchParams.get("range") || "30"); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + await requireWorkspaceAccess(c, domainId); + const firstDay = new Date(); + firstDay.setDate(firstDay.getDate() - (range - 1)); + firstDay.setHours(0, 0, 0, 0); + const domainTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.completedAt, firstDay))); + const localDateKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; + const byDay = new Map(); + for (const t of domainTasks) if (t.completedAt) { + const k = localDateKey(t.completedAt); + byDay.set(k, (byDay.get(k)||0)+1); + } + const items: Array<{ date: string; completed: number }> = []; + const cursor = new Date(firstDay); + for (let i=0;is+i.completed,0)/range; + return c.json({ items, avg: Math.round(avg*10)/10, period: range }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" } }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[analytics] GET /velocity error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get velocity" } }, 500); + } +}); + +analyticsRoutes.get("/cycle", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const range = parseInt(url.searchParams.get("range") || "30"); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + await requireWorkspaceAccess(c, domainId); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - range); + const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), eq(tasks.status, "done"), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt))); + const durations: number[] = []; + for (const t of doneTasks) if (t.completedAt) durations.push((t.completedAt.getTime() - t.createdAt.getTime()) / (1000*60*60*24)); + durations.sort((a,b)=>a-b); + const median = durations.length ? durations[Math.floor(durations.length/2)] : 0; + const avg = durations.length ? durations.reduce((s,v)=>s+v,0)/durations.length : 0; + return c.json({ median: Math.round(median*10)/10, avg: Math.round(avg*10)/10, count: durations.length, period: range }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" } }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[analytics] GET /cycle error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get cycle" } }, 500); + } +}); + // GET /api/analytics/daily?range=... — Daily task creation & completion time series analyticsRoutes.get("/daily", async (c) => { try { @@ -195,10 +294,8 @@ analyticsRoutes.get("/daily", async (c) => { firstDay.setDate(firstDay.getDate() - (range - 1)); firstDay.setHours(0, 0, 0, 0); - const dailyTaskColumns = getTableColumns(tasks); - const domainTasks = await db.select({ ...dailyTaskColumns, statusCategory: statusDefinitions.category }) + const domainTasks = await db.select() .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and( eq(tasks.domainId, domainId), isNull(tasks.deletedAt), @@ -222,7 +319,7 @@ analyticsRoutes.get("/daily", async (c) => { for (const t of domainTasks) { const createdKey = localDateKey(t.createdAt); createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1); - if (t.statusCategory === "done" && t.completedAt) { + if (t.status === "done" && t.completedAt) { const completedKey = localDateKey(t.completedAt); completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1); } diff --git a/apps/api/src/routes/automations.ts b/apps/api/src/routes/automations.ts deleted file mode 100644 index 53179f9..0000000 --- a/apps/api/src/routes/automations.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { Hono } from "hono"; -import { db, automationRules, projects } from "@project-e/db"; -import { and, asc, eq, isNull } from "drizzle-orm"; -import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; -import { recordActivity } from "../middleware/activity"; -import { enqueueWebhooks } from "../middleware/webhook-queue"; -import { z } from "zod"; - -export const automationRoutes = new Hono(); - -const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const; -const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const; -const PRIORITIES = ["low", "medium", "high", "urgent"] as const; - -const triggerSchema = z.object({ - type: z.enum(TRIGGER_TYPES), - params: z.record(z.string(), z.unknown()).optional(), -}); - -const conditionSchema = z.object({ - field: z.enum(["project", "status", "priority", "label"]), - op: z.string().min(1), - value: z.unknown(), -}); - -const actionSchema = z.object({ - type: z.enum(ACTION_TYPES), - params: z.record(z.string(), z.unknown()).optional(), -}); - -const createAutomationSchema = z.object({ - name: z.string().min(1, "Name is required").max(120), - active: z.boolean().optional(), - trigger: triggerSchema, - conditions: z.array(conditionSchema).optional(), - actions: z.array(actionSchema).min(1, "At least one action is required"), -}); - -const updateAutomationSchema = createAutomationSchema.partial(); - -// Validate action params against what the evaluation engine expects, so a bad -// rule surfaces at save time instead of silently doing nothing at run time. -function validateActionParams(actions: { type: string; params?: Record }[]): string | null { - for (const action of actions) { - const params = action.params ?? {}; - switch (action.type) { - case "set_status": - if (!params.statusKey) return "set_status action requires a statusKey param"; - break; - case "set_priority": - if (!params.priority || !PRIORITIES.includes(params.priority as any)) { - return "set_priority action requires a priority param (low, medium, high or urgent)"; - } - break; - case "add_label": - if (!params.label || typeof params.label !== "string" || params.label.trim() === "") { - return "add_label action requires a label param"; - } - break; - case "create_notification": - if (!params.message || typeof params.message !== "string" || params.message.trim() === "") { - return "create_notification action requires a message param"; - } - break; - } - } - return null; -} - -async function getProject(c: any, projectId: string) { - const [project] = await db - .select({ id: projects.id, name: projects.name, domainId: projects.domainId }) - .from(projects) - .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) - .limit(1); - if (!project) { - throw new AuthError("Project not found", 404, "NOT_FOUND"); - } - await requireWorkspaceAccess(c, project.domainId); - return project; -} - -// GET /api/projects/:projectId/automations — List rules for a project -automationRoutes.get("/:projectId/automations", async (c) => { - try { - await requireAuth(c); - const projectId = c.req.param("projectId"); - if (!isUuid(projectId)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - await getProject(c, projectId); - - const items = await db.select() - .from(automationRules) - .where(eq(automationRules.projectId, projectId)) - .orderBy(asc(automationRules.createdAt)); - - return c.json({ items, totalItems: items.length }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[automations] GET error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list automation rules" } }, 500); - } -}); - -// POST /api/projects/:projectId/automations — Create a rule -automationRoutes.post("/:projectId/automations", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - if (!isUuid(projectId)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const body = await c.req.json(); - const data = createAutomationSchema.parse(body); - const project = await getProject(c, projectId); - - const actionError = validateActionParams(data.actions); - if (actionError) { - return c.json({ error: { code: "VALIDATION_ERROR", message: actionError } }, 400); - } - - const [rule] = await db.insert(automationRules).values({ - projectId, - name: data.name, - active: data.active ?? true, - trigger: data.trigger, - conditions: (data.conditions ?? []) as any, - actions: data.actions as any, - }).returning(); - - await recordActivity({ - actor: user.name, - action: "created", - entityType: "automation_rule", - entityId: rule.id, - changes: { name: rule.name, trigger: rule.trigger.type, projectId, projectName: project.name }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.created", entityType: "automation_rule", entityId: rule.id, data: { name: rule.name } }); - - return c.json(rule, 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("[automations] POST error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create automation rule" } }, 500); - } -}); - -// PATCH /api/projects/:projectId/automations/:id — Update a rule -automationRoutes.patch("/:projectId/automations/:id", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - const id = c.req.param("id"); - if (!isUuid(projectId) || !isUuid(id)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const body = await c.req.json(); - const data = updateAutomationSchema.parse(body); - const project = await getProject(c, projectId); - - const [existing] = await db.select() - .from(automationRules) - .where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId))) - .limit(1); - if (!existing) { - return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404); - } - - if (data.actions !== undefined) { - const actionError = validateActionParams(data.actions); - if (actionError) { - return c.json({ error: { code: "VALIDATION_ERROR", message: actionError } }, 400); - } - } - - const updateValues: Record = {}; - if (data.name !== undefined) updateValues.name = data.name; - if (data.active !== undefined) updateValues.active = data.active; - if (data.trigger !== undefined) updateValues.trigger = data.trigger; - if (data.conditions !== undefined) updateValues.conditions = data.conditions; - if (data.actions !== undefined) updateValues.actions = data.actions; - updateValues.updatedAt = new Date(); - - const [updated] = await db.update(automationRules) - .set(updateValues) - .where(eq(automationRules.id, id)) - .returning(); - - await recordActivity({ - actor: user.name, - action: "updated", - entityType: "automation_rule", - entityId: id, - changes: { name: updated.name, projectId, previousName: existing.name }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.updated", entityType: "automation_rule", entityId: id, data: { name: updated.name } }); - - return c.json(updated); - } 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("[automations] PATCH error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update automation rule" } }, 500); - } -}); - -// DELETE /api/projects/:projectId/automations/:id — Delete a rule -automationRoutes.delete("/:projectId/automations/:id", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - const id = c.req.param("id"); - if (!isUuid(projectId) || !isUuid(id)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const project = await getProject(c, projectId); - - const [existing] = await db.select() - .from(automationRules) - .where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId))) - .limit(1); - if (!existing) { - return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404); - } - - // Rules are configuration, not user data — hard delete is correct. - await db.delete(automationRules).where(eq(automationRules.id, id)); - - await recordActivity({ - actor: user.name, - action: "deleted", - entityType: "automation_rule", - entityId: id, - changes: { name: existing.name, projectId }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.deleted", entityType: "automation_rule", entityId: id, data: { name: existing.name } }); - - 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("[automations] DELETE error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete automation rule" } }, 500); - } -}); - -// POST /api/projects/:projectId/automations/:id/toggle — Flip active/inactive -automationRoutes.post("/:projectId/automations/:id/toggle", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - const id = c.req.param("id"); - if (!isUuid(projectId) || !isUuid(id)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const project = await getProject(c, projectId); - - const [existing] = await db.select() - .from(automationRules) - .where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId))) - .limit(1); - if (!existing) { - return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404); - } - - const [updated] = await db.update(automationRules) - .set({ active: !existing.active, updatedAt: new Date() }) - .where(eq(automationRules.id, id)) - .returning(); - - await recordActivity({ - actor: user.name, - action: updated.active ? "enabled" : "disabled", - entityType: "automation_rule", - entityId: id, - changes: { active: updated.active, projectId }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.toggled", entityType: "automation_rule", entityId: id, data: { active: updated.active } }); - - return c.json(updated); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[automations] POST /toggle error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to toggle automation rule" } }, 500); - } -}); diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index 6d18bcd..dfb4cd5 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { db, calendarEvents } from "@project-e/db"; +import { db, calendarEvents, tasks, habits } from "@project-e/db"; import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm"; import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; @@ -222,6 +222,57 @@ calendarRoutes.delete("/events/:id", async (c) => { } }); +// GET /api/calendar/unified?from=&to=&domain — Merged timeline: calendar_events + tasks due + habit reminders +calendarRoutes.get("/unified", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const fromStr = url.searchParams.get("from"); + const toStr = url.searchParams.get("to"); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + 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 [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(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))), + ]); + const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({ + id: `task-${t.id}`, title: t.title, description: null, startTime: t.dueDate, endTime: null, allDay: true, color: "#3b82f6", domainId, entityType: "task", entityId: t.id, recurrenceRule: null, createdAt: t.createdAt, updatedAt: t.updatedAt, + })); + const habitEvents: typeof events = []; + for (const h of domainHabits) { + if (!h.reminderTime || !h.active) continue; + const [hh, mm] = h.reminderTime.split(":").map(Number); + const cursor = new Date(from); + cursor.setHours(0,0,0,0); + while (cursor <= to) { + const skip = (h.skipDays || []).includes(cursor.getDay()); + if (!skip) { + const dt = new Date(cursor); + dt.setHours(hh||9, mm||0, 0, 0); + habitEvents.push({ + id: `habit-${h.id}-${dt.toISOString().slice(0,10)}`, title: h.name, description: h.description, startTime: dt, endTime: null, allDay: false, color: "#f59e0b", domainId, entityType: "habit", entityId: h.id, recurrenceRule: null, createdAt: h.createdAt, updatedAt: h.updatedAt, + } as any); + } + cursor.setDate(cursor.getDate()+1); + if (h.frequency === "weekly" && habitEvents.filter(e=>e.entityId===h.id).length >= 2) break; + } + } + const items = [...events, ...taskEvents as any, ...habitEvents].sort((a,b)=> new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + return c.json({ items, totalItems: items.length, breakdown: { events: events.length, tasks: taskEvents.length, habits: habitEvents.length } }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[calendar] GET /unified error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get unified calendar" } }, 500); + } +}); + // GET /api/calendar/upcoming?days=7 — Next N days calendarRoutes.get("/upcoming", async (c) => { try { diff --git a/apps/api/src/routes/canvas.ts b/apps/api/src/routes/canvas.ts deleted file mode 100644 index 1d14d25..0000000 --- a/apps/api/src/routes/canvas.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { Hono } from "hono"; -import { db, canvases, canvasCards, canvasConnections } from "@project-e/db"; -import { and, asc, desc, eq, notInArray, or, sql } from "drizzle-orm"; -import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; -import { recordActivity } from "../middleware/activity"; -import { z } from "zod"; - -export const canvasRoutes = new Hono(); - -const createCanvasSchema = z.object({ - name: z.string().min(1, "Name is required"), - description: z.string().optional().nullable(), - mode: z.enum(["freeform", "graph"]).optional().default("freeform"), - domain: z.string().min(1, "Domain is required"), - tags: z.array(z.string()).optional().default([]), - viewport: z.object({ x: z.number().default(0), y: z.number().default(0), zoom: z.number().positive().default(1) }).optional(), - background: z.string().optional().nullable(), - customFields: z.record(z.string(), z.unknown()).optional(), -}); - -const updateCanvasSchema = z.object({ - name: z.string().min(1).optional(), - description: z.string().optional().nullable(), - mode: z.enum(["freeform", "graph"]).optional(), - tags: z.array(z.string()).optional(), - viewport: z.object({ x: z.number(), y: z.number(), zoom: z.number().positive() }).optional(), - background: z.string().optional().nullable(), - customFields: z.record(z.string(), z.unknown()).optional(), -}); - -const createCardSchema = z.object({ - type: z.string().min(1).default("note"), - content: z.string().optional().nullable().default(""), - title: z.string().optional().nullable(), - x: z.number().int().optional(), - y: z.number().int().optional(), - width: z.number().int().optional(), - height: z.number().int().optional(), - rotation: z.number().int().optional(), - color: z.string().optional().nullable(), - zIndex: z.number().int().optional(), -}); - -const updateCardSchema = createCardSchema.partial(); - -const bulkSaveCardsSchema = z.object({ - cards: z.array( - z.object({ - id: z.string().uuid().optional(), - type: z.string().min(1).default("note"), - content: z.string().optional().nullable().default(""), - title: z.string().optional().nullable(), - x: z.number().int().optional(), - y: z.number().int().optional(), - width: z.number().int().optional(), - height: z.number().int().optional(), - rotation: z.number().int().optional(), - color: z.string().optional().nullable(), - zIndex: z.number().int().optional(), - }) - ).default([]), -}); - -// GET /api/canvas — List canvases -canvasRoutes.get("/", async (c) => { - try { - const user = await requireAuth(c); - const url = new URL(c.req.url); - const page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); - const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50"))); - const sort = url.searchParams.get("sort") || "-created"; - 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(canvases.domainId, domainId)]; - const sortField = sort.replace(/^-/, ""); - const sortDir = sort.startsWith("-") ? "desc" : "asc"; - const sortColumns: Record = { created: canvases.createdAt, updated: canvases.updatedAt, name: canvases.name }; - const orderColumn = sortDir === "asc" ? asc(sortColumns[sortField] || canvases.createdAt) : desc(sortColumns[sortField] || canvases.createdAt); - - const [items, countResult] = await Promise.all([ - db.select().from(canvases).where(and(...conditions)).orderBy(orderColumn).limit(perPage).offset((page - 1) * perPage), - db.select({ count: sql`count(*)` }).from(canvases).where(and(...conditions)), - ]); - - return c.json({ items, totalItems: Number(countResult[0]?.count || 0), totalPages: Math.ceil(Number(countResult[0]?.count || 0) / perPage), page, perPage }); - } catch (error) { - if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - console.error("[canvas] GET error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list canvases" } }, 500); - } -}); - -// POST /api/canvas — Create -canvasRoutes.post("/", async (c) => { - try { - const user = await requireAuth(c); - const body = await c.req.json(); - const data = createCanvasSchema.parse({ - ...body, - domain: body.domain || (await resolveActiveDomain(user)).id, - }); - - await requireWorkspaceAccess(c, data.domain); - - const [canvas] = await db.insert(canvases).values({ - name: data.name, - description: data.description ?? null, - mode: data.mode, - domainId: data.domain, - tags: data.tags ?? [], - viewport: data.viewport ?? { x: 0, y: 0, zoom: 1 }, - background: data.background ?? null, - customFields: data.customFields ?? {}, - }).returning(); - - await recordActivity({ - actor: user.name, action: "created", entityType: "canvas", entityId: canvas.id, - changes: { name: canvas.name }, workspaceId: data.domain, - }); - - return c.json(canvas, 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("[canvas] POST error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas" } }, 500); - } -}); - -// GET /api/canvas/:id — Read one (full block tree) -canvasRoutes.get("/:id", async (c) => { - try { - 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 [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); - if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); - - await requireWorkspaceAccess(c, canvas.domainId); - - const [cards, connections] = await Promise.all([ - db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)), - db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)), - ]); - - return c.json({ ...canvas, cards, connections }); - } catch (error) { - if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - console.error("[canvas] GET /:id error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get canvas" } }, 500); - } -}); - -// PATCH /api/canvas/:id — Update blocks -canvasRoutes.patch("/:id", 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 data = updateCanvasSchema.parse(body); - - const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); - if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); - - await requireWorkspaceAccess(c, existing.domainId); - - const updateValues: Record = {}; - if (data.name !== undefined) updateValues.name = data.name; - if (data.description !== undefined) updateValues.description = data.description; - if (data.mode !== undefined) updateValues.mode = data.mode; - if (data.tags !== undefined) updateValues.tags = data.tags; - if (data.viewport !== undefined) updateValues.viewport = data.viewport; - if (data.background !== undefined) updateValues.background = data.background; - if (data.customFields !== undefined) updateValues.customFields = data.customFields; - updateValues.updatedAt = new Date(); - - const [updated] = await db.update(canvases).set(updateValues).where(eq(canvases.id, id)).returning(); - - await recordActivity({ - actor: user.name, action: "updated", entityType: "canvas", entityId: id, - changes: { name: updated.name }, workspaceId: existing.domainId, - }); - - return c.json(updated); - } 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("[canvas] PATCH error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas" } }, 500); - } -}); - -// DELETE /api/canvas/:id — Delete -canvasRoutes.delete("/:id", 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 [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); - if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); - - await requireWorkspaceAccess(c, existing.domainId); - - await db.delete(canvases).where(eq(canvases.id, id)); - - await recordActivity({ - actor: user.name, action: "deleted", entityType: "canvas", entityId: id, - changes: { name: existing.name }, workspaceId: existing.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("[canvas] DELETE error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500); - } -}); - -// POST /api/canvas/:id/cards — Create one card (new block) -canvasRoutes.post("/:id/cards", 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 data = createCardSchema.parse(body); - - const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); - if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); - - await requireWorkspaceAccess(c, canvas.domainId); - - // New cards append to the end of the vertical document unless an explicit zIndex is given - const [maxRow] = await db - .select({ max: sql`max(${canvasCards.zIndex})` }) - .from(canvasCards) - .where(eq(canvasCards.canvasId, id)); - const zIndex = data.zIndex ?? Number(maxRow?.max ?? -1) + 1; - - const [card] = await db.insert(canvasCards).values({ - canvasId: id, - type: data.type, - content: data.content ?? "", - title: data.title ?? null, - x: data.x ?? 0, - y: data.y ?? 0, - width: data.width ?? 200, - height: data.height ?? 150, - rotation: data.rotation ?? 0, - color: data.color ?? null, - zIndex, - }).returning(); - - await recordActivity({ - actor: user.name, action: "created", entityType: "canvas_card", entityId: card.id, - changes: { type: card.type, zIndex: card.zIndex }, workspaceId: canvas.domainId, - }); - - return c.json(card, 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("[canvas] POST /:id/cards error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas card" } }, 500); - } -}); - -// PUT /api/canvas/:id/cards — Bulk replace all cards (primary save path) -canvasRoutes.put("/:id/cards", 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 data = bulkSaveCardsSchema.parse(body); - - const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); - if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); - - await requireWorkspaceAccess(c, canvas.domainId); - - const cards = await db.transaction(async (tx) => { - await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id)); - if (data.cards.length === 0) { - // No cards left — drop every connection on this canvas. - await tx.delete(canvasConnections).where(or( - eq(canvasConnections.sourceCardId, id), - eq(canvasConnections.targetCardId, id), - )); - return []; - } - const inserted = await tx.insert(canvasCards).values( - data.cards.map((card, i) => ({ - ...(card.id ? { id: card.id } : {}), - canvasId: id, - type: card.type, - content: card.content ?? "", - title: card.title ?? null, - x: card.x ?? 0, - y: card.y ?? 0, - width: card.width ?? 200, - height: card.height ?? 150, - rotation: card.rotation ?? 0, - color: card.color ?? null, - zIndex: card.zIndex ?? i, - })) - ).returning(); - // Connections to cards that no longer exist must not linger. Cards that - // were re-inserted with their original id keep their connections; any - // connection whose endpoint is missing is dropped. - const keptIds = inserted.map((c) => c.id); - await tx.delete(canvasConnections).where(or( - notInArray(canvasConnections.sourceCardId, keptIds), - notInArray(canvasConnections.targetCardId, keptIds), - )); - return inserted; - }); - - await recordActivity({ - actor: user.name, action: "updated", entityType: "canvas", entityId: id, - changes: { name: canvas.name, cardCount: cards.length }, workspaceId: canvas.domainId, - }); - - return c.json({ ...canvas, cards }); - } 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("[canvas] PUT /:id/cards error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to save canvas cards" } }, 500); - } -}); - -// PATCH /api/canvas/cards/:cardId — Update one card -canvasRoutes.patch("/cards/:cardId", async (c) => { - try { - const user = await requireAuth(c); - const cardId = c.req.param("cardId"); - const body = await c.req.json(); - const data = updateCardSchema.parse(body); - - const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1); - if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404); - - // canvas_cards has no domain_id — resolve ownership through the parent canvas - const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1); - if (canvas) { - await requireWorkspaceAccess(c, canvas.domainId); - } - - const updateValues: Record = {}; - if (data.type !== undefined) updateValues.type = data.type; - if (data.content !== undefined) updateValues.content = data.content; - if (data.title !== undefined) updateValues.title = data.title; - if (data.x !== undefined) updateValues.x = data.x; - if (data.y !== undefined) updateValues.y = data.y; - if (data.width !== undefined) updateValues.width = data.width; - if (data.height !== undefined) updateValues.height = data.height; - if (data.rotation !== undefined) updateValues.rotation = data.rotation; - if (data.color !== undefined) updateValues.color = data.color; - if (data.zIndex !== undefined) updateValues.zIndex = data.zIndex; - updateValues.updatedAt = new Date(); - - const [updated] = await db.update(canvasCards).set(updateValues).where(eq(canvasCards.id, cardId)).returning(); - - await recordActivity({ - actor: user.name, action: "updated", entityType: "canvas_card", entityId: cardId, - changes: { ...data }, workspaceId: canvas?.domainId ?? "", - }); - - return c.json(updated); - } 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("[canvas] PATCH /cards/:cardId error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas card" } }, 500); - } -}); - -// DELETE /api/canvas/cards/:cardId — Delete one card -canvasRoutes.delete("/cards/:cardId", async (c) => { - try { - const user = await requireAuth(c); - const cardId = c.req.param("cardId"); - const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1); - if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404); - - // canvas_cards has no domain_id — resolve ownership through the parent canvas - const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1); - if (canvas) { - await requireWorkspaceAccess(c, canvas.domainId); - } - - // Hard delete — canvas_cards has no deleted_at column - await db.delete(canvasCards).where(eq(canvasCards.id, cardId)); - - await recordActivity({ - actor: user.name, action: "deleted", entityType: "canvas_card", entityId: cardId, - changes: { type: card.type }, workspaceId: canvas?.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("[canvas] DELETE /cards/:cardId error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas card" } }, 500); - } -}); diff --git a/apps/api/src/routes/comments.ts b/apps/api/src/routes/comments.ts index abc3f31..7925621 100644 --- a/apps/api/src/routes/comments.ts +++ b/apps/api/src/routes/comments.ts @@ -6,7 +6,6 @@ import { projects, habits, notes, - canvases, dailyNotes, calendarEvents, } from "@project-e/db"; @@ -14,13 +13,12 @@ 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 { notifyWorkspaceOwner } from "../lib/notify"; 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"]); +const entityTypeEnum = z.enum(["task", "project", "habit", "note", "daily_note", "calendar_event"]); interface EntityWorkspaceLookup { table: AnyPgTable; @@ -35,7 +33,6 @@ const entityWorkspaceLookups: Record = { 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 }, }; @@ -151,23 +148,6 @@ commentRoutes.post("/", async (c) => { workspaceId: domainId, }); - // Mention notification: any @mention in the comment notifies the workspace - // owner (single-user MVP — there is no other recipient to resolve). - if (/\B@[a-zA-Z0-9_.-]+/.test(data.content)) { - try { - await notifyWorkspaceOwner({ - workspaceId: domainId, - type: "mention", - title: "You were mentioned", - body: `${user.name} mentioned you in a comment on this ${data.entityType.replace(/_/g, " ")}`, - entityType: data.entityType, - entityId: data.entityId, - }); - } catch (notifyError) { - console.error(`[comments] Failed to create mention notification for ${data.entityType}:${data.entityId}:`, notifyError); - } - } - return c.json(newComment, 201); } catch (error) { if (error instanceof AuthError) { diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index d5c3b23..039fe37 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { createHash } from "node:crypto"; -import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries, statusDefinitions } from "@project-e/db"; -import { and, asc, desc, eq, ilike, inArray, isNull, or } from "drizzle-orm"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; +import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,8 +81,7 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - status: { type: "string", description: "Filter by status category (todo, in_progress, done, cancelled)" }, - status_id: { type: "string", description: "Filter by a specific status definition ID" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, project_id: { type: "string" }, search: { type: "string" }, @@ -96,14 +95,7 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - if (params.status_id) conditions.push(eq(tasks.statusId, params.status_id as string)); - if (params.status) { - // Category filter — match every status definition in that category. - const matching = await db.select({ id: statusDefinitions.id }) - .from(statusDefinitions) - .where(eq(statusDefinitions.category, params.status as any)); - conditions.push(inArray(tasks.statusId, matching.map((s) => s.id))); - } + if (params.status) conditions.push(eq(tasks.status, params.status as any)); if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); @@ -127,7 +119,7 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status_id: { type: "string", description: "Status definition ID (defaults to the project's default status)" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, @@ -135,33 +127,10 @@ const tools: ToolDefinition[] = [ required: ["domain_id", "title"], }, handler: async (params, auth) => { - // Resolve status: explicit status_id, else the project's default status. - let statusId = (params.status_id as string) ?? null; - let completedAt = null; - if (statusId) { - const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category }) - .from(statusDefinitions) - .where(eq(statusDefinitions.id, statusId)) - .limit(1); - if (!status) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status not found"); - if (!params.project_id || status.projectId !== params.project_id) { - throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status does not belong to the selected project"); - } - completedAt = status.category === "done" ? new Date() : null; - } else if (params.project_id) { - const [defaultStatus] = await db.select({ id: statusDefinitions.id, category: statusDefinitions.category }) - .from(statusDefinitions) - .where(and(eq(statusDefinitions.projectId, params.project_id as string), eq(statusDefinitions.isDefault, true))) - .limit(1); - statusId = defaultStatus?.id ?? null; - completedAt = defaultStatus?.category === "done" ? new Date() : null; - } - const [task] = await db.insert(tasks).values({ title: params.title as string, description: (params.description as string) ?? null, - statusId, - completedAt, + status: (params.status as any) ?? "todo", priority: (params.priority as any) ?? "medium", domainId: params.domain_id as string, projectId: (params.project_id as string) ?? null, @@ -173,7 +142,7 @@ const tools: ToolDefinition[] = [ action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, statusId: task.statusId }, + changes: { title: task.title, status: task.status }, workspaceId: params.domain_id as string, }); @@ -189,7 +158,7 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status_id: { type: "string", description: "Status definition ID (must belong to the task's project)" }, + status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, }, @@ -203,19 +172,7 @@ const tools: ToolDefinition[] = [ const updateData: Record = {}; if (params.title !== undefined) updateData.title = params.title; if (params.description !== undefined) updateData.description = params.description; - if (params.status_id !== undefined) { - const statusId = params.status_id as string; - const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category }) - .from(statusDefinitions) - .where(eq(statusDefinitions.id, statusId)) - .limit(1); - if (!status) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status not found"); - if (!existing.projectId || status.projectId !== existing.projectId) { - throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status does not belong to the task's project"); - } - updateData.statusId = statusId; - updateData.completedAt = status.category === "done" ? new Date() : null; - } + if (params.status !== undefined) updateData.status = params.status; if (params.priority !== undefined) updateData.priority = params.priority; if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null; updateData.updatedAt = new Date(); @@ -279,21 +236,8 @@ const tools: ToolDefinition[] = [ if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found"); await verifyDomainAccess(existing.domainId, auth.userId); - // Complete = move to the project's first "done"-category status. - const [doneStatus] = await db.select({ id: statusDefinitions.id }) - .from(statusDefinitions) - .where(and( - eq(statusDefinitions.projectId, existing.projectId ?? ""), - eq(statusDefinitions.category, "done"), - )) - .orderBy(asc(statusDefinitions.sortOrder)) - .limit(1); - if (!doneStatus) { - throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project has no done-category status"); - } - const [task] = await db.update(tasks) - .set({ statusId: doneStatus.id, completedAt: new Date(), updatedAt: new Date() }) + .set({ status: "done", completedAt: new Date(), updatedAt: new Date() }) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .returning(); @@ -622,7 +566,7 @@ const tools: ToolDefinition[] = [ const results: Record = {}; if (types.includes("tasks")) { - results.tasks = await db.select({ id: tasks.id, title: tasks.title, statusId: tasks.statusId, priority: tasks.priority }).from(tasks) + results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks) .where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit); } if (types.includes("notes")) { diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts index e4662e9..4597829 100644 --- a/apps/api/src/routes/notifications.ts +++ b/apps/api/src/routes/notifications.ts @@ -1,170 +1,44 @@ import { Hono } from "hono"; -import { db, notifications } from "@project-e/db"; -import { and, count, desc, eq, isNull } from "drizzle-orm"; -import { requireAuth, createErrorResponse, AuthError, isUuid } from "../middleware/auth"; +import { db, activityFeed } from "@project-e/db"; +import { and, desc, eq, gte, ne, sql } from "drizzle-orm"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; export const notificationRoutes = new Hono(); -// Notifications are per-user rows, so every route is scoped to the current -// user via requireAuth (not requireWorkspaceAccess). The optional -// workspace_id filter is safe against IDOR because the WHERE clause always -// pins notifications.userId to the authenticated user. +// The bell in the topbar shows a badge for activity_feed events from the last +// 7 days. There is no read/unread state yet, so "count" doubles as the unread +// badge. graph_edge rows are workspace-internal graph plumbing, not user-facing +// activity, so they are excluded from both the count and the feed. +const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; -const PAGE_SIZE_DEFAULT = 30; - -// GET /api/notifications — List the current user's notifications, newest -// first. Query params: workspace_id (filter), unread=true (unread only), -// limit/offset or page/perPage for pagination. +// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace. notificationRoutes.get("/", async (c) => { try { const user = await requireAuth(c); - const workspaceId = c.req.query("workspace_id"); - const unreadOnly = c.req.query("unread") === "true"; + let workspaceId = c.req.query("workspace_id"); + if (!workspaceId) { + const active = await resolveActiveDomain(user); + workspaceId = active.id; + } + await requireWorkspaceAccess(c, workspaceId); - const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "0", 10) || 0, 1), 100); - const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1); - const perPage = Math.min(Math.max(parseInt(c.req.query("perPage") || "0", 10) || 0, 1), 100); - const effectiveLimit = limit || perPage || PAGE_SIZE_DEFAULT; - const offset = Math.max(0, parseInt(c.req.query("offset") || "0", 10) || 0) || (page - 1) * effectiveLimit; + const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100); + const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS); + const conditions = [ + eq(activityFeed.workspaceId, workspaceId), + gte(activityFeed.createdAt, since), + ne(activityFeed.entityType, "graph_edge"), + ]; - const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.deletedAt)]; - if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId)); - if (unreadOnly) conditions.push(isNull(notifications.readAt)); - - const [items, totalResult, unreadResult] = await Promise.all([ - db.select().from(notifications) - .where(and(...conditions)) - .orderBy(desc(notifications.createdAt)) - .limit(effectiveLimit) - .offset(offset), - db.select({ value: count() }).from(notifications).where(and(...conditions)), - db.select({ value: count() }).from(notifications) - .where(and(...conditions, isNull(notifications.readAt))), + 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)), ]); - const totalItems = Number(totalResult[0]?.value ?? 0); - return c.json({ - items, - totalItems, - unreadCount: Number(unreadResult[0]?.value ?? 0), - page, - perPage: effectiveLimit, - limit: effectiveLimit, - offset, - }); + return c.json({ items, count: 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); - } + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); console.error("[notifications] GET error:", error); - return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to list notifications"), 500); - } -}); - -// GET /api/notifications/count — Unread count for the bell badge. -notificationRoutes.get("/count", async (c) => { - try { - const user = await requireAuth(c); - const workspaceId = c.req.query("workspace_id"); - - const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.readAt), isNull(notifications.deletedAt)]; - if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId)); - - const [result] = await db.select({ value: count() }).from(notifications).where(and(...conditions)); - return c.json({ count: Number(result?.value ?? 0) }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[notifications] GET /count error:", error); - return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to get unread count"), 500); - } -}); - -// PATCH /api/notifications/:id — Mark a notification as read. -notificationRoutes.patch("/: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 [existing] = await db.select({ id: notifications.id, userId: notifications.userId, deletedAt: notifications.deletedAt }) - .from(notifications) - .where(and(eq(notifications.id, id), isNull(notifications.deletedAt))) - .limit(1); - if (!existing || existing.userId !== user.id) { - return c.json(createErrorResponse("NOT_FOUND", "Notification not found"), 404); - } - - await db.update(notifications) - .set({ readAt: new Date() }) - .where(eq(notifications.id, id)); - - return c.json({ success: true, id }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[notifications] PATCH error:", error); - return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to update notification"), 500); - } -}); - -// POST /api/notifications/read-all — Mark every notification in the workspace -// (or all notifications when no workspace_id is given) as read. -notificationRoutes.post("/read-all", async (c) => { - try { - const user = await requireAuth(c); - const body = await c.req.json().catch(() => ({})); - const workspaceId = (body as { workspace_id?: string }).workspace_id ?? c.req.query("workspace_id"); - - const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.readAt), isNull(notifications.deletedAt)]; - if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId)); - - const result = await db.update(notifications) - .set({ readAt: new Date() }) - .where(and(...conditions)) - .returning({ id: notifications.id }); - - return c.json({ success: true, updated: result.length }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[notifications] POST /read-all error:", error); - return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to mark all notifications as read"), 500); - } -}); - -// DELETE /api/notifications/:id — Soft-delete a notification. -notificationRoutes.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 [existing] = await db.select({ id: notifications.id, userId: notifications.userId, deletedAt: notifications.deletedAt }) - .from(notifications) - .where(and(eq(notifications.id, id), isNull(notifications.deletedAt))) - .limit(1); - if (!existing || existing.userId !== user.id) { - return c.json(createErrorResponse("NOT_FOUND", "Notification not found"), 404); - } - - await db.update(notifications) - .set({ deletedAt: new Date() }) - .where(eq(notifications.id, id)); - - return c.json({ success: true, id }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[notifications] DELETE error:", error); - return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to delete notification"), 500); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500); } }); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 1f333b7..3ea3294 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, statusDefinitions } from "@project-e/db"; +import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db"; import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; @@ -10,16 +10,6 @@ export const projectRoutes = new Hono(); const projectStatusEnum = z.enum(["active", "paused", "completed", "archived"]); -// Default workflow statuses seeded for every new project. `isDefault` marks the -// status new tasks get (the "todo" start state). Categories map to progress and -// board-column semantics. -const DEFAULT_STATUSES = [ - { key: "todo", label: "Todo", category: "todo", color: "#94a3b8", sortOrder: 0, isDefault: true }, - { key: "in_progress", label: "In Progress", category: "in_progress", color: "#3b82f6", sortOrder: 1, isDefault: false }, - { key: "done", label: "Done", category: "done", color: "#22c55e", sortOrder: 2, isDefault: false }, - { key: "cancelled", label: "Cancelled", category: "cancelled", color: "#ef4444", sortOrder: 3, isDefault: false }, -] as const; - const createProjectSchema = z.object({ name: z.string().min(1, "Name is required"), description: z.string().optional().nullable(), @@ -123,10 +113,9 @@ projectRoutes.get("/", async (c) => { const totalItems = Number(countResult[0]?.count || 0); - // Fetch task counts, status definitions and tags for all projects + // Fetch task counts and tags for all projects let projectTagMap = new Map(); let taskCountMap = new Map(); - let statusMap = new Map(); if (items.length > 0) { const projectIds = items.map(p => p.id); @@ -147,32 +136,15 @@ projectRoutes.get("/", async (c) => { projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color }); } - // Status definitions - const statusRows = await db.select() - .from(statusDefinitions) - .where(inArray(statusDefinitions.projectId, projectIds)) - .orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt)); - for (const row of statusRows) { - if (!statusMap.has(row.projectId)) statusMap.set(row.projectId, []); - statusMap.get(row.projectId)!.push(row); - } - // Task counts for (const projectId of projectIds) { const [totalResult] = await db.select({ count: sql`count(*)` }) .from(tasks) .where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt))); - // "Done" is a status category, not a fixed status — a project's status - // workflow can mark any status with category='done' as completing. const [completedResult] = await db.select({ count: sql`count(*)` }) .from(tasks) - .innerJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) - .where(and( - eq(tasks.projectId, projectId), - eq(statusDefinitions.category, "done"), - isNull(tasks.deletedAt), - )); + .where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt))); taskCountMap.set(projectId, { total: Number(totalResult?.count || 0), @@ -186,7 +158,6 @@ projectRoutes.get("/", async (c) => { return { ...p, tags: projectTagMap.get(p.id) || [], - statuses: statusMap.get(p.id) || [], taskCount: counts.total, completedCount: counts.completed, progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0, @@ -233,11 +204,6 @@ projectRoutes.post("/", async (c) => { targetDate: data.targetDate ? new Date(data.targetDate) : null, }).returning(); - // Seed the default workflow statuses so a new project is usable immediately. - const seededStatuses = await db.insert(statusDefinitions).values( - DEFAULT_STATUSES.map((s) => ({ ...s, projectId: project.id })) - ).returning(); - if (data.tagIds && data.tagIds.length > 0) { await db.insert(projectTags).values( data.tagIds.map(tagId => ({ projectId: project.id, tagId })) @@ -255,7 +221,7 @@ projectRoutes.post("/", async (c) => { await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } }); - return c.json({ ...project, statuses: seededStatuses }, 201); + return c.json(project, 201); } catch (error) { if (error instanceof AuthError) { return c.json({ error: { code: error.code, message: error.message } }, error.status as any); @@ -294,22 +260,11 @@ projectRoutes.get("/:id", async (c) => { .where(eq(sections.projectId, id)) .orderBy(asc(sections.sortOrder)); - // Fetch the project's status workflow - const projectStatuses = await db.select() - .from(statusDefinitions) - .where(eq(statusDefinitions.projectId, id)) - .orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt)); - const statusById = new Map(projectStatuses.map((s) => [s.id, s])); - - // Fetch tasks (with their status definition embedded) - const taskColumns = db.select() + // Fetch tasks + const projectTasks = await db.select() .from(tasks) .where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt))) .orderBy(asc(tasks.order)); - const projectTasks = (await taskColumns).map((t) => ({ - ...t, - status: t.statusId ? statusById.get(t.statusId) ?? null : null, - })); // Fetch tags const tagRows = await db.select({ @@ -322,14 +277,13 @@ projectRoutes.get("/:id", async (c) => { .where(eq(projectTags.projectId, id)); const totalTasks = projectTasks.length; - const completedTasks = projectTasks.filter((t) => t.status?.category === "done").length; + const completedTasks = projectTasks.filter(t => t.status === "done").length; const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; return c.json({ ...project, sections: projectSections, tasks: projectTasks, - statuses: projectStatuses, tags: tagRows, taskCount: totalTasks, completedCount: completedTasks, diff --git a/apps/api/src/routes/statuses.ts b/apps/api/src/routes/statuses.ts deleted file mode 100644 index f5a30df..0000000 --- a/apps/api/src/routes/statuses.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { Hono } from "hono"; -import { db, statusDefinitions, tasks, projects } from "@project-e/db"; -import { and, asc, eq, isNull, sql } from "drizzle-orm"; -import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; -import { recordActivity } from "../middleware/activity"; -import { enqueueWebhooks } from "../middleware/webhook-queue"; -import { z } from "zod"; - -export const statusRoutes = new Hono(); - -const statusCategoryEnum = z.enum(["todo", "in_progress", "done", "cancelled"]); -const MAX_STATUSES_PER_PROJECT = 15; - -const createStatusSchema = z.object({ - key: z - .string() - .regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores") - .optional(), - label: z.string().min(1, "Label is required").max(60), - category: statusCategoryEnum.optional().default("todo"), - color: z.string().optional().nullable(), - sortOrder: z.number().int().optional(), - isDefault: z.boolean().optional(), -}); - -const updateStatusSchema = z.object({ - key: z - .string() - .regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores") - .optional(), - label: z.string().min(1).max(60).optional(), - category: statusCategoryEnum.optional(), - color: z.string().optional().nullable(), - sortOrder: z.number().int().optional(), - isDefault: z.boolean().optional(), -}); - -const reorderStatusSchema = z.object({ - orderedIds: z.array(z.string().uuid("Invalid status id")).min(1, "orderedIds is required"), -}); - -function slugifyKey(label: string): string { - const key = label - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .replace(/_+/g, "_"); - return key || "status"; -} - -async function getProject(c: any, projectId: string) { - const [project] = await db - .select({ id: projects.id, name: projects.name, domainId: projects.domainId }) - .from(projects) - .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) - .limit(1); - if (!project) { - throw new AuthError("Project not found", 404, "NOT_FOUND"); - } - await requireWorkspaceAccess(c, project.domainId); - return project; -} - -// GET /api/projects/:projectId/statuses — List statuses for a project -statusRoutes.get("/:projectId/statuses", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - if (!isUuid(projectId)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const project = await getProject(c, projectId); - - const items = await db.select() - .from(statusDefinitions) - .where(eq(statusDefinitions.projectId, projectId)) - .orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt)); - - return c.json({ items, totalItems: items.length }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[statuses] GET error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list statuses" } }, 500); - } -}); - -// POST /api/projects/:projectId/statuses — Create a status -statusRoutes.post("/:projectId/statuses", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - if (!isUuid(projectId)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const body = await c.req.json(); - const data = createStatusSchema.parse(body); - const project = await getProject(c, projectId); - - // Cap the number of statuses per project - const [countResult] = await db.select({ count: sql`count(*)` }) - .from(statusDefinitions) - .where(eq(statusDefinitions.projectId, projectId)); - if (Number(countResult?.count || 0) >= MAX_STATUSES_PER_PROJECT) { - return c.json({ - error: { code: "VALIDATION_ERROR", message: `Maximum of ${MAX_STATUSES_PER_PROJECT} statuses per project`, details: { limit: MAX_STATUSES_PER_PROJECT } }, - }, 400); - } - - // Uniqueness: the DB has a unique (project_id, key) index; reject up front - // with a friendly error instead of surfacing a constraint violation. - const key = data.key ?? slugifyKey(data.label); - const [existing] = await db.select({ id: statusDefinitions.id }) - .from(statusDefinitions) - .where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.key, key))) - .limit(1); - if (existing) { - return c.json({ error: { code: "CONFLICT", message: `A status with key "${key}" already exists` } }, 409); - } - - let sortOrder = data.sortOrder; - if (sortOrder === undefined) { - const [maxOrder] = await db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }) - .from(statusDefinitions) - .where(eq(statusDefinitions.projectId, projectId)); - sortOrder = Number(maxOrder?.max ?? -1) + 1; - } - - const [status] = await db.insert(statusDefinitions).values({ - projectId, - key, - label: data.label, - category: data.category, - color: data.color ?? null, - sortOrder, - isDefault: data.isDefault ?? false, - }).returning(); - - if (status.isDefault) { - await db.update(statusDefinitions) - .set({ isDefault: false }) - .where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, true))); - await db.update(statusDefinitions) - .set({ isDefault: true }) - .where(eq(statusDefinitions.id, status.id)); - } - - await recordActivity({ - actor: user.name, - action: "created", - entityType: "status", - entityId: status.id, - changes: { key: status.key, label: status.label, category: status.category, projectId, projectName: project.name }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "status.created", entityType: "status", entityId: status.id, data: { key: status.key, label: status.label } }); - - return c.json(status, 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("[statuses] POST error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create status" } }, 500); - } -}); - -// POST /api/projects/:projectId/statuses/reorder — Batch update sortOrder -statusRoutes.post("/:projectId/statuses/reorder", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - if (!isUuid(projectId)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const body = await c.req.json(); - const { orderedIds } = reorderStatusSchema.parse(body); - const project = await getProject(c, projectId); - - const existing = await db.select({ id: statusDefinitions.id }) - .from(statusDefinitions) - .where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, false))); - const validIds = new Set(existing.map((s) => s.id)); - if (!orderedIds.every((id) => validIds.has(id))) { - return c.json({ error: { code: "NOT_FOUND", message: "One or more statuses not found in this project" } }, 404); - } - - await db.transaction(async (tx) => { - for (let i = 0; i < orderedIds.length; i++) { - await tx.update(statusDefinitions) - .set({ sortOrder: i, updatedAt: new Date() }) - .where(eq(statusDefinitions.id, orderedIds[i])); - } - }); - - await recordActivity({ - actor: user.name, - action: "reordered", - entityType: "status", - entityId: orderedIds[0], - changes: { orderedIds, projectId }, - workspaceId: project.domainId, - }); - - return c.json({ success: true, orderedIds }); - } 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("[statuses] POST /reorder error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder statuses" } }, 500); - } -}); - -// PATCH /api/projects/:projectId/statuses/:id — Update a status -statusRoutes.patch("/:projectId/statuses/:id", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - const id = c.req.param("id"); - if (!isUuid(projectId) || !isUuid(id)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const body = await c.req.json(); - const data = updateStatusSchema.parse(body); - const project = await getProject(c, projectId); - - const [existing] = await db.select() - .from(statusDefinitions) - .where(and(eq(statusDefinitions.id, id), eq(statusDefinitions.projectId, projectId))) - .limit(1); - if (!existing) { - return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404); - } - - const updateValues: Record = {}; - if (data.key !== undefined) updateValues.key = data.key; - if (data.label !== undefined) updateValues.label = data.label; - if (data.category !== undefined) updateValues.category = data.category; - if (data.color !== undefined) updateValues.color = data.color; - if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; - if (data.isDefault !== undefined) updateValues.isDefault = data.isDefault; - updateValues.updatedAt = new Date(); - - // Uniqueness check on the (project_id, key) pair - if (data.key !== undefined && data.key !== existing.key) { - const [conflict] = await db.select({ id: statusDefinitions.id }) - .from(statusDefinitions) - .where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.key, data.key))) - .limit(1); - if (conflict && conflict.id !== id) { - return c.json({ error: { code: "CONFLICT", message: `A status with key "${data.key}" already exists` } }, 409); - } - } - - if (data.isDefault === true) { - // Only one default per project - await db.update(statusDefinitions) - .set({ isDefault: false, updatedAt: new Date() }) - .where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, true))); - } - - const [updated] = await db.update(statusDefinitions) - .set(updateValues) - .where(eq(statusDefinitions.id, id)) - .returning(); - - await recordActivity({ - actor: user.name, - action: "updated", - entityType: "status", - entityId: id, - changes: { ...data, projectId, previousLabel: existing.label }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "status.updated", entityType: "status", entityId: id, data: { ...data, projectId } }); - - return c.json(updated); - } 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("[statuses] PATCH error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update status" } }, 500); - } -}); - -// DELETE /api/projects/:projectId/statuses/:id — Delete a status -statusRoutes.delete("/:projectId/statuses/:id", async (c) => { - try { - const user = await requireAuth(c); - const projectId = c.req.param("projectId"); - const id = c.req.param("id"); - if (!isUuid(projectId) || !isUuid(id)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const project = await getProject(c, projectId); - - const [existing] = await db.select() - .from(statusDefinitions) - .where(and(eq(statusDefinitions.id, id), eq(statusDefinitions.projectId, projectId))) - .limit(1); - if (!existing) { - return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404); - } - - // Never allow removing the last status — a project needs at least one. - const [countResult] = await db.select({ count: sql`count(*)` }) - .from(statusDefinitions) - .where(eq(statusDefinitions.projectId, projectId)); - if (Number(countResult?.count || 0) <= 1) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "A project must have at least one status" } }, 400); - } - - // Reassign tasks using this status to the project's default before deleting. - const [defaultStatus] = await db.select() - .from(statusDefinitions) - .where(and( - eq(statusDefinitions.projectId, projectId), - eq(statusDefinitions.isDefault, true), - )) - .limit(1); - const fallbackId = defaultStatus?.id ?? null; - const tasksUsingStatus = await db.select({ id: tasks.id }) - .from(tasks) - .where(and(eq(tasks.statusId, id), isNull(tasks.deletedAt))) - .limit(1); - - if (tasksUsingStatus.length > 0) { - if (!fallbackId) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot delete this status: no default status exists to reassign its tasks" } }, 400); - } - await db.update(tasks) - .set({ statusId: fallbackId, updatedAt: new Date() }) - .where(eq(tasks.statusId, id)); - } - - // If the default is being deleted, promote the first remaining status. - if (existing.isDefault) { - const [nextDefault] = await db.select({ id: statusDefinitions.id }) - .from(statusDefinitions) - .where(and( - eq(statusDefinitions.projectId, projectId), - eq(statusDefinitions.isDefault, false), - )) - .orderBy(asc(statusDefinitions.sortOrder)) - .limit(1); - if (nextDefault) { - await db.update(statusDefinitions) - .set({ isDefault: true, updatedAt: new Date() }) - .where(eq(statusDefinitions.id, nextDefault.id)); - } - } - - await db.delete(statusDefinitions).where(eq(statusDefinitions.id, id)); - - await recordActivity({ - actor: user.name, - action: "deleted", - entityType: "status", - entityId: id, - changes: { key: existing.key, label: existing.label, projectId, reassignedToDefault: tasksUsingStatus.length > 0 }, - workspaceId: project.domainId, - }); - - await enqueueWebhooks({ workspaceId: project.domainId, event: "status.deleted", entityType: "status", entityId: id, data: { key: existing.key } }); - - 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("[statuses] DELETE error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete status" } }, 500); - } -}); \ No newline at end of file diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 3ca3718..96aa63d 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -1,60 +1,21 @@ import { Hono } from "hono"; -import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections, statusDefinitions } from "@project-e/db"; -import { and, asc, desc, eq, exists, getTableColumns, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import { db, tasks, 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 { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { enqueueWebhooks } from "../middleware/webhook-queue"; -import { notifyWorkspaceOwner } from "../lib/notify"; -import { evaluateAutomations } from "../lib/automation-engine"; import { z } from "zod"; import { RRule } from "rrule"; export const taskRoutes = new Hono(); -const STATUS_CATEGORIES = ["todo", "in_progress", "done", "cancelled"] as const; +const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]); const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]); -// Columns of the status_definitions table flattened onto a task row as `status` -// (null when the task has no status or its status was deleted). -const statusColumns = { - id: statusDefinitions.id, - projectId: statusDefinitions.projectId, - key: statusDefinitions.key, - label: statusDefinitions.label, - category: statusDefinitions.category, - color: statusDefinitions.color, - sortOrder: statusDefinitions.sortOrder, - isDefault: statusDefinitions.isDefault, -}; - -type StatusRow = typeof statusDefinitions.$inferSelect; - -async function getProjectStatuses(projectId: string | null | undefined): Promise { - if (!projectId) return []; - return db.select().from(statusDefinitions) - .where(eq(statusDefinitions.projectId, projectId)) - .orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt)); -} - -async function getDefaultStatusId(projectId: string | null | undefined): Promise { - if (!projectId) return null; - const statuses = await getProjectStatuses(projectId); - return statuses.find((s) => s.isDefault)?.id ?? statuses[0]?.id ?? null; -} - -async function getStatusCategory(statusId: string | null | undefined): Promise { - if (!statusId) return null; - const [status] = await db.select({ category: statusDefinitions.category }) - .from(statusDefinitions) - .where(eq(statusDefinitions.id, statusId)) - .limit(1); - return status?.category ?? null; -} - const createTaskSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional().nullable(), - statusId: z.string().uuid().optional().nullable(), + status: taskStatusEnum.optional().default("todo"), priority: taskPriorityEnum.optional().default("medium"), domain: z.string().min(1, "Domain is required"), projectId: z.string().uuid().optional().nullable(), @@ -66,12 +27,13 @@ const createTaskSchema = z.object({ customFields: z.record(z.string(), z.unknown()).optional(), recurrenceRule: z.string().optional().nullable(), tagIds: z.array(z.string().uuid()).optional(), + tagNames: z.array(z.string().min(1)).optional(), }); const updateTaskSchema = z.object({ title: z.string().min(1).optional(), description: z.string().optional().nullable(), - statusId: z.string().uuid().optional().nullable(), + status: taskStatusEnum.optional(), priority: taskPriorityEnum.optional(), projectId: z.string().uuid().optional().nullable(), sectionId: z.string().uuid().optional().nullable(), @@ -150,16 +112,8 @@ taskRoutes.get("/", async (c) => { ]; if (status) { - // Filter by category name (todo/in_progress/...) or by status definition - // id (uuid). Category filters match every project's statuses in that - // category; id filters target one specific project status. - const values = status.split(",").map((v) => v.trim()).filter(Boolean); - const categoryValues = values.filter((v) => (STATUS_CATEGORIES as readonly string[]).includes(v)); - const idValues = values.filter((v) => isUuid(v)); - const statusConds: any[] = []; - if (categoryValues.length > 0) statusConds.push(inArray(statusDefinitions.category, categoryValues as any)); - if (idValues.length > 0) statusConds.push(inArray(statusDefinitions.id, idValues)); - if (statusConds.length > 0) conditions.push(or(...statusConds)); + const statuses = status.split(","); + conditions.push(inArray(tasks.status, statuses as any)); } if (priority) { const priorities = priority.split(","); @@ -211,7 +165,7 @@ taskRoutes.get("/", async (c) => { created: tasks.createdAt, updated: tasks.updatedAt, title: tasks.title, - status: statusDefinitions.sortOrder, + status: tasks.status, priority: tasks.priority, order: tasks.order, due_date: tasks.dueDate, @@ -222,18 +176,15 @@ taskRoutes.get("/", async (c) => { ? asc(sortColumns[sortField] || tasks.createdAt) : desc(sortColumns[sortField] || tasks.createdAt); - const taskColumns = getTableColumns(tasks); const [items, countResult] = await Promise.all([ - db.select({ ...taskColumns, status: statusColumns }) + db.select() .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(...conditions)) .orderBy(orderColumn) .limit(limit || perPage) .offset(offset || (page - 1) * perPage), db.select({ count: sql`count(*)` }) .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(...conditions)), ]); @@ -343,47 +294,39 @@ taskRoutes.post("/", async (c) => { } } - // Resolve the task's status definition. A status is per-project, so it may - // only be set when the task belongs to a project, and the status must belong - // to that project. When omitted, the project's default status is used. - let statusId = data.statusId ?? null; - if (data.statusId) { - const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId }) - .from(statusDefinitions) - .where(eq(statusDefinitions.id, data.statusId)) - .limit(1); - if (!status) { - return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404); - } - if (!data.projectId || status.projectId !== data.projectId) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the selected project" } }, 400); - } - } else if (data.projectId) { - statusId = await getDefaultStatusId(data.projectId); - } - const statusCategory = await getStatusCategory(statusId); - const [task] = await db.insert(tasks).values({ title: data.title, description: data.description ?? null, - statusId, + status: data.status, priority: data.priority, domainId: data.domain, projectId: data.projectId ?? null, sectionId: data.sectionId ?? null, parentId: data.parentId ?? null, dueDate: data.dueDate ? new Date(data.dueDate) : null, - completedAt: statusCategory === "done" ? new Date() : null, estimatedMinutes: data.estimatedMinutes ?? null, order: data.order ?? 0, customFields: data.customFields ?? {}, recurrenceRule: data.recurrenceRule ?? null, }).returning(); - if (data.tagIds && data.tagIds.length > 0) { + const tagIdsToLink: string[] = [...(data.tagIds || [])]; + if ((data as any).tagNames && (data as any).tagNames.length > 0) { + for (const rawName of (data as any).tagNames) { + const name = rawName.trim().toLowerCase(); + if (!name) continue; + let [existing] = await db.select({ id: tagsTable.id }).from(tagsTable).where(eq(tagsTable.name, name)).limit(1); + if (!existing) { + const [created] = await db.insert(tagsTable).values({ name, scope: "tasks" as any }).returning({ id: tagsTable.id }); + existing = created; + } + if (existing && !tagIdsToLink.includes(existing.id)) tagIdsToLink.push(existing.id); + } + } + if (tagIdsToLink.length > 0) { await db.insert(taskTags).values( - data.tagIds.map(tagId => ({ taskId: task.id, tagId })) - ); + tagIdsToLink.map(tagId => ({ taskId: task.id, tagId })) + ).onConflictDoNothing(); } await recordActivity({ @@ -391,40 +334,16 @@ taskRoutes.post("/", async (c) => { action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, statusId: task.statusId, priority: task.priority }, + changes: { title: task.title, status: task.status, priority: task.priority }, workspaceId: data.domain, }); await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } }); - // MVP single-user: the workspace owner is the only assignee, so a new task - // is treated as an assignment to them. - try { - await notifyWorkspaceOwner({ - workspaceId: data.domain, - type: "assignment", - title: "New task assigned to you", - body: `"${task.title}" was created${user.name ? ` by ${user.name}` : ""}`, - entityType: "task", - entityId: task.id, - }); - } catch (notifyError) { - console.error(`[tasks] Failed to create assignment notification for task ${task.id}:`, notifyError); - } - if (data.recurrenceRule) { await syncScheduledJob(task.id, data.recurrenceRule); } - // Fire "task created" automation rules (e.g. set a default priority/label). - await evaluateAutomations({ - projectId: task.projectId, - triggerType: "task_created", - entity: task, - changes: {}, - actor: user.name, - }); - return c.json(task, 201); } catch (error) { if (error instanceof AuthError) { @@ -512,10 +431,8 @@ taskRoutes.get("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); } - const taskColumns = getTableColumns(tasks); - const [task] = await db.select({ ...taskColumns, status: statusColumns }) + const [task] = await db.select() .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) .limit(1); @@ -526,9 +443,8 @@ taskRoutes.get("/:id", async (c) => { await requireWorkspaceAccess(c, task.domainId); // Fetch subtasks - const subtasks = await db.select({ ...taskColumns, status: statusColumns }) + const subtasks = await db.select() .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt))) .orderBy(asc(tasks.order)); @@ -546,24 +462,20 @@ taskRoutes.get("/:id", async (c) => { const depRows = await db.select({ id: tasks.id, title: tasks.title, - statusId: tasks.statusId, - status: statusColumns, + status: tasks.status, }) .from(taskDependencies) .innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id)) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt))); // Fetch dependents (tasks that depend on this task) const dependentRows = await db.select({ id: tasks.id, title: tasks.title, - statusId: tasks.statusId, - status: statusColumns, + status: tasks.status, }) .from(taskDependencies) .innerJoin(tasks, eq(taskDependencies.taskId, tasks.id)) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt))); return c.json({ @@ -627,6 +539,7 @@ taskRoutes.patch("/:id", async (c) => { const updateValues: Record = {}; if (data.title !== undefined) updateValues.title = data.title; if (data.description !== undefined) updateValues.description = data.description; + if (data.status !== undefined) updateValues.status = data.status; if (data.priority !== undefined) updateValues.priority = data.priority; if (data.projectId !== undefined) updateValues.projectId = data.projectId; if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId; @@ -636,31 +549,6 @@ taskRoutes.patch("/:id", async (c) => { if (data.order !== undefined) updateValues.order = data.order; if (data.customFields !== undefined) updateValues.customFields = data.customFields; if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule; - - // Status change: validate the status belongs to the task's project and keep - // completedAt in sync with the "done" category. - let newStatusLabel: string | null = null; - if (data.statusId !== undefined) { - const statusId = data.statusId ?? null; - if (statusId) { - const finalProjectId = (data.projectId !== undefined ? data.projectId : existing.projectId) ?? null; - const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category, label: statusDefinitions.label }) - .from(statusDefinitions) - .where(eq(statusDefinitions.id, statusId)) - .limit(1); - if (!status) { - return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404); - } - if (!finalProjectId || status.projectId !== finalProjectId) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the task's project" } }, 400); - } - updateValues.completedAt = status.category === "done" ? new Date() : null; - newStatusLabel = status.label; - } else { - updateValues.completedAt = null; - } - updateValues.statusId = statusId; - } updateValues.updatedAt = new Date(); const [updated] = await db.update(tasks) @@ -673,44 +561,16 @@ taskRoutes.patch("/:id", async (c) => { action: "updated", entityType: "task", entityId: id, - changes: { ...data, previousStatusId: existing.statusId }, + changes: { ...data, previousStatus: existing.status }, workspaceId: existing.domainId, }); - await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatusId: existing.statusId } }); - - // Notify on actual status changes only (not unrelated field updates). - if (data.statusId !== undefined && data.statusId !== existing.statusId) { - try { - await notifyWorkspaceOwner({ - workspaceId: existing.domainId, - type: "status_change", - title: `Task moved to ${newStatusLabel ?? "a new status"}`, - body: `"${updated.title}" ${newStatusLabel ? `is now ${newStatusLabel}` : "changed status"}`, - entityType: "task", - entityId: id, - }); - } catch (notifyError) { - console.error(`[tasks] Failed to create status-change notification for task ${id}:`, notifyError); - } - } + await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } }); if (data.recurrenceRule !== undefined) { await syncScheduledJob(id, data.recurrenceRule); } - // Fire "status changed" automation rules when the status actually moved - // (e.g. when a status changes to Done, add a "shipped" label). - if (data.statusId !== undefined && data.statusId !== existing.statusId) { - await evaluateAutomations({ - projectId: updated.projectId, - triggerType: "task_status_changed", - entity: updated, - changes: { previousStatusId: existing.statusId }, - actor: user.name, - }); - } - return c.json(updated); } catch (error) { if (error instanceof AuthError) { @@ -992,7 +852,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => { } }); -// POST /api/tasks/:id/status — Change task status (Kanban drag / completion) +// POST /api/tasks/:id/status — Change task status (Kanban drag) taskRoutes.post("/:id/status", async (c) => { try { const user = await requireAuth(c); @@ -1001,8 +861,8 @@ taskRoutes.post("/:id/status", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); } const body = await c.req.json(); - const { statusId } = z.object({ - statusId: z.string().uuid("Invalid status id"), + const { status: newStatus } = z.object({ + status: taskStatusEnum, }).parse(body); const [existing] = await db.select() @@ -1016,33 +876,13 @@ taskRoutes.post("/:id/status", async (c) => { await requireWorkspaceAccess(c, existing.domainId); - if (!existing.projectId) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Tasks without a project cannot have a status" } }, 400); - } - - // The status must belong to the task's project. - const [status] = await db.select({ - id: statusDefinitions.id, - projectId: statusDefinitions.projectId, - category: statusDefinitions.category, - label: statusDefinitions.label, - }) - .from(statusDefinitions) - .where(eq(statusDefinitions.id, statusId)) - .limit(1); - if (!status) { - return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404); - } - if (status.projectId !== existing.projectId) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the task's project" } }, 400); - } - - const isDone = status.category === "done"; const updateValues: Record = { - statusId, - completedAt: isDone ? new Date() : null, + status: newStatus, updatedAt: new Date(), }; + if (newStatus === "done") { + updateValues.completedAt = new Date(); + } const [updated] = await db.update(tasks) .set(updateValues) @@ -1051,39 +891,14 @@ taskRoutes.post("/:id/status", async (c) => { await recordActivity({ actor: user.name, - action: isDone ? "completed" : "updated", + action: newStatus === "done" ? "completed" : "updated", entityType: "task", entityId: id, - changes: { previousStatusId: existing.statusId, newStatusId: statusId, newStatusLabel: status.label }, + changes: { previousStatus: existing.status, newStatus }, workspaceId: existing.domainId, }); - await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { previousStatusId: existing.statusId, newStatusId: statusId } }); - - // Fire "status changed" automation rules (Kanban drags and done checkboxes). - await evaluateAutomations({ - projectId: updated.projectId, - triggerType: "task_status_changed", - entity: updated, - changes: { previousStatusId: existing.statusId }, - actor: user.name, - }); - - // Notify the workspace owner (MVP single-user) of the status change. - try { - await notifyWorkspaceOwner({ - workspaceId: existing.domainId, - type: "status_change", - title: `Task moved to ${status.label}`, - body: `"${existing.title}" is now ${status.label}`, - entityType: "task", - entityId: id, - }); - } catch (notifyError) { - console.error(`[tasks] Failed to create status-change notification for task ${id}:`, notifyError); - } - - return c.json({ ...updated, status }); + return c.json(updated); } catch (error) { if (error instanceof AuthError) { return c.json({ error: { code: error.code, message: error.message } }, error.status as any); diff --git a/apps/api/src/routes/timeline.ts b/apps/api/src/routes/timeline.ts deleted file mode 100644 index ce11004..0000000 --- a/apps/api/src/routes/timeline.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Hono } from "hono"; -import { db, projects, sections, statusDefinitions, taskDependencies, tasks } from "@project-e/db"; -import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm"; -import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; - -export const timelineRoutes = new Hono(); - -// Columns of the status_definitions table flattened onto a task row as `status` -// (null when the task has no status or its status was deleted). -const statusColumns = { - id: statusDefinitions.id, - projectId: statusDefinitions.projectId, - key: statusDefinitions.key, - label: statusDefinitions.label, - category: statusDefinitions.category, - color: statusDefinitions.color, - sortOrder: statusDefinitions.sortOrder, - isDefault: statusDefinitions.isDefault, -}; - -// GET /api/domains/:domainId/projects/:projectId/timeline — Gantt data for a project: -// task bars (with status + dependencies) and milestone sections. Tasks without a -// startDate field use createdAt as the bar start. -timelineRoutes.get("/domains/:domainId/projects/:projectId/timeline", async (c) => { - try { - const user = await requireAuth(c); - void user; - const domainId = c.req.param("domainId"); - const projectId = c.req.param("projectId"); - if (!isUuid(domainId) || !isUuid(projectId)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - - await requireWorkspaceAccess(c, domainId); - - const [project] = await db.select({ id: projects.id }) - .from(projects) - .where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt))) - .limit(1); - if (!project) { - return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); - } - - const [taskRows, milestoneRows] = await Promise.all([ - db.select({ - id: tasks.id, - title: tasks.title, - statusId: tasks.statusId, - sectionId: tasks.sectionId, - dueDate: tasks.dueDate, - createdAt: tasks.createdAt, - status: statusColumns, - }) - .from(tasks) - .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) - .where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt))) - .orderBy(asc(tasks.createdAt)), - db.select({ - id: sections.id, - name: sections.name, - targetDate: sections.targetDate, - sortOrder: sections.sortOrder, - }) - .from(sections) - .where(and( - eq(sections.projectId, projectId), - eq(sections.kind, "milestone"), - isNotNull(sections.targetDate), - )) - .orderBy(asc(sections.targetDate), asc(sections.sortOrder)), - ]); - - // Dependency map: taskId → ids of tasks it depends on. Only edges between - // tasks in this project are kept so arrows never point outside the chart. - const depsByTask = new Map(); - if (taskRows.length > 0) { - const taskIds = taskRows.map((t) => t.id); - const depRows = await db.select({ - taskId: taskDependencies.taskId, - dependsOnTaskId: taskDependencies.dependsOnTaskId, - }) - .from(taskDependencies) - .where(and( - inArray(taskDependencies.taskId, taskIds), - inArray(taskDependencies.dependsOnTaskId, taskIds), - )); - for (const dep of depRows) { - const list = depsByTask.get(dep.taskId) ?? []; - list.push(dep.dependsOnTaskId); - depsByTask.set(dep.taskId, list); - } - } - - return c.json({ - tasks: taskRows.map((t) => ({ - id: t.id, - title: t.title, - startDate: t.createdAt, - dueDate: t.dueDate, - statusId: t.statusId, - status: t.status, - sectionId: t.sectionId, - dependencies: depsByTask.get(t.id) ?? [], - })), - milestones: milestoneRows.map((m) => ({ - id: m.id, - name: m.name, - targetDate: m.targetDate, - })), - }); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - console.error("[timeline] GET error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to load timeline" } }, 500); - } -}); diff --git a/apps/web/package.json b/apps/web/package.json index 7ef976a..680225a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.4.0", + "fuse.js": "^7.5.0", "lucide-react": "^1.24.0", "react": "^19.1.0", "react-big-calendar": "^1.20.0", @@ -64,6 +65,7 @@ "react-force-graph-2d": "^1.29.1", "react-hook-form": "^7.84.0", "recharts": "^3.10.1", + "rrule": "^2.8.1", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", diff --git a/apps/web/src/components/automation-rule-builder.tsx b/apps/web/src/components/automation-rule-builder.tsx deleted file mode 100644 index 2cb469f..0000000 --- a/apps/web/src/components/automation-rule-builder.tsx +++ /dev/null @@ -1,528 +0,0 @@ -import { useState } from "react"; -import { toast } from "sonner"; -import { Plus, Trash2 } from "lucide-react"; -import { api, useApiMutation } from "@/lib/api"; -import type { - AutomationAction, - AutomationActionType, - AutomationCondition, - AutomationConditionField, - AutomationRule, - AutomationTriggerType, - Project, -} from "@/lib/types"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; - -// ── Shared metadata (also used by the Automations tab for summaries) ─────────── - -export const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string }[] = [ - { value: "task_status_changed", label: "Task status changed" }, - { value: "task_created", label: "Task created" }, - { value: "due_date_approaching", label: "Due date approaching" }, -]; - -export const ACTION_OPTIONS: { value: AutomationActionType; label: string }[] = [ - { value: "set_status", label: "Set status" }, - { value: "set_priority", label: "Set priority" }, - { value: "add_label", label: "Add label" }, - { value: "create_notification", label: "Send notification" }, -]; - -export const PRIORITY_OPTIONS: { value: string; label: string }[] = [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "urgent", label: "Urgent" }, -]; - -const CONDITION_FIELD_OPTIONS: { value: AutomationConditionField; label: string }[] = [ - { value: "status", label: "Status" }, - { value: "priority", label: "Priority" }, - { value: "label", label: "Label" }, -]; - -const STATUS_OPS: { value: string; label: string }[] = [ - { value: "to", label: "changes to" }, - { value: "from", label: "changes from" }, - { value: "eq", label: "is" }, -]; - -const PRIORITY_OPS: { value: string; label: string }[] = [ - { value: "eq", label: "is" }, - { value: "neq", label: "is not" }, -]; - -const LABEL_OPS: { value: string; label: string }[] = [ - { value: "has", label: "has" }, - { value: "not_has", label: "does not have" }, -]; - -function opsForField(field: AutomationConditionField): { value: string; label: string }[] { - switch (field) { - case "status": - return STATUS_OPS; - case "priority": - return PRIORITY_OPS; - case "label": - return LABEL_OPS; - default: - return STATUS_OPS; - } -} - -function statusLabel(project: Project, key: string): string { - return project.statuses?.find((s) => s.key === key)?.label ?? key; -} - -function summarizeActions(project: Project, actions: AutomationAction[]): string[] { - return actions.map((action) => { - const params = action.params ?? {}; - switch (action.type) { - case "set_status": - return `Set status to ${statusLabel(project, String(params.statusKey ?? ""))}`; - case "set_priority": - return `Set priority to ${String(params.priority ?? "")}`; - case "add_label": - return `Add label "${String(params.label ?? "")}"`; - case "create_notification": - return `Notify: ${String(params.message ?? "")}`; - default: - return action.type; - } - }); -} - -export { summarizeActions }; - -// ── Rule builder dialog ───────────────────────────────────────────────────────── - -interface DraftCondition { - field: AutomationConditionField; - op: string; - value: string; -} - -interface DraftAction { - type: AutomationActionType; - params: Record; -} - -interface RuleBuilderProps { - project: Project; - open: boolean; - onOpenChange: (open: boolean) => void; - /** When set, the dialog edits this rule instead of creating a new one. */ - rule?: AutomationRule | null; - onSaved?: () => void; -} - -function toDraftConditions(conditions: AutomationCondition[]): DraftCondition[] { - return conditions.map((c) => ({ - field: c.field, - op: c.op, - value: typeof c.value === "string" ? c.value : String(c.value ?? ""), - })); -} - -function toDraftActions(actions: AutomationAction[]): DraftAction[] { - return actions.map((a) => { - const params: Record = {}; - for (const [key, value] of Object.entries(a.params ?? {})) { - params[key] = typeof value === "string" ? value : String(value ?? ""); - } - return { type: a.type, params }; - }); -} - -export function AutomationRuleBuilder({ - project, - open, - onOpenChange, - rule = null, - onSaved, -}: RuleBuilderProps) { - const [name, setName] = useState(rule?.name ?? ""); - const [active, setActive] = useState(rule?.active ?? true); - const [triggerType, setTriggerType] = useState( - rule?.trigger.type ?? "task_status_changed" - ); - const [conditions, setConditions] = useState( - toDraftConditions(rule?.conditions ?? []) - ); - const [actions, setActions] = useState( - toDraftActions(rule?.actions ?? []) - ); - - const createMutation = useApiMutation>( - "post", - `/projects/${project.id}/automations` - ); - const updateMutation = useApiMutation>( - "patch", - `/projects/${project.id}/automations/${rule?.id}` - ); - - const isEditing = Boolean(rule?.id); - const isPending = createMutation.isPending || updateMutation.isPending; - - const updateCondition = (index: number, patch: Partial) => { - setConditions((prev) => - prev.map((c, i) => (i === index ? { ...c, ...patch } : c)) - ); - }; - - const updateAction = (index: number, patch: Partial) => { - setActions((prev) => - prev.map((a, i) => (i === index ? { ...a, ...patch } : a)) - ); - }; - - const handleSubmit = () => { - if (!name.trim()) { - toast.error("Rule name is required"); - return; - } - if (actions.length === 0) { - toast.error("Add at least one action"); - return; - } - - // Drop condition rows whose value is still empty. - const validConditions = conditions.filter( - (c) => typeof c.value === "string" && c.value.trim() !== "" - ); - - const payload: Record = { - name: name.trim(), - active, - trigger: { type: triggerType }, - conditions: validConditions.map((c) => ({ field: c.field, op: c.op, value: c.value })), - actions: actions.map((a) => ({ type: a.type, params: a.params })), - }; - - const onSuccess = () => { - toast.success(isEditing ? "Rule updated" : "Rule created"); - onSaved?.(); - onOpenChange(false); - }; - const onError = (err: Error) => toast.error(err.message); - - if (isEditing) { - updateMutation.mutate(payload, { onSuccess, onError }); - } else { - createMutation.mutate(payload, { onSuccess, onError }); - } - }; - - return ( - - - - {isEditing ? "Edit automation rule" : "Create automation rule"} - - When something happens to a task, automatically run actions. - - - -
-
-
- - setName(e.target.value)} - placeholder="e.g. Ship completed tasks" - /> -
-
- - -
-
- -
- - -
- -
-
- - -
- {conditions.length === 0 ? ( -

- No conditions — the rule fires on every matching event. -

- ) : ( - conditions.map((condition, index) => ( -
- - - {condition.field === "status" ? ( - - ) : condition.field === "priority" ? ( - - ) : ( - updateCondition(index, { value: e.target.value })} - placeholder="Label name" - className="min-w-0 flex-1" - /> - )} - -
- )) - )} -
- -
-
- - -
- {actions.length === 0 ? ( -

No actions — add at least one.

- ) : ( - actions.map((action, index) => ( -
- - {action.type === "set_status" ? ( - - ) : action.type === "set_priority" ? ( - - ) : action.type === "add_label" ? ( - - updateAction(index, { params: { ...action.params, label: e.target.value } }) - } - placeholder="Label name" - className="min-w-0 flex-1" - /> - ) : ( - - updateAction(index, { params: { ...action.params, message: e.target.value } }) - } - placeholder="Notification message" - className="min-w-0 flex-1" - /> - )} - -
- )) - )} -
-
- - - - - -
-
- ); -} diff --git a/apps/web/src/components/entities/entity-comments.tsx b/apps/web/src/components/entities/entity-comments.tsx index b9bdc3f..425dc14 100644 --- a/apps/web/src/components/entities/entity-comments.tsx +++ b/apps/web/src/components/entities/entity-comments.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Trash2 } from "lucide-react"; +import { Trash2, Bot } from "lucide-react"; import { toast } from "sonner"; import { formatDistanceToNow, parseISO } from "date-fns"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -10,6 +10,7 @@ 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"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; interface EntityCommentsProps { entityType: string; @@ -84,20 +85,27 @@ function CommentComposer({ entityId, parentId = null, submitLabel = "Comment", - placeholder = "Write a comment...", + placeholder = "Write a comment... (use @agent to mention)", autoFocus = false, onSubmitted, }: CommentComposerProps) { const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); const [content, setContent] = useState(""); + const showMentionHint = content.includes("@"); const createMutation = useApiMutation( "post", "/comments", { - onSuccess: () => { + onSuccess: (c) => { queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] }); + queryClient.invalidateQueries({ queryKey: ["agent-activity"] }); toast.success("Comment added"); + if (content.includes("@")) { + fetch("/api/agents/dispatch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commentId: (c as any).id || "", entityType, entityId, workspaceId: activeDomainId }) }).catch(()=>{}); + toast.info("Agent notified via @mention"); + } setContent(""); onSubmitted?.(); }, @@ -127,6 +135,7 @@ function CommentComposer({ placeholder={placeholder} autoFocus={autoFocus} /> + {showMentionHint &&

@mention will notify agents

}
-
- ))} -
- Milestones · {milestones.length} -
- - - {/* Scrollable timeline */} -
-
- -
-
-
- {rows} -
- {milestones.map((m) => ( - - ))} -
- - - - - - - - {arrows} - -
-
-
- - )} - - ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-dependency-arrow.tsx b/apps/web/src/components/gantt/gantt-dependency-arrow.tsx deleted file mode 100644 index a7a2bba..0000000 --- a/apps/web/src/components/gantt/gantt-dependency-arrow.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { TimelineTask } from "./gantt-utils"; - -export interface TaskPosition { - /** Left edge of the task's bar in timeline pixels. */ - startX: number; - /** Right edge of the task's bar in timeline pixels. */ - endX: number; - /** Vertical center of the task's row in pixels. */ - y: number; -} - -interface GanttDependencyArrowProps { - /** The task being depended on (arrow originates at the end of its bar). */ - fromTask: TimelineTask; - /** The blocked task (arrowhead lands at the start of its bar). */ - toTask: TimelineTask; - taskPositions: ReadonlyMap; -} - -const BEND = 12; - -/** - * SVG elbow arrow from the end of the blocking task's bar to the start of the - * blocked task's bar. Rendered inside the chart's overlay — the marker - * is defined there under the id `gantt-arrow`. - */ -export function GanttDependencyArrow({ fromTask, toTask, taskPositions }: GanttDependencyArrowProps) { - const from = taskPositions.get(fromTask.id); - const to = taskPositions.get(toTask.id); - if (!from || !to) return null; - - const x1 = from.endX; - const y1 = from.y; - // If the target bar starts before the source ends, drop the arrowhead just - // past the source end so the elbow path never doubles back on itself. - const x2 = Math.max(to.startX, from.endX + BEND); - const y2 = to.y; - const d = `M ${x1} ${y1} H ${x1 + BEND} L ${x2 - BEND} ${y2} H ${x2}`; - - return ( - - ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-milestone.tsx b/apps/web/src/components/gantt/gantt-milestone.tsx deleted file mode 100644 index 1ef81bf..0000000 --- a/apps/web/src/components/gantt/gantt-milestone.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { format, parseISO } from "date-fns"; -import type { TimelineMilestone } from "./gantt-utils"; - -interface GanttMilestoneProps { - milestone: TimelineMilestone; - /** Timeline pixel x where the diamond's center should sit. */ - x: number; -} - -/** Diamond marker for a milestone, vertically centered with its date label. */ -export function GanttMilestone({ milestone, x }: GanttMilestoneProps) { - return ( -
-
- - {format(parseISO(milestone.targetDate), "MMM d")} - -
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-task-bar.tsx b/apps/web/src/components/gantt/gantt-task-bar.tsx deleted file mode 100644 index 9307d70..0000000 --- a/apps/web/src/components/gantt/gantt-task-bar.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useRef, useState } from "react"; -import { addDays, differenceInCalendarDays, format, formatISO, parseISO } from "date-fns"; -import { cn } from "@/lib/utils"; -import { positionForDate, toDayStart, type TimelineTask } from "./gantt-utils"; - -interface GanttTaskBarProps { - task: TimelineTask; - /** Left edge of the bar in timeline pixels. */ - startX: number; - /** Bar width in timeline pixels (right edge = startX + width). */ - width: number; - color: string; - pixelsPerDay: number; - viewStart: Date; - onCommit: (taskId: string, dueDate: string) => void; -} - -interface DragState { - startClientX: number; - origDue: Date; - lastDue: Date; - moved: boolean; -} - -/** - * A task bar on the timeline. Tasks only carry an end date (dueDate), so both - * dragging the body and pulling the right resize handle move the due date; the - * bar stays anchored at its start (createdAt) date. A task without a due date - * renders as a small stub that becomes a 1-day bar when dragged. - */ -export function GanttTaskBar({ task, startX, width, color, pixelsPerDay, viewStart, onCommit }: GanttTaskBarProps) { - const [dragDue, setDragDue] = useState(null); - const dragRef = useRef(null); - - const startDate = toDayStart(task.startDate); - const origDue = task.dueDate ? toDayStart(task.dueDate) : startDate; - const endDate = dragDue ?? origDue; - const barWidth = Math.max(positionForDate(endDate, viewStart, pixelsPerDay) - startX, 6); - const isDone = task.status?.category === "done"; - const isCancelled = task.status?.category === "cancelled"; - const title = - task.dueDate && task.dueDate !== task.startDate - ? `${task.title} — due ${format(parseISO(task.dueDate), "MMM d, yyyy")}` - : task.title; - - const beginDrag = (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - const initial = task.dueDate ? toDayStart(task.dueDate) : startDate; - dragRef.current = { startClientX: e.clientX, origDue: initial, lastDue: initial, moved: false }; - setDragDue(initial); - - const onMove = (ev: PointerEvent) => { - const state = dragRef.current; - if (!state) return; - const dx = ev.clientX - state.startClientX; - const days = Math.round(dx / pixelsPerDay); - let next = addDays(state.origDue, days); - if (next < startDate) next = startDate; - state.lastDue = next; - if (differenceInCalendarDays(next, state.origDue) !== 0) state.moved = true; - setDragDue(next); - }; - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - const state = dragRef.current; - dragRef.current = null; - setDragDue(null); - if (state?.moved) onCommit(task.id, formatISO(state.lastDue)); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - }; - - return ( -
- {barWidth >= 30 ? {task.title} : null} -
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-timeline-header.tsx b/apps/web/src/components/gantt/gantt-timeline-header.tsx deleted file mode 100644 index 597fa88..0000000 --- a/apps/web/src/components/gantt/gantt-timeline-header.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { format, getDaysInMonth, startOfMonth } from "date-fns"; -import { cn } from "@/lib/utils"; -import { - getDateRange, - positionForDate, - TIMELINE_HEADER_HEIGHT, - type ZoomLevel, -} from "./gantt-utils"; - -interface GanttTimelineHeaderProps { - viewStart: Date; - viewEnd: Date; - zoom: ZoomLevel; - pixelsPerDay: number; -} - -function cellWidth(cell: Date, zoom: ZoomLevel, pixelsPerDay: number): number { - switch (zoom) { - case "day": - return pixelsPerDay; - case "week": - return 7 * pixelsPerDay; - case "month": - return getDaysInMonth(cell) * pixelsPerDay; - } -} - -/** - * Two-row date header: a group label row (months for day/week zoom, years for - * month zoom) above the per-column cells. Positioned absolutely inside a - * container that spans the full timeline width so it can be made sticky by the - * parent chart. - */ -export function GanttTimelineHeader({ viewStart, viewEnd, zoom, pixelsPerDay }: GanttTimelineHeaderProps) { - const cells = getDateRange(viewStart, viewEnd, zoom); - - // Group consecutive cells into spans for the top row. - const groups: { key: string; label: string; start: Date; end: Date }[] = []; - for (const cell of cells) { - const key = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "yyyy-MM"); - const label = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "MMMM yyyy"); - const last = groups[groups.length - 1]; - if (last && last.key === key) { - last.end = cell; - } else { - groups.push({ key, label, start: cell, end: cell }); - } - } - - const renderGroup = (group: { key: string; label: string; start: Date; end: Date }) => { - const left = positionForDate(group.start, viewStart, pixelsPerDay); - const width = - positionForDate(group.end, viewStart, pixelsPerDay) + - cellWidth(group.end, zoom, pixelsPerDay) - - left; - return ( -
- {group.label} -
- ); - }; - - const renderCell = (cell: Date) => { - const left = positionForDate(cell, viewStart, pixelsPerDay); - const width = cellWidth(cell, zoom, pixelsPerDay); - const isWeekend = zoom === "day" && (cell.getDay() === 0 || cell.getDay() === 6); - const label = - zoom === "day" - ? format(cell, "EEE d") - : zoom === "week" - ? format(cell, "MMM d") - : format(cell, "MMMM"); - return ( -
- {label} -
- ); - }; - - return ( -
-
- {groups.map(renderGroup)} -
-
- {cells.map(renderCell)} -
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-utils.ts b/apps/web/src/components/gantt/gantt-utils.ts deleted file mode 100644 index d21894a..0000000 --- a/apps/web/src/components/gantt/gantt-utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - addDays, - differenceInCalendarDays, - eachDayOfInterval, - eachMonthOfInterval, - eachWeekOfInterval, - endOfDay, - endOfMonth, - parseISO, - startOfDay, - startOfMonth, - startOfWeek, -} from "date-fns"; -import type { StatusDefinition } from "@/lib/types"; - -export type ZoomLevel = "day" | "week" | "month"; - -export interface TimelineTask { - id: string; - title: string; - startDate: string; - dueDate: string | null; - statusId: string | null; - status: StatusDefinition | null; - sectionId: string | null; - dependencies: string[]; -} - -export interface TimelineMilestone { - id: string; - name: string; - targetDate: string; -} - -export interface TimelineData { - tasks: TimelineTask[]; - milestones: TimelineMilestone[]; -} - -export const ROW_HEIGHT = 40; -export const MILESTONE_BAND_HEIGHT = 48; -export const TIMELINE_HEADER_HEIGHT = 56; -export const TASK_LIST_WIDTH = 224; - -const PIXELS_PER_DAY: Record = { - day: 36, - week: 12, - month: 5, -}; - -export function getPixelsPerDay(zoom: ZoomLevel): number { - return PIXELS_PER_DAY[zoom]; -} - -/** Normalize a date (or ISO string) to local midnight. */ -export function toDayStart(date: Date | string): Date { - return startOfDay(typeof date === "string" ? parseISO(date) : date); -} - -/** Horizontal pixel offset of a date from the view start (local calendar days). */ -export function positionForDate(date: Date | string, viewStart: Date, pixelsPerDay: number): number { - return differenceInCalendarDays(toDayStart(date), startOfDay(viewStart)) * pixelsPerDay; -} - -/** Date (local midnight) at a given horizontal pixel offset from the view start. */ -export function dateForPosition(x: number, viewStart: Date, pixelsPerDay: number): Date { - return addDays(startOfDay(viewStart), Math.round(x / pixelsPerDay)); -} - -/** Column start dates for the timeline header at the given zoom. */ -export function getDateRange(start: Date, end: Date, zoom: ZoomLevel): Date[] { - const s = startOfDay(start); - const e = endOfDay(end); - switch (zoom) { - case "day": - return eachDayOfInterval({ start: s, end: e }); - case "week": - return eachWeekOfInterval({ start: startOfWeek(s, { weekStartsOn: 1 }), end: e }, { weekStartsOn: 1 }); - case "month": - return eachMonthOfInterval({ start: startOfMonth(s), end: endOfMonth(e) }); - } -} \ No newline at end of file diff --git a/apps/web/src/components/notification-center.tsx b/apps/web/src/components/notification-center.tsx deleted file mode 100644 index 0052e02..0000000 --- a/apps/web/src/components/notification-center.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "@tanstack/react-router"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - AlarmClock, - ArrowRightLeft, - AtSign, - Bell, - Bot, - Check, - Inbox, - RefreshCw, - UserPlus, - type LucideIcon, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useApiQuery, useApiMutation, api } from "@/lib/api"; -import { useApiDomain } from "@/lib/stores/use-active-domain-store"; -import { useRealtime } from "@/hooks/use-realtime"; -import { formatDistanceToNow } from "date-fns"; -import type { Notification, NotificationCount, NotificationsResponse } from "@/lib/types"; - -const NOTIFICATION_META: Record = { - mention: { icon: AtSign, color: "text-blue-500" }, - status_change: { icon: ArrowRightLeft, color: "text-violet-500" }, - due_soon: { icon: AlarmClock, color: "text-amber-500" }, - automation: { icon: Bot, color: "text-emerald-500" }, - assignment: { icon: UserPlus, color: "text-cyan-500" }, -}; - -/** Navigate to the entity a notification points at. Returns true when a route - * was matched (and the sheet should close). */ -function navigateToEntity(navigate: ReturnType, n: Notification): boolean { - if (!n.entityId || !n.entityType) return false; - switch (n.entityType) { - case "task": - navigate({ to: "/tasks/$id", params: { id: n.entityId } }); - return true; - case "note": - navigate({ to: "/notes/$id", params: { id: n.entityId } }); - return true; - case "project": - navigate({ to: "/projects/$id", params: { id: n.entityId } }); - return true; - case "habit": - navigate({ to: "/habits/$id", params: { id: n.entityId } }); - return true; - default: - return false; - } -} - -export function NotificationCenter() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const domainId = useApiDomain(); - const [open, setOpen] = useState(false); - - // Own SSE connection so the badge stays live regardless of which page is - // mounted; notification events invalidate the count + list queries. - useRealtime({ enabled: true }); - - const countQuery = useApiQuery( - ["notifications-count", domainId], - "/notifications/count" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}` : ""), - { enabled: !!domainId, refetchInterval: 30_000 } - ); - const unreadCount = countQuery.data?.count ?? 0; - - const listQuery = useApiQuery( - ["notifications", domainId], - "/notifications" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}&limit=50` : ""), - { enabled: !!domainId && open } - ); - const notifications = listQuery.data?.items ?? []; - const loading = listQuery.isLoading || listQuery.isFetching; - - const invalidateNotifications = () => { - queryClient.invalidateQueries({ queryKey: ["notifications-count"] }); - queryClient.invalidateQueries({ queryKey: ["notifications"] }); - }; - - const markRead = useMutation({ - mutationFn: (id: string) => api.patch(`/notifications/${id}`), - onMutate: (id) => { - // Optimistically decrement the badge so the UI feels instant. - queryClient.setQueryData(["notifications-count", domainId], (old) => - old && old.count > 0 ? { count: old.count - 1 } : old - ); - queryClient.setQueryData(["notifications", domainId], (old) => - old - ? { - ...old, - items: old.items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: new Date().toISOString() } : n)), - unreadCount: Math.max(0, old.unreadCount - 1), - } - : old - ); - return id; - }, - onSuccess: invalidateNotifications, - }); - - const markAllRead = useApiMutation<{ success: boolean; updated: number }, { workspace_id?: string }>( - "post", - "/notifications/read-all", - { - onMutate: () => { - queryClient.setQueryData(["notifications-count", domainId], (old) => - old ? { count: 0 } : old - ); - queryClient.setQueryData(["notifications", domainId], (old) => - old - ? { - ...old, - items: old.items.map((n) => (n.readAt ? n : { ...n, readAt: new Date().toISOString() })), - unreadCount: 0, - } - : old - ); - }, - onSuccess: invalidateNotifications, - } - ); - - const handleNotificationClick = (n: Notification) => { - if (!n.readAt) markRead.mutate(n.id); - if (navigateToEntity(navigate, n)) { - setOpen(false); - } - }; - - const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount); - - return ( - - - - - - - - - - {unreadCount === 0 ? "No notifications" : `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`} - - - - - - - Notifications -
- - -
-
- - - {loading && notifications.length === 0 ? ( -
Loading notifications…
- ) : notifications.length === 0 ? ( -
- - No notifications yet -
- ) : ( -
    - {notifications.map((n) => { - const meta = NOTIFICATION_META[n.type] ?? { icon: Bell, color: "text-muted-foreground" }; - const Icon = meta.icon; - const unread = !n.readAt; - return ( -
  • - -
  • - ); - })} -
- )} -
-
-
- ); -} diff --git a/apps/web/src/components/quick-add-bar.tsx b/apps/web/src/components/quick-add-bar.tsx deleted file mode 100644 index 1608ef4..0000000 --- a/apps/web/src/components/quick-add-bar.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { Plus, Calendar, Clock, Flag, FolderKanban, Tag as TagIcon, Repeat, CornerDownLeft } from "lucide-react"; -import { api, useApiQuery } from "@/lib/api"; -import { useApiDomain } from "@/lib/stores/use-active-domain-store"; -import { parseQuickAdd, type ParsedTask, type QuickAddContext } from "@/lib/nlp-parser"; -import { PRIORITY } from "@/lib/status-colors"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import type { Project, Tag, StatusDefinition, PaginatedResponse } from "@/lib/types"; - -function PreviewChip({ - icon, - label, - className, -}: { - icon: React.ReactNode; - label: string; - className?: string; -}) { - return ( - - {icon} - {label} - - ); -} - -export function QuickAddBar() { - const queryClient = useQueryClient(); - const activeDomainId = useApiDomain(); - const inputRef = useRef(null); - const [text, setText] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [focused, setFocused] = useState(false); - - // Projects and tags in the active domain, used to resolve #proj / @tag tokens. - const { data: projectsData } = useApiQuery>( - ["projects", activeDomainId], - activeDomainId ? `/projects?limit=200&domain=${activeDomainId}` : "", - { enabled: !!activeDomainId } - ); - const { data: tagsData } = useApiQuery>( - ["tags", activeDomainId], - "/tags?perPage=200", - { enabled: !!activeDomainId } - ); - - const projects = projectsData?.items ?? []; - const tags = tagsData?.items ?? []; - - // Context for the parser: the set of known project/tag names. - const context: QuickAddContext = useMemo( - () => ({ projectNames: projects.map((p) => p.name), tagNames: tags.map((t) => t.name) }), - [projects, tags] - ); - - const parsed = useMemo(() => parseQuickAdd(text, context), [text, context]); - - // Project statuses (to find the "todo" status when a project is selected). - const resolvedProject = parsed.project - ? projects.find((p) => p.name.toLowerCase() === parsed.project!.toLowerCase()) - : undefined; - const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>( - ["project-statuses", resolvedProject?.id ?? "none"], - resolvedProject ? `/projects/${resolvedProject.id}/statuses` : "", - { enabled: !!resolvedProject } - ); - const todoStatus = statusesData?.items?.find((s) => s.category === "todo"); - - // Keyboard shortcut: `n` (no modifiers, outside editable fields) focuses the - // bar. Mirrors the app's single-key shortcut pattern (?, /, c). - useEffect(() => { - const handler = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if ( - e.metaKey || e.ctrlKey || e.altKey || - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') - ) { - return; - } - if (e.key.toLowerCase() === "n") { - e.preventDefault(); - inputRef.current?.focus(); - } - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, []); - - const canSubmit = parsed.title.trim().length > 0 && !isSubmitting; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!canSubmit) return; - setIsSubmitting(true); - try { - // Resolve matched names back to ids for the API payload. - const tagIds = parsed.tags - ?.map((name) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.id) - .filter((id): id is string => !!id); - - await api.post("/tasks", { - title: parsed.title, - ...(parsed.priority ? { priority: parsed.priority } : {}), - ...(resolvedProject ? { projectId: resolvedProject.id } : {}), - ...(parsed.dueDate ? { dueDate: parsed.dueDate.toISOString() } : {}), - ...(parsed.recurrence ? { recurrenceRule: parsed.recurrence } : {}), - ...(todoStatus ? { statusId: todoStatus.id } : {}), - ...(tagIds && tagIds.length > 0 ? { tagIds } : {}), - ...(activeDomainId ? { domain: activeDomainId } : {}), - }); - - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - queryClient.invalidateQueries({ queryKey: ["projects"] }); - setText(""); - toast.success("Created!"); - } catch (err) { - toast.error((err as Error).message || "Failed to create task"); - } finally { - setIsSubmitting(false); - } - }; - - // Preview chips describing what the parser detected. - const chips: React.ReactNode[] = []; - if (parsed.dueDate) { - chips.push( - } - label={parsed.dueDate.toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - }) + (parsed.dueDate.getHours() || parsed.dueDate.getMinutes() - ? ` ${parsed.dueDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}` - : "")} - /> - ); - } - if (parsed.priority) { - chips.push( - } - label={PRIORITY[parsed.priority]?.label ?? parsed.priority} - className="text-orange-500" - /> - ); - } - if (resolvedProject) { - chips.push( - } - label={resolvedProject.name} - className="text-violet-500" - /> - ); - } - if (parsed.tags?.length) { - for (const tag of parsed.tags) { - chips.push( - } - label={tag} - className="text-sky-500" - /> - ); - } - } - if (parsed.recurrence) { - chips.push( - } label="Recurring" /> - ); - } - - return ( -
-
setFocused(true)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false); - }} - > -
- - setText(e.target.value)} - placeholder="Quick add: 'buy milk tomorrow !high #work'" - className="h-9 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" - aria-label="Quick add task" - /> - -
- {focused && (chips.length > 0 || text.trim().length > 0) && ( -
- {chips.length > 0 ? ( - chips - ) : ( - - !high priority ·{" "} - #project ·{" "} - @tag ·{" "} - tomorrow - - )} -
- )} -
-
- ); -} diff --git a/apps/web/src/components/shell/sidebar.tsx b/apps/web/src/components/shell/sidebar.tsx index 906e39e..fcdbc7b 100644 --- a/apps/web/src/components/shell/sidebar.tsx +++ b/apps/web/src/components/shell/sidebar.tsx @@ -11,7 +11,6 @@ import { NotebookPen, BookOpen, Share2, - LayoutGrid, CalendarDays, Search, BarChart3, @@ -67,7 +66,6 @@ const navItems: NavItem[] = [ { href: "/search", label: "Search", icon: Search }, { href: "/analytics", label: "Analytics", icon: BarChart3 }, { href: "/agents/activity", label: "Agent Activity", icon: Bot }, - { href: "/canvas", label: "Canvas", icon: LayoutGrid }, { href: "/daily", label: "Daily Notes", icon: PenLine }, ]; diff --git a/apps/web/src/components/shell/topbar.tsx b/apps/web/src/components/shell/topbar.tsx index 3bfc97e..e2c9763 100644 --- a/apps/web/src/components/shell/topbar.tsx +++ b/apps/web/src/components/shell/topbar.tsx @@ -1,10 +1,12 @@ -import { Search, Plus, Menu } from "lucide-react"; +import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useAuthStore } from "@/lib/stores/use-auth-store"; +import { useApiQuery } from "@/lib/api"; import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { DomainPicker } from "@/components/shell/domain-picker"; -import { NotificationCenter } from "@/components/notification-center"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, @@ -19,10 +21,27 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { formatDistanceToNow } from "date-fns"; +import type { NotificationsResponse } from "@/lib/types"; + +const ACTION_LABELS: Record = { + created: "created", + updated: "updated", + deleted: "deleted", + completed: "completed", +}; + +function readableEntityType(entityType: string): string { + return entityType + .replace(/_/g, " ") + .replace(/\b\w/g, (ch) => ch.toUpperCase()); +} export function Topbar() { const { setMobileOpen } = useSidebarStore(); + const queryClient = useQueryClient(); const navigate = useNavigate(); + const domainId = useApiDomain(); const user = useAuthStore((s) => s.user); const userName = user?.name || "User"; const userEmail = user?.email || ""; @@ -32,6 +51,20 @@ export function Topbar() { document.dispatchEvent(new CustomEvent("open-command-palette")); }; + const { data: notificationsData } = useApiQuery( + ["notifications", domainId], + "/notifications?workspace_id=" + encodeURIComponent(domainId), + { enabled: !!domainId, refetchInterval: 60_000 } + ); + + const notifications = notificationsData?.items || []; + const count = notificationsData?.count || 0; + const badgeLabel = count > 99 ? "99+" : String(count); + const tooltipText = + count === 0 + ? "No notifications" + : `${count} unread notification${count === 1 ? "" : "s"}`; + return (
- {/* Notifications bell (badge + slide-out panel) */} - + {/* Notifications bell */} + + + + + + + + + {tooltipText} + + +
+ Notifications + +
+ + {notifications.length === 0 ? ( +
+ No notifications +
+ ) : ( + notifications.slice(0, 10).map((n) => ( + + + {readableEntityType(n.entityType)}{" "} + {ACTION_LABELS[n.action] || n.action} + + + {n.actor} · {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })} + + + )) + )} +
+
+
{/* User avatar */} diff --git a/apps/web/src/components/tasks/recurrence-picker.tsx b/apps/web/src/components/tasks/recurrence-picker.tsx new file mode 100644 index 0000000..f0f0d4e --- /dev/null +++ b/apps/web/src/components/tasks/recurrence-picker.tsx @@ -0,0 +1,42 @@ +import { useMemo, useState } from "react"; +import { RRule } from "rrule"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; + +const PRESETS = [ + { value: "", label: "No repeat" }, + { value: "FREQ=DAILY", label: "Daily" }, + { value: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", label: "Weekdays" }, + { value: "FREQ=WEEKLY", label: "Weekly" }, + { value: "FREQ=WEEKLY;BYDAY=MO", label: "Weekly on Monday" }, + { value: "FREQ=MONTHLY", label: "Monthly" }, + { value: "FREQ=YEARLY", label: "Yearly" }, +]; + +export function RecurrencePicker({ value, onChange }: { value: string | null; onChange: (v: string | null) => void }) { + const [preset, setPreset] = useState(value || ""); + const preview = useMemo(() => { + if (!preset) return []; + try { + const rule = RRule.fromString(preset); + const opts = { ...rule.options, dtstart: new Date() }; + const r = new RRule(opts); + return r.all((_, i) => i < 5).map(d => d.toLocaleDateString()); + } catch { return []; } + }, [preset]); + + return ( +
+ + + {preview.length > 0 && ( +

Next: {preview.join(" · ")}

+ )} +
+ ); +} diff --git a/apps/web/src/hooks/use-realtime.ts b/apps/web/src/hooks/use-realtime.ts index 618d8bd..dafe8c0 100644 --- a/apps/web/src/hooks/use-realtime.ts +++ b/apps/web/src/hooks/use-realtime.ts @@ -52,9 +52,6 @@ export function useRealtime(options: UseRealtimeOptions = {}) { case "agent": queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]); break; - case "canvas": - queryKeys.push(["canvas"]); - break; case "webhook": queryKeys.push(["webhooks"]); break; @@ -72,10 +69,6 @@ export function useRealtime(options: UseRealtimeOptions = {}) { case "graph_edge": queryKeys.push(["graph"]); break; - case "notification": - // A new in-app notification landed — refresh the bell badge and list. - queryKeys.push(["notifications"], ["notifications-count"]); - break; default: queryKeys.push([entityType]); } diff --git a/apps/web/src/lib/nlp-parser.test.ts b/apps/web/src/lib/nlp-parser.test.ts deleted file mode 100644 index 8f59298..0000000 --- a/apps/web/src/lib/nlp-parser.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { parseQuickAdd } from "./nlp-parser"; - -// Deterministic "now" so date math is stable across runs. Tests that rely on -// calendar dates are anchored relative to this fixed reference point. -const NOW = new Date("2026-08-19T12:00:00"); // a Wednesday - -function parse(input: string, context?: Parameters[1]) { - return parseQuickAdd(input, context); -} - -// Helper: same calendar day check regardless of time component. -function sameDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -// Patch Date.now for the tests that compute relative dates. -describe("nlp-parser", () => { - const realNow = Date.now; - beforeEach(() => { - Date.now = () => NOW.getTime(); - }); - afterEach(() => { - Date.now = realNow; - }); - - describe("title extraction", () => { - it("keeps plain text as the title", () => { - const r = parse("buy milk"); - expect(r.title).toBe("buy milk"); - }); - - it("removes recognized tokens from the title", () => { - const r = parse("buy milk tomorrow !high"); - expect(r.title).toBe("buy milk"); - }); - - it("trims surrounding whitespace from the title", () => { - const r = parse(" buy milk tomorrow "); - expect(r.title).toBe("buy milk"); - }); - - it("keeps multiple words in original order", () => { - const r = parse("fix login bug in the auth flow"); - expect(r.title).toBe("fix login bug in the auth flow"); - }); - }); - - describe("priority", () => { - it("parses !urgent", () => { - expect(parse("ship !urgent").priority).toBe("urgent"); - }); - it("parses !high", () => { - expect(parse("ship !high").priority).toBe("high"); - }); - it("parses !medium", () => { - expect(parse("ship !medium").priority).toBe("medium"); - }); - it("parses !low", () => { - expect(parse("ship !low").priority).toBe("low"); - }); - it("parses !! as urgent", () => { - expect(parse("ship !!").priority).toBe("urgent"); - }); - it("is case-insensitive", () => { - expect(parse("ship !HIGH").priority).toBe("high"); - }); - }); - - describe("dates", () => { - it("parses tomorrow as next day", () => { - const r = parse("buy milk tomorrow"); - expect(r.dueDate).toBeDefined(); - expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true); - }); - - it("parses in 3 days", () => { - const r = parse("fix bug in 3 days"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true); - }); - - it("parses in 2 weeks", () => { - const r = parse("plan in 2 weeks"); - expect(sameDay(r.dueDate!, new Date(2026, 8, 2))).toBe(true); - }); - - it("parses next friday", () => { - // NOW is Wed 2026-08-19; next friday is 2026-08-21. - const r = parse("review PR next friday"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true); - }); - - it("parses a bare weekday as the next occurrence", () => { - // NOW is Wed 2026-08-19; next monday is 2026-08-24. - const r = parse("standup monday"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 24))).toBe(true); - }); - - it("parses ISO date 2025-01-15", () => { - const r = parse("deadline 2025-01-15"); - expect(sameDay(r.dueDate!, new Date(2025, 0, 15))).toBe(true); - }); - - it("parses month name + day (dec 25)", () => { - const r = parse("gift dec 25"); - expect(sameDay(r.dueDate!, new Date(2026, 11, 25))).toBe(true); - }); - - it("parses end of month", () => { - const r = parse("report end of month"); - // Aug 2026 has 31 days. - expect(sameDay(r.dueDate!, new Date(2026, 7, 31))).toBe(true); - }); - - it("parses next week", () => { - const r = parse("event next week"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 26))).toBe(true); - }); - }); - - describe("times", () => { - it("parses at 2pm on a date", () => { - const r = parse("call tomorrow at 2pm"); - expect(r.dueDate!.getHours()).toBe(14); - expect(r.dueDate!.getMinutes()).toBe(0); - }); - - it("parses at 9am on a date", () => { - const r = parse("standup tomorrow at 9am"); - expect(r.dueDate!.getHours()).toBe(9); - }); - - it("parses 24h time at 14:30", () => { - const r = parse("meeting tomorrow at 14:30"); - expect(r.dueDate!.getHours()).toBe(14); - expect(r.dueDate!.getMinutes()).toBe(30); - }); - - it("removes the time phrase from the title", () => { - const r = parse("call tomorrow at 2pm"); - expect(r.title).toBe("call"); - }); - }); - - describe("projects and tags", () => { - it("resolves #project to a known project name", () => { - const r = parse("buy milk #work", { projectNames: ["Work", "Personal"] }); - expect(r.project).toBe("Work"); - }); - - it("resolves @tag to a known tag name", () => { - const r = parse("task @sarah", { tagNames: ["sarah", "billing"] }); - expect(r.tags).toEqual(["sarah"]); - }); - - it("keeps unknown #project in the title", () => { - const r = parse("fix #hashtag bug", { projectNames: ["Work"] }); - expect(r.project).toBeUndefined(); - expect(r.title).toContain("#hashtag"); - }); - - it("keeps unknown @tag in the title", () => { - const r = parse("mention @nobody", { tagNames: ["sarah"] }); - expect(r.tags).toBeUndefined(); - expect(r.title).toContain("@nobody"); - }); - - it("resolves multiple tags", () => { - const r = parse("task @a @b", { tagNames: ["a", "b", "c"] }); - expect(r.tags).toEqual(["a", "b"]); - }); - }); - - describe("recurrence", () => { - it("parses daily", () => { - expect(parse("standup daily").recurrence).toBe("FREQ=DAILY"); - }); - it("parses weekly", () => { - expect(parse("review weekly").recurrence).toBe("FREQ=WEEKLY"); - }); - it("parses monthly", () => { - expect(parse("report monthly").recurrence).toBe("FREQ=MONTHLY"); - }); - it("parses every monday", () => { - expect(parse("standup every monday").recurrence).toBe("FREQ=WEEKLY;BYDAY=MO"); - }); - it("parses every 2 weeks", () => { - expect(parse("review every 2 weeks").recurrence).toBe("FREQ=WEEKLY;INTERVAL=2"); - }); - it("parses every month on the 15th", () => { - expect(parse("bill every month on the 15th").recurrence).toBe( - "FREQ=MONTHLY;BYMONTHDAY=15" - ); - }); - it("removes recurrence words from the title", () => { - const r = parse("standup every monday"); - expect(r.title).toBe("standup"); - }); - }); - - describe("combined & edge cases", () => { - it("parses a full example", () => { - const r = parse("buy milk tomorrow !high #work @sarah every monday", { - projectNames: ["work"], - tagNames: ["sarah"], - }); - expect(r.title).toBe("buy milk"); - expect(r.priority).toBe("high"); - expect(r.project).toBe("work"); - expect(r.tags).toEqual(["sarah"]); - expect(r.recurrence).toBe("FREQ=WEEKLY;BYDAY=MO"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true); - }); - - it("parses review PR !urgent next friday", () => { - const r = parse("review PR !urgent next friday"); - expect(r.title).toBe("review PR"); - expect(r.priority).toBe("urgent"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true); - }); - - it("parses team standup daily at 9am #engineering", () => { - const r = parse("team standup daily at 9am #engineering", { - projectNames: ["engineering"], - }); - expect(r.title).toBe("team standup"); - expect(r.recurrence).toBe("FREQ=DAILY"); - expect(r.dueDate!.getHours()).toBe(9); - expect(r.project).toBe("engineering"); - }); - - it("parses fix login bug in 3 days !high #backend", () => { - const r = parse("fix login bug in 3 days !high #backend", { - projectNames: ["backend"], - }); - expect(r.title).toBe("fix login bug"); - expect(r.priority).toBe("high"); - expect(r.project).toBe("backend"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true); - }); - - it("keeps the raw input", () => { - const input = "buy milk tomorrow !high"; - expect(parse(input).raw).toBe(input); - }); - - it("returns empty title for only-metadata input", () => { - const r = parse("!high tomorrow", { projectNames: [] }); - expect(r.title).toBe(""); - expect(r.priority).toBe("high"); - }); - }); -}); diff --git a/apps/web/src/lib/nlp-parser.ts b/apps/web/src/lib/nlp-parser.ts deleted file mode 100644 index c77d0e3..0000000 --- a/apps/web/src/lib/nlp-parser.ts +++ /dev/null @@ -1,458 +0,0 @@ -/** - * Natural-language quick-add parser. - * - * A pure, dependency-light tokenizer that turns free-text task input into - * structured task data: - * - * "buy milk tomorrow !high #work @sarah every monday" - * → title: "buy milk", dueDate: tomorrow, priority: "high", - * project: "work" (resolved against context), tags: ["sarah"], - * recurrence: "FREQ=WEEKLY;BYDAY=MO" - * - * No network calls, no external NLP library — just regex tokenization over a - * normalized token stream, evaluated in the browser's local timezone. - */ - -export type QuickAddPriority = "low" | "medium" | "high" | "urgent"; - -export interface ParsedTask { - /** The remaining free text with all recognized tokens removed. */ - title: string; - /** Resolved absolute due date (local timezone), if one was given. */ - dueDate?: Date; - priority?: QuickAddPriority; - /** - * The matched project NAME (from `#project`), when it matches a known name - * in `context.projectNames`. The caller maps this name to an id before - * sending the create request. - */ - project?: string; - /** Matched tag NAMES (from `@label`), when they match `context.tagNames`. */ - tags?: string[]; - /** An RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO"). */ - recurrence?: string; - /** The original, unmodified input string. */ - raw: string; -} - -export interface QuickAddContext { - projectNames?: string[]; - tagNames?: string[]; -} - -// ── Helpers ──────────────────────────────────────────────────────────────────── - -/** Strip leading zeros so "02" reads as "2" (used for ordinal date math). */ -function num(n: string): number { - return parseInt(n.replace(/^0+/, "") || "0", 10); -} - -/** Start-of-day in the local timezone. All date tokens are anchored to this. */ -function startOfDay(d: Date): Date { - const copy = new Date(d); - copy.setHours(0, 0, 0, 0); - return copy; -} - -function addDays(d: Date, days: number): Date { - const copy = new Date(d); - copy.setDate(copy.getDate() + days); - return copy; -} - -function addMonths(d: Date, months: number): Date { - const copy = new Date(d); - copy.setMonth(copy.getMonth() + months); - return copy; -} - -/** Next occurrence of `weekday` (0=Sun..6=Sat). When includeToday, today counts. */ -function nextWeekday(from: Date, weekday: number, includeToday: boolean): Date { - let d = startOfDay(from); - if (!includeToday) d = addDays(d, 1); - while (d.getDay() !== weekday) d = addDays(d, 1); - return d; -} - -// ── Tokenization ────────────────────────────────────────────────────────────── - -type TokenKind = - | "word" - | "priority" - | "project" - | "tag" - | "date" - | "time" - | "recurrence"; - -interface Token { - kind: TokenKind; - value: string; -} - -/** - * Break the input into a token stream, tagging each token with its kind. - * Plain words are kept verbatim (the title is rebuilt from them in order). - */ -function tokenize(input: string): Token[] { - const tokens: Token[] = []; - const re = - /(\*\*)|(!urgent|!high|!medium|!low)|(#[^\s]+)|(@[^\s]+)|(\d{4}-\d{2}-\d{2})|(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)|([0-2]?\d:\d{2}\s?(?:am|pm)?)|([^\s]+)/gi; - - for (const m of input.matchAll(re)) { - const full = m[0]; - if (!full) continue; - - if (/^!!$/.test(full)) { - tokens.push({ kind: "priority", value: "urgent" }); - } else if (/^!(urgent|high|medium|low)$/i.test(full)) { - tokens.push({ kind: "priority", value: full.slice(1).toLowerCase() }); - } else if (/^#[^\s]+$/.test(full)) { - tokens.push({ kind: "project", value: full.slice(1) }); - } else if (/^@[^\s]+$/.test(full)) { - tokens.push({ kind: "tag", value: full.slice(1) }); - } else if (/^\d{4}-\d{2}-\d{2}$/.test(full) || /^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(full)) { - tokens.push({ kind: "date", value: full }); - } else if (/^[0-2]?\d:\d{2}\s?(?:am|pm)?$/i.test(full)) { - tokens.push({ kind: "time", value: full }); - } else { - tokens.push({ kind: "word", value: full }); - } - } - - return tokens; -} - -// ── Recurrence parsing ──────────────────────────────────────────────────────── - -const WEEKDAY_MAP: Record = { - sun: 0, sunday: 0, - mon: 1, monday: 1, - tue: 2, tues: 2, tuesday: 2, - wed: 3, wednesday: 3, - thu: 4, thur: 4, thurs: 4, thursday: 4, - fri: 5, friday: 5, - sat: 6, saturday: 6, -}; - -const MONTH_MAP: Record = { - jan: 0, january: 0, - feb: 1, february: 1, - mar: 2, march: 2, - apr: 3, april: 3, - may: 4, - jun: 5, june: 5, - jul: 6, july: 6, - aug: 7, august: 7, - sep: 8, sept: 8, september: 8, - oct: 9, october: 9, - nov: 10, november: 10, - dec: 11, december: 11, -}; - -/** - * Parse a recurrence phrase into an RRULE (RFC 5545), or null if the window - * does not start with a recurrence. Returns the rule and how many words it - * consumed. - */ -function parseRecurrence(words: string[]): { rrule: string; consumed: number } | null { - const low = words.map((w) => w.toLowerCase()); - - if (low[0] === "daily") return { rrule: "FREQ=DAILY", consumed: 1 }; - if (low[0] === "weekly") return { rrule: "FREQ=WEEKLY", consumed: 1 }; - if (low[0] === "monthly") return { rrule: "FREQ=MONTHLY", consumed: 1 }; - if (low[0] === "yearly") return { rrule: "FREQ=YEARLY", consumed: 1 }; - - if (low[0] === "every") { - let i = 1; - let interval = 1; - if (/^\d+$/.test(low[i] ?? "")) { - interval = num(low[i]); - i += 1; - } - - // Omit INTERVAL when it's the default 1 to keep rules concise. - const intervalPart = interval !== 1 ? `;INTERVAL=${interval}` : ""; - const unit = low[i]; - if (unit === "day" || unit === "days") return { rrule: `FREQ=DAILY${intervalPart}`, consumed: i + 1 }; - if (unit === "week" || unit === "weeks") return { rrule: `FREQ=WEEKLY${intervalPart}`, consumed: i + 1 }; - if (unit === "month" || unit === "months") { - // "every month on the 15th" - if ( - (low[i + 1] === "on" && low[i + 2] === "the" && /^(\d+)(st|nd|rd|th)?$/.test(low[i + 3] ?? "")) - ) { - const day = num(low[i + 3]); - if (day >= 1 && day <= 31) { - return { rrule: `FREQ=MONTHLY${intervalPart};BYMONTHDAY=${day}`, consumed: i + 4 }; - } - } - return { rrule: `FREQ=MONTHLY${intervalPart}`, consumed: i + 1 }; - } - if (unit === "year" || unit === "years") return { rrule: `FREQ=YEARLY${intervalPart}`, consumed: i + 1 }; - - if (WEEKDAY_MAP[unit] !== undefined) { - const byday = unit.slice(0, 2).toUpperCase(); - return { rrule: `FREQ=WEEKLY${intervalPart};BYDAY=${byday}`, consumed: i + 1 }; - } - - return null; - } - - return null; -} - -// ── Date & time parsing ─────────────────────────────────────────────────────── - -/** Parse a single-word date token (ISO date or slash date). */ -function parseSingleWordDate(word: string, now: Date): Date | null { - const low = word.toLowerCase(); - - const iso = low.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); - if (iso) { - const y = num(iso[1]); - const m = num(iso[2]) - 1; - const d = num(iso[3]); - const date = new Date(y, m, d, 0, 0, 0, 0); - if (!isNaN(date.getTime()) && date.getFullYear() === y && date.getMonth() === m && date.getDate() === d) { - return date; - } - return null; - } - - const slash = low.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/); - if (slash) { - const a = num(slash[1]); - const b = num(slash[2]); - if (slash[3]) { - let y = num(slash[3]); - if (y < 100) y += 2000; - const date = new Date(y, a - 1, b, 0, 0, 0, 0); - return isNaN(date.getTime()) ? null : date; - } - // MM/DD — next future occurrence - let date = new Date(now.getFullYear(), a - 1, b, 0, 0, 0, 0); - if (isNaN(date.getTime())) return null; - if (date < startOfDay(now)) date = new Date(now.getFullYear() + 1, a - 1, b, 0, 0, 0, 0); - return date; - } - - return null; -} - -/** Parse a "dec 25" phrase (month name + day). Consumes 2 words. */ -function parseMonthDay(words: string[], now: Date): { date: Date; consumed: number } | null { - const low = words.map((w) => w.toLowerCase()); - if (MONTH_MAP[low[0]] !== undefined && /^(\d{1,2})(st|nd|rd|th)?$/.test(low[1] ?? "")) { - const day = num(low[1]); - if (day < 1 || day > 31) return null; - const month = MONTH_MAP[low[0]]; - let date = new Date(now.getFullYear(), month, day, 0, 0, 0, 0); - if (isNaN(date.getTime())) return null; - if (date < startOfDay(now)) date = new Date(now.getFullYear() + 1, month, day, 0, 0, 0, 0); - return { date, consumed: 2 }; - } - return null; -} - -/** - * Parse a multi-word date phrase ("tomorrow", "next friday", "in 3 days", - * "end of month"). Returns the date plus how many words were consumed. - */ -function parseDatePhrase( - words: string[], - now: Date -): { date: Date; consumed: number } | null { - const low = words.map((w) => w.toLowerCase()); - - if (low[0] === "tomorrow") return { date: addDays(startOfDay(now), 1), consumed: 1 }; - if (low[0] === "today" || low[0] === "tonight") return { date: startOfDay(now), consumed: 1 }; - - if (low[0] === "in" && /^\d+$/.test(low[1] ?? "")) { - const n = num(low[1]); - if (low[2] === "days" || low[2] === "day") return { date: addDays(startOfDay(now), n), consumed: 3 }; - if (low[2] === "weeks" || low[2] === "week") return { date: addDays(startOfDay(now), n * 7), consumed: 3 }; - if (low[2] === "months" || low[2] === "month") return { date: addMonths(startOfDay(now), n), consumed: 3 }; - if (low[2] === "hours" || low[2] === "hour") return { date: new Date(now.getTime() + n * 3600 * 1000), consumed: 3 }; - } - - if ((low[0] === "next" || low[0] === "this") && WEEKDAY_MAP[low[1] ?? ""] !== undefined) { - return { date: nextWeekday(now, WEEKDAY_MAP[low[1]], low[0] === "this"), consumed: 2 }; - } - - if (low[0] === "next" && low[1] === "week") return { date: addDays(startOfDay(now), 7), consumed: 2 }; - if (low[0] === "next" && low[1] === "month") return { date: addMonths(startOfDay(now), 1), consumed: 2 }; - - if (low[0] === "end" && low[1] === "of") { - if (low[2] === "month") { - const sod = startOfDay(now); - return { date: new Date(sod.getFullYear(), sod.getMonth() + 1, 0), consumed: 3 }; - } - if (low[2] === "week") return { date: nextWeekday(now, 6, false), consumed: 3 }; - if (low[2] === "day") return { date: startOfDay(now), consumed: 3 }; - } - - if (WEEKDAY_MAP[low[0]] !== undefined) { - return { date: nextWeekday(now, WEEKDAY_MAP[low[0]], false), consumed: 1 }; - } - - return null; -} - -/** Parse a time token ("2pm", "14:30", "9am") into hours/minutes. */ -function parseTime(word: string): { hours: number; minutes: number } | null { - const colon = word.toLowerCase().match(/^([0-2]?\d):(\d{2})\s?(am|pm)?$/); - if (colon) { - let h = num(colon[1]); - const min = num(colon[2]); - const ampm = colon[3]; - if (ampm === "pm" && h < 12) h += 12; - if (ampm === "am" && h === 12) h = 0; - if (h > 23 || min > 59) return null; - return { hours: h, minutes: min }; - } - const bare = word.toLowerCase().match(/^(\d{1,2})(am|pm)$/); - if (bare) { - let h = num(bare[1]); - if (bare[2] === "pm" && h < 12) h += 12; - if (bare[2] === "am" && h === 12) h = 0; - if (h > 23) return null; - return { hours: h, minutes: 0 }; - } - return null; -} - -// ── Main parser ─────────────────────────────────────────────────────────────── - -/** - * Parse a quick-add string into structured task data. - * - * `context` supplies the user's known project/tag names so `#proj` and `@tag` - * tokens can be matched (the matched NAME is returned; the caller resolves it - * to an id). Unknown tokens are left in the title so nothing is silently lost. - */ -export function parseQuickAdd(input: string, context?: QuickAddContext): ParsedTask { - const tokens = tokenize(input); - // Date.now() (rather than `new Date()`) so tests can pin "now" by patching - // Date.now; the production path is unaffected. - const now = new Date(Date.now()); - - let priority: ParsedTask["priority"]; - let project: string | undefined; - const tags: string[] = []; - let dueDate: Date | undefined; - let recurrence: string | undefined; - // Track which project/tag tokens were resolved so unmatched ones stay in the - // title instead of being silently dropped. - const resolvedProjects = new Set(); - const resolvedTags = new Set(); - - const words = tokens.map((t) => t.value); - - // Pass 1: recurrences (multi-word, e.g. "every monday"). Run before dates so - // a bare weekday inside "every monday" is not mistaken for a one-off date. - let i = 0; - while (i < tokens.length) { - if (tokens[i].kind === "word") { - const rec = parseRecurrence(words.slice(i, i + 6)); - if (rec) { - recurrence = rec.rrule; - for (let k = i; k < i + rec.consumed; k++) tokens[k] = { kind: "recurrence", value: tokens[k].value }; - i += rec.consumed; - continue; - } - } - i += 1; - } - - // Pass 2: everything else. - i = 0; - while (i < tokens.length) { - const t = tokens[i]; - - if (t.kind === "priority") { - priority = t.value as ParsedTask["priority"]; - } else if (t.kind === "project") { - const match = context?.projectNames?.find((p) => p.toLowerCase() === t.value.toLowerCase()); - if (match) { - project = match; - resolvedProjects.add(t.value); - } - } else if (t.kind === "tag") { - const match = context?.tagNames?.find((tg) => tg.toLowerCase() === t.value.toLowerCase()); - if (match) { - tags.push(match); - resolvedTags.add(t.value); - } - } else if (t.kind === "date") { - const parsed = parseSingleWordDate(t.value, now); - if (parsed) dueDate = parsed; - } else if (t.kind === "time") { - const parsed = parseTime(t.value); - if (parsed) { - const base = dueDate ? new Date(dueDate) : startOfDay(now); - base.setHours(parsed.hours, parsed.minutes, 0, 0); - dueDate = base; - } - } else if (t.kind === "word") { - const window = words.slice(i, i + 4); - - // "at 2pm" — apply the time to the resolved (or today's) due date. - if (window[0] === "at" && parseTime(window[1] ?? "")) { - const time = parseTime(window[1])!; - const base = dueDate ? new Date(dueDate) : startOfDay(now); - base.setHours(time.hours, time.minutes, 0, 0); - dueDate = base; - tokens[i] = { kind: "time", value: window[0] }; - tokens[i + 1] = { kind: "time", value: window[1] }; - i += 2; - continue; - } - - const monthDay = parseMonthDay(window, now); - if (monthDay) { - dueDate = monthDay.date; - for (let k = i; k < i + monthDay.consumed; k++) tokens[k] = { kind: "date", value: tokens[k].value }; - i += monthDay.consumed; - continue; - } - - const phrase = parseDatePhrase(window, now); - if (phrase) { - dueDate = phrase.date; - for (let k = i; k < i + phrase.consumed; k++) tokens[k] = { kind: "date", value: tokens[k].value }; - i += phrase.consumed; - continue; - } - } - - i += 1; - } - - // Rebuild the title from the remaining tokens, in order: plain words, plus - // any project/tag tokens that did not resolve to a known name (their original - // # / @ prefix is restored so nothing is silently dropped). - const title = tokens - .filter((t) => { - if (t.kind === "word") return true; - if (t.kind === "project") return !resolvedProjects.has(t.value); - if (t.kind === "tag") return !resolvedTags.has(t.value); - return false; - }) - .map((t) => { - if (t.kind === "project" && !resolvedProjects.has(t.value)) return `#${t.value}`; - if (t.kind === "tag" && !resolvedTags.has(t.value)) return `@${t.value}`; - return t.value; - }) - .join(" ") - .trim(); - - return { - title, - dueDate, - priority, - project, - tags: tags.length > 0 ? tags : undefined, - recurrence, - raw: input, - }; -} diff --git a/apps/web/src/lib/nlp.ts b/apps/web/src/lib/nlp.ts new file mode 100644 index 0000000..72eb250 --- /dev/null +++ b/apps/web/src/lib/nlp.ts @@ -0,0 +1,133 @@ +export interface ParsedTaskInput { + title: string; + dueDate: string | null; + priority: "low" | "medium" | "high" | "urgent" | null; + tags: string[]; +} + +const PRIORITY_MAP: Record = { + p1: "urgent", + p2: "high", + p3: "medium", + p4: "low", + urgent: "urgent", + high: "high", + medium: "medium", + low: "low", +}; + +function parseDueDate(input: string): { date: Date | null; matched: string | null } { + const lower = input.toLowerCase(); + const now = new Date(); + const base = new Date(now); + base.setHours(0, 0, 0, 0); + + let matched: string | null = null; + let date: Date | null = null; + + const tryTime = (s: string): { hours: number; minutes: number; rest: string } | null => { + const m = s.match(/(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i); + if (!m) return null; + let h = parseInt(m[1], 10); + const min = parseInt(m[2] || "0", 10); + const ap = m[3]?.toLowerCase(); + if (ap === "pm" && h < 12) h += 12; + if (ap === "am" && h === 12) h = 0; + return { hours: h, minutes: min, rest: m[0] }; + }; + + const applyTime = (d: Date, raw: string) => { + const t = tryTime(raw); + if (t) { + d.setHours(t.hours, t.minutes, 0, 0); + if (!matched) matched = raw.trim(); + else matched += " " + t.rest; + } else { + d.setHours(9, 0, 0, 0); + } + }; + + if (lower.includes("today")) { + date = new Date(base); + matched = "today"; + const idx = lower.indexOf("today"); + applyTime(date, lower.slice(idx + 5, idx + 20)); + } else if (lower.includes("tomorrow")) { + date = new Date(base); + date.setDate(date.getDate() + 1); + matched = "tomorrow"; + const idx = lower.indexOf("tomorrow"); + applyTime(date, lower.slice(idx + 8, idx + 25)); + } else if (lower.includes("yesterday")) { + date = new Date(base); + date.setDate(date.getDate() - 1); + matched = "yesterday"; + } else if (/in\s+(\d+)\s+days?/i.test(lower)) { + const m = lower.match(/in\s+(\d+)\s+days?/i)!; + date = new Date(base); + date.setDate(date.getDate() + parseInt(m[1], 10)); + matched = m[0]; + } else if (/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(lower)) { + const m = lower.match(/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i)!; + const days: Record = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 }; + const target = days[m[1].toLowerCase()]; + date = new Date(base); + const cur = date.getDay(); + let diff = (target - cur + 7) % 7; + if (diff === 0) diff = 7; + date.setDate(date.getDate() + diff); + matched = m[0]; + } else if (/(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(lower)) { + const m = lower.match(/(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i)!; + const days: Record = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 }; + const target = days[m[1].toLowerCase()]; + date = new Date(base); + const cur = date.getDay(); + let diff = (target - cur + 7) % 7; + if (diff === 0) diff = 7; + date.setDate(date.getDate() + diff); + matched = m[0]; + } + + if (date && !matched?.includes("at") && !matched?.includes(":")) { + const t = tryTime(lower); + if (t && t.hours !== 9) { + date.setHours(t.hours, t.minutes, 0, 0); + matched = (matched || "") + " " + t.rest; + } + } + + return { date, matched: matched?.trim() || null }; +} + +export function parseTaskInput(raw: string): ParsedTaskInput { + let title = raw; + + const tags: string[] = []; + const tagRegex = /#(\w[\w-]*)/g; + let m: RegExpExecArray | null; + while ((m = tagRegex.exec(raw)) !== null) tags.push(m[1].toLowerCase()); + title = title.replace(tagRegex, "").trim(); + + let priority: ParsedTaskInput["priority"] = null; + const prioRegex = /\b(p[1-4]|urgent|high|medium|low)\b/i; + const prioMatch = raw.match(prioRegex); + if (prioMatch) { + const key = prioMatch[1].toLowerCase(); + priority = PRIORITY_MAP[key] || null; + title = title.replace(prioMatch[0], "").trim(); + } + + const due = parseDueDate(raw); + if (due.date) { + const esc = due.matched!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + title = title.replace(new RegExp(esc, "i"), "").trim(); + title = title.replace(/\b(at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/i, "").trim(); + title = title.replace(/\bat\b$/i, "").trim(); + } + + title = title.replace(/\s{2,}/g, " ").trim(); + if (!title) title = raw.replace(tagRegex, "").replace(prioRegex, "").trim() || raw; + + return { title: title || raw, dueDate: due.date ? due.date.toISOString() : null, priority, tags }; +} diff --git a/apps/web/src/lib/status-colors.ts b/apps/web/src/lib/status-colors.ts index 84c4619..f212d00 100644 --- a/apps/web/src/lib/status-colors.ts +++ b/apps/web/src/lib/status-colors.ts @@ -21,40 +21,6 @@ export const TASK_STATUS: Record = { cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" }, }; -/** - * Category → hex color fallback for custom workflow statuses. Custom statuses - * carry their own `color`; when unset the category color applies. Hex (not - * Tailwind classes) so it can drive inline `style={{ backgroundColor }}`. - */ -export const TASK_STATUS_CATEGORY_COLOR: Record = { - todo: "#94a3b8", - in_progress: "#3b82f6", - done: "#22c55e", - cancelled: "#ef4444", -}; - -/** Label for a status definition (falls back to the raw category name). */ -export function getStatusLabel(status: { label?: string; category?: string } | null | undefined): string { - if (status?.label) return status.label; - if (status?.category) return status.category.replace("_", " "); - return "No status"; -} - -/** Hex color for a status definition: its own color or the category fallback. */ -export function getStatusColor(status: { color?: string | null; category?: string } | null | undefined): string { - if (status?.color) return status.color; - return TASK_STATUS_CATEGORY_COLOR[status?.category ?? "todo"] ?? "#94a3b8"; -} - -/** - * Tailwind token for a status definition (used where inline styles are awkward). - * Custom colors can't map to Tailwind classes, so this only covers the category - * fallback; callers needing exact colors should use `getStatusColor`. - */ -export function getStatusToken(status: { category?: string } | null | undefined): StatusToken { - return TASK_STATUS[status?.category ?? "todo"] ?? TASK_STATUS.todo; -} - /** Task priority. Badges use a soft tint (matching text + translucent bg). */ export const PRIORITY: Record = { low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" }, diff --git a/apps/web/src/lib/types/index.ts b/apps/web/src/lib/types/index.ts index 0bb371f..545fa2f 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -1,32 +1,10 @@ // Shared types for Project E entities -export type TaskStatusCategory = "todo" | "in_progress" | "done" | "cancelled"; - -/** A per-project workflow status definition. */ -export interface StatusDefinition { - id: string; - projectId: string; - /** Stable machine key unique per project, e.g. "in_review". */ - key: string; - /** Display label, e.g. "In Review". */ - label: string; - /** UI semantics: drives progress, board columns and completion checks. */ - category: TaskStatusCategory; - /** Hex color, or null to fall back to the category color. */ - color: string | null; - sortOrder: number; - isDefault: boolean; - createdAt: string; - updatedAt: string; -} - export interface Task { id: string; title: string; description: string | null; - statusId: string | null; - /** The resolved status definition (null when unassigned or status deleted). */ - status: StatusDefinition | null; + status: "todo" | "in_progress" | "done" | "cancelled"; priority: "low" | "medium" | "high" | "urgent"; domainId: string; projectId: string | null; @@ -43,16 +21,8 @@ export interface Task { tags: Tag[]; customFields?: Record; subtasks?: Task[]; - dependencies?: TaskSummary[]; - dependents?: TaskSummary[]; -} - -/** Slim task row used in dependency lists. */ -export interface TaskSummary { - id: string; - title: string; - statusId: string | null; - status: StatusDefinition | null; + dependencies?: { id: string; title: string; status: string }[]; + dependents?: { id: string; title: string; status: string }[]; } export interface Habit { @@ -103,8 +73,6 @@ export interface Project { taskCount: number; completedCount: number; progress: number; - /** This project's workflow status definitions (defaults seeded on create). */ - statuses?: StatusDefinition[]; sections?: Section[]; tasks?: Task[]; } @@ -121,40 +89,6 @@ export interface Section { updatedAt: string; } -// ── Automation Rules ──────────────────────────────────────────────────────────── - -export type AutomationTriggerType = "task_status_changed" | "task_created" | "due_date_approaching"; -export type AutomationActionType = "set_status" | "set_priority" | "add_label" | "create_notification"; -export type AutomationConditionField = "project" | "status" | "priority" | "label"; - -export interface AutomationTrigger { - type: AutomationTriggerType; - params?: Record; -} - -export interface AutomationCondition { - field: AutomationConditionField; - op: string; - value: unknown; -} - -export interface AutomationAction { - type: AutomationActionType; - params: Record; -} - -export interface AutomationRule { - id: string; - projectId: string; - name: string; - active: boolean; - trigger: AutomationTrigger; - conditions: AutomationCondition[]; - actions: AutomationAction[]; - createdAt: string; - updatedAt: string; -} - export interface Note { id: string; title: string; @@ -336,34 +270,19 @@ export interface Agent { updatedAt: string; } -export type NotificationType = "mention" | "status_change" | "due_soon" | "automation" | "assignment" | (string & {}); - export interface Notification { id: string; - userId: string; - workspaceId: string | null; - type: NotificationType; - title: string; - body: string | null; - entityType: string | null; - entityId: string | null; - /** NULL = unread. */ - readAt: string | null; + actor: string; + action: string; + entityType: string; + entityId: string; + changes: Record | null; + workspaceId: string; createdAt: string; - deletedAt: string | null; } export interface NotificationsResponse { items: Notification[]; - totalItems: number; - unreadCount: number; - page: number; - perPage: number; - limit: number; - offset: number; -} - -export interface NotificationCount { count: number; } @@ -380,44 +299,7 @@ export interface AgentActivity { createdAt: string; } -// Canvas -export interface Canvas { - id: string; - name: string; - description: string | null; - mode: "freeform" | "graph"; - domainId: string; - tags: string[]; - viewport: { x: number; y: number; zoom: number }; - background: string | null; - customFields: Record; - createdAt: string; - updatedAt: string; - cards?: CanvasCard[]; - connections?: CanvasConnection[]; -} -export interface CanvasCard { - id: string; - canvasId: string; - type: string; - content: string; - position: { x: number; y: number }; - size: { w: number; h: number }; - zIndex: number; - color: string | null; - createdAt: string; - updatedAt: string; -} - -export interface CanvasConnection { - id: string; - canvasId: string; - sourceCardId: string; - targetCardId: string; - label: string | null; - color: string | null; -} // Daily Notes export interface DailyNote { @@ -444,6 +326,8 @@ export interface HabitAnalytics { habitConsistency: number; totalHabits: number; totalLogs: number; + totalExpected?: number; + perHabit?: Array<{ id: string; name: string; completed: number; expected: number; consistency: number }>; activeStreaks: number; bestStreak: number; period: number; diff --git a/apps/web/src/routeTree.ts b/apps/web/src/routeTree.ts index 8b59500..16e0ab9 100644 --- a/apps/web/src/routeTree.ts +++ b/apps/web/src/routeTree.ts @@ -13,13 +13,11 @@ import { Route as searchRoute } from "./routes/_app/search"; import { Route as analyticsRoute } from "./routes/_app/analytics"; import { Route as agentsRoute } from "./routes/_app/agents"; import { Route as agentActivityRoute } from "./routes/_app/agents/activity"; -import { Route as canvasRoute } from "./routes/_app/canvas"; import { Route as dailyRoute } from "./routes/_app/daily"; import { Route as settingsRoute } from "./routes/_app/settings"; import { Route as taskDetailRoute } from "./routes/_app/tasks/$id"; import { Route as habitDetailRoute } from "./routes/_app/habits/$id"; import { Route as noteDetailRoute } from "./routes/_app/notes/$id"; -import { Route as canvasDetailRoute } from "./routes/_app/canvas/$id"; import { Route as projectDetailRoute } from "./routes/_app/projects/$id"; const appChildren = [ @@ -34,13 +32,11 @@ const appChildren = [ analyticsRoute, agentsRoute, agentActivityRoute, - canvasRoute, dailyRoute, settingsRoute, taskDetailRoute, habitDetailRoute, noteDetailRoute, - canvasDetailRoute, projectDetailRoute, ]; diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 695d294..b211892 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -5,7 +5,6 @@ import { Sidebar } from "@/components/shell/sidebar"; import { Topbar } from "@/components/shell/topbar"; import { CommandPalette } from "@/components/shell/command-palette"; import { ShortcutsHelp } from "@/components/shell/shortcuts-help"; -import { QuickAddBar } from "@/components/quick-add-bar"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; function AppLayout() { @@ -42,7 +41,6 @@ function AppLayout() {
-
); } diff --git a/apps/web/src/routes/_app/analytics.tsx b/apps/web/src/routes/_app/analytics.tsx index 2782e6d..bb99bf6 100644 --- a/apps/web/src/routes/_app/analytics.tsx +++ b/apps/web/src/routes/_app/analytics.tsx @@ -24,6 +24,8 @@ function AnalyticsPage() { const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix); const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix); const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix); + const { data: velocityData } = useApiQuery<{ items: Array<{date:string;completed:number}>; avg:number }>(["analytics-velocity", activeDomainId, range], "/analytics/velocity?range=" + range + domainSuffix); + const { data: cycleData } = useApiQuery<{ median:number; avg:number; count:number }>(["analytics-cycle", activeDomainId, range], "/analytics/cycle?range=" + range + domainSuffix); const analyticsLoading = habitsLoading || projectsLoading || dailyLoading; const analyticsError = habitsError || projectsError || dailyError; @@ -36,9 +38,10 @@ function AnalyticsPage() { const dailyItems = dailyData?.items || []; const habitRateData = useMemo(() => { + const expected = (habitData as any)?.totalExpected ?? (habitData?.totalHabits || 0) * parseInt(range); return [ { name: "Completed", value: habitData?.totalLogs || 0 }, - { name: "Missed", value: Math.max(0, (habitData?.totalHabits || 1) * parseInt(range) - (habitData?.totalLogs || 0)) }, + { name: "Missed", value: Math.max(0, expected - (habitData?.totalLogs || 0)) }, ]; }, [habitData, range]); @@ -161,6 +164,30 @@ function AnalyticsPage() { ({ date: d.date, count: d.completed }))} days={parseInt(range)} /> + + {/* Velocity */} + + + Velocity + avg {velocityData?.avg ?? 0}/day + + + {velocityData?.items ? :

No data

} +
+
+ + {/* Cycle time */} + + + Cycle Time + + +
+

{cycleData ? `${cycleData.median}d median · ${cycleData.avg}d avg` : "—"}

+

{cycleData?.count ?? 0} tasks completed in range

+
+
+
)} diff --git a/apps/web/src/routes/_app/calendar.tsx b/apps/web/src/routes/_app/calendar.tsx index f9b3f32..fb9a6e0 100644 --- a/apps/web/src/routes/_app/calendar.tsx +++ b/apps/web/src/routes/_app/calendar.tsx @@ -185,13 +185,28 @@ function CalendarPage() { const activeDomainId = useApiDomain(); + const [showTasks, setShowTasks] = useState(true); + const [showHabits, setShowHabits] = useState(true); + + const fromISO = useMemo(() => { const d = new Date(date); d.setDate(1); d.setHours(0,0,0,0); d.setMonth(d.getMonth()-1); return d.toISOString(); }, [date]); + const toISO = useMemo(() => { const d = new Date(date); d.setMonth(d.getMonth()+2); d.setDate(0); d.setHours(23,59,59,999); return d.toISOString(); }, [date]); const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>( - ["calendar-events", activeDomainId], - `/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "") + ["calendar-events", activeDomainId, fromISO, toISO], + `/calendar/events?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "") ); - - const events = eventsData?.items || []; + const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>( + ["calendar-unified", activeDomainId, fromISO, toISO], + `/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "") + ); + const events = useMemo(() => { + if (!unifiedData?.items) return eventsData?.items || []; + const base = unifiedData.items; + if (!showTasks && !showHabits) return base.filter(e => !e.entityType || (e.entityType!=="task" && e.entityType!=="habit")); + if (!showTasks) return base.filter(e => e.entityType!=="task"); + if (!showHabits) return base.filter(e => e.entityType!=="habit"); + return base; + }, [unifiedData, eventsData, showTasks, showHabits]); const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete("/calendar/events/" + id), @@ -223,11 +238,16 @@ function CalendarPage() { const handleEventDrop = useCallback( ({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => { - api.patch(`/calendar/events/${event.id}`, { - startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(), - endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(), - }).then(() => { + const isTask = event.entityType === "task" && String(event.id).startsWith("task-"); + const patchUrl = isTask ? `/tasks/${event.entityId}` : `/calendar/events/${event.id}`; + const patchData = isTask ? { dueDate: (start instanceof Date ? start : new Date(start as string)).toISOString() } : { + startTime: start instanceof Date ? start.toISOString() : new Date(start as string).toISOString(), + endTime: end instanceof Date ? end.toISOString() : new Date(end as string).toISOString(), + }; + api.patch(patchUrl, patchData).then(() => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); + queryClient.invalidateQueries({ queryKey: ["calendar-unified"] }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); }); }, [queryClient] @@ -235,11 +255,13 @@ function CalendarPage() { const handleEventResize = useCallback( ({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => { + if (String(event.id).startsWith("task-") || String(event.id).startsWith("habit-")) return; api.patch(`/calendar/events/${event.id}`, { - startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(), - endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(), + startTime: start instanceof Date ? start.toISOString() : new Date(start as string).toISOString(), + endTime: end instanceof Date ? end.toISOString() : new Date(end as string).toISOString(), }).then(() => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); + queryClient.invalidateQueries({ queryKey: ["calendar-unified"] }); }); }, [queryClient] @@ -268,7 +290,12 @@ function CalendarPage() { -
+
+
+ + +
+
{["month", "week", "day", "agenda"].map((name) => (
{eventsLoading && events.length === 0 ? ( diff --git a/apps/web/src/routes/_app/canvas.tsx b/apps/web/src/routes/_app/canvas.tsx deleted file mode 100644 index f457812..0000000 --- a/apps/web/src/routes/_app/canvas.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; -import { createRoute, useNavigate } from "@tanstack/react-router"; -import { Route as appRoute } from "../_app"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, useApiQuery, useApiMutation } from "@/lib/api"; -import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { cn } from "@/lib/utils"; -import { useApiDomain } from "@/lib/stores/use-active-domain-store"; -import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types"; - -const BLOCK_TYPES = [ - { id: "text", label: "Text", icon: Type }, - { id: "heading1", label: "Heading 1", icon: Heading1 }, - { id: "heading2", label: "Heading 2", icon: Heading2 }, - { id: "heading3", label: "Heading 3", icon: Heading2 }, - { id: "bullet_list", label: "Bullet List", icon: List }, - { id: "todo", label: "Todo", icon: CheckSquare }, - { id: "code", label: "Code", icon: Code }, - { id: "image", label: "Image", icon: Image }, -] as const; - -// ─── Block Editor ──────────────────────────────────────────────────────── - -function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: { - block: { id: string; type: string; content: string }; - onChange: (id: string, content: string) => void; - onDelete: (id: string) => void; - onMoveUp: () => void; - onMoveDown: () => void; -}) { - const [showSlash, setShowSlash] = useState(false); - const inputRef = useRef(null); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "/" && block.content === "") { - setShowSlash(true); - } - if (e.key === "Escape") { - setShowSlash(false); - } - }; - - const handleChange = (value: string) => { - onChange(block.id, value); - if (value.startsWith("/")) { - setShowSlash(true); - } else { - setShowSlash(false); - } - }; - - const insertBlockType = (type: string) => { - onChange(block.id, ""); - // We can't change the type directly in this simple model, so we signal via a custom event - const event = new CustomEvent("change-block-type", { detail: { id: block.id, type } }); - window.dispatchEvent(event); - setShowSlash(false); - }; - - const renderEditor = () => { - switch (block.type) { - case "heading1": - return ( - handleChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Heading 1..." - className="w-full text-2xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/50" - /> - ); - case "heading2": - return ( - handleChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Heading 2..." - className="w-full text-xl font-semibold bg-transparent border-none outline-none placeholder:text-muted-foreground/50" - /> - ); - case "heading3": - return ( - handleChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Heading 3..." - className="w-full text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted-foreground/50" - /> - ); - case "todo": - return ( -
- - handleChange(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Todo item..." - className="flex-1 bg-transparent border-none outline-none placeholder:text-muted-foreground/50" - /> -
- ); - case "code": - return ( -