feat: add 5 PM features — custom statuses, Gantt, quick-add, automations, notifications
- Custom workflow statuses: per-project configurable status definitions replacing the fixed task_status enum. Each project defines its own workflow with drag-to-reorder, color coding, and category mapping. - Gantt/timeline view: full project roadmap with task bars, dependency arrows, milestone diamonds, zoom levels (day/week/month), and drag to reschedule. - Natural-language quick-add: NLP parser extracts dates, priorities, projects, labels, and recurrence from free text. Floating bar with 'n' shortcut and live parsed preview. - Automation rules: no-code trigger-action system per project. Triggers on status change, task creation, due date approaching. Actions set status/priority, add labels, create notifications. - Notification center: in-app bell icon with unread badge, slide-out panel, real-time SSE updates, mark read/all read. Replaces raw activity feed dropdown. Schema: adds status_definitions, automation_rules, notifications tables. Migrations: 0007, 0008, 0009. 41 NLP parser tests pass.
This commit is contained in:
@@ -10,6 +10,8 @@ import { domainRoutes } from "./routes/domains";
|
|||||||
import { taskRoutes } from "./routes/tasks";
|
import { taskRoutes } from "./routes/tasks";
|
||||||
import { habitRoutes } from "./routes/habits";
|
import { habitRoutes } from "./routes/habits";
|
||||||
import { projectRoutes } from "./routes/projects";
|
import { projectRoutes } from "./routes/projects";
|
||||||
|
import { statusRoutes } from "./routes/statuses";
|
||||||
|
import { automationRoutes } from "./routes/automations";
|
||||||
import { noteRoutes } from "./routes/notes";
|
import { noteRoutes } from "./routes/notes";
|
||||||
import { searchRoutes } from "./routes/search";
|
import { searchRoutes } from "./routes/search";
|
||||||
import { calendarRoutes } from "./routes/calendar";
|
import { calendarRoutes } from "./routes/calendar";
|
||||||
@@ -27,6 +29,7 @@ import { analyticsRoutes } from "./routes/analytics";
|
|||||||
import { activityRoutes } from "./routes/activity";
|
import { activityRoutes } from "./routes/activity";
|
||||||
import { importExportRoutes } from "./routes/import-export";
|
import { importExportRoutes } from "./routes/import-export";
|
||||||
import { notificationRoutes } from "./routes/notifications";
|
import { notificationRoutes } from "./routes/notifications";
|
||||||
|
import { timelineRoutes } from "./routes/timeline";
|
||||||
import { healthHandler } from "./routes/health";
|
import { healthHandler } from "./routes/health";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -48,6 +51,8 @@ app.route("/api/domains", domainRoutes);
|
|||||||
app.route("/api/tasks", taskRoutes);
|
app.route("/api/tasks", taskRoutes);
|
||||||
app.route("/api/habits", habitRoutes);
|
app.route("/api/habits", habitRoutes);
|
||||||
app.route("/api/projects", projectRoutes);
|
app.route("/api/projects", projectRoutes);
|
||||||
|
app.route("/api/projects", statusRoutes);
|
||||||
|
app.route("/api/projects", automationRoutes);
|
||||||
app.route("/api/notes", noteRoutes);
|
app.route("/api/notes", noteRoutes);
|
||||||
app.route("/api/search", searchRoutes);
|
app.route("/api/search", searchRoutes);
|
||||||
app.route("/api/calendar", calendarRoutes);
|
app.route("/api/calendar", calendarRoutes);
|
||||||
@@ -66,6 +71,7 @@ app.route("/api/activity", activityRoutes);
|
|||||||
app.route("/api/notifications", notificationRoutes);
|
app.route("/api/notifications", notificationRoutes);
|
||||||
app.route("/api", importExportRoutes);
|
app.route("/api", importExportRoutes);
|
||||||
app.route("/api", realtimeRoutes);
|
app.route("/api", realtimeRoutes);
|
||||||
|
app.route("/api", timelineRoutes);
|
||||||
app.route("/api/mcp", mcpRoutes);
|
app.route("/api/mcp", mcpRoutes);
|
||||||
|
|
||||||
// Persist uncaught server errors so the Settings → Error Log tab shows real
|
// Persist uncaught server errors so the Settings → Error Log tab shows real
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
|
import { db, tasks, habits, habitCompletions, projects, statusDefinitions } from "@project-e/db";
|
||||||
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
|
import { and, eq, gte, getTableColumns, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||||
|
|
||||||
export const analyticsRoutes = new Hono();
|
export const analyticsRoutes = new Hono();
|
||||||
@@ -22,15 +22,18 @@ analyticsRoutes.get("/productivity", async (c) => {
|
|||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - range);
|
startDate.setDate(startDate.getDate() - range);
|
||||||
|
|
||||||
const allTasks = await db.select()
|
const taskColumns = getTableColumns(tasks);
|
||||||
|
const allTasks = await db.select({ ...taskColumns, statusCategory: statusDefinitions.category })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(tasks.domainId, domainId),
|
eq(tasks.domainId, domainId),
|
||||||
gte(tasks.createdAt, startDate),
|
gte(tasks.createdAt, startDate),
|
||||||
isNull(tasks.deletedAt),
|
isNull(tasks.deletedAt),
|
||||||
));
|
));
|
||||||
|
|
||||||
const completedTasks = allTasks.filter(t => t.status === "done");
|
// "Done" is a status category; a status marked category='done' completes a task.
|
||||||
|
const completedTasks = allTasks.filter(t => t.statusCategory === "done");
|
||||||
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
|
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -127,8 +130,9 @@ analyticsRoutes.get("/projects", async (c) => {
|
|||||||
|
|
||||||
// Count tasks per project (any status, including non-done) for the domain
|
// Count tasks per project (any status, including non-done) for the domain
|
||||||
const taskRows = projectIds.length > 0
|
const taskRows = projectIds.length > 0
|
||||||
? await db.select({ projectId: tasks.projectId, status: tasks.status })
|
? await db.select({ projectId: tasks.projectId, statusCategory: statusDefinitions.category })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(
|
.where(and(
|
||||||
isNull(tasks.deletedAt),
|
isNull(tasks.deletedAt),
|
||||||
inArray(tasks.projectId, projectIds),
|
inArray(tasks.projectId, projectIds),
|
||||||
@@ -140,7 +144,7 @@ analyticsRoutes.get("/projects", async (c) => {
|
|||||||
if (!t.projectId) continue;
|
if (!t.projectId) continue;
|
||||||
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
|
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
|
||||||
entry.totalTasks += 1;
|
entry.totalTasks += 1;
|
||||||
if (t.status === "done") entry.completedTasks += 1;
|
if (t.statusCategory === "done") entry.completedTasks += 1;
|
||||||
counts.set(t.projectId, entry);
|
counts.set(t.projectId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +195,10 @@ analyticsRoutes.get("/daily", async (c) => {
|
|||||||
firstDay.setDate(firstDay.getDate() - (range - 1));
|
firstDay.setDate(firstDay.getDate() - (range - 1));
|
||||||
firstDay.setHours(0, 0, 0, 0);
|
firstDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const domainTasks = await db.select()
|
const dailyTaskColumns = getTableColumns(tasks);
|
||||||
|
const domainTasks = await db.select({ ...dailyTaskColumns, statusCategory: statusDefinitions.category })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(tasks.domainId, domainId),
|
eq(tasks.domainId, domainId),
|
||||||
isNull(tasks.deletedAt),
|
isNull(tasks.deletedAt),
|
||||||
@@ -216,7 +222,7 @@ analyticsRoutes.get("/daily", async (c) => {
|
|||||||
for (const t of domainTasks) {
|
for (const t of domainTasks) {
|
||||||
const createdKey = localDateKey(t.createdAt);
|
const createdKey = localDateKey(t.createdAt);
|
||||||
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
|
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
|
||||||
if (t.status === "done" && t.completedAt) {
|
if (t.statusCategory === "done" && t.completedAt) {
|
||||||
const completedKey = localDateKey(t.completedAt);
|
const completedKey = localDateKey(t.completedAt);
|
||||||
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
|
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
|||||||
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
|
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
|
||||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
|
import { notifyWorkspaceOwner } from "../lib/notify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const commentRoutes = new Hono();
|
export const commentRoutes = new Hono();
|
||||||
@@ -150,6 +151,23 @@ commentRoutes.post("/", async (c) => {
|
|||||||
workspaceId: domainId,
|
workspaceId: domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mention notification: any @mention in the comment notifies the workspace
|
||||||
|
// owner (single-user MVP — there is no other recipient to resolve).
|
||||||
|
if (/\B@[a-zA-Z0-9_.-]+/.test(data.content)) {
|
||||||
|
try {
|
||||||
|
await notifyWorkspaceOwner({
|
||||||
|
workspaceId: domainId,
|
||||||
|
type: "mention",
|
||||||
|
title: "You were mentioned",
|
||||||
|
body: `${user.name} mentioned you in a comment on this ${data.entityType.replace(/_/g, " ")}`,
|
||||||
|
entityType: data.entityType,
|
||||||
|
entityId: data.entityId,
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error(`[comments] Failed to create mention notification for ${data.entityType}:${data.entityId}:`, notifyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(newComment, 201);
|
return c.json(newComment, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
|
|||||||
+67
-11
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
|
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries, statusDefinitions } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
|
|
||||||
export const mcpRoutes = new Hono();
|
export const mcpRoutes = new Hono();
|
||||||
@@ -81,7 +81,8 @@ const tools: ToolDefinition[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
domain_id: { type: "string", description: "Workspace/domain ID" },
|
||||||
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
|
status: { type: "string", description: "Filter by status category (todo, in_progress, done, cancelled)" },
|
||||||
|
status_id: { type: "string", description: "Filter by a specific status definition ID" },
|
||||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||||
project_id: { type: "string" },
|
project_id: { type: "string" },
|
||||||
search: { type: "string" },
|
search: { type: "string" },
|
||||||
@@ -95,7 +96,14 @@ const tools: ToolDefinition[] = [
|
|||||||
eq(tasks.domainId, params.domain_id as string),
|
eq(tasks.domainId, params.domain_id as string),
|
||||||
isNull(tasks.deletedAt),
|
isNull(tasks.deletedAt),
|
||||||
];
|
];
|
||||||
if (params.status) conditions.push(eq(tasks.status, params.status as any));
|
if (params.status_id) conditions.push(eq(tasks.statusId, params.status_id as string));
|
||||||
|
if (params.status) {
|
||||||
|
// Category filter — match every status definition in that category.
|
||||||
|
const matching = await db.select({ id: statusDefinitions.id })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.category, params.status as any));
|
||||||
|
conditions.push(inArray(tasks.statusId, matching.map((s) => s.id)));
|
||||||
|
}
|
||||||
if (params.priority) conditions.push(eq(tasks.priority, params.priority as any));
|
if (params.priority) conditions.push(eq(tasks.priority, params.priority as any));
|
||||||
if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string));
|
if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string));
|
||||||
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
|
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
|
||||||
@@ -119,7 +127,7 @@ const tools: ToolDefinition[] = [
|
|||||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
domain_id: { type: "string", description: "Workspace/domain ID" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
|
status_id: { type: "string", description: "Status definition ID (defaults to the project's default status)" },
|
||||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||||
due_date: { type: "string" },
|
due_date: { type: "string" },
|
||||||
project_id: { type: "string" },
|
project_id: { type: "string" },
|
||||||
@@ -127,10 +135,33 @@ const tools: ToolDefinition[] = [
|
|||||||
required: ["domain_id", "title"],
|
required: ["domain_id", "title"],
|
||||||
},
|
},
|
||||||
handler: async (params, auth) => {
|
handler: async (params, auth) => {
|
||||||
|
// Resolve status: explicit status_id, else the project's default status.
|
||||||
|
let statusId = (params.status_id as string) ?? null;
|
||||||
|
let completedAt = null;
|
||||||
|
if (statusId) {
|
||||||
|
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.id, statusId))
|
||||||
|
.limit(1);
|
||||||
|
if (!status) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status not found");
|
||||||
|
if (!params.project_id || status.projectId !== params.project_id) {
|
||||||
|
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status does not belong to the selected project");
|
||||||
|
}
|
||||||
|
completedAt = status.category === "done" ? new Date() : null;
|
||||||
|
} else if (params.project_id) {
|
||||||
|
const [defaultStatus] = await db.select({ id: statusDefinitions.id, category: statusDefinitions.category })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(and(eq(statusDefinitions.projectId, params.project_id as string), eq(statusDefinitions.isDefault, true)))
|
||||||
|
.limit(1);
|
||||||
|
statusId = defaultStatus?.id ?? null;
|
||||||
|
completedAt = defaultStatus?.category === "done" ? new Date() : null;
|
||||||
|
}
|
||||||
|
|
||||||
const [task] = await db.insert(tasks).values({
|
const [task] = await db.insert(tasks).values({
|
||||||
title: params.title as string,
|
title: params.title as string,
|
||||||
description: (params.description as string) ?? null,
|
description: (params.description as string) ?? null,
|
||||||
status: (params.status as any) ?? "todo",
|
statusId,
|
||||||
|
completedAt,
|
||||||
priority: (params.priority as any) ?? "medium",
|
priority: (params.priority as any) ?? "medium",
|
||||||
domainId: params.domain_id as string,
|
domainId: params.domain_id as string,
|
||||||
projectId: (params.project_id as string) ?? null,
|
projectId: (params.project_id as string) ?? null,
|
||||||
@@ -142,7 +173,7 @@ const tools: ToolDefinition[] = [
|
|||||||
action: "created",
|
action: "created",
|
||||||
entityType: "task",
|
entityType: "task",
|
||||||
entityId: task.id,
|
entityId: task.id,
|
||||||
changes: { title: task.title, status: task.status },
|
changes: { title: task.title, statusId: task.statusId },
|
||||||
workspaceId: params.domain_id as string,
|
workspaceId: params.domain_id as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +189,7 @@ const tools: ToolDefinition[] = [
|
|||||||
task_id: { type: "string" },
|
task_id: { type: "string" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
|
status_id: { type: "string", description: "Status definition ID (must belong to the task's project)" },
|
||||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||||
due_date: { type: "string" },
|
due_date: { type: "string" },
|
||||||
},
|
},
|
||||||
@@ -172,7 +203,19 @@ const tools: ToolDefinition[] = [
|
|||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (params.title !== undefined) updateData.title = params.title;
|
if (params.title !== undefined) updateData.title = params.title;
|
||||||
if (params.description !== undefined) updateData.description = params.description;
|
if (params.description !== undefined) updateData.description = params.description;
|
||||||
if (params.status !== undefined) updateData.status = params.status;
|
if (params.status_id !== undefined) {
|
||||||
|
const statusId = params.status_id as string;
|
||||||
|
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.id, statusId))
|
||||||
|
.limit(1);
|
||||||
|
if (!status) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status not found");
|
||||||
|
if (!existing.projectId || status.projectId !== existing.projectId) {
|
||||||
|
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status does not belong to the task's project");
|
||||||
|
}
|
||||||
|
updateData.statusId = statusId;
|
||||||
|
updateData.completedAt = status.category === "done" ? new Date() : null;
|
||||||
|
}
|
||||||
if (params.priority !== undefined) updateData.priority = params.priority;
|
if (params.priority !== undefined) updateData.priority = params.priority;
|
||||||
if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null;
|
if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null;
|
||||||
updateData.updatedAt = new Date();
|
updateData.updatedAt = new Date();
|
||||||
@@ -236,8 +279,21 @@ const tools: ToolDefinition[] = [
|
|||||||
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
await verifyDomainAccess(existing.domainId, auth.userId);
|
||||||
|
|
||||||
|
// Complete = move to the project's first "done"-category status.
|
||||||
|
const [doneStatus] = await db.select({ id: statusDefinitions.id })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(and(
|
||||||
|
eq(statusDefinitions.projectId, existing.projectId ?? ""),
|
||||||
|
eq(statusDefinitions.category, "done"),
|
||||||
|
))
|
||||||
|
.orderBy(asc(statusDefinitions.sortOrder))
|
||||||
|
.limit(1);
|
||||||
|
if (!doneStatus) {
|
||||||
|
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project has no done-category status");
|
||||||
|
}
|
||||||
|
|
||||||
const [task] = await db.update(tasks)
|
const [task] = await db.update(tasks)
|
||||||
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
|
.set({ statusId: doneStatus.id, completedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -566,7 +622,7 @@ const tools: ToolDefinition[] = [
|
|||||||
const results: Record<string, unknown[]> = {};
|
const results: Record<string, unknown[]> = {};
|
||||||
|
|
||||||
if (types.includes("tasks")) {
|
if (types.includes("tasks")) {
|
||||||
results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks)
|
results.tasks = await db.select({ id: tasks.id, title: tasks.title, statusId: tasks.statusId, priority: tasks.priority }).from(tasks)
|
||||||
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit);
|
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit);
|
||||||
}
|
}
|
||||||
if (types.includes("notes")) {
|
if (types.includes("notes")) {
|
||||||
|
|||||||
@@ -1,44 +1,170 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, activityFeed } from "@project-e/db";
|
import { db, notifications } from "@project-e/db";
|
||||||
import { and, desc, eq, gte, ne, sql } from "drizzle-orm";
|
import { and, count, desc, eq, isNull } from "drizzle-orm";
|
||||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
import { requireAuth, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
|
||||||
|
|
||||||
export const notificationRoutes = new Hono();
|
export const notificationRoutes = new Hono();
|
||||||
|
|
||||||
// The bell in the topbar shows a badge for activity_feed events from the last
|
// Notifications are per-user rows, so every route is scoped to the current
|
||||||
// 7 days. There is no read/unread state yet, so "count" doubles as the unread
|
// user via requireAuth (not requireWorkspaceAccess). The optional
|
||||||
// badge. graph_edge rows are workspace-internal graph plumbing, not user-facing
|
// workspace_id filter is safe against IDOR because the WHERE clause always
|
||||||
// activity, so they are excluded from both the count and the feed.
|
// pins notifications.userId to the authenticated user.
|
||||||
const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace.
|
const PAGE_SIZE_DEFAULT = 30;
|
||||||
|
|
||||||
|
// GET /api/notifications — List the current user's notifications, newest
|
||||||
|
// first. Query params: workspace_id (filter), unread=true (unread only),
|
||||||
|
// limit/offset or page/perPage for pagination.
|
||||||
notificationRoutes.get("/", async (c) => {
|
notificationRoutes.get("/", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
let workspaceId = c.req.query("workspace_id");
|
const workspaceId = c.req.query("workspace_id");
|
||||||
if (!workspaceId) {
|
const unreadOnly = c.req.query("unread") === "true";
|
||||||
const active = await resolveActiveDomain(user);
|
|
||||||
workspaceId = active.id;
|
|
||||||
}
|
|
||||||
await requireWorkspaceAccess(c, workspaceId);
|
|
||||||
|
|
||||||
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100);
|
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "0", 10) || 0, 1), 100);
|
||||||
const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS);
|
const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
|
||||||
const conditions = [
|
const perPage = Math.min(Math.max(parseInt(c.req.query("perPage") || "0", 10) || 0, 1), 100);
|
||||||
eq(activityFeed.workspaceId, workspaceId),
|
const effectiveLimit = limit || perPage || PAGE_SIZE_DEFAULT;
|
||||||
gte(activityFeed.createdAt, since),
|
const offset = Math.max(0, parseInt(c.req.query("offset") || "0", 10) || 0) || (page - 1) * effectiveLimit;
|
||||||
ne(activityFeed.entityType, "graph_edge"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const [items, countResult] = await Promise.all([
|
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.deletedAt)];
|
||||||
db.select().from(activityFeed).where(and(...conditions)).orderBy(desc(activityFeed.createdAt)).limit(limit),
|
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId));
|
||||||
db.select({ count: sql<number>`count(*)` }).from(activityFeed).where(and(...conditions)),
|
if (unreadOnly) conditions.push(isNull(notifications.readAt));
|
||||||
|
|
||||||
|
const [items, totalResult, unreadResult] = await Promise.all([
|
||||||
|
db.select().from(notifications)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(notifications.createdAt))
|
||||||
|
.limit(effectiveLimit)
|
||||||
|
.offset(offset),
|
||||||
|
db.select({ value: count() }).from(notifications).where(and(...conditions)),
|
||||||
|
db.select({ value: count() }).from(notifications)
|
||||||
|
.where(and(...conditions, isNull(notifications.readAt))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ items, count: Number(countResult[0]?.count || 0) });
|
const totalItems = Number(totalResult[0]?.value ?? 0);
|
||||||
|
return c.json({
|
||||||
|
items,
|
||||||
|
totalItems,
|
||||||
|
unreadCount: Number(unreadResult[0]?.value ?? 0),
|
||||||
|
page,
|
||||||
|
perPage: effectiveLimit,
|
||||||
|
limit: effectiveLimit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
console.error("[notifications] GET error:", error);
|
console.error("[notifications] GET error:", error);
|
||||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500);
|
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to list notifications"), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/notifications/count — Unread count for the bell badge.
|
||||||
|
notificationRoutes.get("/count", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const workspaceId = c.req.query("workspace_id");
|
||||||
|
|
||||||
|
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.readAt), isNull(notifications.deletedAt)];
|
||||||
|
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId));
|
||||||
|
|
||||||
|
const [result] = await db.select({ value: count() }).from(notifications).where(and(...conditions));
|
||||||
|
return c.json({ count: Number(result?.value ?? 0) });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
|
console.error("[notifications] GET /count error:", error);
|
||||||
|
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to get unread count"), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/notifications/:id — Mark a notification as read.
|
||||||
|
notificationRoutes.patch("/:id", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const id = c.req.param("id");
|
||||||
|
if (!isUuid(id)) {
|
||||||
|
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select({ id: notifications.id, userId: notifications.userId, deletedAt: notifications.deletedAt })
|
||||||
|
.from(notifications)
|
||||||
|
.where(and(eq(notifications.id, id), isNull(notifications.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing || existing.userId !== user.id) {
|
||||||
|
return c.json(createErrorResponse("NOT_FOUND", "Notification not found"), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(notifications)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(eq(notifications.id, id));
|
||||||
|
|
||||||
|
return c.json({ success: true, id });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
|
console.error("[notifications] PATCH error:", error);
|
||||||
|
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to update notification"), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/read-all — Mark every notification in the workspace
|
||||||
|
// (or all notifications when no workspace_id is given) as read.
|
||||||
|
notificationRoutes.post("/read-all", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const workspaceId = (body as { workspace_id?: string }).workspace_id ?? c.req.query("workspace_id");
|
||||||
|
|
||||||
|
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.readAt), isNull(notifications.deletedAt)];
|
||||||
|
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId));
|
||||||
|
|
||||||
|
const result = await db.update(notifications)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(and(...conditions))
|
||||||
|
.returning({ id: notifications.id });
|
||||||
|
|
||||||
|
return c.json({ success: true, updated: result.length });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
|
console.error("[notifications] POST /read-all error:", error);
|
||||||
|
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to mark all notifications as read"), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/notifications/:id — Soft-delete a notification.
|
||||||
|
notificationRoutes.delete("/:id", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const id = c.req.param("id");
|
||||||
|
if (!isUuid(id)) {
|
||||||
|
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select({ id: notifications.id, userId: notifications.userId, deletedAt: notifications.deletedAt })
|
||||||
|
.from(notifications)
|
||||||
|
.where(and(eq(notifications.id, id), isNull(notifications.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing || existing.userId !== user.id) {
|
||||||
|
return c.json(createErrorResponse("NOT_FOUND", "Notification not found"), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(notifications)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(eq(notifications.id, id));
|
||||||
|
|
||||||
|
return c.json({ success: true, id });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
}
|
||||||
|
console.error("[notifications] DELETE error:", error);
|
||||||
|
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to delete notification"), 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, statusDefinitions } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
@@ -10,6 +10,16 @@ export const projectRoutes = new Hono();
|
|||||||
|
|
||||||
const projectStatusEnum = z.enum(["active", "paused", "completed", "archived"]);
|
const projectStatusEnum = z.enum(["active", "paused", "completed", "archived"]);
|
||||||
|
|
||||||
|
// Default workflow statuses seeded for every new project. `isDefault` marks the
|
||||||
|
// status new tasks get (the "todo" start state). Categories map to progress and
|
||||||
|
// board-column semantics.
|
||||||
|
const DEFAULT_STATUSES = [
|
||||||
|
{ key: "todo", label: "Todo", category: "todo", color: "#94a3b8", sortOrder: 0, isDefault: true },
|
||||||
|
{ key: "in_progress", label: "In Progress", category: "in_progress", color: "#3b82f6", sortOrder: 1, isDefault: false },
|
||||||
|
{ key: "done", label: "Done", category: "done", color: "#22c55e", sortOrder: 2, isDefault: false },
|
||||||
|
{ key: "cancelled", label: "Cancelled", category: "cancelled", color: "#ef4444", sortOrder: 3, isDefault: false },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const createProjectSchema = z.object({
|
const createProjectSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
@@ -113,9 +123,10 @@ projectRoutes.get("/", async (c) => {
|
|||||||
|
|
||||||
const totalItems = Number(countResult[0]?.count || 0);
|
const totalItems = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
// Fetch task counts and tags for all projects
|
// Fetch task counts, status definitions and tags for all projects
|
||||||
let projectTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
let projectTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||||
let taskCountMap = new Map<string, { total: number; completed: number }>();
|
let taskCountMap = new Map<string, { total: number; completed: number }>();
|
||||||
|
let statusMap = new Map<string, typeof statusDefinitions.$inferSelect[]>();
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
const projectIds = items.map(p => p.id);
|
const projectIds = items.map(p => p.id);
|
||||||
@@ -136,15 +147,32 @@ projectRoutes.get("/", async (c) => {
|
|||||||
projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color });
|
projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status definitions
|
||||||
|
const statusRows = await db.select()
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(inArray(statusDefinitions.projectId, projectIds))
|
||||||
|
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
|
||||||
|
for (const row of statusRows) {
|
||||||
|
if (!statusMap.has(row.projectId)) statusMap.set(row.projectId, []);
|
||||||
|
statusMap.get(row.projectId)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
// Task counts
|
// Task counts
|
||||||
for (const projectId of projectIds) {
|
for (const projectId of projectIds) {
|
||||||
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
|
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)));
|
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
// "Done" is a status category, not a fixed status — a project's status
|
||||||
|
// workflow can mark any status with category='done' as completing.
|
||||||
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
|
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt)));
|
.innerJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
|
.where(and(
|
||||||
|
eq(tasks.projectId, projectId),
|
||||||
|
eq(statusDefinitions.category, "done"),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
taskCountMap.set(projectId, {
|
taskCountMap.set(projectId, {
|
||||||
total: Number(totalResult?.count || 0),
|
total: Number(totalResult?.count || 0),
|
||||||
@@ -158,6 +186,7 @@ projectRoutes.get("/", async (c) => {
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
tags: projectTagMap.get(p.id) || [],
|
tags: projectTagMap.get(p.id) || [],
|
||||||
|
statuses: statusMap.get(p.id) || [],
|
||||||
taskCount: counts.total,
|
taskCount: counts.total,
|
||||||
completedCount: counts.completed,
|
completedCount: counts.completed,
|
||||||
progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0,
|
progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0,
|
||||||
@@ -204,6 +233,11 @@ projectRoutes.post("/", async (c) => {
|
|||||||
targetDate: data.targetDate ? new Date(data.targetDate) : null,
|
targetDate: data.targetDate ? new Date(data.targetDate) : null,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
|
// Seed the default workflow statuses so a new project is usable immediately.
|
||||||
|
const seededStatuses = await db.insert(statusDefinitions).values(
|
||||||
|
DEFAULT_STATUSES.map((s) => ({ ...s, projectId: project.id }))
|
||||||
|
).returning();
|
||||||
|
|
||||||
if (data.tagIds && data.tagIds.length > 0) {
|
if (data.tagIds && data.tagIds.length > 0) {
|
||||||
await db.insert(projectTags).values(
|
await db.insert(projectTags).values(
|
||||||
data.tagIds.map(tagId => ({ projectId: project.id, tagId }))
|
data.tagIds.map(tagId => ({ projectId: project.id, tagId }))
|
||||||
@@ -221,7 +255,7 @@ projectRoutes.post("/", async (c) => {
|
|||||||
|
|
||||||
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
|
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
|
||||||
|
|
||||||
return c.json(project, 201);
|
return c.json({ ...project, statuses: seededStatuses }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
@@ -260,11 +294,22 @@ projectRoutes.get("/:id", async (c) => {
|
|||||||
.where(eq(sections.projectId, id))
|
.where(eq(sections.projectId, id))
|
||||||
.orderBy(asc(sections.sortOrder));
|
.orderBy(asc(sections.sortOrder));
|
||||||
|
|
||||||
// Fetch tasks
|
// Fetch the project's status workflow
|
||||||
const projectTasks = await db.select()
|
const projectStatuses = await db.select()
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.projectId, id))
|
||||||
|
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
|
||||||
|
const statusById = new Map(projectStatuses.map((s) => [s.id, s]));
|
||||||
|
|
||||||
|
// Fetch tasks (with their status definition embedded)
|
||||||
|
const taskColumns = db.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)))
|
.where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)))
|
||||||
.orderBy(asc(tasks.order));
|
.orderBy(asc(tasks.order));
|
||||||
|
const projectTasks = (await taskColumns).map((t) => ({
|
||||||
|
...t,
|
||||||
|
status: t.statusId ? statusById.get(t.statusId) ?? null : null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Fetch tags
|
// Fetch tags
|
||||||
const tagRows = await db.select({
|
const tagRows = await db.select({
|
||||||
@@ -277,13 +322,14 @@ projectRoutes.get("/:id", async (c) => {
|
|||||||
.where(eq(projectTags.projectId, id));
|
.where(eq(projectTags.projectId, id));
|
||||||
|
|
||||||
const totalTasks = projectTasks.length;
|
const totalTasks = projectTasks.length;
|
||||||
const completedTasks = projectTasks.filter(t => t.status === "done").length;
|
const completedTasks = projectTasks.filter((t) => t.status?.category === "done").length;
|
||||||
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
...project,
|
...project,
|
||||||
sections: projectSections,
|
sections: projectSections,
|
||||||
tasks: projectTasks,
|
tasks: projectTasks,
|
||||||
|
statuses: projectStatuses,
|
||||||
tags: tagRows,
|
tags: tagRows,
|
||||||
taskCount: totalTasks,
|
taskCount: totalTasks,
|
||||||
completedCount: completedTasks,
|
completedCount: completedTasks,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
+227
-28
@@ -1,21 +1,60 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
|
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections, statusDefinitions } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, exists, getTableColumns, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
|
import { notifyWorkspaceOwner } from "../lib/notify";
|
||||||
|
import { evaluateAutomations } from "../lib/automation-engine";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { RRule } from "rrule";
|
import { RRule } from "rrule";
|
||||||
|
|
||||||
export const taskRoutes = new Hono();
|
export const taskRoutes = new Hono();
|
||||||
|
|
||||||
const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
|
const STATUS_CATEGORIES = ["todo", "in_progress", "done", "cancelled"] as const;
|
||||||
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]);
|
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]);
|
||||||
|
|
||||||
|
// Columns of the status_definitions table flattened onto a task row as `status`
|
||||||
|
// (null when the task has no status or its status was deleted).
|
||||||
|
const statusColumns = {
|
||||||
|
id: statusDefinitions.id,
|
||||||
|
projectId: statusDefinitions.projectId,
|
||||||
|
key: statusDefinitions.key,
|
||||||
|
label: statusDefinitions.label,
|
||||||
|
category: statusDefinitions.category,
|
||||||
|
color: statusDefinitions.color,
|
||||||
|
sortOrder: statusDefinitions.sortOrder,
|
||||||
|
isDefault: statusDefinitions.isDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusRow = typeof statusDefinitions.$inferSelect;
|
||||||
|
|
||||||
|
async function getProjectStatuses(projectId: string | null | undefined): Promise<StatusRow[]> {
|
||||||
|
if (!projectId) return [];
|
||||||
|
return db.select().from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.projectId, projectId))
|
||||||
|
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDefaultStatusId(projectId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!projectId) return null;
|
||||||
|
const statuses = await getProjectStatuses(projectId);
|
||||||
|
return statuses.find((s) => s.isDefault)?.id ?? statuses[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatusCategory(statusId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!statusId) return null;
|
||||||
|
const [status] = await db.select({ category: statusDefinitions.category })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.id, statusId))
|
||||||
|
.limit(1);
|
||||||
|
return status?.category ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const createTaskSchema = z.object({
|
const createTaskSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
status: taskStatusEnum.optional().default("todo"),
|
statusId: z.string().uuid().optional().nullable(),
|
||||||
priority: taskPriorityEnum.optional().default("medium"),
|
priority: taskPriorityEnum.optional().default("medium"),
|
||||||
domain: z.string().min(1, "Domain is required"),
|
domain: z.string().min(1, "Domain is required"),
|
||||||
projectId: z.string().uuid().optional().nullable(),
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
@@ -32,7 +71,7 @@ const createTaskSchema = z.object({
|
|||||||
const updateTaskSchema = z.object({
|
const updateTaskSchema = z.object({
|
||||||
title: z.string().min(1).optional(),
|
title: z.string().min(1).optional(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
status: taskStatusEnum.optional(),
|
statusId: z.string().uuid().optional().nullable(),
|
||||||
priority: taskPriorityEnum.optional(),
|
priority: taskPriorityEnum.optional(),
|
||||||
projectId: z.string().uuid().optional().nullable(),
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
sectionId: z.string().uuid().optional().nullable(),
|
sectionId: z.string().uuid().optional().nullable(),
|
||||||
@@ -111,8 +150,16 @@ taskRoutes.get("/", async (c) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const statuses = status.split(",");
|
// Filter by category name (todo/in_progress/...) or by status definition
|
||||||
conditions.push(inArray(tasks.status, statuses as any));
|
// id (uuid). Category filters match every project's statuses in that
|
||||||
|
// category; id filters target one specific project status.
|
||||||
|
const values = status.split(",").map((v) => v.trim()).filter(Boolean);
|
||||||
|
const categoryValues = values.filter((v) => (STATUS_CATEGORIES as readonly string[]).includes(v));
|
||||||
|
const idValues = values.filter((v) => isUuid(v));
|
||||||
|
const statusConds: any[] = [];
|
||||||
|
if (categoryValues.length > 0) statusConds.push(inArray(statusDefinitions.category, categoryValues as any));
|
||||||
|
if (idValues.length > 0) statusConds.push(inArray(statusDefinitions.id, idValues));
|
||||||
|
if (statusConds.length > 0) conditions.push(or(...statusConds));
|
||||||
}
|
}
|
||||||
if (priority) {
|
if (priority) {
|
||||||
const priorities = priority.split(",");
|
const priorities = priority.split(",");
|
||||||
@@ -164,7 +211,7 @@ taskRoutes.get("/", async (c) => {
|
|||||||
created: tasks.createdAt,
|
created: tasks.createdAt,
|
||||||
updated: tasks.updatedAt,
|
updated: tasks.updatedAt,
|
||||||
title: tasks.title,
|
title: tasks.title,
|
||||||
status: tasks.status,
|
status: statusDefinitions.sortOrder,
|
||||||
priority: tasks.priority,
|
priority: tasks.priority,
|
||||||
order: tasks.order,
|
order: tasks.order,
|
||||||
due_date: tasks.dueDate,
|
due_date: tasks.dueDate,
|
||||||
@@ -175,15 +222,18 @@ taskRoutes.get("/", async (c) => {
|
|||||||
? asc(sortColumns[sortField] || tasks.createdAt)
|
? asc(sortColumns[sortField] || tasks.createdAt)
|
||||||
: desc(sortColumns[sortField] || tasks.createdAt);
|
: desc(sortColumns[sortField] || tasks.createdAt);
|
||||||
|
|
||||||
|
const taskColumns = getTableColumns(tasks);
|
||||||
const [items, countResult] = await Promise.all([
|
const [items, countResult] = await Promise.all([
|
||||||
db.select()
|
db.select({ ...taskColumns, status: statusColumns })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(orderColumn)
|
.orderBy(orderColumn)
|
||||||
.limit(limit || perPage)
|
.limit(limit || perPage)
|
||||||
.offset(offset || (page - 1) * perPage),
|
.offset(offset || (page - 1) * perPage),
|
||||||
db.select({ count: sql<number>`count(*)` })
|
db.select({ count: sql<number>`count(*)` })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(...conditions)),
|
.where(and(...conditions)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -293,16 +343,37 @@ taskRoutes.post("/", async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the task's status definition. A status is per-project, so it may
|
||||||
|
// only be set when the task belongs to a project, and the status must belong
|
||||||
|
// to that project. When omitted, the project's default status is used.
|
||||||
|
let statusId = data.statusId ?? null;
|
||||||
|
if (data.statusId) {
|
||||||
|
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.id, data.statusId))
|
||||||
|
.limit(1);
|
||||||
|
if (!status) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
|
||||||
|
}
|
||||||
|
if (!data.projectId || status.projectId !== data.projectId) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the selected project" } }, 400);
|
||||||
|
}
|
||||||
|
} else if (data.projectId) {
|
||||||
|
statusId = await getDefaultStatusId(data.projectId);
|
||||||
|
}
|
||||||
|
const statusCategory = await getStatusCategory(statusId);
|
||||||
|
|
||||||
const [task] = await db.insert(tasks).values({
|
const [task] = await db.insert(tasks).values({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? null,
|
description: data.description ?? null,
|
||||||
status: data.status,
|
statusId,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
domainId: data.domain,
|
domainId: data.domain,
|
||||||
projectId: data.projectId ?? null,
|
projectId: data.projectId ?? null,
|
||||||
sectionId: data.sectionId ?? null,
|
sectionId: data.sectionId ?? null,
|
||||||
parentId: data.parentId ?? null,
|
parentId: data.parentId ?? null,
|
||||||
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||||
|
completedAt: statusCategory === "done" ? new Date() : null,
|
||||||
estimatedMinutes: data.estimatedMinutes ?? null,
|
estimatedMinutes: data.estimatedMinutes ?? null,
|
||||||
order: data.order ?? 0,
|
order: data.order ?? 0,
|
||||||
customFields: data.customFields ?? {},
|
customFields: data.customFields ?? {},
|
||||||
@@ -320,16 +391,40 @@ taskRoutes.post("/", async (c) => {
|
|||||||
action: "created",
|
action: "created",
|
||||||
entityType: "task",
|
entityType: "task",
|
||||||
entityId: task.id,
|
entityId: task.id,
|
||||||
changes: { title: task.title, status: task.status, priority: task.priority },
|
changes: { title: task.title, statusId: task.statusId, priority: task.priority },
|
||||||
workspaceId: data.domain,
|
workspaceId: data.domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } });
|
await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } });
|
||||||
|
|
||||||
|
// MVP single-user: the workspace owner is the only assignee, so a new task
|
||||||
|
// is treated as an assignment to them.
|
||||||
|
try {
|
||||||
|
await notifyWorkspaceOwner({
|
||||||
|
workspaceId: data.domain,
|
||||||
|
type: "assignment",
|
||||||
|
title: "New task assigned to you",
|
||||||
|
body: `"${task.title}" was created${user.name ? ` by ${user.name}` : ""}`,
|
||||||
|
entityType: "task",
|
||||||
|
entityId: task.id,
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error(`[tasks] Failed to create assignment notification for task ${task.id}:`, notifyError);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.recurrenceRule) {
|
if (data.recurrenceRule) {
|
||||||
await syncScheduledJob(task.id, data.recurrenceRule);
|
await syncScheduledJob(task.id, data.recurrenceRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire "task created" automation rules (e.g. set a default priority/label).
|
||||||
|
await evaluateAutomations({
|
||||||
|
projectId: task.projectId,
|
||||||
|
triggerType: "task_created",
|
||||||
|
entity: task,
|
||||||
|
changes: {},
|
||||||
|
actor: user.name,
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(task, 201);
|
return c.json(task, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -417,8 +512,10 @@ taskRoutes.get("/:id", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [task] = await db.select()
|
const taskColumns = getTableColumns(tasks);
|
||||||
|
const [task] = await db.select({ ...taskColumns, status: statusColumns })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -429,8 +526,9 @@ taskRoutes.get("/:id", async (c) => {
|
|||||||
await requireWorkspaceAccess(c, task.domainId);
|
await requireWorkspaceAccess(c, task.domainId);
|
||||||
|
|
||||||
// Fetch subtasks
|
// Fetch subtasks
|
||||||
const subtasks = await db.select()
|
const subtasks = await db.select({ ...taskColumns, status: statusColumns })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt)))
|
.where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt)))
|
||||||
.orderBy(asc(tasks.order));
|
.orderBy(asc(tasks.order));
|
||||||
|
|
||||||
@@ -448,20 +546,24 @@ taskRoutes.get("/:id", async (c) => {
|
|||||||
const depRows = await db.select({
|
const depRows = await db.select({
|
||||||
id: tasks.id,
|
id: tasks.id,
|
||||||
title: tasks.title,
|
title: tasks.title,
|
||||||
status: tasks.status,
|
statusId: tasks.statusId,
|
||||||
|
status: statusColumns,
|
||||||
})
|
})
|
||||||
.from(taskDependencies)
|
.from(taskDependencies)
|
||||||
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
|
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
|
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
// Fetch dependents (tasks that depend on this task)
|
// Fetch dependents (tasks that depend on this task)
|
||||||
const dependentRows = await db.select({
|
const dependentRows = await db.select({
|
||||||
id: tasks.id,
|
id: tasks.id,
|
||||||
title: tasks.title,
|
title: tasks.title,
|
||||||
status: tasks.status,
|
statusId: tasks.statusId,
|
||||||
|
status: statusColumns,
|
||||||
})
|
})
|
||||||
.from(taskDependencies)
|
.from(taskDependencies)
|
||||||
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
|
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
|
||||||
|
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
|
||||||
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
|
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -525,7 +627,6 @@ taskRoutes.patch("/:id", async (c) => {
|
|||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.title !== undefined) updateValues.title = data.title;
|
if (data.title !== undefined) updateValues.title = data.title;
|
||||||
if (data.description !== undefined) updateValues.description = data.description;
|
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.priority !== undefined) updateValues.priority = data.priority;
|
||||||
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
|
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
|
||||||
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
|
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
|
||||||
@@ -535,6 +636,31 @@ taskRoutes.patch("/:id", async (c) => {
|
|||||||
if (data.order !== undefined) updateValues.order = data.order;
|
if (data.order !== undefined) updateValues.order = data.order;
|
||||||
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
|
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
|
||||||
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
|
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
|
||||||
|
|
||||||
|
// Status change: validate the status belongs to the task's project and keep
|
||||||
|
// completedAt in sync with the "done" category.
|
||||||
|
let newStatusLabel: string | null = null;
|
||||||
|
if (data.statusId !== undefined) {
|
||||||
|
const statusId = data.statusId ?? null;
|
||||||
|
if (statusId) {
|
||||||
|
const finalProjectId = (data.projectId !== undefined ? data.projectId : existing.projectId) ?? null;
|
||||||
|
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category, label: statusDefinitions.label })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.id, statusId))
|
||||||
|
.limit(1);
|
||||||
|
if (!status) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
|
||||||
|
}
|
||||||
|
if (!finalProjectId || status.projectId !== finalProjectId) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the task's project" } }, 400);
|
||||||
|
}
|
||||||
|
updateValues.completedAt = status.category === "done" ? new Date() : null;
|
||||||
|
newStatusLabel = status.label;
|
||||||
|
} else {
|
||||||
|
updateValues.completedAt = null;
|
||||||
|
}
|
||||||
|
updateValues.statusId = statusId;
|
||||||
|
}
|
||||||
updateValues.updatedAt = new Date();
|
updateValues.updatedAt = new Date();
|
||||||
|
|
||||||
const [updated] = await db.update(tasks)
|
const [updated] = await db.update(tasks)
|
||||||
@@ -547,16 +673,44 @@ taskRoutes.patch("/:id", async (c) => {
|
|||||||
action: "updated",
|
action: "updated",
|
||||||
entityType: "task",
|
entityType: "task",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
changes: { ...data, previousStatus: existing.status },
|
changes: { ...data, previousStatusId: existing.statusId },
|
||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatusId: existing.statusId } });
|
||||||
|
|
||||||
|
// Notify on actual status changes only (not unrelated field updates).
|
||||||
|
if (data.statusId !== undefined && data.statusId !== existing.statusId) {
|
||||||
|
try {
|
||||||
|
await notifyWorkspaceOwner({
|
||||||
|
workspaceId: existing.domainId,
|
||||||
|
type: "status_change",
|
||||||
|
title: `Task moved to ${newStatusLabel ?? "a new status"}`,
|
||||||
|
body: `"${updated.title}" ${newStatusLabel ? `is now ${newStatusLabel}` : "changed status"}`,
|
||||||
|
entityType: "task",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error(`[tasks] Failed to create status-change notification for task ${id}:`, notifyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.recurrenceRule !== undefined) {
|
if (data.recurrenceRule !== undefined) {
|
||||||
await syncScheduledJob(id, data.recurrenceRule);
|
await syncScheduledJob(id, data.recurrenceRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire "status changed" automation rules when the status actually moved
|
||||||
|
// (e.g. when a status changes to Done, add a "shipped" label).
|
||||||
|
if (data.statusId !== undefined && data.statusId !== existing.statusId) {
|
||||||
|
await evaluateAutomations({
|
||||||
|
projectId: updated.projectId,
|
||||||
|
triggerType: "task_status_changed",
|
||||||
|
entity: updated,
|
||||||
|
changes: { previousStatusId: existing.statusId },
|
||||||
|
actor: user.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(updated);
|
return c.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -838,7 +992,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/tasks/:id/status — Change task status (Kanban drag)
|
// POST /api/tasks/:id/status — Change task status (Kanban drag / completion)
|
||||||
taskRoutes.post("/:id/status", async (c) => {
|
taskRoutes.post("/:id/status", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
@@ -847,8 +1001,8 @@ taskRoutes.post("/:id/status", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||||
}
|
}
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { status: newStatus } = z.object({
|
const { statusId } = z.object({
|
||||||
status: taskStatusEnum,
|
statusId: z.string().uuid("Invalid status id"),
|
||||||
}).parse(body);
|
}).parse(body);
|
||||||
|
|
||||||
const [existing] = await db.select()
|
const [existing] = await db.select()
|
||||||
@@ -862,13 +1016,33 @@ taskRoutes.post("/:id/status", async (c) => {
|
|||||||
|
|
||||||
await requireWorkspaceAccess(c, existing.domainId);
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
|
if (!existing.projectId) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Tasks without a project cannot have a status" } }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status must belong to the task's project.
|
||||||
|
const [status] = await db.select({
|
||||||
|
id: statusDefinitions.id,
|
||||||
|
projectId: statusDefinitions.projectId,
|
||||||
|
category: statusDefinitions.category,
|
||||||
|
label: statusDefinitions.label,
|
||||||
|
})
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.id, statusId))
|
||||||
|
.limit(1);
|
||||||
|
if (!status) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
|
||||||
|
}
|
||||||
|
if (status.projectId !== existing.projectId) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the task's project" } }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDone = status.category === "done";
|
||||||
const updateValues: Record<string, unknown> = {
|
const updateValues: Record<string, unknown> = {
|
||||||
status: newStatus,
|
statusId,
|
||||||
|
completedAt: isDone ? new Date() : null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
if (newStatus === "done") {
|
|
||||||
updateValues.completedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db.update(tasks)
|
const [updated] = await db.update(tasks)
|
||||||
.set(updateValues)
|
.set(updateValues)
|
||||||
@@ -877,14 +1051,39 @@ taskRoutes.post("/:id/status", async (c) => {
|
|||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
actor: user.name,
|
actor: user.name,
|
||||||
action: newStatus === "done" ? "completed" : "updated",
|
action: isDone ? "completed" : "updated",
|
||||||
entityType: "task",
|
entityType: "task",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
changes: { previousStatus: existing.status, newStatus },
|
changes: { previousStatusId: existing.statusId, newStatusId: statusId, newStatusLabel: status.label },
|
||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json(updated);
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { previousStatusId: existing.statusId, newStatusId: statusId } });
|
||||||
|
|
||||||
|
// Fire "status changed" automation rules (Kanban drags and done checkboxes).
|
||||||
|
await evaluateAutomations({
|
||||||
|
projectId: updated.projectId,
|
||||||
|
triggerType: "task_status_changed",
|
||||||
|
entity: updated,
|
||||||
|
changes: { previousStatusId: existing.statusId },
|
||||||
|
actor: user.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the workspace owner (MVP single-user) of the status change.
|
||||||
|
try {
|
||||||
|
await notifyWorkspaceOwner({
|
||||||
|
workspaceId: existing.domainId,
|
||||||
|
type: "status_change",
|
||||||
|
title: `Task moved to ${status.label}`,
|
||||||
|
body: `"${existing.title}" is now ${status.label}`,
|
||||||
|
entityType: "task",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error(`[tasks] Failed to create status-change notification for task ${id}:`, notifyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ...updated, status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
|
import { Search, Plus, Menu } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
|
||||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||||
import { useApiQuery } from "@/lib/api";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { DomainPicker } from "@/components/shell/domain-picker";
|
import { DomainPicker } from "@/components/shell/domain-picker";
|
||||||
|
import { NotificationCenter } from "@/components/notification-center";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -21,27 +19,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import type { NotificationsResponse } from "@/lib/types";
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
|
||||||
created: "created",
|
|
||||||
updated: "updated",
|
|
||||||
deleted: "deleted",
|
|
||||||
completed: "completed",
|
|
||||||
};
|
|
||||||
|
|
||||||
function readableEntityType(entityType: string): string {
|
|
||||||
return entityType
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Topbar() {
|
export function Topbar() {
|
||||||
const { setMobileOpen } = useSidebarStore();
|
const { setMobileOpen } = useSidebarStore();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const domainId = useApiDomain();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const userName = user?.name || "User";
|
const userName = user?.name || "User";
|
||||||
const userEmail = user?.email || "";
|
const userEmail = user?.email || "";
|
||||||
@@ -51,20 +32,6 @@ export function Topbar() {
|
|||||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: notificationsData } = useApiQuery<NotificationsResponse>(
|
|
||||||
["notifications", domainId],
|
|
||||||
"/notifications?workspace_id=" + encodeURIComponent(domainId),
|
|
||||||
{ enabled: !!domainId, refetchInterval: 60_000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const notifications = notificationsData?.items || [];
|
|
||||||
const count = notificationsData?.count || 0;
|
|
||||||
const badgeLabel = count > 99 ? "99+" : String(count);
|
|
||||||
const tooltipText =
|
|
||||||
count === 0
|
|
||||||
? "No notifications"
|
|
||||||
: `${count} unread notification${count === 1 ? "" : "s"}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
||||||
@@ -119,67 +86,15 @@ export function Topbar() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Notifications bell */}
|
{/* Notifications bell (badge + slide-out panel) */}
|
||||||
<TooltipProvider>
|
<NotificationCenter />
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
{count > 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>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{tooltipText}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
|
||||||
<div className="flex items-center justify-between px-2 py-1.5">
|
|
||||||
<span className="text-sm font-medium">Notifications</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-xs"
|
|
||||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["notifications"] })}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3 mr-1" />Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No notifications
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
notifications.slice(0, 10).map((n) => (
|
|
||||||
<DropdownMenuItem key={n.id} className="flex cursor-default flex-col items-start gap-0.5 py-2">
|
|
||||||
<span className="text-sm capitalize">
|
|
||||||
{readableEntityType(n.entityType)}{" "}
|
|
||||||
{ACTION_LABELS[n.action] || n.action}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{n.actor} · {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{/* User avatar */}
|
{/* User avatar */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
|||||||
case "graph_edge":
|
case "graph_edge":
|
||||||
queryKeys.push(["graph"]);
|
queryKeys.push(["graph"]);
|
||||||
break;
|
break;
|
||||||
|
case "notification":
|
||||||
|
// A new in-app notification landed — refresh the bell badge and list.
|
||||||
|
queryKeys.push(["notifications"], ["notifications-count"]);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
queryKeys.push([entityType]);
|
queryKeys.push([entityType]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,40 @@ export const TASK_STATUS: Record<string, StatusToken> = {
|
|||||||
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
|
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category → hex color fallback for custom workflow statuses. Custom statuses
|
||||||
|
* carry their own `color`; when unset the category color applies. Hex (not
|
||||||
|
* Tailwind classes) so it can drive inline `style={{ backgroundColor }}`.
|
||||||
|
*/
|
||||||
|
export const TASK_STATUS_CATEGORY_COLOR: Record<string, string> = {
|
||||||
|
todo: "#94a3b8",
|
||||||
|
in_progress: "#3b82f6",
|
||||||
|
done: "#22c55e",
|
||||||
|
cancelled: "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Label for a status definition (falls back to the raw category name). */
|
||||||
|
export function getStatusLabel(status: { label?: string; category?: string } | null | undefined): string {
|
||||||
|
if (status?.label) return status.label;
|
||||||
|
if (status?.category) return status.category.replace("_", " ");
|
||||||
|
return "No status";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hex color for a status definition: its own color or the category fallback. */
|
||||||
|
export function getStatusColor(status: { color?: string | null; category?: string } | null | undefined): string {
|
||||||
|
if (status?.color) return status.color;
|
||||||
|
return TASK_STATUS_CATEGORY_COLOR[status?.category ?? "todo"] ?? "#94a3b8";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tailwind token for a status definition (used where inline styles are awkward).
|
||||||
|
* Custom colors can't map to Tailwind classes, so this only covers the category
|
||||||
|
* fallback; callers needing exact colors should use `getStatusColor`.
|
||||||
|
*/
|
||||||
|
export function getStatusToken(status: { category?: string } | null | undefined): StatusToken {
|
||||||
|
return TASK_STATUS[status?.category ?? "todo"] ?? TASK_STATUS.todo;
|
||||||
|
}
|
||||||
|
|
||||||
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||||
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||||
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
// Shared types for Project E entities
|
// Shared types for Project E entities
|
||||||
|
|
||||||
|
export type TaskStatusCategory = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
|
|
||||||
|
/** A per-project workflow status definition. */
|
||||||
|
export interface StatusDefinition {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Stable machine key unique per project, e.g. "in_review". */
|
||||||
|
key: string;
|
||||||
|
/** Display label, e.g. "In Review". */
|
||||||
|
label: string;
|
||||||
|
/** UI semantics: drives progress, board columns and completion checks. */
|
||||||
|
category: TaskStatusCategory;
|
||||||
|
/** Hex color, or null to fall back to the category color. */
|
||||||
|
color: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: "todo" | "in_progress" | "done" | "cancelled";
|
statusId: string | null;
|
||||||
|
/** The resolved status definition (null when unassigned or status deleted). */
|
||||||
|
status: StatusDefinition | null;
|
||||||
priority: "low" | "medium" | "high" | "urgent";
|
priority: "low" | "medium" | "high" | "urgent";
|
||||||
domainId: string;
|
domainId: string;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
@@ -21,8 +43,16 @@ export interface Task {
|
|||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
customFields?: Record<string, unknown>;
|
customFields?: Record<string, unknown>;
|
||||||
subtasks?: Task[];
|
subtasks?: Task[];
|
||||||
dependencies?: { id: string; title: string; status: string }[];
|
dependencies?: TaskSummary[];
|
||||||
dependents?: { id: string; title: string; status: string }[];
|
dependents?: TaskSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slim task row used in dependency lists. */
|
||||||
|
export interface TaskSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
statusId: string | null;
|
||||||
|
status: StatusDefinition | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Habit {
|
export interface Habit {
|
||||||
@@ -73,6 +103,8 @@ export interface Project {
|
|||||||
taskCount: number;
|
taskCount: number;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
/** This project's workflow status definitions (defaults seeded on create). */
|
||||||
|
statuses?: StatusDefinition[];
|
||||||
sections?: Section[];
|
sections?: Section[];
|
||||||
tasks?: Task[];
|
tasks?: Task[];
|
||||||
}
|
}
|
||||||
@@ -89,6 +121,40 @@ export interface Section {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Automation Rules ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AutomationTriggerType = "task_status_changed" | "task_created" | "due_date_approaching";
|
||||||
|
export type AutomationActionType = "set_status" | "set_priority" | "add_label" | "create_notification";
|
||||||
|
export type AutomationConditionField = "project" | "status" | "priority" | "label";
|
||||||
|
|
||||||
|
export interface AutomationTrigger {
|
||||||
|
type: AutomationTriggerType;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationCondition {
|
||||||
|
field: AutomationConditionField;
|
||||||
|
op: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationAction {
|
||||||
|
type: AutomationActionType;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationRule {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
trigger: AutomationTrigger;
|
||||||
|
conditions: AutomationCondition[];
|
||||||
|
actions: AutomationAction[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -270,19 +336,34 @@ export interface Agent {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationType = "mention" | "status_change" | "due_soon" | "automation" | "assignment" | (string & {});
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
actor: string;
|
userId: string;
|
||||||
action: string;
|
workspaceId: string | null;
|
||||||
entityType: string;
|
type: NotificationType;
|
||||||
entityId: string;
|
title: string;
|
||||||
changes: Record<string, unknown> | null;
|
body: string | null;
|
||||||
workspaceId: string;
|
entityType: string | null;
|
||||||
|
entityId: string | null;
|
||||||
|
/** NULL = unread. */
|
||||||
|
readAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationsResponse {
|
export interface NotificationsResponse {
|
||||||
items: Notification[];
|
items: Notification[];
|
||||||
|
totalItems: number;
|
||||||
|
unreadCount: number;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationCount {
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Sidebar } from "@/components/shell/sidebar";
|
|||||||
import { Topbar } from "@/components/shell/topbar";
|
import { Topbar } from "@/components/shell/topbar";
|
||||||
import { CommandPalette } from "@/components/shell/command-palette";
|
import { CommandPalette } from "@/components/shell/command-palette";
|
||||||
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
|
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
|
||||||
|
import { QuickAddBar } from "@/components/quick-add-bar";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
@@ -41,6 +42,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<ShortcutsHelp />
|
<ShortcutsHelp />
|
||||||
|
<QuickAddBar />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function TasksDueWidget() {
|
|||||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix);
|
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix);
|
||||||
const tasks = data?.items || [];
|
const tasks = data?.items || [];
|
||||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
||||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status?.category !== "done");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{today.length === 0 && overdue.length === 0 ? (
|
{today.length === 0 && overdue.length === 0 ? (
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
FolderKanban,
|
FolderKanban,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Plus,
|
Plus,
|
||||||
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { differenceInCalendarDays, format, parseISO } from "date-fns";
|
import { differenceInCalendarDays, format, parseISO } from "date-fns";
|
||||||
import { api, useApiQuery } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
} from "@/components/entities/inline-edit";
|
} from "@/components/entities/inline-edit";
|
||||||
import { EntityActivity } from "@/components/entities/entity-activity";
|
import { EntityActivity } from "@/components/entities/entity-activity";
|
||||||
import { EntityComments } from "@/components/entities/entity-comments";
|
import { EntityComments } from "@/components/entities/entity-comments";
|
||||||
|
import { AutomationRuleBuilder, TRIGGER_OPTIONS, summarizeActions } from "@/components/automation-rule-builder";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -52,9 +55,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { LoadingState, ErrorState } from "@/components/state";
|
import { LoadingState, ErrorState } from "@/components/state";
|
||||||
import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
import { GanttChart } from "@/components/gantt/gantt-chart";
|
||||||
import type { Project, Section, Task } from "@/lib/types";
|
import type { TimelineData } from "@/components/gantt/gantt-utils";
|
||||||
|
import { getStatusToken, PRIORITY, PROJECT_STATUS } from "@/lib/status-colors";
|
||||||
|
import type { AutomationRule, Project, Section, Task } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
|
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
|
||||||
@@ -226,6 +232,16 @@ function ProjectDetail() {
|
|||||||
},
|
},
|
||||||
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
|
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
|
||||||
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
|
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
|
||||||
|
{
|
||||||
|
value: "timeline",
|
||||||
|
label: "Timeline",
|
||||||
|
content: <ProjectTimeline project={project} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "automations",
|
||||||
|
label: "Automations",
|
||||||
|
content: <ProjectAutomations project={project} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "activity",
|
value: "activity",
|
||||||
label: "Activity",
|
label: "Activity",
|
||||||
@@ -342,8 +358,8 @@ function ProjectTasks({ project }: { project: Project }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
|
mutationFn: ({ taskId, statusId }: { taskId: string; statusId: string | null }) =>
|
||||||
api.post<Task>(`/tasks/${taskId}/status`, { status }),
|
api.post<Task>(`/tasks/${taskId}/status`, { statusId }),
|
||||||
onMutate: (vars) => setPendingId(vars.taskId),
|
onMutate: (vars) => setPendingId(vars.taskId),
|
||||||
onSettled: () => setPendingId(null),
|
onSettled: () => setPendingId(null),
|
||||||
onSuccess: refresh,
|
onSuccess: refresh,
|
||||||
@@ -438,6 +454,7 @@ function ProjectTasks({ project }: { project: Project }) {
|
|||||||
<TaskRow
|
<TaskRow
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
project={project}
|
||||||
pending={pendingId === task.id}
|
pending={pendingId === task.id}
|
||||||
onToggle={(vars) => toggleMutation.mutate(vars)}
|
onToggle={(vars) => toggleMutation.mutate(vars)}
|
||||||
onOpen={() => openTask(task.id)}
|
onOpen={() => openTask(task.id)}
|
||||||
@@ -460,6 +477,7 @@ function ProjectTasks({ project }: { project: Project }) {
|
|||||||
<TaskRow
|
<TaskRow
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
project={project}
|
||||||
pending={pendingId === task.id}
|
pending={pendingId === task.id}
|
||||||
onToggle={(vars) => toggleMutation.mutate(vars)}
|
onToggle={(vars) => toggleMutation.mutate(vars)}
|
||||||
onOpen={() => openTask(task.id)}
|
onOpen={() => openTask(task.id)}
|
||||||
@@ -475,37 +493,43 @@ function ProjectTasks({ project }: { project: Project }) {
|
|||||||
|
|
||||||
function TaskRow({
|
function TaskRow({
|
||||||
task,
|
task,
|
||||||
|
project,
|
||||||
pending,
|
pending,
|
||||||
onToggle,
|
onToggle,
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
project: Project;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
onToggle: (vars: { taskId: string; status: Task["status"] }) => void;
|
onToggle: (vars: { taskId: string; statusId: string | null }) => void;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isDone = task.status?.category === "done";
|
||||||
|
const statuses = project.statuses ?? [];
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={task.status === "done"}
|
checked={isDone}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
onToggle({
|
onToggle({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
status: task.status === "done" ? "todo" : "done",
|
statusId: isDone
|
||||||
|
? (statuses.find((s) => s.category === "todo")?.id ?? null)
|
||||||
|
: (statuses.find((s) => s.category === "done")?.id ?? null),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
aria-label={
|
aria-label={
|
||||||
"Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done")
|
"Mark " + task.title + " " + (isDone ? "as not done" : "as done")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[task.status]?.dot)} />
|
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(task.status).dot)} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||||
task.status === "done" && "text-muted-foreground line-through"
|
isDone && "text-muted-foreground line-through"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
@@ -690,6 +714,156 @@ function Sections({ project }: { project: Project }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectAutomations({ project }: { project: Project }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [builderOpen, setBuilderOpen] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState<AutomationRule | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error, refetch } = useApiQuery<{
|
||||||
|
items: AutomationRule[];
|
||||||
|
}>(["automations", project.id], `/projects/${project.id}/automations`);
|
||||||
|
|
||||||
|
const rules = data?.items ?? [];
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["automations", project.id] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: (ruleId: string) =>
|
||||||
|
api.post(`/projects/${project.id}/automations/${ruleId}/toggle`),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (ruleId: string) =>
|
||||||
|
api.delete(`/projects/${project.id}/automations/${ruleId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Rule deleted");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(errorMessage(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setBuilderOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (rule: AutomationRule) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
setBuilderOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingState label="Loading automations..." />;
|
||||||
|
if (isError) return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically run actions when tasks change in this project.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4" /> Create Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No automation rules yet — e.g. add a "shipped" label when a task is done.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rules.map((rule) => {
|
||||||
|
const triggerLabel =
|
||||||
|
TRIGGER_OPTIONS.find((t) => t.value === rule.trigger.type)?.label ??
|
||||||
|
rule.trigger.type;
|
||||||
|
const actionSummaries = summarizeActions(project, rule.actions);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/30 px-4 py-3"
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold">{rule.name}</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
When {triggerLabel.toLowerCase()}
|
||||||
|
{rule.conditions.length > 0
|
||||||
|
? ` (${rule.conditions.length} ${rule.conditions.length === 1 ? "condition" : "conditions"})`
|
||||||
|
: ""}{" "}
|
||||||
|
→ {actionSummaries.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={rule.active}
|
||||||
|
onCheckedChange={() => toggleMutation.mutate(rule.id)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-label={"Toggle " + rule.name}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => openEdit(rule)}
|
||||||
|
aria-label={"Edit " + rule.name}
|
||||||
|
title="Edit rule"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => deleteMutation.mutate(rule.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
aria-label={"Delete " + rule.name}
|
||||||
|
title="Delete rule"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{builderOpen ? (
|
||||||
|
<AutomationRuleBuilder
|
||||||
|
project={project}
|
||||||
|
open={builderOpen}
|
||||||
|
onOpenChange={setBuilderOpen}
|
||||||
|
rule={editingRule}
|
||||||
|
onSaved={refresh}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectTimeline({ project }: { project: Project }) {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useApiQuery<TimelineData>(
|
||||||
|
["timeline", project.domainId, project.id],
|
||||||
|
`/domains/${project.domainId}/projects/${project.id}/timeline`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingState label="Loading timeline..." />;
|
||||||
|
if (isError) return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
|
||||||
|
if (!data) return <ErrorState message="Timeline data unavailable" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GanttChart
|
||||||
|
domainId={project.domainId}
|
||||||
|
projectId={project.id}
|
||||||
|
tasks={data.tasks}
|
||||||
|
milestones={data.milestones}
|
||||||
|
statuses={project.statuses ?? []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createRoute({
|
export const Route = createRoute({
|
||||||
getParentRoute: () => appRoute,
|
getParentRoute: () => appRoute,
|
||||||
path: "projects/$id",
|
path: "projects/$id",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -27,8 +27,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
||||||
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
import { getStatusLabel, getStatusToken, TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||||
import type { Task, PaginatedResponse } from "@/lib/types";
|
import type { StatusDefinition, Task, TaskStatusCategory, PaginatedResponse } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const STATUS_COLUMNS = [
|
const STATUS_COLUMNS = [
|
||||||
@@ -99,16 +99,28 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_STATUS = "__none__";
|
||||||
|
|
||||||
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const activeDomainId = useApiDomain();
|
const activeDomainId = useApiDomain();
|
||||||
const [title, setTitle] = useState(task?.title || "");
|
const [title, setTitle] = useState(task?.title || "");
|
||||||
const [description, setDescription] = useState(task?.description || "");
|
const [description, setDescription] = useState(task?.description || "");
|
||||||
const [status, setStatus] = useState(task?.status || "todo");
|
const [statusId, setStatusId] = useState(task?.statusId ?? "");
|
||||||
const [priority, setPriority] = useState(task?.priority || "medium");
|
const [priority, setPriority] = useState(task?.priority || "medium");
|
||||||
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
||||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
|
||||||
|
|
||||||
|
// Status is a per-project status definition, so it can only be picked when the
|
||||||
|
// task belongs to a project whose statuses we can load.
|
||||||
|
const projectId = task?.projectId ?? null;
|
||||||
|
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||||
|
["project-statuses", projectId ?? "none"],
|
||||||
|
projectId ? `/projects/${projectId}/statuses` : "",
|
||||||
|
{ enabled: !!projectId }
|
||||||
|
);
|
||||||
|
const statusOptions = statusesData?.items ?? [];
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -128,7 +140,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
const data: any = { title: title.trim(), description: description || null, status, priority };
|
const data: any = { title: title.trim(), description: description || null, priority };
|
||||||
|
if (statusId) data.statusId = statusId;
|
||||||
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
||||||
const customFields = { ...customFieldValues };
|
const customFields = { ...customFieldValues };
|
||||||
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
||||||
@@ -150,18 +163,23 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
{statusOptions.length > 0 && (
|
||||||
<Label htmlFor="status">Status</Label>
|
<div>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
|
<Label htmlFor="status">Status</Label>
|
||||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
<Select
|
||||||
<SelectContent>
|
value={statusId || NO_STATUS}
|
||||||
<SelectItem value="todo">Todo</SelectItem>
|
onValueChange={(v) => setStatusId(v === NO_STATUS ? "" : v)}
|
||||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
>
|
||||||
<SelectItem value="done">Done</SelectItem>
|
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
<SelectItem value={NO_STATUS}>No status</SelectItem>
|
||||||
</Select>
|
{statusOptions.map((s) => (
|
||||||
</div>
|
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="priority">Priority</Label>
|
<Label htmlFor="priority">Priority</Label>
|
||||||
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
|
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
|
||||||
@@ -242,8 +260,8 @@ function TasksPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
mutationFn: ({ id, statusId }: { id: string; statusId: string }) =>
|
||||||
api.post("/tasks/" + id + "/status", { status }),
|
api.post("/tasks/" + id + "/status", { statusId }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
},
|
},
|
||||||
@@ -276,11 +294,29 @@ function TasksPage() {
|
|||||||
useSensor(KeyboardSensor)
|
useSensor(KeyboardSensor)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Status definitions are per-project; cache them so a board drop can resolve
|
||||||
|
// the target category to a real statusId without refetching every time.
|
||||||
|
const statusCache = useRef(new Map<string, StatusDefinition[]>());
|
||||||
|
|
||||||
|
const resolveProjectStatus = useCallback(
|
||||||
|
async (projectId: string | null, category: TaskStatusCategory): Promise<StatusDefinition | null> => {
|
||||||
|
if (!projectId) return null;
|
||||||
|
let statuses = statusCache.current.get(projectId);
|
||||||
|
if (!statuses) {
|
||||||
|
const res = await api.get<{ items: StatusDefinition[] }>(`/projects/${projectId}/statuses`);
|
||||||
|
statuses = res.items ?? [];
|
||||||
|
statusCache.current.set(projectId, statuses);
|
||||||
|
}
|
||||||
|
return statuses.find((s) => s.category === category) ?? null;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string);
|
setActiveId(event.active.id as string);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
@@ -294,7 +330,7 @@ function TasksPage() {
|
|||||||
// Tasks of a column in persisted order
|
// Tasks of a column in persisted order
|
||||||
const columnTasks = (status: string) =>
|
const columnTasks = (status: string) =>
|
||||||
tasks
|
tasks
|
||||||
.filter((t) => t.status === status)
|
.filter((t) => t.status?.category === status)
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
// Decide the target column and insertion index:
|
// Decide the target column and insertion index:
|
||||||
@@ -308,7 +344,7 @@ function TasksPage() {
|
|||||||
} else {
|
} else {
|
||||||
const overTask = tasks.find((t) => t.id === overId);
|
const overTask = tasks.find((t) => t.id === overId);
|
||||||
if (!overTask) return;
|
if (!overTask) return;
|
||||||
targetColumn = overTask.status;
|
targetColumn = overTask.status?.category ?? "todo";
|
||||||
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
||||||
insertIndex = overIndex === -1 ? -1 : overIndex;
|
insertIndex = overIndex === -1 ? -1 : overIndex;
|
||||||
}
|
}
|
||||||
@@ -330,16 +366,24 @@ function TasksPage() {
|
|||||||
currentIds.every((id, i) => id === targetIds[i]);
|
currentIds.every((id, i) => id === targetIds[i]);
|
||||||
if (unchanged) return;
|
if (unchanged) return;
|
||||||
|
|
||||||
|
const statusChanged = draggedTask.status?.category !== targetColumn;
|
||||||
|
|
||||||
|
// Resolve the target status definition for the dragged task's project so
|
||||||
|
// the optimistic update and the API call carry a real statusId.
|
||||||
|
let targetStatus: StatusDefinition | null = null;
|
||||||
|
if (statusChanged) {
|
||||||
|
targetStatus = await resolveProjectStatus(draggedTask.projectId, targetColumn as TaskStatusCategory);
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistic local update so the board reorders immediately
|
// Optimistic local update so the board reorders immediately
|
||||||
const statusChanged = draggedTask.status !== targetColumn;
|
|
||||||
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
||||||
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
|
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return {
|
return {
|
||||||
...old,
|
...old,
|
||||||
items: old.items.map((t) => {
|
items: old.items.map((t) => {
|
||||||
if (t.id === taskId && statusChanged) {
|
if (t.id === taskId && statusChanged && targetStatus) {
|
||||||
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
|
return { ...t, status: targetStatus, statusId: targetStatus.id, order: orderById.get(t.id) ?? t.order };
|
||||||
}
|
}
|
||||||
const order = orderById.get(t.id);
|
const order = orderById.get(t.id);
|
||||||
return order !== undefined ? { ...t, order } : t;
|
return order !== undefined ? { ...t, order } : t;
|
||||||
@@ -347,8 +391,8 @@ function TasksPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (statusChanged) {
|
if (statusChanged && targetStatus) {
|
||||||
statusMutation.mutate({ id: taskId, status: targetColumn });
|
statusMutation.mutate({ id: taskId, statusId: targetStatus.id });
|
||||||
}
|
}
|
||||||
reorderMutation.mutate({ orderedIds: targetIds });
|
reorderMutation.mutate({ orderedIds: targetIds });
|
||||||
};
|
};
|
||||||
@@ -367,7 +411,7 @@ function TasksPage() {
|
|||||||
...col,
|
...col,
|
||||||
color: TASK_STATUS[col.id].dot,
|
color: TASK_STATUS[col.id].dot,
|
||||||
tasks: tasks
|
tasks: tasks
|
||||||
.filter((t) => t.status === col.id)
|
.filter((t) => t.status?.category === col.id)
|
||||||
.sort((a, b) => a.order - b.order),
|
.sort((a, b) => a.order - b.order),
|
||||||
}));
|
}));
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
@@ -470,7 +514,7 @@ function TasksPage() {
|
|||||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
||||||
<TableCell className="font-medium">{task.title}</TableCell>
|
<TableCell className="font-medium">{task.title}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
|
<Badge className={cn("text-[10px]", getStatusToken(task.status).badge)}>{getStatusLabel(task.status)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||||
|
|||||||
@@ -55,17 +55,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { LoadingState, ErrorState } from "@/components/state";
|
import { LoadingState, ErrorState } from "@/components/state";
|
||||||
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
import { getStatusLabel, getStatusToken, PRIORITY } from "@/lib/status-colors";
|
||||||
import type { PaginatedResponse, Project, Task } from "@/lib/types";
|
import type { PaginatedResponse, Project, StatusDefinition, Task } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const STATUS_OPTIONS: InlineSelectOption[] = [
|
|
||||||
{ value: "todo", label: "Todo" },
|
|
||||||
{ value: "in_progress", label: "In Progress" },
|
|
||||||
{ value: "done", label: "Done" },
|
|
||||||
{ value: "cancelled", label: "Cancelled" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PRIORITY_OPTIONS: InlineSelectOption[] = [
|
const PRIORITY_OPTIONS: InlineSelectOption[] = [
|
||||||
{ value: "low", label: "Low" },
|
{ value: "low", label: "Low" },
|
||||||
{ value: "medium", label: "Medium" },
|
{ value: "medium", label: "Medium" },
|
||||||
@@ -112,6 +105,18 @@ function TaskDetail() {
|
|||||||
"/tasks/" + id
|
"/tasks/" + id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load this task's project status definitions so we can resolve a real
|
||||||
|
// statusId when toggling completion (done ↔ todo) and when rendering the
|
||||||
|
// status inline-select.
|
||||||
|
const projectId = task?.projectId ?? null;
|
||||||
|
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||||
|
["project-statuses", projectId ?? "none"],
|
||||||
|
projectId ? `/projects/${projectId}/statuses` : "",
|
||||||
|
{ enabled: !!projectId }
|
||||||
|
);
|
||||||
|
const projectStatuses = statusesData?.items ?? [];
|
||||||
|
const statusById = new Map(projectStatuses.map((s) => [s.id, s]));
|
||||||
|
|
||||||
const { patch } = useOptimisticPatch<Task>({
|
const { patch } = useOptimisticPatch<Task>({
|
||||||
entityKey: ["task", id],
|
entityKey: ["task", id],
|
||||||
listKeys: LIST_KEYS,
|
listKeys: LIST_KEYS,
|
||||||
@@ -120,10 +125,13 @@ function TaskDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toggleComplete = useMutation({
|
const toggleComplete = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
api.post<Task>(`/tasks/${id}/status`, {
|
const isDone = task?.status?.category === "done";
|
||||||
status: task?.status === "done" ? "todo" : "done",
|
const target = projectStatuses.find((s) => s.category === (isDone ? "todo" : "done"));
|
||||||
}),
|
return api.post<Task>(`/tasks/${id}/status`, {
|
||||||
|
statusId: target?.id ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["task", id] });
|
queryClient.invalidateQueries({ queryKey: ["task", id] });
|
||||||
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
|
for (const key of LIST_KEYS) queryClient.invalidateQueries({ queryKey: key });
|
||||||
@@ -152,7 +160,7 @@ function TaskDetail() {
|
|||||||
}
|
}
|
||||||
if (!task) return <ErrorState message="Task not found" />;
|
if (!task) return <ErrorState message="Task not found" />;
|
||||||
|
|
||||||
const isDone = task.status === "done";
|
const isDone = task.status?.category === "done";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityDetailPage
|
<EntityDetailPage
|
||||||
@@ -168,14 +176,17 @@ function TaskDetail() {
|
|||||||
badges={
|
badges={
|
||||||
<>
|
<>
|
||||||
<InlineSelect
|
<InlineSelect
|
||||||
value={task.status}
|
value={task.status?.id ?? ""}
|
||||||
options={STATUS_OPTIONS}
|
options={projectStatuses.map((s) => ({ value: s.id, label: s.label }))}
|
||||||
displayValue={(v) => (
|
displayValue={(v) => {
|
||||||
<Badge className={TASK_STATUS[v]?.badge}>
|
const status = statusById.get(v);
|
||||||
{TASK_STATUS[v]?.label ?? v}
|
return (
|
||||||
</Badge>
|
<Badge className={getStatusToken(status).badge}>
|
||||||
)}
|
{status ? status.label : "No status"}
|
||||||
onSave={(status) => patch({ id, data: { status } })}
|
</Badge>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onSave={(statusId) => patch({ id, data: { statusId: statusId || null } })}
|
||||||
/>
|
/>
|
||||||
<InlineSelect
|
<InlineSelect
|
||||||
value={task.priority}
|
value={task.priority}
|
||||||
@@ -364,6 +375,16 @@ function Subtasks({ task }: { task: Task }) {
|
|||||||
const [newTitle, setNewTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Subtasks live in the same project as the parent, so their status
|
||||||
|
// definitions are the parent's project statuses.
|
||||||
|
const projectId = task.projectId ?? null;
|
||||||
|
const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>(
|
||||||
|
["project-statuses", projectId ?? "none"],
|
||||||
|
projectId ? `/projects/${projectId}/statuses` : "",
|
||||||
|
{ enabled: !!projectId }
|
||||||
|
);
|
||||||
|
const projectStatuses = statusesData?.items ?? [];
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["task", task.id] });
|
queryClient.invalidateQueries({ queryKey: ["task", task.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
@@ -381,8 +402,8 @@ function Subtasks({ task }: { task: Task }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: ({ subId, status }: { subId: string; status: Task["status"] }) =>
|
mutationFn: ({ subId, statusId }: { subId: string; statusId: string }) =>
|
||||||
api.post<Task>(`/tasks/${subId}/status`, { status }),
|
api.post<Task>(`/tasks/${subId}/status`, { statusId }),
|
||||||
onMutate: (vars) => setPendingId(vars.subId),
|
onMutate: (vars) => setPendingId(vars.subId),
|
||||||
onSettled: () => setPendingId(null),
|
onSettled: () => setPendingId(null),
|
||||||
onSuccess: refresh,
|
onSuccess: refresh,
|
||||||
@@ -421,35 +442,37 @@ function Subtasks({ task }: { task: Task }) {
|
|||||||
<p className="py-6 text-center text-sm text-muted-foreground">No subtasks yet.</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">No subtasks yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{subtasks.map((sub) => (
|
{subtasks.map((sub) => {
|
||||||
<div
|
const subDone = sub.status?.category === "done";
|
||||||
key={sub.id}
|
return (
|
||||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
<div
|
||||||
>
|
key={sub.id}
|
||||||
<Checkbox
|
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||||
checked={sub.status === "done"}
|
|
||||||
disabled={pendingId === sub.id}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
toggleMutation.mutate({
|
|
||||||
subId: sub.id,
|
|
||||||
status: sub.status === "done" ? "todo" : "done",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label={"Mark " + sub.title + " " + (sub.status === "done" ? "as not done" : "as done")}
|
|
||||||
/>
|
|
||||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[sub.status]?.dot)} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
|
||||||
sub.status === "done" && "text-muted-foreground line-through"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{sub.title}
|
<Checkbox
|
||||||
</button>
|
checked={subDone}
|
||||||
</div>
|
disabled={pendingId === sub.id}
|
||||||
))}
|
onCheckedChange={() => {
|
||||||
|
const target = projectStatuses.find((s) => s.category === (subDone ? "todo" : "done"));
|
||||||
|
if (!target) return;
|
||||||
|
toggleMutation.mutate({ subId: sub.id, statusId: target.id });
|
||||||
|
}}
|
||||||
|
aria-label={"Mark " + sub.title + " " + (subDone ? "as not done" : "as done")}
|
||||||
|
/>
|
||||||
|
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(sub.status).dot)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
||||||
|
subDone && "text-muted-foreground line-through"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +546,7 @@ function Dependencies({ task }: { task: Task }) {
|
|||||||
key={dep.id}
|
key={dep.id}
|
||||||
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
|
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(dep.status).dot)} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
|
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
|
||||||
@@ -531,8 +554,8 @@ function Dependencies({ task }: { task: Task }) {
|
|||||||
>
|
>
|
||||||
{dep.title}
|
{dep.title}
|
||||||
</button>
|
</button>
|
||||||
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
|
<Badge className={cn("text-[10px]", getStatusToken(dep.status).badge)}>
|
||||||
{TASK_STATUS[dep.status]?.label ?? dep.status}
|
{getStatusLabel(dep.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -582,7 +605,7 @@ function Dependencies({ task }: { task: Task }) {
|
|||||||
key={dep.id}
|
key={dep.id}
|
||||||
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
|
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(dep.status).dot)} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
|
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
|
||||||
@@ -590,8 +613,8 @@ function Dependencies({ task }: { task: Task }) {
|
|||||||
>
|
>
|
||||||
{dep.title}
|
{dep.title}
|
||||||
</button>
|
</button>
|
||||||
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
|
<Badge className={cn("text-[10px]", getStatusToken(dep.status).badge)}>
|
||||||
{TASK_STATUS[dep.status]?.label ?? dep.status}
|
{getStatusLabel(dep.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-5
@@ -1,5 +1,5 @@
|
|||||||
import { db, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks } from '@project-e/db';
|
import { db, sql, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks, statusDefinitions, projects, domains, notifications } from '@project-e/db';
|
||||||
import { and, eq, lte, isNull, or } from 'drizzle-orm';
|
import { and, asc, eq, gte, lte, isNotNull, isNull, or } from 'drizzle-orm';
|
||||||
import { createHmac } from 'node:crypto';
|
import { createHmac } from 'node:crypto';
|
||||||
import { RRule } from 'rrule';
|
import { RRule } from 'rrule';
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ async function poll(): Promise<void> {
|
|||||||
// queue so they are picked up in the same iteration.
|
// queue so they are picked up in the same iteration.
|
||||||
const scheduledEnqueued = await processDueScheduledJobs();
|
const scheduledEnqueued = await processDueScheduledJobs();
|
||||||
|
|
||||||
|
// Create due-soon notifications for tasks whose due date approaches. Runs
|
||||||
|
// on every poll iteration but is deduped, so it is cheap in the steady state.
|
||||||
|
await processDueSoonNotifications();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Get pending jobs that are due.
|
// Get pending jobs that are due.
|
||||||
@@ -203,6 +207,75 @@ async function advanceScheduledJob(scheduled: typeof scheduledJobs.$inferSelect)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Due-soon notifications ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DUE_SOON_WINDOW_MS = 24 * 60 * 60 * 1000; // notify when due within 24h
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a `due_soon` notification for each incomplete task whose due date is
|
||||||
|
* within the next 24 hours. Deduped per task: a task is skipped when a
|
||||||
|
* `due_soon` notification for it was already created in the last 24 hours, so
|
||||||
|
* the user is not nagged on every poll iteration.
|
||||||
|
*/
|
||||||
|
async function processDueSoonNotifications(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const soon = new Date(now.getTime() + DUE_SOON_WINDOW_MS);
|
||||||
|
|
||||||
|
const dueTasks = await db.select({
|
||||||
|
id: tasks.id,
|
||||||
|
title: tasks.title,
|
||||||
|
dueDate: tasks.dueDate,
|
||||||
|
domainId: tasks.domainId,
|
||||||
|
})
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
isNull(tasks.completedAt),
|
||||||
|
isNotNull(tasks.dueDate),
|
||||||
|
gte(tasks.dueDate, now),
|
||||||
|
lte(tasks.dueDate, soon),
|
||||||
|
))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
for (const task of dueTasks) {
|
||||||
|
const [domain] = await db.select({ ownerId: domains.ownerId })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.id, task.domainId))
|
||||||
|
.limit(1);
|
||||||
|
if (!domain?.ownerId) continue;
|
||||||
|
|
||||||
|
const [existing] = await db.select({ id: notifications.id })
|
||||||
|
.from(notifications)
|
||||||
|
.where(and(
|
||||||
|
eq(notifications.userId, domain.ownerId),
|
||||||
|
eq(notifications.type, 'due_soon'),
|
||||||
|
eq(notifications.entityType, 'task'),
|
||||||
|
eq(notifications.entityId, task.id),
|
||||||
|
gte(notifications.createdAt, new Date(now.getTime() - DUE_SOON_WINDOW_MS)),
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
const [notification] = await db.insert(notifications).values({
|
||||||
|
userId: domain.ownerId,
|
||||||
|
workspaceId: task.domainId,
|
||||||
|
type: 'due_soon',
|
||||||
|
title: 'Task due soon',
|
||||||
|
body: `"${task.title}" is due within 24 hours`,
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: task.id,
|
||||||
|
}).returning({ id: notifications.id });
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ type: 'notification', action: 'created', id: notification.id, workspace_id: task.domainId });
|
||||||
|
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
||||||
|
console.log(`[Worker] Due-soon notification created for task ${task.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Worker] processDueSoonNotifications error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Webhook delivery ─────────────────────────────────────────────────────────────
|
// ── Webhook delivery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleWebhookDelivery(job: typeof jobs.$inferSelect): Promise<void> {
|
async function handleWebhookDelivery(job: typeof jobs.$inferSelect): Promise<void> {
|
||||||
@@ -312,11 +385,34 @@ async function handleRecurringSpawn(job: typeof jobs.$inferSelect): Promise<void
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (originalTask) {
|
if (originalTask) {
|
||||||
|
// Resolve the project's default status so spawned instances start in the
|
||||||
|
// project's configured initial state instead of a hardcoded "todo".
|
||||||
|
let statusId: string | null = null;
|
||||||
|
if (originalTask.projectId) {
|
||||||
|
const [defaultStatus] = await db.select({ id: statusDefinitions.id })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(and(
|
||||||
|
eq(statusDefinitions.projectId, originalTask.projectId),
|
||||||
|
eq(statusDefinitions.isDefault, true),
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
if (defaultStatus) {
|
||||||
|
statusId = defaultStatus.id;
|
||||||
|
} else {
|
||||||
|
const [firstStatus] = await db.select({ id: statusDefinitions.id })
|
||||||
|
.from(statusDefinitions)
|
||||||
|
.where(eq(statusDefinitions.projectId, originalTask.projectId))
|
||||||
|
.orderBy(asc(statusDefinitions.sortOrder))
|
||||||
|
.limit(1);
|
||||||
|
statusId = firstStatus?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new task instance
|
// Create a new task instance
|
||||||
await db.insert(tasks).values({
|
const [spawned] = await db.insert(tasks).values({
|
||||||
title: originalTask.title,
|
title: originalTask.title,
|
||||||
description: originalTask.description,
|
description: originalTask.description,
|
||||||
status: 'todo',
|
statusId,
|
||||||
priority: originalTask.priority,
|
priority: originalTask.priority,
|
||||||
domainId: originalTask.domainId,
|
domainId: originalTask.domainId,
|
||||||
projectId: originalTask.projectId,
|
projectId: originalTask.projectId,
|
||||||
@@ -326,8 +422,28 @@ async function handleRecurringSpawn(job: typeof jobs.$inferSelect): Promise<void
|
|||||||
recurrenceRule: originalTask.recurrenceRule,
|
recurrenceRule: originalTask.recurrenceRule,
|
||||||
order: originalTask.order,
|
order: originalTask.order,
|
||||||
customFields: originalTask.customFields,
|
customFields: originalTask.customFields,
|
||||||
});
|
}).returning({ id: tasks.id });
|
||||||
console.log(`[Worker] Spawned new task instance for ${scheduled.entityId}`);
|
console.log(`[Worker] Spawned new task instance for ${scheduled.entityId}`);
|
||||||
|
|
||||||
|
// Notify the workspace owner that a recurring task was spawned.
|
||||||
|
const [domain] = await db.select({ ownerId: domains.ownerId })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.id, originalTask.domainId))
|
||||||
|
.limit(1);
|
||||||
|
if (domain?.ownerId) {
|
||||||
|
const [notification] = await db.insert(notifications).values({
|
||||||
|
userId: domain.ownerId,
|
||||||
|
workspaceId: originalTask.domainId,
|
||||||
|
type: 'automation',
|
||||||
|
title: 'Recurring task created',
|
||||||
|
body: `"${originalTask.title}" was created by your recurring schedule`,
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: spawned.id,
|
||||||
|
}).returning({ id: notifications.id });
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ type: 'notification', action: 'created', id: notification.id, workspace_id: originalTask.domainId });
|
||||||
|
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (scheduled.entityType === 'habit') {
|
} else if (scheduled.entityType === 'habit') {
|
||||||
// For habits, we just log — habit completions are user-driven
|
// For habits, we just log — habit completions are user-driven
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- Custom workflow statuses: replace the fixed task_status enum
|
||||||
|
-- (todo/in_progress/done/cancelled) with per-project status_definitions rows.
|
||||||
|
--
|
||||||
|
-- Idempotent — safe to run on every deploy BEFORE `drizzle-kit push`. The push
|
||||||
|
-- then removes the legacy `tasks.status` column and the `task_status` enum type,
|
||||||
|
-- which is why the backfill must run first:
|
||||||
|
--
|
||||||
|
-- 1. Create the status_category enum + status_definitions table if missing.
|
||||||
|
-- 2. Seed the four default statuses (todo/in_progress/done/cancelled) for
|
||||||
|
-- every project that has none yet.
|
||||||
|
-- 3. Backfill tasks.status_id by joining on the legacy `status` column when it
|
||||||
|
-- still exists; otherwise fall back to each project's default status.
|
||||||
|
--
|
||||||
|
-- Fresh installs (no projects table yet) skip the data steps; the API seeds
|
||||||
|
-- default statuses when a project is created.
|
||||||
|
|
||||||
|
-- ─── 1. status_category enum (Postgres has no CREATE TYPE IF NOT EXISTS) ──────
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'status_category') THEN
|
||||||
|
CREATE TYPE "status_category" AS ENUM ('todo', 'in_progress', 'done', 'cancelled');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── 2. status_definitions table ───────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS "status_definitions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"category" "status_category" DEFAULT 'todo' NOT NULL,
|
||||||
|
"color" text,
|
||||||
|
"sort_order" integer DEFAULT 0,
|
||||||
|
"is_default" boolean DEFAULT false,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'status_definitions_project_id_projects_id_fk') THEN
|
||||||
|
ALTER TABLE "status_definitions" ADD CONSTRAINT "status_definitions_project_id_projects_id_fk"
|
||||||
|
FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "status_definitions_project_key_idx" ON "status_definitions" USING btree ("project_id","key");
|
||||||
|
CREATE INDEX IF NOT EXISTS "status_definitions_project_id_idx" ON "status_definitions" USING btree ("project_id");
|
||||||
|
|
||||||
|
-- ─── 3. tasks.status_id column ──────────────────────────────────────────────────
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN IF NOT EXISTS "status_id" uuid;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_status_id_status_definitions_id_fk') THEN
|
||||||
|
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_status_id_status_definitions_id_fk"
|
||||||
|
FOREIGN KEY ("status_id") REFERENCES "public"."status_definitions"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "tasks_status_id_idx" ON "tasks" USING btree ("status_id");
|
||||||
|
|
||||||
|
-- ─── 4. Seed default statuses per project ───────────────────────────────────────
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.projects') IS NOT NULL AND to_regclass('public.status_definitions') IS NOT NULL THEN
|
||||||
|
EXECUTE '
|
||||||
|
INSERT INTO "status_definitions" ("project_id", "key", "label", "category", "color", "sort_order", "is_default")
|
||||||
|
SELECT p."id", d."key", d."label", d."category", d."color", d."sort_order", d."is_default"
|
||||||
|
FROM "projects" p
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
(''todo'', ''Todo'', ''todo'', ''#94a3b8'', 0, true),
|
||||||
|
(''in_progress'', ''In Progress'', ''in_progress'', ''#3b82f6'', 1, false),
|
||||||
|
(''done'', ''Done'', ''done'', ''#22c55e'', 2, false),
|
||||||
|
(''cancelled'', ''Cancelled'', ''cancelled'', ''#ef4444'', 3, false)
|
||||||
|
) AS d("key", "label", "category", "color", "sort_order", "is_default")
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "status_definitions" sd WHERE sd."project_id" = p."id"
|
||||||
|
)';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── 5. Backfill tasks.status_id ────────────────────────────────────────────────
|
||||||
|
-- Legacy status column still present → map each task to the status with the
|
||||||
|
-- same key in its project (preserves the old todo/in_progress/done/cancelled).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tasks' AND column_name = 'status'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tasks' AND column_name = 'status_id'
|
||||||
|
) THEN
|
||||||
|
EXECUTE '
|
||||||
|
UPDATE "tasks" t
|
||||||
|
SET "status_id" = sd."id"
|
||||||
|
FROM "status_definitions" sd
|
||||||
|
WHERE t."project_id" = sd."project_id"
|
||||||
|
AND t."status"::text = sd."key"
|
||||||
|
AND t."status_id" IS NULL';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Legacy column already gone (or never existed) → assign each null-status task
|
||||||
|
-- its project''s default status so nothing is left un-categorized.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'tasks' AND column_name = 'status'
|
||||||
|
) THEN
|
||||||
|
EXECUTE '
|
||||||
|
UPDATE "tasks" t
|
||||||
|
SET "status_id" = sd."id"
|
||||||
|
FROM "status_definitions" sd
|
||||||
|
WHERE sd."project_id" = t."project_id"
|
||||||
|
AND sd."is_default" = true
|
||||||
|
AND t."status_id" IS NULL';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "automation_rules" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"project_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"active" boolean DEFAULT true,
|
||||||
|
"trigger" jsonb NOT NULL,
|
||||||
|
"conditions" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"actions" jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "automation_rules" ADD CONSTRAINT "automation_rules_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "automation_rules_project_id_idx" ON "automation_rules" USING btree ("project_id");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"workspace_id" uuid,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"body" text,
|
||||||
|
"entity_type" text,
|
||||||
|
"entity_id" uuid,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_workspace_id_domains_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_user_read_idx" ON "notifications" USING btree ("user_id","read_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_workspace_id_idx" ON "notifications" USING btree ("workspace_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_entity_idx" ON "notifications" USING btree ("entity_type","entity_id");
|
||||||
@@ -50,6 +50,27 @@
|
|||||||
"when": 1786392464857,
|
"when": 1786392464857,
|
||||||
"tag": "0006_minor_doctor_octopus",
|
"tag": "0006_minor_doctor_octopus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786800000000,
|
||||||
|
"tag": "0007_custom_task_statuses",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786900000000,
|
||||||
|
"tag": "0008_automation_rules",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786920000000,
|
||||||
|
"tag": "0009_notifications",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/user/projects/dev/ProjectE/node_modules
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:sync": "drizzle-kit push --force",
|
"db:sync": "drizzle-kit push --force",
|
||||||
"db:triggers": "bun script/apply-triggers.ts",
|
"db:triggers": "bun script/apply-triggers.ts",
|
||||||
|
"db:statuses": "bun script/migrate-task-statuses.ts",
|
||||||
"db:migrate": "bun run db:sync && bun run db:triggers",
|
"db:migrate": "bun run db:sync && bun run db:triggers",
|
||||||
"deploy": "bash script/deploy.sh"
|
"deploy": "bash script/deploy.sh"
|
||||||
},
|
},
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/user/projects/dev/ProjectE/packages/db/node_modules
|
||||||
@@ -23,7 +23,7 @@ export const tsvector = customType<{ data: string }>({
|
|||||||
|
|
||||||
// ── Enums ──────────────────────────────────────────────────────────────────────
|
// ── Enums ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done', 'cancelled']);
|
export const statusCategoryEnum = pgEnum('status_category', ['todo', 'in_progress', 'done', 'cancelled']);
|
||||||
export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high', 'urgent']);
|
export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high', 'urgent']);
|
||||||
export const habitFrequencyEnum = pgEnum('habit_frequency', ['daily', 'weekly', 'custom']);
|
export const habitFrequencyEnum = pgEnum('habit_frequency', ['daily', 'weekly', 'custom']);
|
||||||
export const habitDifficultyEnum = pgEnum('habit_difficulty', ['easy', 'medium', 'hard']);
|
export const habitDifficultyEnum = pgEnum('habit_difficulty', ['easy', 'medium', 'hard']);
|
||||||
@@ -119,6 +119,65 @@ export const projects = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Status Definitions ─────────────────────────────────────────────────────────
|
||||||
|
// Per-project configurable task workflow statuses. `category` maps a user-defined
|
||||||
|
// status to the UI semantics that drive progress (done), board columns, and
|
||||||
|
// completion checks. `key` is a stable machine id per project; `label` is the
|
||||||
|
// display name.
|
||||||
|
|
||||||
|
export const statusDefinitions = pgTable(
|
||||||
|
'status_definitions',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references((): any => projects.id, { onDelete: 'cascade' }),
|
||||||
|
key: text('key').notNull(),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
category: statusCategoryEnum('category').notNull().default('todo'),
|
||||||
|
color: text('color'),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
isDefault: boolean('is_default').default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('status_definitions_project_key_idx').on(table.projectId, table.key),
|
||||||
|
index('status_definitions_project_id_idx').on(table.projectId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Automation Rules ─────────────────────────────────────────────────────────────
|
||||||
|
// No-code trigger-action rules scoped to a single project. `trigger` names the
|
||||||
|
// event, `conditions` filter when it fires, and `actions` run in order when a
|
||||||
|
// rule matches. Config-only rows — deleted rules are hard-deleted.
|
||||||
|
|
||||||
|
export const automationRules = pgTable(
|
||||||
|
'automation_rules',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references((): any => projects.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
active: boolean('active').default(true),
|
||||||
|
trigger: jsonb('trigger')
|
||||||
|
.notNull()
|
||||||
|
.$type<{ type: string; params?: Record<string, any> }>(),
|
||||||
|
conditions: jsonb('conditions')
|
||||||
|
.$type<{ field: string; op: string; value: any }[]>()
|
||||||
|
.default([]),
|
||||||
|
actions: jsonb('actions')
|
||||||
|
.notNull()
|
||||||
|
.$type<{ type: string; params: Record<string, any> }[]>(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('automation_rules_project_id_idx').on(table.projectId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// ── Sections ────────────────────────────────────────────────────────────────────
|
// ── Sections ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const sections = pgTable(
|
export const sections = pgTable(
|
||||||
@@ -151,7 +210,7 @@ export const tasks = pgTable(
|
|||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
status: taskStatusEnum('status').notNull().default('todo'),
|
statusId: uuid('status_id').references((): any => statusDefinitions.id, { onDelete: 'set null' }),
|
||||||
priority: taskPriorityEnum('priority').notNull().default('medium'),
|
priority: taskPriorityEnum('priority').notNull().default('medium'),
|
||||||
domainId: uuid('domain_id')
|
domainId: uuid('domain_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -176,7 +235,7 @@ export const tasks = pgTable(
|
|||||||
index('tasks_project_id_idx').on(table.projectId),
|
index('tasks_project_id_idx').on(table.projectId),
|
||||||
index('tasks_section_id_idx').on(table.sectionId),
|
index('tasks_section_id_idx').on(table.sectionId),
|
||||||
index('tasks_parent_id_idx').on(table.parentId),
|
index('tasks_parent_id_idx').on(table.parentId),
|
||||||
index('tasks_status_idx').on(table.status),
|
index('tasks_status_id_idx').on(table.statusId),
|
||||||
index('tasks_priority_idx').on(table.priority),
|
index('tasks_priority_idx').on(table.priority),
|
||||||
index('tasks_due_date_idx').on(table.dueDate),
|
index('tasks_due_date_idx').on(table.dueDate),
|
||||||
index('tasks_order_idx').on(table.order),
|
index('tasks_order_idx').on(table.order),
|
||||||
@@ -416,6 +475,36 @@ export const activityFeed = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Notifications ─────────────────────────────────────────────────────────────
|
||||||
|
// Per-user in-app notifications. `readAt` NULL means unread; rows are soft-
|
||||||
|
// deleted (deleted_at set) rather than removed so the unread/read history can
|
||||||
|
// be reconstructed if a notification is ever un-deleted.
|
||||||
|
|
||||||
|
export const notifications = pgTable(
|
||||||
|
'notifications',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references((): any => users.id, { onDelete: 'cascade' }),
|
||||||
|
workspaceId: uuid('workspace_id').references((): any => domains.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(), // 'mention', 'status_change', 'due_soon', 'automation', 'assignment'
|
||||||
|
title: text('title').notNull(),
|
||||||
|
body: text('body'),
|
||||||
|
entityType: text('entity_type'),
|
||||||
|
entityId: uuid('entity_id'),
|
||||||
|
readAt: timestamp('read_at', { withTimezone: true }), // NULL = unread
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('notifications_user_id_idx').on(table.userId),
|
||||||
|
index('notifications_user_read_idx').on(table.userId, table.readAt),
|
||||||
|
index('notifications_workspace_id_idx').on(table.workspaceId),
|
||||||
|
index('notifications_entity_idx').on(table.entityType, table.entityId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// ── Comments ───────────────────────────────────────────────────────────────────
|
// ── Comments ───────────────────────────────────────────────────────────────────
|
||||||
// Nested comment threads on any entity. Comments were previously stored as
|
// Nested comment threads on any entity. Comments were previously stored as
|
||||||
// activity_feed rows (entity_type='comment'); they now live here so replies
|
// activity_feed rows (entity_type='comment'); they now live here so replies
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// ── Task ─────────────────────────────────────────────────────────────────────
|
// ── Task ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// @deprecated — task status is now a per-project status_definition row.
|
||||||
|
// Kept as the category enum / default fallback only.
|
||||||
export const TASK_STATUS = ['todo', 'in_progress', 'done', 'cancelled'] as const;
|
export const TASK_STATUS = ['todo', 'in_progress', 'done', 'cancelled'] as const;
|
||||||
|
export const TASK_STATUS_CATEGORY = ['todo', 'in_progress', 'done', 'cancelled'] as const;
|
||||||
export const TASK_PRIORITY = ['low', 'medium', 'high', 'urgent'] as const;
|
export const TASK_PRIORITY = ['low', 'medium', 'high', 'urgent'] as const;
|
||||||
|
|
||||||
// ── Habit ────────────────────────────────────────────────────────────────────
|
// ── Habit ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ if ! bun install --frozen-lockfile; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 6. Database schema + triggers (both idempotent) ───────────────────────
|
# ── 6. Database schema + triggers (both idempotent) ───────────────────────
|
||||||
|
echo "Backfilling custom task statuses (db:statuses) ..."
|
||||||
|
bun run db:statuses
|
||||||
echo "Migrating database schema + triggers (db:migrate) ..."
|
echo "Migrating database schema + triggers (db:migrate) ..."
|
||||||
bun run db:migrate
|
bun run db:migrate
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { sql } from "../packages/db/src/client";
|
||||||
|
|
||||||
|
// Apply the custom-workflow-statuses migration idempotently.
|
||||||
|
// 0007_custom_task_statuses.sql uses CREATE ... IF NOT EXISTS, pg_constraint /
|
||||||
|
// information_schema guards, and idempotent backfill UPDATEs, so it is safe to
|
||||||
|
// run on every deploy. It must run BEFORE `drizzle-kit push`: the push removes
|
||||||
|
// the legacy tasks.status column and task_status enum type, and this script
|
||||||
|
// backfills tasks.status_id from that column first.
|
||||||
|
const statusesFile = fileURLToPath(
|
||||||
|
new URL("../drizzle/0007_custom_task_statuses.sql", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Applying custom task statuses migration from ${statusesFile} ...`);
|
||||||
|
await sql.file(statusesFile);
|
||||||
|
console.log("Custom task statuses migration applied successfully.");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to apply custom task statuses migration:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user