feat(plane-lift): API routes, UI components, migrations — Phase 2

New API routes:
- statuses.ts: CRUD for custom task statuses
- automations.ts: automation rules engine
- timeline.ts: entity timeline/activity view
- activity.ts: activity feed endpoint

New UI components:
- gantt/: gantt chart (6 files: chart, task-bar, milestone, timeline, deps, utils)
- automation-rule-builder.tsx: visual rule editor
- notification-center.tsx: in-app notifications
- quick-add-bar.tsx: global quick-add
- entities/: detail-page, activity, comments, inline-edit, note-editor
- tasks/: recurrence-picker

New hooks:
- use-optimistic-patch.ts: optimistic UI updates

New libs:
- nlp-parser.ts + test: natural language task parsing
- notify.ts: notification dispatch
- automation-engine.ts: rule evaluation

DB migrations:
- 0007_custom_task_statuses.sql
- 0008_automation_rules.sql
- 0009_notifications.sql
- migrate-task-statuses.ts: backfill script

Modified:
- tasks.ts: plane-lift integration (stateId/moduleId/cycleId)
- analytics.ts: updated for new schema
- canvas/$id.tsx: restored
This commit is contained in:
Hermes
2026-09-07 18:09:03 +00:00
parent 28b2cbd095
commit 7041906e7d
24 changed files with 4117 additions and 36 deletions
+291
View File
@@ -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<string, any>;
/** Diff of the mutation, e.g. { previousStatusId } for status changes. */
changes?: Record<string, any>;
/** 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<string, any>;
statusById: Map<string, StatusRow>;
idByKey: Map<string, string>;
categoryById: Map<string, string>;
}
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<string, any> },
ctx: RuleContext,
actor: string
): Promise<boolean> {
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<void> {
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<string, string>();
const categoryById = new Map<string, string>();
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);
}
}
+65
View File
@@ -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<typeof notifications.$inferSelect> {
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<CreateNotificationParams, "userId">): Promise<typeof notifications.$inferSelect | null> {
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 });
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, inArray, isNull, isNotNull, or } from "drizzle-orm";
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono();
@@ -30,7 +30,7 @@ analyticsRoutes.get("/productivity", async (c) => {
isNull(tasks.deletedAt),
));
const completedTasks = allTasks.filter(t => t.completedAt !== null);
const completedTasks = allTasks.filter(t => t.status === "done");
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
return c.json({
@@ -163,9 +163,9 @@ analyticsRoutes.get("/projects", async (c) => {
const projectIds = allProjects.map((p) => p.id);
// Count tasks per project for the domain
// Count tasks per project (any status, including non-done) for the domain
const taskRows = projectIds.length > 0
? await db.select({ projectId: tasks.projectId, completedAt: tasks.completedAt })
? await db.select({ projectId: tasks.projectId, status: tasks.status })
.from(tasks)
.where(and(
isNull(tasks.deletedAt),
@@ -178,7 +178,7 @@ analyticsRoutes.get("/projects", async (c) => {
if (!t.projectId) continue;
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
entry.totalTasks += 1;
if (t.completedAt) entry.completedTasks += 1;
if (t.status === "done") entry.completedTasks += 1;
counts.set(t.projectId, entry);
}
@@ -261,7 +261,7 @@ analyticsRoutes.get("/cycle", async (c) => {
await requireWorkspaceAccess(c, domainId);
const startDate = new Date();
startDate.setDate(startDate.getDate() - range);
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNotNull(tasks.completedAt), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), eq(tasks.status, "done"), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
const durations: number[] = [];
for (const t of doneTasks) if (t.completedAt) durations.push((t.completedAt.getTime() - t.createdAt.getTime()) / (1000*60*60*24));
durations.sort((a,b)=>a-b);
@@ -319,7 +319,7 @@ analyticsRoutes.get("/daily", async (c) => {
for (const t of domainTasks) {
const createdKey = localDateKey(t.createdAt);
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
if (t.completedAt) {
if (t.status === "done" && t.completedAt) {
const completedKey = localDateKey(t.completedAt);
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
}
+309
View File
@@ -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, unknown> }[]): 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<string, unknown> = {};
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);
}
});
+389
View File
@@ -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<number>`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<number>`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<string, unknown> = {};
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<number>`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);
}
});
+207 -29
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, tasks, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -9,18 +9,17 @@ import { RRule } from "rrule";
export const taskRoutes = new Hono();
const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]);
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional().nullable(),
status: taskStatusEnum.optional().default("todo"),
priority: taskPriorityEnum.optional().default("medium"),
domain: z.string().min(1, "Domain is required"),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
stateId: z.string().uuid().optional().nullable(),
moduleId: z.string().uuid().optional().nullable(),
cycleId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
@@ -34,12 +33,10 @@ const createTaskSchema = z.object({
const updateTaskSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional().nullable(),
status: taskStatusEnum.optional(),
priority: taskPriorityEnum.optional(),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
stateId: z.string().uuid().optional().nullable(),
moduleId: z.string().uuid().optional().nullable(),
cycleId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
@@ -89,7 +86,7 @@ taskRoutes.get("/", async (c) => {
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
const filter = url.searchParams.get("filter") || undefined;
const sort = url.searchParams.get("sort") || "-created";
const stateId = url.searchParams.get("state_id");
const status = url.searchParams.get("status");
const priority = url.searchParams.get("priority");
const tag = url.searchParams.get("tag");
const search = url.searchParams.get("search");
@@ -114,9 +111,9 @@ taskRoutes.get("/", async (c) => {
isNull(tasks.deletedAt),
];
if (stateId) {
const stateIds = stateId.split(",");
conditions.push(inArray(tasks.stateId, stateIds));
if (status) {
const statuses = status.split(",");
conditions.push(inArray(tasks.status, statuses as any));
}
if (priority) {
const priorities = priority.split(",");
@@ -168,6 +165,7 @@ taskRoutes.get("/", async (c) => {
created: tasks.createdAt,
updated: tasks.updatedAt,
title: tasks.title,
status: tasks.status,
priority: tasks.priority,
order: tasks.order,
due_date: tasks.dueDate,
@@ -299,13 +297,11 @@ taskRoutes.post("/", async (c) => {
const [task] = await db.insert(tasks).values({
title: data.title,
description: data.description ?? null,
status: data.status,
priority: data.priority,
domainId: data.domain,
projectId: data.projectId ?? null,
sectionId: data.sectionId ?? null,
stateId: data.stateId ?? null,
moduleId: data.moduleId ?? null,
cycleId: data.cycleId ?? null,
parentId: data.parentId ?? null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
estimatedMinutes: data.estimatedMinutes ?? null,
@@ -338,7 +334,7 @@ taskRoutes.post("/", async (c) => {
action: "created",
entityType: "task",
entityId: task.id,
changes: { title: task.title, priority: task.priority },
changes: { title: task.title, status: task.status, priority: task.priority },
workspaceId: data.domain,
});
@@ -462,9 +458,25 @@ taskRoutes.get("/:id", async (c) => {
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(eq(taskTags.taskId, id));
// Dependencies are now managed via the links table (Phase 2)
const depRows: { id: string; title: string }[] = [];
const dependentRows: { id: string; title: string }[] = [];
// Fetch dependencies (tasks this task depends on)
const depRows = await db.select({
id: tasks.id,
title: tasks.title,
status: tasks.status,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.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,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
return c.json({
...task,
@@ -527,12 +539,10 @@ taskRoutes.patch("/:id", async (c) => {
const updateValues: Record<string, unknown> = {};
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;
if (data.stateId !== undefined) updateValues.stateId = data.stateId;
if (data.moduleId !== undefined) updateValues.moduleId = data.moduleId;
if (data.cycleId !== undefined) updateValues.cycleId = data.cycleId;
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
@@ -551,11 +561,11 @@ taskRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "task",
entityId: id,
changes: { ...data, previousStateId: existing.stateId },
changes: { ...data, previousStatus: existing.status },
workspaceId: existing.domainId,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStateId: existing.stateId } });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
if (data.recurrenceRule !== undefined) {
await syncScheduledJob(id, data.recurrenceRule);
@@ -718,19 +728,187 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
}
});
// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2)
// POST /api/tasks/:id/dependencies — Make this task depend on another task
taskRoutes.post("/:id/dependencies", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { dependsOnTaskId } = z.object({
dependsOnTaskId: z.string().uuid("Invalid task id"),
}).parse(body);
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// A task cannot depend on itself
if (dependsOnTaskId === id) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
}
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
.limit(1);
if (!depTask) {
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
}
if (depTask.domainId !== task.domainId) {
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
}
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
let currentId: string | null = dependsOnTaskId;
const visited = new Set<string>([id]);
while (currentId) {
if (visited.has(currentId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
}
visited.add(currentId);
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, currentId))
.limit(1);
currentId = next?.dependsOnTaskId ?? null;
}
// Junction table has a composite PK — ignore duplicate edges
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "dependency_added",
entityType: "task",
entityId: id,
changes: { dependsOnTaskId },
workspaceId: task.domainId,
});
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/dependencies error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
}
});
// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2)
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const depId = c.req.param("depId");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// Junction table has no deleted_at — hard delete is correct here
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
await recordActivity({
actor: user.name,
action: "dependency_removed",
entityType: "task",
entityId: id,
changes: { removedDependsOnTaskId: depId },
workspaceId: task.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] DELETE /:id/dependencies/:depId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
}
});
// POST /api/tasks/:id/status — Deprecated: use state_id instead (Phase 2)
// POST /api/tasks/:id/status — Change task status (Kanban drag)
taskRoutes.post("/:id/status", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404);
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { status: newStatus } = z.object({
status: taskStatusEnum,
}).parse(body);
const [existing] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {
status: newStatus,
updatedAt: new Date(),
};
if (newStatus === "done") {
updateValues.completedAt = new Date();
}
const [updated] = await db.update(tasks)
.set(updateValues)
.where(eq(tasks.id, id))
.returning();
await recordActivity({
actor: user.name,
action: newStatus === "done" ? "completed" : "updated",
entityType: "task",
entityId: id,
changes: { previousStatus: existing.status, newStatus },
workspaceId: existing.domainId,
});
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/status error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update task status" } }, 500);
}
});
// GET /api/tasks/:id/history — Status change log (from activity feed)
+118
View File
@@ -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<string, string[]>();
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);
}
});
@@ -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<string, string>;
}
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<string, string> = {};
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<AutomationTriggerType>(
rule?.trigger.type ?? "task_status_changed"
);
const [conditions, setConditions] = useState<DraftCondition[]>(
toDraftConditions(rule?.conditions ?? [])
);
const [actions, setActions] = useState<DraftAction[]>(
toDraftActions(rule?.actions ?? [])
);
const createMutation = useApiMutation<AutomationRule, Record<string, unknown>>(
"post",
`/projects/${project.id}/automations`
);
const updateMutation = useApiMutation<AutomationRule, Record<string, unknown>>(
"patch",
`/projects/${project.id}/automations/${rule?.id}`
);
const isEditing = Boolean(rule?.id);
const isPending = createMutation.isPending || updateMutation.isPending;
const updateCondition = (index: number, patch: Partial<DraftCondition>) => {
setConditions((prev) =>
prev.map((c, i) => (i === index ? { ...c, ...patch } : c))
);
};
const updateAction = (index: number, patch: Partial<DraftAction>) => {
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<string, unknown> = {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isEditing ? "Edit automation rule" : "Create automation rule"}</DialogTitle>
<DialogDescription>
When something happens to a task, automatically run actions.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="flex items-end gap-4">
<div className="flex-1 space-y-1.5">
<Label htmlFor="rule-name">Rule name</Label>
<Input
id="rule-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Ship completed tasks"
/>
</div>
<div className="flex items-center gap-2 pb-1">
<Switch
checked={active}
onCheckedChange={setActive}
aria-label="Rule active"
/>
<Label htmlFor="rule-active" className="cursor-pointer">
{active ? "Active" : "Inactive"}
</Label>
</div>
</div>
<div className="space-y-1.5">
<Label>When</Label>
<Select value={triggerType} onValueChange={(v) => setTriggerType(v as AutomationTriggerType)}>
<SelectTrigger aria-label="Trigger" className="w-full">
<SelectValue placeholder="Select a trigger" />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Conditions (optional)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setConditions((prev) => [
...prev,
{ field: "status", op: "to", value: project.statuses?.[0]?.key ?? "" },
])
}
>
<Plus className="h-3.5 w-3.5" /> Add condition
</Button>
</div>
{conditions.length === 0 ? (
<p className="text-xs text-muted-foreground">
No conditions the rule fires on every matching event.
</p>
) : (
conditions.map((condition, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={condition.field}
onValueChange={(v) => {
const field = v as AutomationConditionField;
updateCondition(index, {
field,
op: opsForField(field)[0]?.value ?? "eq",
value: "",
});
}}
>
<SelectTrigger aria-label="Condition field" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_FIELD_OPTIONS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.op}
onValueChange={(v) => updateCondition(index, { op: v })}
>
<SelectTrigger aria-label="Condition operator" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{opsForField(condition.field).map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{condition.field === "status" ? (
<Select
value={condition.value}
onValueChange={(v) => updateCondition(index, { value: v })}
>
<SelectTrigger aria-label="Status" className="min-w-0 flex-1">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{(project.statuses ?? []).map((s) => (
<SelectItem key={s.id} value={s.key}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : condition.field === "priority" ? (
<Select
value={condition.value}
onValueChange={(v) => updateCondition(index, { value: v })}
>
<SelectTrigger aria-label="Priority" className="min-w-0 flex-1">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={condition.value}
onChange={(e) => updateCondition(index, { value: e.target.value })}
placeholder="Label name"
className="min-w-0 flex-1"
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setConditions((prev) => prev.filter((_, i) => i !== index))}
aria-label="Remove condition"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Actions</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setActions((prev) => [
...prev,
{ type: "set_status", params: { statusKey: project.statuses?.[0]?.key ?? "" } },
])
}
>
<Plus className="h-3.5 w-3.5" /> Add action
</Button>
</div>
{actions.length === 0 ? (
<p className="text-xs text-muted-foreground">No actions add at least one.</p>
) : (
actions.map((action, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={action.type}
onValueChange={(v) => {
const type = v as AutomationActionType;
const defaults: Record<AutomationActionType, Record<string, string>> = {
set_status: { statusKey: project.statuses?.[0]?.key ?? "" },
set_priority: { priority: "medium" },
add_label: { label: "" },
create_notification: { message: "" },
};
updateAction(index, { type, params: defaults[type] });
}}
>
<SelectTrigger aria-label="Action type" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
{action.type === "set_status" ? (
<Select
value={action.params.statusKey ?? ""}
onValueChange={(v) =>
updateAction(index, { params: { ...action.params, statusKey: v } })
}
>
<SelectTrigger aria-label="Status" className="min-w-0 flex-1">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{(project.statuses ?? []).map((s) => (
<SelectItem key={s.id} value={s.key}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : action.type === "set_priority" ? (
<Select
value={action.params.priority ?? ""}
onValueChange={(v) =>
updateAction(index, { params: { ...action.params, priority: v } })
}
>
<SelectTrigger aria-label="Priority" className="min-w-0 flex-1">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : action.type === "add_label" ? (
<Input
value={action.params.label ?? ""}
onChange={(e) =>
updateAction(index, { params: { ...action.params, label: e.target.value } })
}
placeholder="Label name"
className="min-w-0 flex-1"
/>
) : (
<Input
value={action.params.message ?? ""}
onChange={(e) =>
updateAction(index, { params: { ...action.params, message: e.target.value } })
}
placeholder="Notification message"
className="min-w-0 flex-1"
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setActions((prev) => prev.filter((_, i) => i !== index))}
aria-label="Remove action"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isPending}>
{isEditing ? "Save changes" : "Create rule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<ZoomLevel>("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<string, TaskPosition>();
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 (
<div
key={task.id}
className="absolute left-0 right-0 border-b border-border/50"
style={{ top: i * ROW_HEIGHT, height: ROW_HEIGHT }}
>
<GanttTaskBar
task={task}
startX={pos.startX}
width={Math.max(pos.endX - pos.startX, 6)}
color={getStatusColor(resolveStatus(task))}
pixelsPerDay={pixelsPerDay}
viewStart={viewStart}
onCommit={(taskId, dueDate) => dueMutation.mutate({ taskId, dueDate })}
/>
</div>
);
});
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(
<GanttDependencyArrow
key={`${task.id}-${depId}`}
fromTask={dep}
toTask={task}
taskPositions={taskPositions}
/>
);
}
}
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<ToggleGroup
type="single"
size="sm"
value={zoom}
onValueChange={(v) => {
if (v) setZoom(v as ZoomLevel);
}}
>
<ToggleGroupItem value="day">Day</ToggleGroupItem>
<ToggleGroupItem value="week">Week</ToggleGroupItem>
<ToggleGroupItem value="month">Month</ToggleGroupItem>
</ToggleGroup>
<span className="text-xs text-muted-foreground">
{format(viewStart, "MMM d")} {format(addDays(viewStart, totalDays - 1), "MMM d, yyyy")}
</span>
</div>
{tasks.length === 0 && milestones.length === 0 ? (
<EmptyState
title="No timeline data"
description="Add tasks or set a milestone target date to see the Gantt view."
/>
) : (
<div className="overflow-auto rounded-lg border" style={{ maxHeight: "72vh" }}>
<div className="flex" style={{ width: TASK_LIST_WIDTH + totalWidth }}>
{/* Fixed task list */}
<div className="sticky left-0 z-20 shrink-0 border-r bg-background">
<div
className="sticky top-0 z-30 flex items-center border-b bg-background px-3 text-xs font-semibold text-muted-foreground"
style={{ height: TIMELINE_HEADER_HEIGHT }}
>
Tasks · {tasks.length}
</div>
{tasks.map((task) => (
<div key={task.id} className="flex h-10 items-center gap-2 border-b px-3">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: getStatusColor(resolveStatus(task)) }}
/>
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
className="min-w-0 flex-1 truncate text-left text-xs text-foreground/90 hover:underline"
title={task.title}
>
{task.title}
</button>
</div>
))}
<div
className="flex items-center border-t px-3 text-xs font-semibold text-muted-foreground"
style={{ height: MILESTONE_BAND_HEIGHT }}
>
Milestones · {milestones.length}
</div>
</div>
{/* Scrollable timeline */}
<div className="min-w-0 flex-1">
<div className="sticky top-0 z-10 bg-background">
<GanttTimelineHeader
viewStart={viewStart}
viewEnd={addDays(viewStart, totalDays - 1)}
zoom={zoom}
pixelsPerDay={pixelsPerDay}
/>
</div>
<div className="relative" style={{ height: rowsHeight }}>
<div
className="absolute inset-0"
style={{ backgroundImage: gridBackground, backgroundSize: `${pixelsPerDay}px 100%` }}
/>
{rows}
<div
className="absolute left-0 right-0 border-t border-border/50 bg-muted/20"
style={{ top: tasks.length * ROW_HEIGHT, height: MILESTONE_BAND_HEIGHT }}
>
{milestones.map((m) => (
<GanttMilestone
key={m.id}
milestone={m}
x={positionForDate(m.targetDate, viewStart, pixelsPerDay) - 8}
/>
))}
</div>
<svg
className="pointer-events-none absolute left-0 top-0 z-10"
width={totalWidth}
height={rowsHeight}
>
<defs>
<marker
id="gantt-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
</marker>
</defs>
<line
x1={todayX + 0.5}
y1={0}
x2={todayX + 0.5}
y2={rowsHeight}
className="stroke-red-500"
strokeWidth={1.5}
strokeDasharray="4 3"
/>
{arrows}
</svg>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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<string, TaskPosition>;
}
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 <svg> 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 (
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
markerEnd="url(#gantt-arrow)"
className="text-muted-foreground/70"
/>
);
}
@@ -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 (
<div
className="absolute flex flex-col items-center"
style={{ left: x }}
title={`Milestone: ${milestone.name}${format(parseISO(milestone.targetDate), "MMM d, yyyy")}`}
>
<div className="mt-1.5 h-4 w-4 rotate-45 rounded-[2px] border-2 border-background bg-amber-400 shadow-sm" />
<span className="mt-1 whitespace-nowrap text-[10px] text-muted-foreground">
{format(parseISO(milestone.targetDate), "MMM d")}
</span>
</div>
);
}
@@ -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<Date | null>(null);
const dragRef = useRef<DragState | null>(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 (
<div
onPointerDown={beginDrag}
className={cn(
"absolute top-1.5 h-6 touch-none select-none overflow-hidden rounded-md px-1.5",
"text-[11px] font-medium leading-6 text-white shadow-sm",
"cursor-grab hover:shadow-md active:cursor-grabbing",
isDone && "opacity-60"
)}
style={{
left: startX,
width: barWidth,
backgroundColor: color,
backgroundImage: isCancelled
? "repeating-linear-gradient(45deg, transparent 0 4px, rgba(255,255,255,0.35) 4px 8px)"
: undefined,
}}
title={title}
aria-label={`${task.title}, drag to change due date`}
>
{barWidth >= 30 ? <span className="block truncate">{task.title}</span> : null}
<div
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize"
onPointerDown={beginDrag}
role="presentation"
/>
</div>
);
}
@@ -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 (
<div
key={group.key}
className="absolute top-0 h-full overflow-hidden px-2 text-[11px] font-semibold leading-6 text-muted-foreground"
style={{ left, width }}
>
{group.label}
</div>
);
};
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 (
<div
key={format(cell, "yyyy-MM-dd")}
className={cn(
"absolute top-0 h-full overflow-hidden border-r border-border/60 px-1.5 text-[11px] leading-8",
isWeekend && "bg-muted/50"
)}
style={{ left, width }}
>
{label}
</div>
);
};
return (
<div className="relative border-b bg-background" style={{ height: TIMELINE_HEADER_HEIGHT }}>
<div className="absolute inset-x-0 top-0 border-b bg-muted/40" style={{ height: 24 }}>
{groups.map(renderGroup)}
</div>
<div className="absolute inset-x-0 bottom-0" style={{ height: 32 }}>
{cells.map(renderCell)}
</div>
</div>
);
}
@@ -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<ZoomLevel, number> = {
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) });
}
}
@@ -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<string, { icon: LucideIcon; color: string }> = {
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<typeof useNavigate>, 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<NotificationCount>(
["notifications-count", domainId],
"/notifications/count" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}` : ""),
{ enabled: !!domainId, refetchInterval: 30_000 }
);
const unreadCount = countQuery.data?.count ?? 0;
const listQuery = useApiQuery<NotificationsResponse>(
["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<NotificationCount>(["notifications-count", domainId], (old) =>
old && old.count > 0 ? { count: old.count - 1 } : old
);
queryClient.setQueryData<NotificationsResponse>(["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<NotificationCount>(["notifications-count", domainId], (old) =>
old ? { count: 0 } : old
);
queryClient.setQueryData<NotificationsResponse>(["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 (
<Sheet open={open} onOpenChange={setOpen}>
<TooltipProvider>
<Tooltip>
<SheetTrigger asChild>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
>
{badgeLabel}
</span>
)}
</Button>
</TooltipTrigger>
</SheetTrigger>
<TooltipContent>
{unreadCount === 0 ? "No notifications" : `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base">Notifications</SheetTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
disabled={unreadCount === 0 || markAllRead.isPending}
onClick={() => markAllRead.mutate({ workspace_id: domainId || undefined })}
>
<Check className="mr-1 h-3 w-3" />
Mark all read
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Refresh notifications"
onClick={() => invalidateNotifications()}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</SheetHeader>
<ScrollArea className="h-full flex-1">
{loading && notifications.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">Loading notifications</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="h-8 w-8 opacity-40" />
No notifications yet
</div>
) : (
<ul className="divide-y">
{notifications.map((n) => {
const meta = NOTIFICATION_META[n.type] ?? { icon: Bell, color: "text-muted-foreground" };
const Icon = meta.icon;
const unread = !n.readAt;
return (
<li key={n.id}>
<button
type="button"
onClick={() => handleNotificationClick(n)}
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/60 focus:outline-none focus-visible:bg-accent/60 ${
unread ? "bg-accent/40" : ""
}`}
>
<span
aria-hidden="true"
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ${meta.color}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-sm ${unread ? "font-semibold" : "font-medium text-muted-foreground"}`}>
{n.title}
</span>
{n.body && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{n.body}</span>
)}
<span className="mt-1 block text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</span>
{unread && (
<span aria-hidden="true" className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
)}
</button>
</li>
);
})}
</ul>
)}
</ScrollArea>
</SheetContent>
</Sheet>
);
}
+234
View File
@@ -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 (
<Badge variant="outline" className={cn("gap-1 font-normal text-xs", className)}>
{icon}
{label}
</Badge>
);
}
export function QuickAddBar() {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const inputRef = useRef<HTMLInputElement>(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<PaginatedResponse<Project>>(
["projects", activeDomainId],
activeDomainId ? `/projects?limit=200&domain=${activeDomainId}` : "",
{ enabled: !!activeDomainId }
);
const { data: tagsData } = useApiQuery<PaginatedResponse<Tag>>(
["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(
<PreviewChip
key="due"
icon={<Calendar className="h-3 w-3" />}
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(
<PreviewChip
key="prio"
icon={<Flag className="h-3 w-3" />}
label={PRIORITY[parsed.priority]?.label ?? parsed.priority}
className="text-orange-500"
/>
);
}
if (resolvedProject) {
chips.push(
<PreviewChip
key="proj"
icon={<FolderKanban className="h-3 w-3" />}
label={resolvedProject.name}
className="text-violet-500"
/>
);
}
if (parsed.tags?.length) {
for (const tag of parsed.tags) {
chips.push(
<PreviewChip
key={`tag-${tag}`}
icon={<TagIcon className="h-3 w-3" />}
label={tag}
className="text-sky-500"
/>
);
}
}
if (parsed.recurrence) {
chips.push(
<PreviewChip key="rec" icon={<Repeat className="h-3 w-3" />} label="Recurring" />
);
}
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 flex justify-center px-4">
<form
onSubmit={handleSubmit}
className="pointer-events-auto w-full max-w-xl rounded-xl border bg-background/95 shadow-lg backdrop-blur"
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
>
<div className="flex items-center gap-2 px-3 py-2">
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
value={text}
onChange={(e) => 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"
/>
<Button
type="submit"
size="sm"
variant="ghost"
className="h-8 gap-1"
disabled={!canSubmit}
>
Add
<CornerDownLeft className="h-3.5 w-3.5" />
</Button>
</div>
{focused && (chips.length > 0 || text.trim().length > 0) && (
<div className="flex flex-wrap items-center gap-1.5 border-t px-3 py-2">
{chips.length > 0 ? (
chips
) : (
<span className="text-xs text-muted-foreground">
<kbd className="rounded border bg-muted px-1">!high</kbd> priority ·{" "}
<kbd className="rounded border bg-muted px-1">#project</kbd> ·{" "}
<kbd className="rounded border bg-muted px-1">@tag</kbd> ·{" "}
<kbd className="rounded border bg-muted px-1">tomorrow</kbd>
</span>
)}
</div>
)}
</form>
</div>
);
}
+257
View File
@@ -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<typeof parseQuickAdd>[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");
});
});
});
+458
View File
@@ -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<string, number> = {
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<string, number> = {
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<string>();
const resolvedTags = new Set<string>();
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,
};
}
+181
View File
@@ -0,0 +1,181 @@
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calendar, Clock, FileText, LayoutDashboard, Trash2 } from "lucide-react";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { InlineTextarea } from "@/components/entities/inline-edit";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import type { Canvas } from "@/lib/types";
import { CanvasEditor } from "../canvas";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function formatCustomFieldValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function CanvasDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const queryClient = useQueryClient();
useRealtime({ enabled: true });
const { data: canvas, isLoading, isError, error, refetch } = useApiQuery<Canvas>(
["canvas", id],
"/canvas/" + id
);
const { patch } = useOptimisticPatch<Canvas>({
entityKey: ["canvas", id],
listKeys: [["canvas"]],
patchUrl: (cid) => `/canvas/${cid}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/canvas/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["canvas"] });
toast.success("Canvas deleted");
navigate({ to: "/canvas" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading canvas..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!canvas) return <ErrorState message="Canvas not found" />;
const customFieldEntries = Object.entries(canvas.customFields ?? {});
return (
<div className="mx-auto max-w-5xl">
<div className="flex flex-col gap-6 lg:flex-row">
<div className="min-w-0 flex-1">
<CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />
</div>
<aside className="w-full shrink-0 space-y-6 lg:w-72">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea
value={canvas.description ?? ""}
onSave={(description) =>
patch({ id, data: { description: description || null } })
}
placeholder="Add a description…"
/>
</div>
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Details</p>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<LayoutDashboard className="h-4 w-4 shrink-0 text-muted-foreground" />
<Badge variant="outline">{canvas.mode}</Badge>
</div>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{canvas.cards?.length ?? 0} blocks</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Created {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Updated {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
</div>
</div>
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Tags</p>
{canvas.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{canvas.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No tags</p>
)}
</div>
{customFieldEntries.length > 0 && (
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Custom fields</p>
<dl className="space-y-2">
{customFieldEntries.map(([key, value]) => (
<div key={key} className="flex items-baseline justify-between gap-2 text-sm">
<dt className="shrink-0 text-muted-foreground">{key}</dt>
<dd className="truncate text-right">{formatCustomFieldValue(value)}</dd>
</div>
))}
</dl>
</div>
)}
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
<Trash2 className="h-4 w-4" /> Delete Canvas
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{canvas.name}"? All blocks in it will be
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</aside>
</div>
</div>
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "canvas/$id",
component: CanvasDetail,
});