From b814a4788d1d4198353049700b10e62bdbb23224 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 19 Aug 2026 10:54:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=205=20PM=20features=20=E2=80=94=20c?= =?UTF-8?q?ustom=20statuses,=20Gantt,=20quick-add,=20automations,=20notifi?= =?UTF-8?q?cations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom workflow statuses: per-project configurable status definitions replacing the fixed task_status enum. Each project defines its own workflow with drag-to-reorder, color coding, and category mapping. - Gantt/timeline view: full project roadmap with task bars, dependency arrows, milestone diamonds, zoom levels (day/week/month), and drag to reschedule. - Natural-language quick-add: NLP parser extracts dates, priorities, projects, labels, and recurrence from free text. Floating bar with 'n' shortcut and live parsed preview. - Automation rules: no-code trigger-action system per project. Triggers on status change, task creation, due date approaching. Actions set status/priority, add labels, create notifications. - Notification center: in-app bell icon with unread badge, slide-out panel, real-time SSE updates, mark read/all read. Replaces raw activity feed dropdown. Schema: adds status_definitions, automation_rules, notifications tables. Migrations: 0007, 0008, 0009. 41 NLP parser tests pass. --- apps/api/src/index.ts | 6 + apps/api/src/lib/automation-engine.ts | 291 ++++++++++ apps/api/src/lib/notify.ts | 65 +++ apps/api/src/routes/analytics.ts | 22 +- apps/api/src/routes/automations.ts | 309 ++++++++++ apps/api/src/routes/comments.ts | 18 + 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 | 255 ++++++++- apps/api/src/routes/timeline.ts | 118 ++++ .../components/automation-rule-builder.tsx | 528 ++++++++++++++++++ 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/topbar.tsx | 95 +--- apps/web/src/hooks/use-realtime.ts | 4 + apps/web/src/lib/nlp-parser.test.ts | 257 +++++++++ apps/web/src/lib/nlp-parser.ts | 458 +++++++++++++++ apps/web/src/lib/status-colors.ts | 34 ++ apps/web/src/lib/types/index.ts | 99 +++- apps/web/src/routes/_app.tsx | 2 + apps/web/src/routes/_app/index.tsx | 2 +- apps/web/src/routes/_app/projects/$id.tsx | 194 ++++++- apps/web/src/routes/_app/tasks.tsx | 102 +++- apps/web/src/routes/_app/tasks/$id.tsx | 139 +++-- apps/web/tsconfig.json | 1 + apps/worker/src/index.ts | 126 ++++- drizzle/0007_custom_task_statuses.sql | 121 ++++ drizzle/0008_automation_rules.sql | 14 + drizzle/0009_notifications.sql | 20 + drizzle/meta/_journal.json | 21 + node_modules | 1 + package.json | 1 + packages/db/node_modules | 1 + packages/db/src/schema.ts | 95 +++- packages/shared/src/constants/index.ts | 3 + script/deploy.sh | 2 + script/migrate-task-statuses.ts | 22 + tsconfig.tsbuildinfo | 2 +- 46 files changed, 4965 insertions(+), 288 deletions(-) create mode 100644 apps/api/src/lib/automation-engine.ts create mode 100644 apps/api/src/lib/notify.ts create mode 100644 apps/api/src/routes/automations.ts create mode 100644 apps/api/src/routes/statuses.ts create mode 100644 apps/api/src/routes/timeline.ts create mode 100644 apps/web/src/components/automation-rule-builder.tsx create mode 100644 apps/web/src/components/gantt/gantt-chart.tsx create mode 100644 apps/web/src/components/gantt/gantt-dependency-arrow.tsx create mode 100644 apps/web/src/components/gantt/gantt-milestone.tsx create mode 100644 apps/web/src/components/gantt/gantt-task-bar.tsx create mode 100644 apps/web/src/components/gantt/gantt-timeline-header.tsx create mode 100644 apps/web/src/components/gantt/gantt-utils.ts create mode 100644 apps/web/src/components/notification-center.tsx create mode 100644 apps/web/src/components/quick-add-bar.tsx create mode 100644 apps/web/src/lib/nlp-parser.test.ts create mode 100644 apps/web/src/lib/nlp-parser.ts create mode 100644 drizzle/0007_custom_task_statuses.sql create mode 100644 drizzle/0008_automation_rules.sql create mode 100644 drizzle/0009_notifications.sql create mode 120000 node_modules create mode 120000 packages/db/node_modules create mode 100644 script/migrate-task-statuses.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4576057..708491e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,8 @@ 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"; @@ -27,6 +29,7 @@ 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(); @@ -48,6 +51,8 @@ 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); @@ -66,6 +71,7 @@ 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 new file mode 100644 index 0000000..b8ee784 --- /dev/null +++ b/apps/api/src/lib/automation-engine.ts @@ -0,0 +1,291 @@ +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 new file mode 100644 index 0000000..523950b --- /dev/null +++ b/apps/api/src/lib/notify.ts @@ -0,0 +1,65 @@ +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/routes/analytics.ts b/apps/api/src/routes/analytics.ts index 9d4324e..ba26bca 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 } from "@project-e/db"; -import { and, eq, gte, inArray, isNull, or } from "drizzle-orm"; +import { db, tasks, habits, habitCompletions, projects, statusDefinitions } from "@project-e/db"; +import { and, eq, gte, getTableColumns, inArray, isNull, or } from "drizzle-orm"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; export const analyticsRoutes = new Hono(); @@ -22,15 +22,18 @@ analyticsRoutes.get("/productivity", async (c) => { const startDate = new Date(); startDate.setDate(startDate.getDate() - range); - const allTasks = await db.select() + const taskColumns = getTableColumns(tasks); + const allTasks = await db.select({ ...taskColumns, statusCategory: statusDefinitions.category }) .from(tasks) + .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and( eq(tasks.domainId, domainId), gte(tasks.createdAt, startDate), isNull(tasks.deletedAt), )); - const completedTasks = allTasks.filter(t => t.status === "done"); + // "Done" is a status category; a status marked category='done' completes a task. + const completedTasks = allTasks.filter(t => t.statusCategory === "done"); const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0; return c.json({ @@ -127,8 +130,9 @@ 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, status: tasks.status }) + ? await db.select({ projectId: tasks.projectId, statusCategory: statusDefinitions.category }) .from(tasks) + .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and( isNull(tasks.deletedAt), inArray(tasks.projectId, projectIds), @@ -140,7 +144,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.status === "done") entry.completedTasks += 1; + if (t.statusCategory === "done") entry.completedTasks += 1; counts.set(t.projectId, entry); } @@ -191,8 +195,10 @@ analyticsRoutes.get("/daily", async (c) => { firstDay.setDate(firstDay.getDate() - (range - 1)); firstDay.setHours(0, 0, 0, 0); - const domainTasks = await db.select() + const dailyTaskColumns = getTableColumns(tasks); + const domainTasks = await db.select({ ...dailyTaskColumns, statusCategory: statusDefinitions.category }) .from(tasks) + .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and( eq(tasks.domainId, domainId), isNull(tasks.deletedAt), @@ -216,7 +222,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.status === "done" && t.completedAt) { + if (t.statusCategory === "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 new file mode 100644 index 0000000..53179f9 --- /dev/null +++ b/apps/api/src/routes/automations.ts @@ -0,0 +1,309 @@ +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/comments.ts b/apps/api/src/routes/comments.ts index b9461f5..abc3f31 100644 --- a/apps/api/src/routes/comments.ts +++ b/apps/api/src/routes/comments.ts @@ -14,6 +14,7 @@ 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(); @@ -150,6 +151,23 @@ 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 039fe37..d5c3b23 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 } from "@project-e/db"; -import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; +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 { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,7 +81,8 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + 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" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, project_id: { type: "string" }, search: { type: "string" }, @@ -95,7 +96,14 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - if (params.status) conditions.push(eq(tasks.status, params.status as any)); + 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.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}%`)); @@ -119,7 +127,7 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + status_id: { type: "string", description: "Status definition ID (defaults to the project's default status)" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, @@ -127,10 +135,33 @@ 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, - status: (params.status as any) ?? "todo", + statusId, + completedAt, priority: (params.priority as any) ?? "medium", domainId: params.domain_id as string, projectId: (params.project_id as string) ?? null, @@ -142,7 +173,7 @@ const tools: ToolDefinition[] = [ action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, status: task.status }, + changes: { title: task.title, statusId: task.statusId }, workspaceId: params.domain_id as string, }); @@ -158,7 +189,7 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + status_id: { type: "string", description: "Status definition ID (must belong to the task's project)" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, }, @@ -172,7 +203,19 @@ 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 !== undefined) updateData.status = params.status; + 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.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(); @@ -236,8 +279,21 @@ 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({ status: "done", completedAt: new Date(), updatedAt: new Date() }) + .set({ statusId: doneStatus.id, completedAt: new Date(), updatedAt: new Date() }) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .returning(); @@ -566,7 +622,7 @@ const tools: ToolDefinition[] = [ const results: Record = {}; if (types.includes("tasks")) { - results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks) + results.tasks = await db.select({ id: tasks.id, title: tasks.title, statusId: tasks.statusId, 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 4597829..e4662e9 100644 --- a/apps/api/src/routes/notifications.ts +++ b/apps/api/src/routes/notifications.ts @@ -1,44 +1,170 @@ import { Hono } from "hono"; -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"; +import { db, notifications } from "@project-e/db"; +import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { requireAuth, createErrorResponse, AuthError, isUuid } from "../middleware/auth"; export const notificationRoutes = new Hono(); -// 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; +// 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. -// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace. +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. notificationRoutes.get("/", async (c) => { try { const user = await requireAuth(c); - let workspaceId = c.req.query("workspace_id"); - if (!workspaceId) { - const active = await resolveActiveDomain(user); - workspaceId = active.id; - } - await requireWorkspaceAccess(c, workspaceId); + const workspaceId = c.req.query("workspace_id"); + const unreadOnly = c.req.query("unread") === "true"; - 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 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 [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 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))), ]); - return c.json({ items, count: Number(countResult[0]?.count || 0) }); + const totalItems = Number(totalResult[0]?.value ?? 0); + return c.json({ + items, + totalItems, + unreadCount: Number(unreadResult[0]?.value ?? 0), + page, + perPage: effectiveLimit, + limit: effectiveLimit, + offset, + }); } 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({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500); + 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); } }); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 3ea3294..1f333b7 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 } from "@project-e/db"; +import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, statusDefinitions } 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,6 +10,16 @@ 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(), @@ -113,9 +123,10 @@ projectRoutes.get("/", async (c) => { const totalItems = Number(countResult[0]?.count || 0); - // Fetch task counts and tags for all projects + // Fetch task counts, status definitions 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); @@ -136,15 +147,32 @@ 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) - .where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt))); + .innerJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) + .where(and( + eq(tasks.projectId, projectId), + eq(statusDefinitions.category, "done"), + isNull(tasks.deletedAt), + )); taskCountMap.set(projectId, { total: Number(totalResult?.count || 0), @@ -158,6 +186,7 @@ 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, @@ -204,6 +233,11 @@ 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 })) @@ -221,7 +255,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, 201); + return c.json({ ...project, statuses: seededStatuses }, 201); } catch (error) { if (error instanceof AuthError) { return c.json({ error: { code: error.code, message: error.message } }, error.status as any); @@ -260,11 +294,22 @@ projectRoutes.get("/:id", async (c) => { .where(eq(sections.projectId, id)) .orderBy(asc(sections.sortOrder)); - // Fetch tasks - const projectTasks = await db.select() + // 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() .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({ @@ -277,13 +322,14 @@ projectRoutes.get("/:id", async (c) => { .where(eq(projectTags.projectId, id)); const totalTasks = projectTasks.length; - const completedTasks = projectTasks.filter(t => t.status === "done").length; + const completedTasks = projectTasks.filter((t) => t.status?.category === "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 new file mode 100644 index 0000000..f5a30df --- /dev/null +++ b/apps/api/src/routes/statuses.ts @@ -0,0 +1,389 @@ +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 87f0c1c..3ca3718 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -1,21 +1,60 @@ import { Hono } from "hono"; -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 { 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 { 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 taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]); +const STATUS_CATEGORIES = ["todo", "in_progress", "done", "cancelled"] as const; 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(), - status: taskStatusEnum.optional().default("todo"), + statusId: z.string().uuid().optional().nullable(), priority: taskPriorityEnum.optional().default("medium"), domain: z.string().min(1, "Domain is required"), projectId: z.string().uuid().optional().nullable(), @@ -32,7 +71,7 @@ const createTaskSchema = z.object({ const updateTaskSchema = z.object({ title: z.string().min(1).optional(), description: z.string().optional().nullable(), - status: taskStatusEnum.optional(), + statusId: z.string().uuid().optional().nullable(), priority: taskPriorityEnum.optional(), projectId: z.string().uuid().optional().nullable(), sectionId: z.string().uuid().optional().nullable(), @@ -111,8 +150,16 @@ taskRoutes.get("/", async (c) => { ]; if (status) { - const statuses = status.split(","); - conditions.push(inArray(tasks.status, statuses as any)); + // 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)); } if (priority) { const priorities = priority.split(","); @@ -164,7 +211,7 @@ taskRoutes.get("/", async (c) => { created: tasks.createdAt, updated: tasks.updatedAt, title: tasks.title, - status: tasks.status, + status: statusDefinitions.sortOrder, priority: tasks.priority, order: tasks.order, due_date: tasks.dueDate, @@ -175,15 +222,18 @@ 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() + db.select({ ...taskColumns, status: statusColumns }) .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)), ]); @@ -293,16 +343,37 @@ 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, - status: data.status, + statusId, 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 ?? {}, @@ -320,16 +391,40 @@ taskRoutes.post("/", async (c) => { action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, status: task.status, priority: task.priority }, + changes: { title: task.title, statusId: task.statusId, 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) { @@ -417,8 +512,10 @@ taskRoutes.get("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); } - const [task] = await db.select() + const taskColumns = getTableColumns(tasks); + const [task] = await db.select({ ...taskColumns, status: statusColumns }) .from(tasks) + .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) .limit(1); @@ -429,8 +526,9 @@ taskRoutes.get("/:id", async (c) => { await requireWorkspaceAccess(c, task.domainId); // Fetch subtasks - const subtasks = await db.select() + const subtasks = await db.select({ ...taskColumns, status: statusColumns }) .from(tasks) + .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt))) .orderBy(asc(tasks.order)); @@ -448,20 +546,24 @@ taskRoutes.get("/:id", async (c) => { const depRows = await db.select({ id: tasks.id, title: tasks.title, - status: tasks.status, + statusId: tasks.statusId, + status: statusColumns, }) .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, - status: tasks.status, + statusId: tasks.statusId, + status: statusColumns, }) .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({ @@ -525,7 +627,6 @@ 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; @@ -535,6 +636,31 @@ 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) @@ -547,16 +673,44 @@ taskRoutes.patch("/:id", async (c) => { action: "updated", entityType: "task", entityId: id, - changes: { ...data, previousStatus: existing.status }, + changes: { ...data, previousStatusId: existing.statusId }, workspaceId: existing.domainId, }); - await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } }); + 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); + } + } 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) { @@ -838,7 +992,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => { } }); -// POST /api/tasks/:id/status — Change task status (Kanban drag) +// POST /api/tasks/:id/status — Change task status (Kanban drag / completion) taskRoutes.post("/:id/status", async (c) => { try { const user = await requireAuth(c); @@ -847,8 +1001,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 { status: newStatus } = z.object({ - status: taskStatusEnum, + const { statusId } = z.object({ + statusId: z.string().uuid("Invalid status id"), }).parse(body); const [existing] = await db.select() @@ -862,13 +1016,33 @@ 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 = { - status: newStatus, + statusId, + completedAt: isDone ? new Date() : null, updatedAt: new Date(), }; - if (newStatus === "done") { - updateValues.completedAt = new Date(); - } const [updated] = await db.update(tasks) .set(updateValues) @@ -877,14 +1051,39 @@ taskRoutes.post("/:id/status", async (c) => { await recordActivity({ actor: user.name, - action: newStatus === "done" ? "completed" : "updated", + action: isDone ? "completed" : "updated", entityType: "task", entityId: id, - changes: { previousStatus: existing.status, newStatus }, + changes: { previousStatusId: existing.statusId, newStatusId: statusId, newStatusLabel: status.label }, workspaceId: existing.domainId, }); - return c.json(updated); + 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 }); } 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 new file mode 100644 index 0000000..ce11004 --- /dev/null +++ b/apps/api/src/routes/timeline.ts @@ -0,0 +1,118 @@ +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/src/components/automation-rule-builder.tsx b/apps/web/src/components/automation-rule-builder.tsx new file mode 100644 index 0000000..2cb469f --- /dev/null +++ b/apps/web/src/components/automation-rule-builder.tsx @@ -0,0 +1,528 @@ +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/gantt/gantt-chart.tsx b/apps/web/src/components/gantt/gantt-chart.tsx new file mode 100644 index 0000000..9c5ae75 --- /dev/null +++ b/apps/web/src/components/gantt/gantt-chart.tsx @@ -0,0 +1,267 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { toast } from "sonner"; +import { addDays, differenceInCalendarDays, endOfDay, format, startOfDay } from "date-fns"; +import { api } from "@/lib/api"; +import { getStatusColor } from "@/lib/status-colors"; +import type { StatusDefinition } from "@/lib/types"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { EmptyState } from "@/components/state"; +import { GanttTimelineHeader } from "./gantt-timeline-header"; +import { GanttTaskBar } from "./gantt-task-bar"; +import { GanttMilestone } from "./gantt-milestone"; +import { GanttDependencyArrow, type TaskPosition } from "./gantt-dependency-arrow"; +import { + getPixelsPerDay, + MILESTONE_BAND_HEIGHT, + positionForDate, + ROW_HEIGHT, + TASK_LIST_WIDTH, + TIMELINE_HEADER_HEIGHT, + toDayStart, + type TimelineMilestone, + type TimelineTask, + type ZoomLevel, +} from "./gantt-utils"; + +interface GanttChartProps { + domainId: string; + projectId: string; + tasks: TimelineTask[]; + milestones: TimelineMilestone[]; + /** Fallback lookup when the API's joined status is null. */ + statuses?: StatusDefinition[]; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : "Something went wrong"; +} + +const GRID_LINE_COLOR = "rgba(148,163,184,0.15)"; + +export function GanttChart({ projectId, tasks, milestones, statuses }: GanttChartProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [zoom, setZoom] = useState("week"); + const pixelsPerDay = getPixelsPerDay(zoom); + + const resolveStatus = (task: TimelineTask): StatusDefinition | null => + task.status ?? statuses?.find((s) => s.id === task.statusId) ?? null; + + const dueMutation = useMutation({ + mutationFn: ({ taskId, dueDate }: { taskId: string; dueDate: string }) => + api.patch(`/tasks/${taskId}`, { dueDate }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["timeline"] }); + queryClient.invalidateQueries({ queryKey: ["project", projectId] }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (err) => toast.error(errorMessage(err)), + }); + + const { viewStart, viewEnd, totalDays, totalWidth, todayX, taskPositions, rowsHeight } = useMemo(() => { + const today = startOfDay(new Date()); + const all: Date[] = [today]; + for (const t of tasks) { + all.push(toDayStart(t.startDate)); + if (t.dueDate) all.push(toDayStart(t.dueDate)); + } + for (const m of milestones) all.push(toDayStart(m.targetDate)); + + const minTime = Math.min(...all.map((d) => d.getTime())); + const maxTime = Math.max(...all.map((d) => d.getTime())); + const viewStart = startOfDay(addDays(new Date(minTime), -7)); + const viewEnd = startOfDay(addDays(new Date(maxTime), 7)); + const totalDays = Math.max(differenceInCalendarDays(viewEnd, viewStart) + 1, 7); + const totalWidth = totalDays * pixelsPerDay; + + const positions = new Map(); + tasks.forEach((task, i) => { + const start = toDayStart(task.startDate); + const end = task.dueDate ? toDayStart(task.dueDate) : start; + positions.set(task.id, { + startX: positionForDate(start, viewStart, pixelsPerDay), + endX: positionForDate(end, viewStart, pixelsPerDay), + y: i * ROW_HEIGHT + ROW_HEIGHT / 2, + }); + }); + + return { + viewStart, + viewEnd, + totalDays, + totalWidth, + todayX: positionForDate(today, viewStart, pixelsPerDay), + taskPositions: positions, + rowsHeight: tasks.length * ROW_HEIGHT + MILESTONE_BAND_HEIGHT, + }; + }, [tasks, milestones, pixelsPerDay]); + + const gridBackground = `repeating-linear-gradient(to right, ${GRID_LINE_COLOR} 0, ${GRID_LINE_COLOR} 1px, transparent 1px, transparent ${pixelsPerDay}px)`; + + const rows: ReactNode[] = tasks.map((task, i) => { + const pos = taskPositions.get(task.id); + if (!pos) return null; + return ( +
+ dueMutation.mutate({ taskId, dueDate })} + /> +
+ ); + }); + + const taskById = new Map(tasks.map((t) => [t.id, t])); + const arrows: ReactNode[] = []; + for (const task of tasks) { + for (const depId of task.dependencies) { + const dep = taskById.get(depId); + if (dep) { + arrows.push( + + ); + } + } + } + + return ( +
+
+ { + if (v) setZoom(v as ZoomLevel); + }} + > + Day + Week + Month + + + {format(viewStart, "MMM d")} – {format(addDays(viewStart, totalDays - 1), "MMM d, yyyy")} + +
+ + {tasks.length === 0 && milestones.length === 0 ? ( + + ) : ( +
+
+ {/* Fixed task list */} +
+
+ Tasks · {tasks.length} +
+ {tasks.map((task) => ( +
+ + +
+ ))} +
+ 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 new file mode 100644 index 0000000..a7a2bba --- /dev/null +++ b/apps/web/src/components/gantt/gantt-dependency-arrow.tsx @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..1ef81bf --- /dev/null +++ b/apps/web/src/components/gantt/gantt-milestone.tsx @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..9307d70 --- /dev/null +++ b/apps/web/src/components/gantt/gantt-task-bar.tsx @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..597fa88 --- /dev/null +++ b/apps/web/src/components/gantt/gantt-timeline-header.tsx @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..d21894a --- /dev/null +++ b/apps/web/src/components/gantt/gantt-utils.ts @@ -0,0 +1,82 @@ +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 new file mode 100644 index 0000000..0052e02 --- /dev/null +++ b/apps/web/src/components/notification-center.tsx @@ -0,0 +1,253 @@ +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 new file mode 100644 index 0000000..1608ef4 --- /dev/null +++ b/apps/web/src/components/quick-add-bar.tsx @@ -0,0 +1,234 @@ +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/topbar.tsx b/apps/web/src/components/shell/topbar.tsx index e2c9763..3bfc97e 100644 --- a/apps/web/src/components/shell/topbar.tsx +++ b/apps/web/src/components/shell/topbar.tsx @@ -1,12 +1,10 @@ -import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react"; +import { Search, Plus, Menu } 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, @@ -21,27 +19,10 @@ 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 || ""; @@ -51,20 +32,6 @@ 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 */} - - - - - - - - - {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 })} - - - )) - )} -
-
-
+ {/* Notifications bell (badge + slide-out panel) */} + {/* User avatar */} diff --git a/apps/web/src/hooks/use-realtime.ts b/apps/web/src/hooks/use-realtime.ts index a04020d..618d8bd 100644 --- a/apps/web/src/hooks/use-realtime.ts +++ b/apps/web/src/hooks/use-realtime.ts @@ -72,6 +72,10 @@ 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 new file mode 100644 index 0000000..8f59298 --- /dev/null +++ b/apps/web/src/lib/nlp-parser.test.ts @@ -0,0 +1,257 @@ +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 new file mode 100644 index 0000000..c77d0e3 --- /dev/null +++ b/apps/web/src/lib/nlp-parser.ts @@ -0,0 +1,458 @@ +/** + * 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/status-colors.ts b/apps/web/src/lib/status-colors.ts index f212d00..84c4619 100644 --- a/apps/web/src/lib/status-colors.ts +++ b/apps/web/src/lib/status-colors.ts @@ -21,6 +21,40 @@ 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 0a338f5..0bb371f 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -1,10 +1,32 @@ // 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; - status: "todo" | "in_progress" | "done" | "cancelled"; + statusId: string | null; + /** The resolved status definition (null when unassigned or status deleted). */ + status: StatusDefinition | null; priority: "low" | "medium" | "high" | "urgent"; domainId: string; projectId: string | null; @@ -21,8 +43,16 @@ export interface Task { tags: Tag[]; customFields?: Record; subtasks?: Task[]; - dependencies?: { id: string; title: string; status: string }[]; - dependents?: { id: string; title: string; status: string }[]; + dependencies?: TaskSummary[]; + dependents?: TaskSummary[]; +} + +/** Slim task row used in dependency lists. */ +export interface TaskSummary { + id: string; + title: string; + statusId: string | null; + status: StatusDefinition | null; } export interface Habit { @@ -73,6 +103,8 @@ 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[]; } @@ -89,6 +121,40 @@ 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; @@ -270,19 +336,34 @@ export interface Agent { updatedAt: string; } +export type NotificationType = "mention" | "status_change" | "due_soon" | "automation" | "assignment" | (string & {}); + export interface Notification { id: string; - actor: string; - action: string; - entityType: string; - entityId: string; - changes: Record | null; - workspaceId: string; + userId: string; + workspaceId: string | null; + type: NotificationType; + title: string; + body: string | null; + entityType: string | null; + entityId: string | null; + /** NULL = unread. */ + readAt: string | null; 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; } diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index b211892..695d294 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -5,6 +5,7 @@ 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() { @@ -41,6 +42,7 @@ function AppLayout() {
+
); } diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index e16937b..be91c72 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -37,7 +37,7 @@ function TasksDueWidget() { const { data } = useApiQuery>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix); const tasks = data?.items || []; const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); - const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done"); + const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status?.category !== "done"); return (
{today.length === 0 && overdue.length === 0 ? ( diff --git a/apps/web/src/routes/_app/projects/$id.tsx b/apps/web/src/routes/_app/projects/$id.tsx index 64e652b..ff82ae8 100644 --- a/apps/web/src/routes/_app/projects/$id.tsx +++ b/apps/web/src/routes/_app/projects/$id.tsx @@ -10,8 +10,10 @@ import { FolderKanban, ListTodo, Plus, + Pencil, Trash2, X, + Zap, } from "lucide-react"; import { differenceInCalendarDays, format, parseISO } from "date-fns"; import { api, useApiQuery } from "@/lib/api"; @@ -28,6 +30,7 @@ import { } from "@/components/entities/inline-edit"; import { EntityActivity } from "@/components/entities/entity-activity"; import { EntityComments } from "@/components/entities/entity-comments"; +import { AutomationRuleBuilder, TRIGGER_OPTIONS, summarizeActions } from "@/components/automation-rule-builder"; import { AlertDialog, AlertDialogAction, @@ -52,9 +55,12 @@ import { SelectValue, } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Switch } from "@/components/ui/switch"; import { LoadingState, ErrorState } from "@/components/state"; -import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors"; -import type { Project, Section, Task } from "@/lib/types"; +import { GanttChart } from "@/components/gantt/gantt-chart"; +import type { TimelineData } from "@/components/gantt/gantt-utils"; +import { getStatusToken, PRIORITY, PROJECT_STATUS } from "@/lib/status-colors"; +import type { AutomationRule, Project, Section, Task } from "@/lib/types"; import { cn } from "@/lib/utils"; const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [ @@ -226,6 +232,16 @@ function ProjectDetail() { }, { value: "tasks", label: "Tasks", content: }, { value: "sections", label: "Sections", content: }, + { + value: "timeline", + label: "Timeline", + content: , + }, + { + value: "automations", + label: "Automations", + content: , + }, { value: "activity", label: "Activity", @@ -342,8 +358,8 @@ function ProjectTasks({ project }: { project: Project }) { }); const toggleMutation = useMutation({ - mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) => - api.post(`/tasks/${taskId}/status`, { status }), + mutationFn: ({ taskId, statusId }: { taskId: string; statusId: string | null }) => + api.post(`/tasks/${taskId}/status`, { statusId }), onMutate: (vars) => setPendingId(vars.taskId), onSettled: () => setPendingId(null), onSuccess: refresh, @@ -438,6 +454,7 @@ function ProjectTasks({ project }: { project: Project }) { toggleMutation.mutate(vars)} onOpen={() => openTask(task.id)} @@ -460,6 +477,7 @@ function ProjectTasks({ project }: { project: Project }) { toggleMutation.mutate(vars)} onOpen={() => openTask(task.id)} @@ -475,37 +493,43 @@ function ProjectTasks({ project }: { project: Project }) { function TaskRow({ task, + project, pending, onToggle, onOpen, }: { task: Task; + project: Project; pending: boolean; - onToggle: (vars: { taskId: string; status: Task["status"] }) => void; + onToggle: (vars: { taskId: string; statusId: string | null }) => void; onOpen: () => void; }) { + const isDone = task.status?.category === "done"; + const statuses = project.statuses ?? []; return (
onToggle({ taskId: task.id, - status: task.status === "done" ? "todo" : "done", + statusId: isDone + ? (statuses.find((s) => s.category === "todo")?.id ?? null) + : (statuses.find((s) => s.category === "done")?.id ?? null), }) } aria-label={ - "Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done") + "Mark " + task.title + " " + (isDone ? "as not done" : "as done") } /> - + +
+ + {rules.length === 0 ? ( +

+ No automation rules yet — e.g. add a "shipped" label when a task is done. +

+ ) : ( +
+ {rules.map((rule) => { + const triggerLabel = + TRIGGER_OPTIONS.find((t) => t.value === rule.trigger.type)?.label ?? + rule.trigger.type; + const actionSummaries = summarizeActions(project, rule.actions); + return ( +
+ +
+

{rule.name}

+

+ When {triggerLabel.toLowerCase()} + {rule.conditions.length > 0 + ? ` (${rule.conditions.length} ${rule.conditions.length === 1 ? "condition" : "conditions"})` + : ""}{" "} + → {actionSummaries.join(", ")} +

+
+ toggleMutation.mutate(rule.id)} + disabled={toggleMutation.isPending} + aria-label={"Toggle " + rule.name} + /> + + +
+ ); + })} +
+ )} + + {builderOpen ? ( + + ) : null} +
+ ); +} + +function ProjectTimeline({ project }: { project: Project }) { + const { data, isLoading, isError, error, refetch } = useApiQuery( + ["timeline", project.domainId, project.id], + `/domains/${project.domainId}/projects/${project.id}/timeline` + ); + + if (isLoading) return ; + if (isError) return refetch()} />; + if (!data) return ; + + return ( + + ); +} + export const Route = createRoute({ getParentRoute: () => appRoute, path: "projects/$id", diff --git a/apps/web/src/routes/_app/tasks.tsx b/apps/web/src/routes/_app/tasks.tsx index 3e4f8b0..d7c2112 100644 --- a/apps/web/src/routes/_app/tasks.tsx +++ b/apps/web/src/routes/_app/tasks.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { createRoute, useNavigate } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -27,8 +27,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { LoadingState, EmptyState, ErrorState } from "@/components/state"; import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs"; -import { TASK_STATUS, PRIORITY } from "@/lib/status-colors"; -import type { Task, PaginatedResponse } from "@/lib/types"; +import { getStatusLabel, getStatusToken, TASK_STATUS, PRIORITY } from "@/lib/status-colors"; +import type { StatusDefinition, Task, TaskStatusCategory, PaginatedResponse } from "@/lib/types"; import { cn } from "@/lib/utils"; const STATUS_COLUMNS = [ @@ -99,16 +99,28 @@ function ColumnDroppable({ id, className, children }: { id: string; className?: ); } +const NO_STATUS = "__none__"; + function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) { const queryClient = useQueryClient(); const activeDomainId = useApiDomain(); const [title, setTitle] = useState(task?.title || ""); const [description, setDescription] = useState(task?.description || ""); - const [status, setStatus] = useState(task?.status || "todo"); + const [statusId, setStatusId] = useState(task?.statusId ?? ""); const [priority, setPriority] = useState(task?.priority || "medium"); const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : ""); const [customFieldValues, setCustomFieldValues] = useState>(() => ({ ...(task?.customFields ?? {}) })); + // Status is a per-project status definition, so it can only be picked when the + // task belongs to a project whose statuses we can load. + const projectId = task?.projectId ?? null; + const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>( + ["project-statuses", projectId ?? "none"], + projectId ? `/projects/${projectId}/statuses` : "", + { enabled: !!projectId } + ); + const statusOptions = statusesData?.items ?? []; + const createMutation = useMutation({ mutationFn: (data: any) => api.post("/tasks", data), onSuccess: () => { @@ -128,7 +140,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) return; - const data: any = { title: title.trim(), description: description || null, status, priority }; + const data: any = { title: title.trim(), description: description || null, priority }; + if (statusId) data.statusId = statusId; if (dueDate) data.dueDate = new Date(dueDate).toISOString(); const customFields = { ...customFieldValues }; if (Object.keys(customFields).length > 0) data.customFields = customFields; @@ -150,18 +163,23 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {