refactor: remove canvas, automations, and custom statuses; simplify notification and status model

This commit is contained in:
2026-08-22 18:00:41 +00:00
parent 7b2cdc3bae
commit ffc50091b1
69 changed files with 834 additions and 6438 deletions
-8
View File
@@ -10,8 +10,6 @@ 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";
@@ -19,7 +17,6 @@ import { graphRoutes } from "./routes/graph";
import { dashboardRoutes } from "./routes/dashboard"; import { dashboardRoutes } from "./routes/dashboard";
import { agentRoutes } from "./routes/agents"; import { agentRoutes } from "./routes/agents";
import { webhookRoutes } from "./routes/webhooks"; import { webhookRoutes } from "./routes/webhooks";
import { canvasRoutes } from "./routes/canvas";
import { commentRoutes } from "./routes/comments"; import { commentRoutes } from "./routes/comments";
import { dailyNoteRoutes } from "./routes/daily-notes"; import { dailyNoteRoutes } from "./routes/daily-notes";
import { tagRoutes } from "./routes/tags"; import { tagRoutes } from "./routes/tags";
@@ -29,7 +26,6 @@ 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();
@@ -51,8 +47,6 @@ 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);
@@ -60,7 +54,6 @@ app.route("/api/graph", graphRoutes);
app.route("/api/dashboard", dashboardRoutes); app.route("/api/dashboard", dashboardRoutes);
app.route("/api/agents", agentRoutes); app.route("/api/agents", agentRoutes);
app.route("/api/webhooks", webhookRoutes); app.route("/api/webhooks", webhookRoutes);
app.route("/api/canvas", canvasRoutes);
app.route("/api/comments", commentRoutes); app.route("/api/comments", commentRoutes);
app.route("/api/daily-notes", dailyNoteRoutes); app.route("/api/daily-notes", dailyNoteRoutes);
app.route("/api/tags", tagRoutes); app.route("/api/tags", tagRoutes);
@@ -71,7 +64,6 @@ 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
-291
View File
@@ -1,291 +0,0 @@
import { db, automationRules, tasks, taskTags, tags as tagsTable, statusDefinitions } from "@project-e/db";
import { and, eq, isNull } from "drizzle-orm";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { notifyWorkspaceOwner } from "./notify";
export const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const;
export const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const;
// Safety limit: cap the number of actions executed per trigger event so a
// misconfigured rule can never cascade into runaway writes.
const MAX_ACTIONS_PER_EVENT = 10;
export interface EvaluateAutomationsParams {
projectId: string | null | undefined;
triggerType: string;
/** The affected entity row (e.g. the task after its mutation). */
entity: Record<string, any>;
/** Diff of the mutation, e.g. { previousStatusId } for status changes. */
changes?: Record<string, any>;
/** Display name of the user who triggered the event. Defaults to "Automation". */
actor?: string;
}
type AutomationRuleRow = typeof automationRules.$inferSelect;
type StatusRow = typeof statusDefinitions.$inferSelect;
interface RuleContext {
projectId: string;
statusKey: string | null;
previousStatusKey: string | null;
priority: string | null;
labels: string[];
/** The affected entity row (e.g. the task after its mutation). */
entity: Record<string, any>;
statusById: Map<string, StatusRow>;
idByKey: Map<string, string>;
categoryById: Map<string, string>;
}
function matchesCondition(
condition: { field: string; op: string; value: any },
ctx: RuleContext
): boolean {
const value = condition.value;
switch (condition.field) {
case "project":
return ctx.projectId === value;
case "status": {
// `to`/`from` are status-change semantics; everything else compares the
// task's current status key.
if (condition.op === "to") return ctx.statusKey === value;
if (condition.op === "from") return ctx.previousStatusKey === value;
if (condition.op === "neq") return ctx.statusKey !== value;
return ctx.statusKey === value;
}
case "priority":
if (condition.op === "in") {
return Array.isArray(value) && value.includes(ctx.priority);
}
if (condition.op === "neq") return ctx.priority !== value;
return ctx.priority === value;
case "label":
if (condition.op === "not_has") return !ctx.labels.includes(value);
return ctx.labels.includes(value);
default:
return true;
}
}
/**
* Execute a single automation action immediately. Returns true when the action
* actually ran (i.e. it should count against the per-event limit), false when it
* was a no-op (e.g. status key no longer exists in the project).
*/
async function executeAction(
action: { type: string; params: Record<string, any> },
ctx: RuleContext,
actor: string
): Promise<boolean> {
const params = action.params ?? {};
const entity = ctx.entity;
switch (action.type) {
case "set_status": {
const statusId = ctx.idByKey.get(params.statusKey as string);
if (!statusId) return false;
const category = ctx.categoryById.get(statusId) ?? "todo";
await db.update(tasks)
.set({
statusId,
completedAt: category === "done" ? new Date() : null,
updatedAt: new Date(),
})
.where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt)));
await recordActivity({
actor,
action: "updated",
entityType: "task",
entityId: entity.id,
changes: {
previousStatusId: entity.statusId,
newStatusId: statusId,
viaAutomation: true,
automation: params.statusKey,
},
workspaceId: entity.domainId,
});
await enqueueWebhooks({
workspaceId: entity.domainId,
event: "task.updated",
entityType: "task",
entityId: entity.id,
data: { previousStatusId: entity.statusId, newStatusId: statusId, viaAutomation: true },
});
return true;
}
case "set_priority": {
const priority = params.priority as string;
await db.update(tasks)
.set({ priority: priority as any, updatedAt: new Date() })
.where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt)));
await recordActivity({
actor,
action: "updated",
entityType: "task",
entityId: entity.id,
changes: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true },
workspaceId: entity.domainId,
});
await enqueueWebhooks({
workspaceId: entity.domainId,
event: "task.updated",
entityType: "task",
entityId: entity.id,
data: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true },
});
return true;
}
case "add_label": {
const label = (params.label as string).trim();
if (!label) return false;
// Find-or-create a tag so rules can attach labels that don't exist yet.
let tagId: string | null = null;
const [existingTag] = await db.select({ id: tagsTable.id })
.from(tagsTable)
.where(eq(tagsTable.name, label))
.limit(1);
if (existingTag) {
tagId = existingTag.id;
} else {
const [created] = await db.insert(tagsTable).values({ name: label, scope: "tasks" }).returning({ id: tagsTable.id });
tagId = created.id;
}
await db.insert(taskTags).values({ taskId: entity.id, tagId }).onConflictDoNothing();
await recordActivity({
actor,
action: "tagged",
entityType: "task",
entityId: entity.id,
changes: { tagId, tagName: label, viaAutomation: true },
workspaceId: entity.domainId,
});
return true;
}
case "create_notification": {
const message = (params.message as string).trim();
if (!message) return false;
// Create a real in-app notification so it lands in the bell's unread
// badge and the notification sheet (single-user MVP: goes to the
// workspace owner). The activity row is kept for the audit trail.
await notifyWorkspaceOwner({
workspaceId: entity.domainId,
type: "automation",
title: message,
body: `Automation fired on "${entity.title ?? "task"}"`,
entityType: "task",
entityId: entity.id,
});
await recordActivity({
actor,
action: "notified",
entityType: "task",
entityId: entity.id,
changes: { message, viaAutomation: true },
workspaceId: entity.domainId,
});
return true;
}
default:
return false;
}
}
/**
* Find every active rule for a project whose trigger matches `triggerType`,
* check their conditions, and run the matching rules' actions immediately.
*
* Never throws: automation failures must not fail the user's request. All errors
* are logged. The total number of actions executed per event is capped at
* `MAX_ACTIONS_PER_EVENT`.
*/
export async function evaluateAutomations({
projectId,
triggerType,
entity,
changes = {},
actor = "Automation",
}: EvaluateAutomationsParams): Promise<void> {
if (!projectId || !entity?.id) return;
try {
const rules = await db.select()
.from(automationRules)
.where(and(
eq(automationRules.projectId, projectId),
eq(automationRules.active, true),
));
const matching = rules.filter((rule: AutomationRuleRow) => rule.trigger.type === triggerType);
if (matching.length === 0) return;
// Build the context maps once per event: status definitions for the project
// (key <-> id) and the task's current labels for condition matching.
const statuses = await db.select()
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId));
const statusById = new Map(statuses.map((s) => [s.id, s]));
const idByKey = new Map<string, string>();
const categoryById = new Map<string, string>();
for (const status of statuses) {
idByKey.set(status.key, status.id);
categoryById.set(status.id, status.category);
}
const tagRows = await db.select({ name: tagsTable.name })
.from(taskTags)
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(eq(taskTags.taskId, entity.id));
const statusKey = entity.statusId ? (statusById.get(entity.statusId)?.key ?? null) : null;
const previousStatusKey = changes.previousStatusId
? (statusById.get(changes.previousStatusId)?.key ?? null)
: null;
const ctx: RuleContext = {
projectId,
statusKey,
previousStatusKey,
priority: entity.priority ?? null,
labels: tagRows.map((r) => r.name),
entity,
statusById,
idByKey,
categoryById,
};
let executedActions = 0;
for (const rule of matching) {
if (executedActions >= MAX_ACTIONS_PER_EVENT) break;
const conditionsMatch = (rule.conditions ?? []).every((condition) =>
matchesCondition(condition, ctx)
);
if (!conditionsMatch) continue;
for (const action of rule.actions ?? []) {
if (executedActions >= MAX_ACTIONS_PER_EVENT) {
console.warn(`[automations] Hit safety limit of ${MAX_ACTIONS_PER_EVENT} actions for event ${triggerType} in project ${projectId}`);
break;
}
const ran = await executeAction(action, ctx, actor);
if (ran) executedActions += 1;
}
}
} catch (error) {
console.error(`[automations] evaluateAutomations failed for ${triggerType} in project ${projectId}:`, error);
}
}
-65
View File
@@ -1,65 +0,0 @@
import { db, sql, notifications, domains } from "@project-e/db";
import { eq } from "drizzle-orm";
export type NotificationType =
| "mention"
| "status_change"
| "due_soon"
| "automation"
| "assignment";
export interface CreateNotificationParams {
userId: string;
workspaceId?: string | null;
type: NotificationType | string;
title: string;
body?: string | null;
entityType?: string | null;
entityId?: string | null;
}
/**
* Insert an in-app notification and fan it out over the realtime SSE stream so
* open clients refresh their bell count and list without polling. The event
* mirrors the pg_notify shape used by recordActivity: `{ type, action, id,
* workspace_id }` with `type: "notification"`.
*/
export async function createNotification(params: CreateNotificationParams): Promise<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 });
}
-2
View File
@@ -6,7 +6,6 @@ import {
habits, habits,
projects, projects,
notes, notes,
canvases,
dailyNotes, dailyNotes,
calendarEvents, calendarEvents,
webhooks, webhooks,
@@ -69,7 +68,6 @@ const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId }, project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId }, note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId }, daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId }, calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId }, webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId },
+1 -5
View File
@@ -6,7 +6,6 @@ import {
projects, projects,
habits, habits,
notes, notes,
canvases,
dailyNotes, dailyNotes,
calendarEvents, calendarEvents,
} from "@project-e/db"; } from "@project-e/db";
@@ -20,7 +19,7 @@ export const activityRoutes = new Hono();
// Entity types that participate in the generic activity feed / comments API. // Entity types that participate in the generic activity feed / comments API.
// The comments table stores the same entityType strings, so keep this enum and // The comments table stores the same entityType strings, so keep this enum and
// the entityWorkspaceLookups map below in sync. // the entityWorkspaceLookups map below in sync.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]); const entityTypeEnum = z.enum(["task", "project", "habit", "note", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup { interface EntityWorkspaceLookup {
table: AnyPgTable; table: AnyPgTable;
@@ -28,14 +27,11 @@ interface EntityWorkspaceLookup {
workspaceColumn: AnyPgColumn; workspaceColumn: AnyPgColumn;
} }
// Maps an entityType (as recorded in activity_feed) to the table + column that
// holds its owning workspace/domain. All of these tables use domain_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = { const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId }, task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId }, project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId }, note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId }, daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId }, calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
}; };
+32
View File
@@ -329,6 +329,38 @@ agentRoutes.get("/:id/permissions", async (c) => {
} }
}); });
// POST /api/agents/dispatch — Stub for @mention dispatch (enqueues worker job)
agentRoutes.post("/dispatch", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const domainId = body.workspaceId || body.domain || (await resolveActiveDomain(user)).id;
await requireWorkspaceAccess(c, domainId);
const agentsList = await db.select().from(agents).where(and(eq(agents.domainId, domainId), eq(agents.status, "active"))).limit(1);
if (agentsList.length === 0) return c.json({ success: false, message: "No active agents" }, 200);
const agent = agentsList[0];
await db.insert(agentTasks).values({
agentId: agent.id,
taskType: "mention",
input: { commentId: body.commentId, entityType: body.entityType, entityId: body.entityId, author: user.name },
status: "pending",
});
await db.insert(agentActivity).values({
agentId: agent.id,
action: "mentioned",
entityType: body.entityType || "comment",
entityId: body.entityId,
details: { commentId: body.commentId },
success: true,
});
return c.json({ success: true, agentId: agent.id });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[agents] POST /dispatch error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Dispatch failed" } }, 500);
}
});
// GET /api/agents/:id/activity — Agent activity log (or all if id=_all) // GET /api/agents/:id/activity — Agent activity log (or all if id=_all)
agentRoutes.get("/:id/activity", async (c) => { agentRoutes.get("/:id/activity", async (c) => {
try { try {
+115 -18
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tasks, habits, habitCompletions, projects, statusDefinitions } from "@project-e/db"; import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, getTableColumns, inArray, isNull, or } from "drizzle-orm"; import { and, eq, gte, 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,18 +22,15 @@ analyticsRoutes.get("/productivity", async (c) => {
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - range); startDate.setDate(startDate.getDate() - range);
const taskColumns = getTableColumns(tasks); const allTasks = await db.select()
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),
)); ));
// "Done" is a status category; a status marked category='done' completes a task. const completedTasks = allTasks.filter(t => t.status === "done");
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({
@@ -51,7 +48,7 @@ analyticsRoutes.get("/productivity", async (c) => {
} }
}); });
// GET /api/analytics/habits?range=... — Habit completion rate // GET /api/analytics/habits?range=... — Habit completion rate (fixed per-habit expected)
analyticsRoutes.get("/habits", async (c) => { analyticsRoutes.get("/habits", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
@@ -74,7 +71,6 @@ analyticsRoutes.get("/habits", async (c) => {
const habitIds = allHabits.map((h) => h.id); const habitIds = allHabits.map((h) => h.id);
// Only count completions belonging to habits in this domain (not all completions globally)
const allLogs = habitIds.length > 0 const allLogs = habitIds.length > 0
? await db.select() ? await db.select()
.from(habitCompletions) .from(habitCompletions)
@@ -84,8 +80,45 @@ analyticsRoutes.get("/habits", async (c) => {
)) ))
: []; : [];
const habitConsistency = allHabits.length > 0 const logsByHabit = new Map<string, number>();
? Math.round((allLogs.length / (allHabits.length * range)) * 100) for (const lg of allLogs) logsByHabit.set(lg.habitId, (logsByHabit.get(lg.habitId) || 0) + 1);
const expectedForHabit = (h: typeof allHabits[number]) => {
if (!h.active) return 0;
const skipSet = new Set(h.skipDays || []);
if (h.frequency === "daily") {
let expected = 0;
const cursor = new Date(startDate);
for (let i = 0; i < range; i++) {
if (!skipSet.has(cursor.getUTCDay())) expected += h.goalPerPeriod || 1;
cursor.setDate(cursor.getDate() + 1);
}
return expected;
}
if (h.frequency === "weekly") {
const weeks = Math.ceil(range / 7);
return weeks * (h.goalPerPeriod || 1);
}
return range * (h.goalPerPeriod || 1);
};
let totalExpected = 0;
const perHabit: Array<{ id: string; name: string; completed: number; expected: number; consistency: number }> = [];
for (const h of allHabits) {
const expected = expectedForHabit(h);
const completed = logsByHabit.get(h.id) || 0;
totalExpected += expected;
perHabit.push({
id: h.id,
name: h.name,
completed,
expected,
consistency: expected > 0 ? Math.min(100, Math.round((completed / expected) * 100)) : 0,
});
}
const habitConsistency = totalExpected > 0
? Math.min(100, Math.round((allLogs.length / totalExpected) * 100))
: 0; : 0;
const activeStreaks = allHabits.filter(h => (h.streakCount || 0) > 0); const activeStreaks = allHabits.filter(h => (h.streakCount || 0) > 0);
@@ -95,6 +128,8 @@ analyticsRoutes.get("/habits", async (c) => {
habitConsistency, habitConsistency,
totalHabits: allHabits.length, totalHabits: allHabits.length,
totalLogs: allLogs.length, totalLogs: allLogs.length,
totalExpected,
perHabit,
activeStreaks: activeStreaks.length, activeStreaks: activeStreaks.length,
bestStreak, bestStreak,
period: range, period: range,
@@ -130,9 +165,8 @@ 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, statusCategory: statusDefinitions.category }) ? await db.select({ projectId: tasks.projectId, status: tasks.status })
.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),
@@ -144,7 +178,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.statusCategory === "done") entry.completedTasks += 1; if (t.status === "done") entry.completedTasks += 1;
counts.set(t.projectId, entry); counts.set(t.projectId, entry);
} }
@@ -176,6 +210,71 @@ analyticsRoutes.get("/projects", async (c) => {
} }
}); });
// GET /api/analytics/velocity?range=... — Tasks completed per day (used by new dashboard)
analyticsRoutes.get("/velocity", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const range = parseInt(url.searchParams.get("range") || "30");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - (range - 1));
firstDay.setHours(0, 0, 0, 0);
const domainTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.completedAt, firstDay)));
const localDateKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
const byDay = new Map<string, number>();
for (const t of domainTasks) if (t.completedAt) {
const k = localDateKey(t.completedAt);
byDay.set(k, (byDay.get(k)||0)+1);
}
const items: Array<{ date: string; completed: number }> = [];
const cursor = new Date(firstDay);
for (let i=0;i<range;i++) {
const k = localDateKey(cursor);
items.push({ date: k, completed: byDay.get(k)||0 });
cursor.setDate(cursor.getDate()+1);
}
const avg = items.reduce((s,i)=>s+i.completed,0)/range;
return c.json({ items, avg: Math.round(avg*10)/10, period: range }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" } });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[analytics] GET /velocity error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get velocity" } }, 500);
}
});
analyticsRoutes.get("/cycle", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const range = parseInt(url.searchParams.get("range") || "30");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const startDate = new Date();
startDate.setDate(startDate.getDate() - range);
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), eq(tasks.status, "done"), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
const durations: number[] = [];
for (const t of doneTasks) if (t.completedAt) durations.push((t.completedAt.getTime() - t.createdAt.getTime()) / (1000*60*60*24));
durations.sort((a,b)=>a-b);
const median = durations.length ? durations[Math.floor(durations.length/2)] : 0;
const avg = durations.length ? durations.reduce((s,v)=>s+v,0)/durations.length : 0;
return c.json({ median: Math.round(median*10)/10, avg: Math.round(avg*10)/10, count: durations.length, period: range }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" } });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[analytics] GET /cycle error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get cycle" } }, 500);
}
});
// GET /api/analytics/daily?range=... — Daily task creation & completion time series // GET /api/analytics/daily?range=... — Daily task creation & completion time series
analyticsRoutes.get("/daily", async (c) => { analyticsRoutes.get("/daily", async (c) => {
try { try {
@@ -195,10 +294,8 @@ 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 dailyTaskColumns = getTableColumns(tasks); const domainTasks = await db.select()
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),
@@ -222,7 +319,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.statusCategory === "done" && t.completedAt) { if (t.status === "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);
} }
-309
View File
@@ -1,309 +0,0 @@
import { Hono } from "hono";
import { db, automationRules, projects } from "@project-e/db";
import { and, asc, eq, isNull } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
export const automationRoutes = new Hono();
const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const;
const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const;
const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
const triggerSchema = z.object({
type: z.enum(TRIGGER_TYPES),
params: z.record(z.string(), z.unknown()).optional(),
});
const conditionSchema = z.object({
field: z.enum(["project", "status", "priority", "label"]),
op: z.string().min(1),
value: z.unknown(),
});
const actionSchema = z.object({
type: z.enum(ACTION_TYPES),
params: z.record(z.string(), z.unknown()).optional(),
});
const createAutomationSchema = z.object({
name: z.string().min(1, "Name is required").max(120),
active: z.boolean().optional(),
trigger: triggerSchema,
conditions: z.array(conditionSchema).optional(),
actions: z.array(actionSchema).min(1, "At least one action is required"),
});
const updateAutomationSchema = createAutomationSchema.partial();
// Validate action params against what the evaluation engine expects, so a bad
// rule surfaces at save time instead of silently doing nothing at run time.
function validateActionParams(actions: { type: string; params?: Record<string, 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);
}
});
+52 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, calendarEvents } from "@project-e/db"; import { db, calendarEvents, tasks, habits } from "@project-e/db";
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm"; import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
@@ -222,6 +222,57 @@ calendarRoutes.delete("/events/:id", async (c) => {
} }
}); });
// GET /api/calendar/unified?from=&to=&domain — Merged timeline: calendar_events + tasks due + habit reminders
calendarRoutes.get("/unified", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const from = fromStr ? new Date(fromStr) : new Date(new Date().setDate(new Date().getDate() - 7));
const to = toStr ? new Date(toStr) : new Date(new Date().setDate(new Date().getDate() + 30));
const [events, domainTasks, domainHabits] = await Promise.all([
db.select().from(calendarEvents).where(and(eq(calendarEvents.domainId, domainId), gte(calendarEvents.startTime, from), lte(calendarEvents.startTime, to))).orderBy(asc(calendarEvents.startTime)),
db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to))).orderBy(asc(tasks.dueDate)),
db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
]);
const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({
id: `task-${t.id}`, title: t.title, description: null, startTime: t.dueDate, endTime: null, allDay: true, color: "#3b82f6", domainId, entityType: "task", entityId: t.id, recurrenceRule: null, createdAt: t.createdAt, updatedAt: t.updatedAt,
}));
const habitEvents: typeof events = [];
for (const h of domainHabits) {
if (!h.reminderTime || !h.active) continue;
const [hh, mm] = h.reminderTime.split(":").map(Number);
const cursor = new Date(from);
cursor.setHours(0,0,0,0);
while (cursor <= to) {
const skip = (h.skipDays || []).includes(cursor.getDay());
if (!skip) {
const dt = new Date(cursor);
dt.setHours(hh||9, mm||0, 0, 0);
habitEvents.push({
id: `habit-${h.id}-${dt.toISOString().slice(0,10)}`, title: h.name, description: h.description, startTime: dt, endTime: null, allDay: false, color: "#f59e0b", domainId, entityType: "habit", entityId: h.id, recurrenceRule: null, createdAt: h.createdAt, updatedAt: h.updatedAt,
} as any);
}
cursor.setDate(cursor.getDate()+1);
if (h.frequency === "weekly" && habitEvents.filter(e=>e.entityId===h.id).length >= 2) break;
}
}
const items = [...events, ...taskEvents as any, ...habitEvents].sort((a,b)=> new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
return c.json({ items, totalItems: items.length, breakdown: { events: events.length, tasks: taskEvents.length, habits: habitEvents.length } });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[calendar] GET /unified error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get unified calendar" } }, 500);
}
});
// GET /api/calendar/upcoming?days=7 — Next N days // GET /api/calendar/upcoming?days=7 — Next N days
calendarRoutes.get("/upcoming", async (c) => { calendarRoutes.get("/upcoming", async (c) => {
try { try {
-423
View File
@@ -1,423 +0,0 @@
import { Hono } from "hono";
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
import { and, asc, desc, eq, notInArray, or, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
export const canvasRoutes = new Hono();
const createCanvasSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional().nullable(),
mode: z.enum(["freeform", "graph"]).optional().default("freeform"),
domain: z.string().min(1, "Domain is required"),
tags: z.array(z.string()).optional().default([]),
viewport: z.object({ x: z.number().default(0), y: z.number().default(0), zoom: z.number().positive().default(1) }).optional(),
background: z.string().optional().nullable(),
customFields: z.record(z.string(), z.unknown()).optional(),
});
const updateCanvasSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
mode: z.enum(["freeform", "graph"]).optional(),
tags: z.array(z.string()).optional(),
viewport: z.object({ x: z.number(), y: z.number(), zoom: z.number().positive() }).optional(),
background: z.string().optional().nullable(),
customFields: z.record(z.string(), z.unknown()).optional(),
});
const createCardSchema = z.object({
type: z.string().min(1).default("note"),
content: z.string().optional().nullable().default(""),
title: z.string().optional().nullable(),
x: z.number().int().optional(),
y: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
rotation: z.number().int().optional(),
color: z.string().optional().nullable(),
zIndex: z.number().int().optional(),
});
const updateCardSchema = createCardSchema.partial();
const bulkSaveCardsSchema = z.object({
cards: z.array(
z.object({
id: z.string().uuid().optional(),
type: z.string().min(1).default("note"),
content: z.string().optional().nullable().default(""),
title: z.string().optional().nullable(),
x: z.number().int().optional(),
y: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
rotation: z.number().int().optional(),
color: z.string().optional().nullable(),
zIndex: z.number().int().optional(),
})
).default([]),
});
// GET /api/canvas — List canvases
canvasRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
const sort = url.searchParams.get("sort") || "-created";
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(canvases.domainId, domainId)];
const sortField = sort.replace(/^-/, "");
const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortColumns: Record<string, any> = { created: canvases.createdAt, updated: canvases.updatedAt, name: canvases.name };
const orderColumn = sortDir === "asc" ? asc(sortColumns[sortField] || canvases.createdAt) : desc(sortColumns[sortField] || canvases.createdAt);
const [items, countResult] = await Promise.all([
db.select().from(canvases).where(and(...conditions)).orderBy(orderColumn).limit(perPage).offset((page - 1) * perPage),
db.select({ count: sql<number>`count(*)` }).from(canvases).where(and(...conditions)),
]);
return c.json({ items, totalItems: Number(countResult[0]?.count || 0), totalPages: Math.ceil(Number(countResult[0]?.count || 0) / perPage), page, perPage });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list canvases" } }, 500);
}
});
// POST /api/canvas — Create
canvasRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createCanvasSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
await requireWorkspaceAccess(c, data.domain);
const [canvas] = await db.insert(canvases).values({
name: data.name,
description: data.description ?? null,
mode: data.mode,
domainId: data.domain,
tags: data.tags ?? [],
viewport: data.viewport ?? { x: 0, y: 0, zoom: 1 },
background: data.background ?? null,
customFields: data.customFields ?? {},
}).returning();
await recordActivity({
actor: user.name, action: "created", entityType: "canvas", entityId: canvas.id,
changes: { name: canvas.name }, workspaceId: data.domain,
});
return c.json(canvas, 201);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas" } }, 500);
}
});
// GET /api/canvas/:id — Read one (full block tree)
canvasRoutes.get("/:id", async (c) => {
try {
await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
const [cards, connections] = await Promise.all([
db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)),
db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)),
]);
return c.json({ ...canvas, cards, connections });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] GET /:id error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get canvas" } }, 500);
}
});
// PATCH /api/canvas/:id — Update blocks
canvasRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateCanvasSchema.parse(body);
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description;
if (data.mode !== undefined) updateValues.mode = data.mode;
if (data.tags !== undefined) updateValues.tags = data.tags;
if (data.viewport !== undefined) updateValues.viewport = data.viewport;
if (data.background !== undefined) updateValues.background = data.background;
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
updateValues.updatedAt = new Date();
const [updated] = await db.update(canvases).set(updateValues).where(eq(canvases.id, id)).returning();
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas", entityId: id,
changes: { name: updated.name }, workspaceId: existing.domainId,
});
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas" } }, 500);
}
});
// DELETE /api/canvas/:id — Delete
canvasRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(canvases).where(eq(canvases.id, id));
await recordActivity({
actor: user.name, action: "deleted", entityType: "canvas", entityId: id,
changes: { name: existing.name }, workspaceId: existing.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500);
}
});
// POST /api/canvas/:id/cards — Create one card (new block)
canvasRoutes.post("/:id/cards", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = createCardSchema.parse(body);
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
// New cards append to the end of the vertical document unless an explicit zIndex is given
const [maxRow] = await db
.select({ max: sql<number>`max(${canvasCards.zIndex})` })
.from(canvasCards)
.where(eq(canvasCards.canvasId, id));
const zIndex = data.zIndex ?? Number(maxRow?.max ?? -1) + 1;
const [card] = await db.insert(canvasCards).values({
canvasId: id,
type: data.type,
content: data.content ?? "",
title: data.title ?? null,
x: data.x ?? 0,
y: data.y ?? 0,
width: data.width ?? 200,
height: data.height ?? 150,
rotation: data.rotation ?? 0,
color: data.color ?? null,
zIndex,
}).returning();
await recordActivity({
actor: user.name, action: "created", entityType: "canvas_card", entityId: card.id,
changes: { type: card.type, zIndex: card.zIndex }, workspaceId: canvas.domainId,
});
return c.json(card, 201);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] POST /:id/cards error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas card" } }, 500);
}
});
// PUT /api/canvas/:id/cards — Bulk replace all cards (primary save path)
canvasRoutes.put("/:id/cards", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = bulkSaveCardsSchema.parse(body);
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
const cards = await db.transaction(async (tx) => {
await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id));
if (data.cards.length === 0) {
// No cards left — drop every connection on this canvas.
await tx.delete(canvasConnections).where(or(
eq(canvasConnections.sourceCardId, id),
eq(canvasConnections.targetCardId, id),
));
return [];
}
const inserted = await tx.insert(canvasCards).values(
data.cards.map((card, i) => ({
...(card.id ? { id: card.id } : {}),
canvasId: id,
type: card.type,
content: card.content ?? "",
title: card.title ?? null,
x: card.x ?? 0,
y: card.y ?? 0,
width: card.width ?? 200,
height: card.height ?? 150,
rotation: card.rotation ?? 0,
color: card.color ?? null,
zIndex: card.zIndex ?? i,
}))
).returning();
// Connections to cards that no longer exist must not linger. Cards that
// were re-inserted with their original id keep their connections; any
// connection whose endpoint is missing is dropped.
const keptIds = inserted.map((c) => c.id);
await tx.delete(canvasConnections).where(or(
notInArray(canvasConnections.sourceCardId, keptIds),
notInArray(canvasConnections.targetCardId, keptIds),
));
return inserted;
});
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas", entityId: id,
changes: { name: canvas.name, cardCount: cards.length }, workspaceId: canvas.domainId,
});
return c.json({ ...canvas, cards });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] PUT /:id/cards error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to save canvas cards" } }, 500);
}
});
// PATCH /api/canvas/cards/:cardId — Update one card
canvasRoutes.patch("/cards/:cardId", async (c) => {
try {
const user = await requireAuth(c);
const cardId = c.req.param("cardId");
const body = await c.req.json();
const data = updateCardSchema.parse(body);
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
// canvas_cards has no domain_id — resolve ownership through the parent canvas
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
if (canvas) {
await requireWorkspaceAccess(c, canvas.domainId);
}
const updateValues: Record<string, unknown> = {};
if (data.type !== undefined) updateValues.type = data.type;
if (data.content !== undefined) updateValues.content = data.content;
if (data.title !== undefined) updateValues.title = data.title;
if (data.x !== undefined) updateValues.x = data.x;
if (data.y !== undefined) updateValues.y = data.y;
if (data.width !== undefined) updateValues.width = data.width;
if (data.height !== undefined) updateValues.height = data.height;
if (data.rotation !== undefined) updateValues.rotation = data.rotation;
if (data.color !== undefined) updateValues.color = data.color;
if (data.zIndex !== undefined) updateValues.zIndex = data.zIndex;
updateValues.updatedAt = new Date();
const [updated] = await db.update(canvasCards).set(updateValues).where(eq(canvasCards.id, cardId)).returning();
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas_card", entityId: cardId,
changes: { ...data }, workspaceId: canvas?.domainId ?? "",
});
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] PATCH /cards/:cardId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas card" } }, 500);
}
});
// DELETE /api/canvas/cards/:cardId — Delete one card
canvasRoutes.delete("/cards/:cardId", async (c) => {
try {
const user = await requireAuth(c);
const cardId = c.req.param("cardId");
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
// canvas_cards has no domain_id — resolve ownership through the parent canvas
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
if (canvas) {
await requireWorkspaceAccess(c, canvas.domainId);
}
// Hard delete — canvas_cards has no deleted_at column
await db.delete(canvasCards).where(eq(canvasCards.id, cardId));
await recordActivity({
actor: user.name, action: "deleted", entityType: "canvas_card", entityId: cardId,
changes: { type: card.type }, workspaceId: canvas?.domainId ?? "",
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] DELETE /cards/:cardId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas card" } }, 500);
}
});
+1 -21
View File
@@ -6,7 +6,6 @@ import {
projects, projects,
habits, habits,
notes, notes,
canvases,
dailyNotes, dailyNotes,
calendarEvents, calendarEvents,
} from "@project-e/db"; } from "@project-e/db";
@@ -14,13 +13,12 @@ import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"; import 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();
// Entity types that support comments. Keep in sync with entityWorkspaceLookups. // Entity types that support comments. Keep in sync with entityWorkspaceLookups.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]); const entityTypeEnum = z.enum(["task", "project", "habit", "note", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup { interface EntityWorkspaceLookup {
table: AnyPgTable; table: AnyPgTable;
@@ -35,7 +33,6 @@ const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId }, project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId }, note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId }, daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId }, calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
}; };
@@ -151,23 +148,6 @@ 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) {
+11 -67
View File
@@ -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, statusDefinitions } from "@project-e/db"; import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, or } from "drizzle-orm"; import { and, asc, desc, eq, ilike, 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,8 +81,7 @@ 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", description: "Filter by status category (todo, in_progress, done, cancelled)" }, status: { type: "string", enum: ["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" },
@@ -96,14 +95,7 @@ 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_id) conditions.push(eq(tasks.statusId, params.status_id as string)); if (params.status) conditions.push(eq(tasks.status, params.status as any));
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}%`));
@@ -127,7 +119,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_id: { type: "string", description: "Status definition ID (defaults to the project's default status)" }, status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" }, due_date: { type: "string" },
project_id: { type: "string" }, project_id: { type: "string" },
@@ -135,33 +127,10 @@ 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,
statusId, status: (params.status as any) ?? "todo",
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,
@@ -173,7 +142,7 @@ const tools: ToolDefinition[] = [
action: "created", action: "created",
entityType: "task", entityType: "task",
entityId: task.id, entityId: task.id,
changes: { title: task.title, statusId: task.statusId }, changes: { title: task.title, status: task.status },
workspaceId: params.domain_id as string, workspaceId: params.domain_id as string,
}); });
@@ -189,7 +158,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_id: { type: "string", description: "Status definition ID (must belong to the task's project)" }, status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" }, due_date: { type: "string" },
}, },
@@ -203,19 +172,7 @@ 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_id !== undefined) { if (params.status !== undefined) updateData.status = params.status;
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();
@@ -279,21 +236,8 @@ 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({ statusId: doneStatus.id, completedAt: new Date(), updatedAt: new Date() }) .set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning(); .returning();
@@ -622,7 +566,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, statusId: tasks.statusId, priority: tasks.priority }).from(tasks) results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks)
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit); .where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit);
} }
if (types.includes("notes")) { if (types.includes("notes")) {
+28 -154
View File
@@ -1,170 +1,44 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, notifications } from "@project-e/db"; import { db, activityFeed } from "@project-e/db";
import { and, count, desc, eq, isNull } from "drizzle-orm"; import { and, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, AuthError, isUuid } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const notificationRoutes = new Hono(); export const notificationRoutes = new Hono();
// Notifications are per-user rows, so every route is scoped to the current // The bell in the topbar shows a badge for activity_feed events from the last
// user via requireAuth (not requireWorkspaceAccess). The optional // 7 days. There is no read/unread state yet, so "count" doubles as the unread
// workspace_id filter is safe against IDOR because the WHERE clause always // badge. graph_edge rows are workspace-internal graph plumbing, not user-facing
// pins notifications.userId to the authenticated user. // activity, so they are excluded from both the count and the feed.
const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const PAGE_SIZE_DEFAULT = 30; // GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace.
// 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);
const workspaceId = c.req.query("workspace_id"); let workspaceId = c.req.query("workspace_id");
const unreadOnly = c.req.query("unread") === "true"; if (!workspaceId) {
const active = await resolveActiveDomain(user);
workspaceId = active.id;
}
await requireWorkspaceAccess(c, workspaceId);
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "0", 10) || 0, 1), 100); const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100);
const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1); const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS);
const perPage = Math.min(Math.max(parseInt(c.req.query("perPage") || "0", 10) || 0, 1), 100); const conditions = [
const effectiveLimit = limit || perPage || PAGE_SIZE_DEFAULT; eq(activityFeed.workspaceId, workspaceId),
const offset = Math.max(0, parseInt(c.req.query("offset") || "0", 10) || 0) || (page - 1) * effectiveLimit; gte(activityFeed.createdAt, since),
ne(activityFeed.entityType, "graph_edge"),
];
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.deletedAt)]; const [items, countResult] = await Promise.all([
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId)); db.select().from(activityFeed).where(and(...conditions)).orderBy(desc(activityFeed.createdAt)).limit(limit),
if (unreadOnly) conditions.push(isNull(notifications.readAt)); db.select({ count: sql<number>`count(*)` }).from(activityFeed).where(and(...conditions)),
const [items, totalResult, unreadResult] = await Promise.all([
db.select().from(notifications)
.where(and(...conditions))
.orderBy(desc(notifications.createdAt))
.limit(effectiveLimit)
.offset(offset),
db.select({ value: count() }).from(notifications).where(and(...conditions)),
db.select({ value: count() }).from(notifications)
.where(and(...conditions, isNull(notifications.readAt))),
]); ]);
const totalItems = Number(totalResult[0]?.value ?? 0); return c.json({ items, count: Number(countResult[0]?.count || 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) { 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);
}
console.error("[notifications] GET error:", error); console.error("[notifications] GET error:", error);
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to list notifications"), 500); return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get 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);
} }
}); });
+7 -53
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, statusDefinitions } from "@project-e/db"; import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; import { 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,16 +10,6 @@ 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(),
@@ -123,10 +113,9 @@ projectRoutes.get("/", async (c) => {
const totalItems = Number(countResult[0]?.count || 0); const totalItems = Number(countResult[0]?.count || 0);
// Fetch task counts, status definitions and tags for all projects // Fetch task counts and tags for all projects
let projectTagMap = new Map<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);
@@ -147,32 +136,15 @@ 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)
.innerJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) .where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt)));
.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),
@@ -186,7 +158,6 @@ 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,
@@ -233,11 +204,6 @@ 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 }))
@@ -255,7 +221,7 @@ projectRoutes.post("/", async (c) => {
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } }); await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
return c.json({ ...project, statuses: seededStatuses }, 201); return c.json(project, 201);
} catch (error) { } 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);
@@ -294,22 +260,11 @@ projectRoutes.get("/:id", async (c) => {
.where(eq(sections.projectId, id)) .where(eq(sections.projectId, id))
.orderBy(asc(sections.sortOrder)); .orderBy(asc(sections.sortOrder));
// Fetch the project's status workflow // Fetch tasks
const projectStatuses = await db.select() const projectTasks = 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({
@@ -322,14 +277,13 @@ 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?.category === "done").length; const completedTasks = projectTasks.filter(t => t.status === "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,
-389
View File
@@ -1,389 +0,0 @@
import { Hono } from "hono";
import { db, statusDefinitions, tasks, projects } from "@project-e/db";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
export const statusRoutes = new Hono();
const statusCategoryEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
const MAX_STATUSES_PER_PROJECT = 15;
const createStatusSchema = z.object({
key: z
.string()
.regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores")
.optional(),
label: z.string().min(1, "Label is required").max(60),
category: statusCategoryEnum.optional().default("todo"),
color: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
isDefault: z.boolean().optional(),
});
const updateStatusSchema = z.object({
key: z
.string()
.regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores")
.optional(),
label: z.string().min(1).max(60).optional(),
category: statusCategoryEnum.optional(),
color: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
isDefault: z.boolean().optional(),
});
const reorderStatusSchema = z.object({
orderedIds: z.array(z.string().uuid("Invalid status id")).min(1, "orderedIds is required"),
});
function slugifyKey(label: string): string {
const key = label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return key || "status";
}
async function getProject(c: any, projectId: string) {
const [project] = await db
.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
throw new AuthError("Project not found", 404, "NOT_FOUND");
}
await requireWorkspaceAccess(c, project.domainId);
return project;
}
// GET /api/projects/:projectId/statuses — List statuses for a project
statusRoutes.get("/:projectId/statuses", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const items = await db.select()
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId))
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
return c.json({ items, totalItems: items.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[statuses] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list statuses" } }, 500);
}
});
// POST /api/projects/:projectId/statuses — Create a status
statusRoutes.post("/:projectId/statuses", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = createStatusSchema.parse(body);
const project = await getProject(c, projectId);
// Cap the number of statuses per project
const [countResult] = await db.select({ count: sql<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);
}
});
+45 -230
View File
@@ -1,60 +1,21 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections, statusDefinitions } from "@project-e/db"; import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
import { and, asc, desc, eq, exists, getTableColumns, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth"; import { 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 STATUS_CATEGORIES = ["todo", "in_progress", "done", "cancelled"] as const; const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]); 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(),
statusId: z.string().uuid().optional().nullable(), status: taskStatusEnum.optional().default("todo"),
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(),
@@ -66,12 +27,13 @@ const createTaskSchema = z.object({
customFields: z.record(z.string(), z.unknown()).optional(), customFields: z.record(z.string(), z.unknown()).optional(),
recurrenceRule: z.string().optional().nullable(), recurrenceRule: z.string().optional().nullable(),
tagIds: z.array(z.string().uuid()).optional(), tagIds: z.array(z.string().uuid()).optional(),
tagNames: z.array(z.string().min(1)).optional(),
}); });
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(),
statusId: z.string().uuid().optional().nullable(), status: taskStatusEnum.optional(),
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(),
@@ -150,16 +112,8 @@ taskRoutes.get("/", async (c) => {
]; ];
if (status) { if (status) {
// Filter by category name (todo/in_progress/...) or by status definition const statuses = status.split(",");
// id (uuid). Category filters match every project's statuses in that conditions.push(inArray(tasks.status, statuses as any));
// 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(",");
@@ -211,7 +165,7 @@ taskRoutes.get("/", async (c) => {
created: tasks.createdAt, created: tasks.createdAt,
updated: tasks.updatedAt, updated: tasks.updatedAt,
title: tasks.title, title: tasks.title,
status: statusDefinitions.sortOrder, status: tasks.status,
priority: tasks.priority, priority: tasks.priority,
order: tasks.order, order: tasks.order,
due_date: tasks.dueDate, due_date: tasks.dueDate,
@@ -222,18 +176,15 @@ 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({ ...taskColumns, status: statusColumns }) db.select()
.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)),
]); ]);
@@ -343,47 +294,39 @@ taskRoutes.post("/", async (c) => {
} }
} }
// Resolve the task's status definition. A status is per-project, so it may
// only be set when the task belongs to a project, and the status must belong
// to that project. When omitted, the project's default status is used.
let statusId = data.statusId ?? null;
if (data.statusId) {
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId })
.from(statusDefinitions)
.where(eq(statusDefinitions.id, data.statusId))
.limit(1);
if (!status) {
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
}
if (!data.projectId || status.projectId !== data.projectId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the selected project" } }, 400);
}
} else if (data.projectId) {
statusId = await getDefaultStatusId(data.projectId);
}
const statusCategory = await getStatusCategory(statusId);
const [task] = await db.insert(tasks).values({ const [task] = await db.insert(tasks).values({
title: data.title, title: data.title,
description: data.description ?? null, description: data.description ?? null,
statusId, status: data.status,
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 ?? {},
recurrenceRule: data.recurrenceRule ?? null, recurrenceRule: data.recurrenceRule ?? null,
}).returning(); }).returning();
if (data.tagIds && data.tagIds.length > 0) { const tagIdsToLink: string[] = [...(data.tagIds || [])];
if ((data as any).tagNames && (data as any).tagNames.length > 0) {
for (const rawName of (data as any).tagNames) {
const name = rawName.trim().toLowerCase();
if (!name) continue;
let [existing] = await db.select({ id: tagsTable.id }).from(tagsTable).where(eq(tagsTable.name, name)).limit(1);
if (!existing) {
const [created] = await db.insert(tagsTable).values({ name, scope: "tasks" as any }).returning({ id: tagsTable.id });
existing = created;
}
if (existing && !tagIdsToLink.includes(existing.id)) tagIdsToLink.push(existing.id);
}
}
if (tagIdsToLink.length > 0) {
await db.insert(taskTags).values( await db.insert(taskTags).values(
data.tagIds.map(tagId => ({ taskId: task.id, tagId })) tagIdsToLink.map(tagId => ({ taskId: task.id, tagId }))
); ).onConflictDoNothing();
} }
await recordActivity({ await recordActivity({
@@ -391,40 +334,16 @@ taskRoutes.post("/", async (c) => {
action: "created", action: "created",
entityType: "task", entityType: "task",
entityId: task.id, entityId: task.id,
changes: { title: task.title, statusId: task.statusId, priority: task.priority }, changes: { title: task.title, status: task.status, priority: task.priority },
workspaceId: data.domain, 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) {
@@ -512,10 +431,8 @@ 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 taskColumns = getTableColumns(tasks); const [task] = await db.select()
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);
@@ -526,9 +443,8 @@ taskRoutes.get("/:id", async (c) => {
await requireWorkspaceAccess(c, task.domainId); await requireWorkspaceAccess(c, task.domainId);
// Fetch subtasks // Fetch subtasks
const subtasks = await db.select({ ...taskColumns, status: statusColumns }) const subtasks = await db.select()
.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));
@@ -546,24 +462,20 @@ 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,
statusId: tasks.statusId, status: tasks.status,
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,
statusId: tasks.statusId, status: tasks.status,
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({
@@ -627,6 +539,7 @@ 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;
@@ -636,31 +549,6 @@ 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)
@@ -673,44 +561,16 @@ taskRoutes.patch("/:id", async (c) => {
action: "updated", action: "updated",
entityType: "task", entityType: "task",
entityId: id, entityId: id,
changes: { ...data, previousStatusId: existing.statusId }, changes: { ...data, previousStatus: existing.status },
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatusId: existing.statusId } }); await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
// 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) {
@@ -992,7 +852,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
} }
}); });
// POST /api/tasks/:id/status — Change task status (Kanban drag / completion) // POST /api/tasks/:id/status — Change task status (Kanban drag)
taskRoutes.post("/:id/status", async (c) => { taskRoutes.post("/:id/status", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
@@ -1001,8 +861,8 @@ taskRoutes.post("/:id/status", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); 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 { statusId } = z.object({ const { status: newStatus } = z.object({
statusId: z.string().uuid("Invalid status id"), status: taskStatusEnum,
}).parse(body); }).parse(body);
const [existing] = await db.select() const [existing] = await db.select()
@@ -1016,33 +876,13 @@ 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> = {
statusId, status: newStatus,
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)
@@ -1051,39 +891,14 @@ taskRoutes.post("/:id/status", async (c) => {
await recordActivity({ await recordActivity({
actor: user.name, actor: user.name,
action: isDone ? "completed" : "updated", action: newStatus === "done" ? "completed" : "updated",
entityType: "task", entityType: "task",
entityId: id, entityId: id,
changes: { previousStatusId: existing.statusId, newStatusId: statusId, newStatusLabel: status.label }, changes: { previousStatus: existing.status, newStatus },
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { previousStatusId: existing.statusId, newStatusId: statusId } }); return c.json(updated);
// 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);
-118
View File
@@ -1,118 +0,0 @@
import { Hono } from "hono";
import { db, projects, sections, statusDefinitions, taskDependencies, tasks } from "@project-e/db";
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
export const timelineRoutes = new Hono();
// Columns of the status_definitions table flattened onto a task row as `status`
// (null when the task has no status or its status was deleted).
const statusColumns = {
id: statusDefinitions.id,
projectId: statusDefinitions.projectId,
key: statusDefinitions.key,
label: statusDefinitions.label,
category: statusDefinitions.category,
color: statusDefinitions.color,
sortOrder: statusDefinitions.sortOrder,
isDefault: statusDefinitions.isDefault,
};
// GET /api/domains/:domainId/projects/:projectId/timeline — Gantt data for a project:
// task bars (with status + dependencies) and milestone sections. Tasks without a
// startDate field use createdAt as the bar start.
timelineRoutes.get("/domains/:domainId/projects/:projectId/timeline", async (c) => {
try {
const user = await requireAuth(c);
void user;
const domainId = c.req.param("domainId");
const projectId = c.req.param("projectId");
if (!isUuid(domainId) || !isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
await requireWorkspaceAccess(c, domainId);
const [project] = await db.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
const [taskRows, milestoneRows] = await Promise.all([
db.select({
id: tasks.id,
title: tasks.title,
statusId: tasks.statusId,
sectionId: tasks.sectionId,
dueDate: tasks.dueDate,
createdAt: tasks.createdAt,
status: statusColumns,
})
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.createdAt)),
db.select({
id: sections.id,
name: sections.name,
targetDate: sections.targetDate,
sortOrder: sections.sortOrder,
})
.from(sections)
.where(and(
eq(sections.projectId, projectId),
eq(sections.kind, "milestone"),
isNotNull(sections.targetDate),
))
.orderBy(asc(sections.targetDate), asc(sections.sortOrder)),
]);
// Dependency map: taskId → ids of tasks it depends on. Only edges between
// tasks in this project are kept so arrows never point outside the chart.
const depsByTask = new Map<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);
}
});
+2
View File
@@ -57,6 +57,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"fuse.js": "^7.5.0",
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-big-calendar": "^1.20.0", "react-big-calendar": "^1.20.0",
@@ -64,6 +65,7 @@
"react-force-graph-2d": "^1.29.1", "react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.84.0", "react-hook-form": "^7.84.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"rrule": "^2.8.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -1,528 +0,0 @@
import { useState } from "react";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { api, useApiMutation } from "@/lib/api";
import type {
AutomationAction,
AutomationActionType,
AutomationCondition,
AutomationConditionField,
AutomationRule,
AutomationTriggerType,
Project,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
// ── Shared metadata (also used by the Automations tab for summaries) ───────────
export const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string }[] = [
{ value: "task_status_changed", label: "Task status changed" },
{ value: "task_created", label: "Task created" },
{ value: "due_date_approaching", label: "Due date approaching" },
];
export const ACTION_OPTIONS: { value: AutomationActionType; label: string }[] = [
{ value: "set_status", label: "Set status" },
{ value: "set_priority", label: "Set priority" },
{ value: "add_label", label: "Add label" },
{ value: "create_notification", label: "Send notification" },
];
export const PRIORITY_OPTIONS: { value: string; label: string }[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
];
const CONDITION_FIELD_OPTIONS: { value: AutomationConditionField; label: string }[] = [
{ value: "status", label: "Status" },
{ value: "priority", label: "Priority" },
{ value: "label", label: "Label" },
];
const STATUS_OPS: { value: string; label: string }[] = [
{ value: "to", label: "changes to" },
{ value: "from", label: "changes from" },
{ value: "eq", label: "is" },
];
const PRIORITY_OPS: { value: string; label: string }[] = [
{ value: "eq", label: "is" },
{ value: "neq", label: "is not" },
];
const LABEL_OPS: { value: string; label: string }[] = [
{ value: "has", label: "has" },
{ value: "not_has", label: "does not have" },
];
function opsForField(field: AutomationConditionField): { value: string; label: string }[] {
switch (field) {
case "status":
return STATUS_OPS;
case "priority":
return PRIORITY_OPS;
case "label":
return LABEL_OPS;
default:
return STATUS_OPS;
}
}
function statusLabel(project: Project, key: string): string {
return project.statuses?.find((s) => s.key === key)?.label ?? key;
}
function summarizeActions(project: Project, actions: AutomationAction[]): string[] {
return actions.map((action) => {
const params = action.params ?? {};
switch (action.type) {
case "set_status":
return `Set status to ${statusLabel(project, String(params.statusKey ?? ""))}`;
case "set_priority":
return `Set priority to ${String(params.priority ?? "")}`;
case "add_label":
return `Add label "${String(params.label ?? "")}"`;
case "create_notification":
return `Notify: ${String(params.message ?? "")}`;
default:
return action.type;
}
});
}
export { summarizeActions };
// ── Rule builder dialog ─────────────────────────────────────────────────────────
interface DraftCondition {
field: AutomationConditionField;
op: string;
value: string;
}
interface DraftAction {
type: AutomationActionType;
params: Record<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>
);
}
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react"; import { Trash2, Bot } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatDistanceToNow, parseISO } from "date-fns"; import { formatDistanceToNow, parseISO } from "date-fns";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ErrorState, LoadingState } from "@/components/state"; import { ErrorState, LoadingState } from "@/components/state";
import { api, useApiMutation, useApiQuery } from "@/lib/api"; import { api, useApiMutation, useApiQuery } from "@/lib/api";
import type { Comment } from "@/lib/types"; import type { Comment } from "@/lib/types";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
interface EntityCommentsProps { interface EntityCommentsProps {
entityType: string; entityType: string;
@@ -84,20 +85,27 @@ function CommentComposer({
entityId, entityId,
parentId = null, parentId = null,
submitLabel = "Comment", submitLabel = "Comment",
placeholder = "Write a comment...", placeholder = "Write a comment... (use @agent to mention)",
autoFocus = false, autoFocus = false,
onSubmitted, onSubmitted,
}: CommentComposerProps) { }: CommentComposerProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const showMentionHint = content.includes("@");
const createMutation = useApiMutation<Comment, CreateCommentVariables>( const createMutation = useApiMutation<Comment, CreateCommentVariables>(
"post", "post",
"/comments", "/comments",
{ {
onSuccess: () => { onSuccess: (c) => {
queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] }); queryClient.invalidateQueries({ queryKey: ["comments", entityType, entityId] });
queryClient.invalidateQueries({ queryKey: ["agent-activity"] });
toast.success("Comment added"); toast.success("Comment added");
if (content.includes("@")) {
fetch("/api/agents/dispatch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commentId: (c as any).id || "", entityType, entityId, workspaceId: activeDomainId }) }).catch(()=>{});
toast.info("Agent notified via @mention");
}
setContent(""); setContent("");
onSubmitted?.(); onSubmitted?.();
}, },
@@ -127,6 +135,7 @@ function CommentComposer({
placeholder={placeholder} placeholder={placeholder}
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{showMentionHint && <p className="text-[11px] text-muted-foreground flex items-center gap-1"><Bot className="h-3 w-3" /> @mention will notify agents</p>}
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={submit} disabled={!canSubmit}> <Button size="sm" onClick={submit} disabled={!canSubmit}>
{createMutation.isPending ? "Posting..." : submitLabel} {createMutation.isPending ? "Posting..." : submitLabel}
@@ -1,267 +0,0 @@
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>
);
}
@@ -1,50 +0,0 @@
import type { TimelineTask } from "./gantt-utils";
export interface TaskPosition {
/** Left edge of the task's bar in timeline pixels. */
startX: number;
/** Right edge of the task's bar in timeline pixels. */
endX: number;
/** Vertical center of the task's row in pixels. */
y: number;
}
interface GanttDependencyArrowProps {
/** The task being depended on (arrow originates at the end of its bar). */
fromTask: TimelineTask;
/** The blocked task (arrowhead lands at the start of its bar). */
toTask: TimelineTask;
taskPositions: ReadonlyMap<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"
/>
);
}
@@ -1,24 +0,0 @@
import { format, parseISO } from "date-fns";
import type { TimelineMilestone } from "./gantt-utils";
interface GanttMilestoneProps {
milestone: TimelineMilestone;
/** Timeline pixel x where the diamond's center should sit. */
x: number;
}
/** Diamond marker for a milestone, vertically centered with its date label. */
export function GanttMilestone({ milestone, x }: GanttMilestoneProps) {
return (
<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>
);
}
@@ -1,105 +0,0 @@
import { useRef, useState } from "react";
import { addDays, differenceInCalendarDays, format, formatISO, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { positionForDate, toDayStart, type TimelineTask } from "./gantt-utils";
interface GanttTaskBarProps {
task: TimelineTask;
/** Left edge of the bar in timeline pixels. */
startX: number;
/** Bar width in timeline pixels (right edge = startX + width). */
width: number;
color: string;
pixelsPerDay: number;
viewStart: Date;
onCommit: (taskId: string, dueDate: string) => void;
}
interface DragState {
startClientX: number;
origDue: Date;
lastDue: Date;
moved: boolean;
}
/**
* A task bar on the timeline. Tasks only carry an end date (dueDate), so both
* dragging the body and pulling the right resize handle move the due date; the
* bar stays anchored at its start (createdAt) date. A task without a due date
* renders as a small stub that becomes a 1-day bar when dragged.
*/
export function GanttTaskBar({ task, startX, width, color, pixelsPerDay, viewStart, onCommit }: GanttTaskBarProps) {
const [dragDue, setDragDue] = useState<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>
);
}
@@ -1,101 +0,0 @@
import { format, getDaysInMonth, startOfMonth } from "date-fns";
import { cn } from "@/lib/utils";
import {
getDateRange,
positionForDate,
TIMELINE_HEADER_HEIGHT,
type ZoomLevel,
} from "./gantt-utils";
interface GanttTimelineHeaderProps {
viewStart: Date;
viewEnd: Date;
zoom: ZoomLevel;
pixelsPerDay: number;
}
function cellWidth(cell: Date, zoom: ZoomLevel, pixelsPerDay: number): number {
switch (zoom) {
case "day":
return pixelsPerDay;
case "week":
return 7 * pixelsPerDay;
case "month":
return getDaysInMonth(cell) * pixelsPerDay;
}
}
/**
* Two-row date header: a group label row (months for day/week zoom, years for
* month zoom) above the per-column cells. Positioned absolutely inside a
* container that spans the full timeline width so it can be made sticky by the
* parent chart.
*/
export function GanttTimelineHeader({ viewStart, viewEnd, zoom, pixelsPerDay }: GanttTimelineHeaderProps) {
const cells = getDateRange(viewStart, viewEnd, zoom);
// Group consecutive cells into spans for the top row.
const groups: { key: string; label: string; start: Date; end: Date }[] = [];
for (const cell of cells) {
const key = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "yyyy-MM");
const label = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "MMMM yyyy");
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.end = cell;
} else {
groups.push({ key, label, start: cell, end: cell });
}
}
const renderGroup = (group: { key: string; label: string; start: Date; end: Date }) => {
const left = positionForDate(group.start, viewStart, pixelsPerDay);
const width =
positionForDate(group.end, viewStart, pixelsPerDay) +
cellWidth(group.end, zoom, pixelsPerDay) -
left;
return (
<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>
);
}
@@ -1,82 +0,0 @@
import {
addDays,
differenceInCalendarDays,
eachDayOfInterval,
eachMonthOfInterval,
eachWeekOfInterval,
endOfDay,
endOfMonth,
parseISO,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns";
import type { StatusDefinition } from "@/lib/types";
export type ZoomLevel = "day" | "week" | "month";
export interface TimelineTask {
id: string;
title: string;
startDate: string;
dueDate: string | null;
statusId: string | null;
status: StatusDefinition | null;
sectionId: string | null;
dependencies: string[];
}
export interface TimelineMilestone {
id: string;
name: string;
targetDate: string;
}
export interface TimelineData {
tasks: TimelineTask[];
milestones: TimelineMilestone[];
}
export const ROW_HEIGHT = 40;
export const MILESTONE_BAND_HEIGHT = 48;
export const TIMELINE_HEADER_HEIGHT = 56;
export const TASK_LIST_WIDTH = 224;
const PIXELS_PER_DAY: Record<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) });
}
}
@@ -1,253 +0,0 @@
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlarmClock,
ArrowRightLeft,
AtSign,
Bell,
Bot,
Check,
Inbox,
RefreshCw,
UserPlus,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useApiQuery, useApiMutation, api } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { formatDistanceToNow } from "date-fns";
import type { Notification, NotificationCount, NotificationsResponse } from "@/lib/types";
const NOTIFICATION_META: Record<string, { icon: LucideIcon; color: string }> = {
mention: { icon: AtSign, color: "text-blue-500" },
status_change: { icon: ArrowRightLeft, color: "text-violet-500" },
due_soon: { icon: AlarmClock, color: "text-amber-500" },
automation: { icon: Bot, color: "text-emerald-500" },
assignment: { icon: UserPlus, color: "text-cyan-500" },
};
/** Navigate to the entity a notification points at. Returns true when a route
* was matched (and the sheet should close). */
function navigateToEntity(navigate: ReturnType<typeof useNavigate>, n: Notification): boolean {
if (!n.entityId || !n.entityType) return false;
switch (n.entityType) {
case "task":
navigate({ to: "/tasks/$id", params: { id: n.entityId } });
return true;
case "note":
navigate({ to: "/notes/$id", params: { id: n.entityId } });
return true;
case "project":
navigate({ to: "/projects/$id", params: { id: n.entityId } });
return true;
case "habit":
navigate({ to: "/habits/$id", params: { id: n.entityId } });
return true;
default:
return false;
}
}
export function NotificationCenter() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const domainId = useApiDomain();
const [open, setOpen] = useState(false);
// Own SSE connection so the badge stays live regardless of which page is
// mounted; notification events invalidate the count + list queries.
useRealtime({ enabled: true });
const countQuery = useApiQuery<NotificationCount>(
["notifications-count", domainId],
"/notifications/count" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}` : ""),
{ enabled: !!domainId, refetchInterval: 30_000 }
);
const unreadCount = countQuery.data?.count ?? 0;
const listQuery = useApiQuery<NotificationsResponse>(
["notifications", domainId],
"/notifications" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}&limit=50` : ""),
{ enabled: !!domainId && open }
);
const notifications = listQuery.data?.items ?? [];
const loading = listQuery.isLoading || listQuery.isFetching;
const invalidateNotifications = () => {
queryClient.invalidateQueries({ queryKey: ["notifications-count"] });
queryClient.invalidateQueries({ queryKey: ["notifications"] });
};
const markRead = useMutation({
mutationFn: (id: string) => api.patch(`/notifications/${id}`),
onMutate: (id) => {
// Optimistically decrement the badge so the UI feels instant.
queryClient.setQueryData<NotificationCount>(["notifications-count", domainId], (old) =>
old && old.count > 0 ? { count: old.count - 1 } : old
);
queryClient.setQueryData<NotificationsResponse>(["notifications", domainId], (old) =>
old
? {
...old,
items: old.items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: new Date().toISOString() } : n)),
unreadCount: Math.max(0, old.unreadCount - 1),
}
: old
);
return id;
},
onSuccess: invalidateNotifications,
});
const markAllRead = useApiMutation<{ success: boolean; updated: number }, { workspace_id?: string }>(
"post",
"/notifications/read-all",
{
onMutate: () => {
queryClient.setQueryData<NotificationCount>(["notifications-count", domainId], (old) =>
old ? { count: 0 } : old
);
queryClient.setQueryData<NotificationsResponse>(["notifications", domainId], (old) =>
old
? {
...old,
items: old.items.map((n) => (n.readAt ? n : { ...n, readAt: new Date().toISOString() })),
unreadCount: 0,
}
: old
);
},
onSuccess: invalidateNotifications,
}
);
const handleNotificationClick = (n: Notification) => {
if (!n.readAt) markRead.mutate(n.id);
if (navigateToEntity(navigate, n)) {
setOpen(false);
}
};
const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount);
return (
<Sheet open={open} onOpenChange={setOpen}>
<TooltipProvider>
<Tooltip>
<SheetTrigger asChild>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
>
{badgeLabel}
</span>
)}
</Button>
</TooltipTrigger>
</SheetTrigger>
<TooltipContent>
{unreadCount === 0 ? "No notifications" : `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base">Notifications</SheetTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
disabled={unreadCount === 0 || markAllRead.isPending}
onClick={() => markAllRead.mutate({ workspace_id: domainId || undefined })}
>
<Check className="mr-1 h-3 w-3" />
Mark all read
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Refresh notifications"
onClick={() => invalidateNotifications()}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</SheetHeader>
<ScrollArea className="h-full flex-1">
{loading && notifications.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">Loading notifications</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="h-8 w-8 opacity-40" />
No notifications yet
</div>
) : (
<ul className="divide-y">
{notifications.map((n) => {
const meta = NOTIFICATION_META[n.type] ?? { icon: Bell, color: "text-muted-foreground" };
const Icon = meta.icon;
const unread = !n.readAt;
return (
<li key={n.id}>
<button
type="button"
onClick={() => handleNotificationClick(n)}
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/60 focus:outline-none focus-visible:bg-accent/60 ${
unread ? "bg-accent/40" : ""
}`}
>
<span
aria-hidden="true"
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted ${meta.color}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-sm ${unread ? "font-semibold" : "font-medium text-muted-foreground"}`}>
{n.title}
</span>
{n.body && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{n.body}</span>
)}
<span className="mt-1 block text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</span>
{unread && (
<span aria-hidden="true" className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
)}
</button>
</li>
);
})}
</ul>
)}
</ScrollArea>
</SheetContent>
</Sheet>
);
}
-234
View File
@@ -1,234 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus, Calendar, Clock, Flag, FolderKanban, Tag as TagIcon, Repeat, CornerDownLeft } from "lucide-react";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { parseQuickAdd, type ParsedTask, type QuickAddContext } from "@/lib/nlp-parser";
import { PRIORITY } from "@/lib/status-colors";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { Project, Tag, StatusDefinition, PaginatedResponse } from "@/lib/types";
function PreviewChip({
icon,
label,
className,
}: {
icon: React.ReactNode;
label: string;
className?: string;
}) {
return (
<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>
);
}
@@ -11,7 +11,6 @@ import {
NotebookPen, NotebookPen,
BookOpen, BookOpen,
Share2, Share2,
LayoutGrid,
CalendarDays, CalendarDays,
Search, Search,
BarChart3, BarChart3,
@@ -67,7 +66,6 @@ const navItems: NavItem[] = [
{ href: "/search", label: "Search", icon: Search }, { href: "/search", label: "Search", icon: Search },
{ href: "/analytics", label: "Analytics", icon: BarChart3 }, { href: "/analytics", label: "Analytics", icon: BarChart3 },
{ href: "/agents/activity", label: "Agent Activity", icon: Bot }, { href: "/agents/activity", label: "Agent Activity", icon: Bot },
{ href: "/canvas", label: "Canvas", icon: LayoutGrid },
{ href: "/daily", label: "Daily Notes", icon: PenLine }, { href: "/daily", label: "Daily Notes", icon: PenLine },
]; ];
+90 -5
View File
@@ -1,10 +1,12 @@
import { Search, Plus, Menu } from "lucide-react"; import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { 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,
@@ -19,10 +21,27 @@ 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 || "";
@@ -32,6 +51,20 @@ 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"
@@ -86,15 +119,67 @@ export function Topbar() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
{/* Notifications bell (badge + slide-out panel) */} {/* Notifications bell */}
<NotificationCenter /> <TooltipProvider>
<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} &middot; {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">{userInitials}</AvatarFallback> <AvatarFallback className="text-xs">U</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -0,0 +1,42 @@
import { useMemo, useState } from "react";
import { RRule } from "rrule";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
const PRESETS = [
{ value: "", label: "No repeat" },
{ value: "FREQ=DAILY", label: "Daily" },
{ value: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", label: "Weekdays" },
{ value: "FREQ=WEEKLY", label: "Weekly" },
{ value: "FREQ=WEEKLY;BYDAY=MO", label: "Weekly on Monday" },
{ value: "FREQ=MONTHLY", label: "Monthly" },
{ value: "FREQ=YEARLY", label: "Yearly" },
];
export function RecurrencePicker({ value, onChange }: { value: string | null; onChange: (v: string | null) => void }) {
const [preset, setPreset] = useState(value || "");
const preview = useMemo(() => {
if (!preset) return [];
try {
const rule = RRule.fromString(preset);
const opts = { ...rule.options, dtstart: new Date() };
const r = new RRule(opts);
return r.all((_, i) => i < 5).map(d => d.toLocaleDateString());
} catch { return []; }
}, [preset]);
return (
<div className="space-y-2">
<Label>Repeat</Label>
<Select value={preset} onValueChange={(v) => { setPreset(v); onChange(v || null); }}>
<SelectTrigger><SelectValue placeholder="No repeat" /></SelectTrigger>
<SelectContent>
{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
{preview.length > 0 && (
<p className="text-xs text-muted-foreground">Next: {preview.join(" · ")}</p>
)}
</div>
);
}
-7
View File
@@ -52,9 +52,6 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
case "agent": case "agent":
queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]); queryKeys.push(["agents"], ["agents-list"], ["agent-activity"]);
break; break;
case "canvas":
queryKeys.push(["canvas"]);
break;
case "webhook": case "webhook":
queryKeys.push(["webhooks"]); queryKeys.push(["webhooks"]);
break; break;
@@ -72,10 +69,6 @@ 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]);
} }
-257
View File
@@ -1,257 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { parseQuickAdd } from "./nlp-parser";
// Deterministic "now" so date math is stable across runs. Tests that rely on
// calendar dates are anchored relative to this fixed reference point.
const NOW = new Date("2026-08-19T12:00:00"); // a Wednesday
function parse(input: string, context?: Parameters<typeof parseQuickAdd>[1]) {
return parseQuickAdd(input, context);
}
// Helper: same calendar day check regardless of time component.
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
// Patch Date.now for the tests that compute relative dates.
describe("nlp-parser", () => {
const realNow = Date.now;
beforeEach(() => {
Date.now = () => NOW.getTime();
});
afterEach(() => {
Date.now = realNow;
});
describe("title extraction", () => {
it("keeps plain text as the title", () => {
const r = parse("buy milk");
expect(r.title).toBe("buy milk");
});
it("removes recognized tokens from the title", () => {
const r = parse("buy milk tomorrow !high");
expect(r.title).toBe("buy milk");
});
it("trims surrounding whitespace from the title", () => {
const r = parse(" buy milk tomorrow ");
expect(r.title).toBe("buy milk");
});
it("keeps multiple words in original order", () => {
const r = parse("fix login bug in the auth flow");
expect(r.title).toBe("fix login bug in the auth flow");
});
});
describe("priority", () => {
it("parses !urgent", () => {
expect(parse("ship !urgent").priority).toBe("urgent");
});
it("parses !high", () => {
expect(parse("ship !high").priority).toBe("high");
});
it("parses !medium", () => {
expect(parse("ship !medium").priority).toBe("medium");
});
it("parses !low", () => {
expect(parse("ship !low").priority).toBe("low");
});
it("parses !! as urgent", () => {
expect(parse("ship !!").priority).toBe("urgent");
});
it("is case-insensitive", () => {
expect(parse("ship !HIGH").priority).toBe("high");
});
});
describe("dates", () => {
it("parses tomorrow as next day", () => {
const r = parse("buy milk tomorrow");
expect(r.dueDate).toBeDefined();
expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true);
});
it("parses in 3 days", () => {
const r = parse("fix bug in 3 days");
expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true);
});
it("parses in 2 weeks", () => {
const r = parse("plan in 2 weeks");
expect(sameDay(r.dueDate!, new Date(2026, 8, 2))).toBe(true);
});
it("parses next friday", () => {
// NOW is Wed 2026-08-19; next friday is 2026-08-21.
const r = parse("review PR next friday");
expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true);
});
it("parses a bare weekday as the next occurrence", () => {
// NOW is Wed 2026-08-19; next monday is 2026-08-24.
const r = parse("standup monday");
expect(sameDay(r.dueDate!, new Date(2026, 7, 24))).toBe(true);
});
it("parses ISO date 2025-01-15", () => {
const r = parse("deadline 2025-01-15");
expect(sameDay(r.dueDate!, new Date(2025, 0, 15))).toBe(true);
});
it("parses month name + day (dec 25)", () => {
const r = parse("gift dec 25");
expect(sameDay(r.dueDate!, new Date(2026, 11, 25))).toBe(true);
});
it("parses end of month", () => {
const r = parse("report end of month");
// Aug 2026 has 31 days.
expect(sameDay(r.dueDate!, new Date(2026, 7, 31))).toBe(true);
});
it("parses next week", () => {
const r = parse("event next week");
expect(sameDay(r.dueDate!, new Date(2026, 7, 26))).toBe(true);
});
});
describe("times", () => {
it("parses at 2pm on a date", () => {
const r = parse("call tomorrow at 2pm");
expect(r.dueDate!.getHours()).toBe(14);
expect(r.dueDate!.getMinutes()).toBe(0);
});
it("parses at 9am on a date", () => {
const r = parse("standup tomorrow at 9am");
expect(r.dueDate!.getHours()).toBe(9);
});
it("parses 24h time at 14:30", () => {
const r = parse("meeting tomorrow at 14:30");
expect(r.dueDate!.getHours()).toBe(14);
expect(r.dueDate!.getMinutes()).toBe(30);
});
it("removes the time phrase from the title", () => {
const r = parse("call tomorrow at 2pm");
expect(r.title).toBe("call");
});
});
describe("projects and tags", () => {
it("resolves #project to a known project name", () => {
const r = parse("buy milk #work", { projectNames: ["Work", "Personal"] });
expect(r.project).toBe("Work");
});
it("resolves @tag to a known tag name", () => {
const r = parse("task @sarah", { tagNames: ["sarah", "billing"] });
expect(r.tags).toEqual(["sarah"]);
});
it("keeps unknown #project in the title", () => {
const r = parse("fix #hashtag bug", { projectNames: ["Work"] });
expect(r.project).toBeUndefined();
expect(r.title).toContain("#hashtag");
});
it("keeps unknown @tag in the title", () => {
const r = parse("mention @nobody", { tagNames: ["sarah"] });
expect(r.tags).toBeUndefined();
expect(r.title).toContain("@nobody");
});
it("resolves multiple tags", () => {
const r = parse("task @a @b", { tagNames: ["a", "b", "c"] });
expect(r.tags).toEqual(["a", "b"]);
});
});
describe("recurrence", () => {
it("parses daily", () => {
expect(parse("standup daily").recurrence).toBe("FREQ=DAILY");
});
it("parses weekly", () => {
expect(parse("review weekly").recurrence).toBe("FREQ=WEEKLY");
});
it("parses monthly", () => {
expect(parse("report monthly").recurrence).toBe("FREQ=MONTHLY");
});
it("parses every monday", () => {
expect(parse("standup every monday").recurrence).toBe("FREQ=WEEKLY;BYDAY=MO");
});
it("parses every 2 weeks", () => {
expect(parse("review every 2 weeks").recurrence).toBe("FREQ=WEEKLY;INTERVAL=2");
});
it("parses every month on the 15th", () => {
expect(parse("bill every month on the 15th").recurrence).toBe(
"FREQ=MONTHLY;BYMONTHDAY=15"
);
});
it("removes recurrence words from the title", () => {
const r = parse("standup every monday");
expect(r.title).toBe("standup");
});
});
describe("combined & edge cases", () => {
it("parses a full example", () => {
const r = parse("buy milk tomorrow !high #work @sarah every monday", {
projectNames: ["work"],
tagNames: ["sarah"],
});
expect(r.title).toBe("buy milk");
expect(r.priority).toBe("high");
expect(r.project).toBe("work");
expect(r.tags).toEqual(["sarah"]);
expect(r.recurrence).toBe("FREQ=WEEKLY;BYDAY=MO");
expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true);
});
it("parses review PR !urgent next friday", () => {
const r = parse("review PR !urgent next friday");
expect(r.title).toBe("review PR");
expect(r.priority).toBe("urgent");
expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true);
});
it("parses team standup daily at 9am #engineering", () => {
const r = parse("team standup daily at 9am #engineering", {
projectNames: ["engineering"],
});
expect(r.title).toBe("team standup");
expect(r.recurrence).toBe("FREQ=DAILY");
expect(r.dueDate!.getHours()).toBe(9);
expect(r.project).toBe("engineering");
});
it("parses fix login bug in 3 days !high #backend", () => {
const r = parse("fix login bug in 3 days !high #backend", {
projectNames: ["backend"],
});
expect(r.title).toBe("fix login bug");
expect(r.priority).toBe("high");
expect(r.project).toBe("backend");
expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true);
});
it("keeps the raw input", () => {
const input = "buy milk tomorrow !high";
expect(parse(input).raw).toBe(input);
});
it("returns empty title for only-metadata input", () => {
const r = parse("!high tomorrow", { projectNames: [] });
expect(r.title).toBe("");
expect(r.priority).toBe("high");
});
});
});
-458
View File
@@ -1,458 +0,0 @@
/**
* Natural-language quick-add parser.
*
* A pure, dependency-light tokenizer that turns free-text task input into
* structured task data:
*
* "buy milk tomorrow !high #work @sarah every monday"
* title: "buy milk", dueDate: tomorrow, priority: "high",
* project: "work" (resolved against context), tags: ["sarah"],
* recurrence: "FREQ=WEEKLY;BYDAY=MO"
*
* No network calls, no external NLP library just regex tokenization over a
* normalized token stream, evaluated in the browser's local timezone.
*/
export type QuickAddPriority = "low" | "medium" | "high" | "urgent";
export interface ParsedTask {
/** The remaining free text with all recognized tokens removed. */
title: string;
/** Resolved absolute due date (local timezone), if one was given. */
dueDate?: Date;
priority?: QuickAddPriority;
/**
* The matched project NAME (from `#project`), when it matches a known name
* in `context.projectNames`. The caller maps this name to an id before
* sending the create request.
*/
project?: string;
/** Matched tag NAMES (from `@label`), when they match `context.tagNames`. */
tags?: string[];
/** An RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO"). */
recurrence?: string;
/** The original, unmodified input string. */
raw: string;
}
export interface QuickAddContext {
projectNames?: string[];
tagNames?: string[];
}
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Strip leading zeros so "02" reads as "2" (used for ordinal date math). */
function num(n: string): number {
return parseInt(n.replace(/^0+/, "") || "0", 10);
}
/** Start-of-day in the local timezone. All date tokens are anchored to this. */
function startOfDay(d: Date): Date {
const copy = new Date(d);
copy.setHours(0, 0, 0, 0);
return copy;
}
function addDays(d: Date, days: number): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + days);
return copy;
}
function addMonths(d: Date, months: number): Date {
const copy = new Date(d);
copy.setMonth(copy.getMonth() + months);
return copy;
}
/** Next occurrence of `weekday` (0=Sun..6=Sat). When includeToday, today counts. */
function nextWeekday(from: Date, weekday: number, includeToday: boolean): Date {
let d = startOfDay(from);
if (!includeToday) d = addDays(d, 1);
while (d.getDay() !== weekday) d = addDays(d, 1);
return d;
}
// ── Tokenization ──────────────────────────────────────────────────────────────
type TokenKind =
| "word"
| "priority"
| "project"
| "tag"
| "date"
| "time"
| "recurrence";
interface Token {
kind: TokenKind;
value: string;
}
/**
* Break the input into a token stream, tagging each token with its kind.
* Plain words are kept verbatim (the title is rebuilt from them in order).
*/
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
const re =
/(\*\*)|(!urgent|!high|!medium|!low)|(#[^\s]+)|(@[^\s]+)|(\d{4}-\d{2}-\d{2})|(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)|([0-2]?\d:\d{2}\s?(?:am|pm)?)|([^\s]+)/gi;
for (const m of input.matchAll(re)) {
const full = m[0];
if (!full) continue;
if (/^!!$/.test(full)) {
tokens.push({ kind: "priority", value: "urgent" });
} else if (/^!(urgent|high|medium|low)$/i.test(full)) {
tokens.push({ kind: "priority", value: full.slice(1).toLowerCase() });
} else if (/^#[^\s]+$/.test(full)) {
tokens.push({ kind: "project", value: full.slice(1) });
} else if (/^@[^\s]+$/.test(full)) {
tokens.push({ kind: "tag", value: full.slice(1) });
} else if (/^\d{4}-\d{2}-\d{2}$/.test(full) || /^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(full)) {
tokens.push({ kind: "date", value: full });
} else if (/^[0-2]?\d:\d{2}\s?(?:am|pm)?$/i.test(full)) {
tokens.push({ kind: "time", value: full });
} else {
tokens.push({ kind: "word", value: full });
}
}
return tokens;
}
// ── Recurrence parsing ────────────────────────────────────────────────────────
const WEEKDAY_MAP: Record<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,
};
}
+133
View File
@@ -0,0 +1,133 @@
export interface ParsedTaskInput {
title: string;
dueDate: string | null;
priority: "low" | "medium" | "high" | "urgent" | null;
tags: string[];
}
const PRIORITY_MAP: Record<string, ParsedTaskInput["priority"]> = {
p1: "urgent",
p2: "high",
p3: "medium",
p4: "low",
urgent: "urgent",
high: "high",
medium: "medium",
low: "low",
};
function parseDueDate(input: string): { date: Date | null; matched: string | null } {
const lower = input.toLowerCase();
const now = new Date();
const base = new Date(now);
base.setHours(0, 0, 0, 0);
let matched: string | null = null;
let date: Date | null = null;
const tryTime = (s: string): { hours: number; minutes: number; rest: string } | null => {
const m = s.match(/(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
if (!m) return null;
let h = parseInt(m[1], 10);
const min = parseInt(m[2] || "0", 10);
const ap = m[3]?.toLowerCase();
if (ap === "pm" && h < 12) h += 12;
if (ap === "am" && h === 12) h = 0;
return { hours: h, minutes: min, rest: m[0] };
};
const applyTime = (d: Date, raw: string) => {
const t = tryTime(raw);
if (t) {
d.setHours(t.hours, t.minutes, 0, 0);
if (!matched) matched = raw.trim();
else matched += " " + t.rest;
} else {
d.setHours(9, 0, 0, 0);
}
};
if (lower.includes("today")) {
date = new Date(base);
matched = "today";
const idx = lower.indexOf("today");
applyTime(date, lower.slice(idx + 5, idx + 20));
} else if (lower.includes("tomorrow")) {
date = new Date(base);
date.setDate(date.getDate() + 1);
matched = "tomorrow";
const idx = lower.indexOf("tomorrow");
applyTime(date, lower.slice(idx + 8, idx + 25));
} else if (lower.includes("yesterday")) {
date = new Date(base);
date.setDate(date.getDate() - 1);
matched = "yesterday";
} else if (/in\s+(\d+)\s+days?/i.test(lower)) {
const m = lower.match(/in\s+(\d+)\s+days?/i)!;
date = new Date(base);
date.setDate(date.getDate() + parseInt(m[1], 10));
matched = m[0];
} else if (/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(lower)) {
const m = lower.match(/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i)!;
const days: Record<string, number> = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
const target = days[m[1].toLowerCase()];
date = new Date(base);
const cur = date.getDay();
let diff = (target - cur + 7) % 7;
if (diff === 0) diff = 7;
date.setDate(date.getDate() + diff);
matched = m[0];
} else if (/(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(lower)) {
const m = lower.match(/(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i)!;
const days: Record<string, number> = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
const target = days[m[1].toLowerCase()];
date = new Date(base);
const cur = date.getDay();
let diff = (target - cur + 7) % 7;
if (diff === 0) diff = 7;
date.setDate(date.getDate() + diff);
matched = m[0];
}
if (date && !matched?.includes("at") && !matched?.includes(":")) {
const t = tryTime(lower);
if (t && t.hours !== 9) {
date.setHours(t.hours, t.minutes, 0, 0);
matched = (matched || "") + " " + t.rest;
}
}
return { date, matched: matched?.trim() || null };
}
export function parseTaskInput(raw: string): ParsedTaskInput {
let title = raw;
const tags: string[] = [];
const tagRegex = /#(\w[\w-]*)/g;
let m: RegExpExecArray | null;
while ((m = tagRegex.exec(raw)) !== null) tags.push(m[1].toLowerCase());
title = title.replace(tagRegex, "").trim();
let priority: ParsedTaskInput["priority"] = null;
const prioRegex = /\b(p[1-4]|urgent|high|medium|low)\b/i;
const prioMatch = raw.match(prioRegex);
if (prioMatch) {
const key = prioMatch[1].toLowerCase();
priority = PRIORITY_MAP[key] || null;
title = title.replace(prioMatch[0], "").trim();
}
const due = parseDueDate(raw);
if (due.date) {
const esc = due.matched!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
title = title.replace(new RegExp(esc, "i"), "").trim();
title = title.replace(/\b(at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/i, "").trim();
title = title.replace(/\bat\b$/i, "").trim();
}
title = title.replace(/\s{2,}/g, " ").trim();
if (!title) title = raw.replace(tagRegex, "").replace(prioRegex, "").trim() || raw;
return { title: title || raw, dueDate: due.date ? due.date.toISOString() : null, priority, tags };
}
-34
View File
@@ -21,40 +21,6 @@ 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" },
+11 -127
View File
@@ -1,32 +1,10 @@
// 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;
statusId: string | null; status: "todo" | "in_progress" | "done" | "cancelled";
/** 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;
@@ -43,16 +21,8 @@ export interface Task {
tags: Tag[]; tags: Tag[];
customFields?: Record<string, unknown>; customFields?: Record<string, unknown>;
subtasks?: Task[]; subtasks?: Task[];
dependencies?: TaskSummary[]; dependencies?: { id: string; title: string; status: string }[];
dependents?: TaskSummary[]; dependents?: { id: string; title: string; status: string }[];
}
/** 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 {
@@ -103,8 +73,6 @@ 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[];
} }
@@ -121,40 +89,6 @@ 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;
@@ -336,34 +270,19 @@ 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;
userId: string; actor: string;
workspaceId: string | null; action: string;
type: NotificationType; entityType: string;
title: string; entityId: string;
body: string | null; changes: Record<string, unknown> | null;
entityType: string | null; workspaceId: string;
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;
} }
@@ -380,44 +299,7 @@ export interface AgentActivity {
createdAt: string; createdAt: string;
} }
// Canvas
export interface Canvas {
id: string;
name: string;
description: string | null;
mode: "freeform" | "graph";
domainId: string;
tags: string[];
viewport: { x: number; y: number; zoom: number };
background: string | null;
customFields: Record<string, unknown>;
createdAt: string;
updatedAt: string;
cards?: CanvasCard[];
connections?: CanvasConnection[];
}
export interface CanvasCard {
id: string;
canvasId: string;
type: string;
content: string;
position: { x: number; y: number };
size: { w: number; h: number };
zIndex: number;
color: string | null;
createdAt: string;
updatedAt: string;
}
export interface CanvasConnection {
id: string;
canvasId: string;
sourceCardId: string;
targetCardId: string;
label: string | null;
color: string | null;
}
// Daily Notes // Daily Notes
export interface DailyNote { export interface DailyNote {
@@ -444,6 +326,8 @@ export interface HabitAnalytics {
habitConsistency: number; habitConsistency: number;
totalHabits: number; totalHabits: number;
totalLogs: number; totalLogs: number;
totalExpected?: number;
perHabit?: Array<{ id: string; name: string; completed: number; expected: number; consistency: number }>;
activeStreaks: number; activeStreaks: number;
bestStreak: number; bestStreak: number;
period: number; period: number;
-4
View File
@@ -13,13 +13,11 @@ import { Route as searchRoute } from "./routes/_app/search";
import { Route as analyticsRoute } from "./routes/_app/analytics"; import { Route as analyticsRoute } from "./routes/_app/analytics";
import { Route as agentsRoute } from "./routes/_app/agents"; import { Route as agentsRoute } from "./routes/_app/agents";
import { Route as agentActivityRoute } from "./routes/_app/agents/activity"; import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
import { Route as canvasRoute } from "./routes/_app/canvas";
import { Route as dailyRoute } from "./routes/_app/daily"; import { Route as dailyRoute } from "./routes/_app/daily";
import { Route as settingsRoute } from "./routes/_app/settings"; import { Route as settingsRoute } from "./routes/_app/settings";
import { Route as taskDetailRoute } from "./routes/_app/tasks/$id"; import { Route as taskDetailRoute } from "./routes/_app/tasks/$id";
import { Route as habitDetailRoute } from "./routes/_app/habits/$id"; import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
import { Route as noteDetailRoute } from "./routes/_app/notes/$id"; import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
import { Route as canvasDetailRoute } from "./routes/_app/canvas/$id";
import { Route as projectDetailRoute } from "./routes/_app/projects/$id"; import { Route as projectDetailRoute } from "./routes/_app/projects/$id";
const appChildren = [ const appChildren = [
@@ -34,13 +32,11 @@ const appChildren = [
analyticsRoute, analyticsRoute,
agentsRoute, agentsRoute,
agentActivityRoute, agentActivityRoute,
canvasRoute,
dailyRoute, dailyRoute,
settingsRoute, settingsRoute,
taskDetailRoute, taskDetailRoute,
habitDetailRoute, habitDetailRoute,
noteDetailRoute, noteDetailRoute,
canvasDetailRoute,
projectDetailRoute, projectDetailRoute,
]; ];
-2
View File
@@ -5,7 +5,6 @@ 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() {
@@ -42,7 +41,6 @@ function AppLayout() {
</div> </div>
<CommandPalette /> <CommandPalette />
<ShortcutsHelp /> <ShortcutsHelp />
<QuickAddBar />
</div> </div>
); );
} }
+28 -1
View File
@@ -24,6 +24,8 @@ function AnalyticsPage() {
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix); const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix); const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix); const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
const { data: velocityData } = useApiQuery<{ items: Array<{date:string;completed:number}>; avg:number }>(["analytics-velocity", activeDomainId, range], "/analytics/velocity?range=" + range + domainSuffix);
const { data: cycleData } = useApiQuery<{ median:number; avg:number; count:number }>(["analytics-cycle", activeDomainId, range], "/analytics/cycle?range=" + range + domainSuffix);
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading; const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
const analyticsError = habitsError || projectsError || dailyError; const analyticsError = habitsError || projectsError || dailyError;
@@ -36,9 +38,10 @@ function AnalyticsPage() {
const dailyItems = dailyData?.items || []; const dailyItems = dailyData?.items || [];
const habitRateData = useMemo(() => { const habitRateData = useMemo(() => {
const expected = (habitData as any)?.totalExpected ?? (habitData?.totalHabits || 0) * parseInt(range);
return [ return [
{ name: "Completed", value: habitData?.totalLogs || 0 }, { name: "Completed", value: habitData?.totalLogs || 0 },
{ name: "Missed", value: Math.max(0, (habitData?.totalHabits || 1) * parseInt(range) - (habitData?.totalLogs || 0)) }, { name: "Missed", value: Math.max(0, expected - (habitData?.totalLogs || 0)) },
]; ];
}, [habitData, range]); }, [habitData, range]);
@@ -161,6 +164,30 @@ function AnalyticsPage() {
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} /> <CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
</CardContent> </CardContent>
</Card> </Card>
{/* Velocity */}
<Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Velocity</CardTitle>
<span className="text-xs text-muted-foreground">avg {velocityData?.avg ?? 0}/day</span>
</CardHeader>
<CardContent className="p-4">
{velocityData?.items ? <LineChart data={velocityData.items} xKey="date" yKey="completed" /> : <p className="text-sm text-muted-foreground">No data</p>}
</CardContent>
</Card>
{/* Cycle time */}
<Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Cycle Time</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="text-center py-4">
<p className="text-2xl font-bold">{cycleData ? `${cycleData.median}d median · ${cycleData.avg}d avg` : "—"}</p>
<p className="text-xs text-muted-foreground">{cycleData?.count ?? 0} tasks completed in range</p>
</div>
</CardContent>
</Card>
</div> </div>
)} )}
</div> </div>
+39 -11
View File
@@ -185,13 +185,28 @@ function CalendarPage() {
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const [showTasks, setShowTasks] = useState(true);
const [showHabits, setShowHabits] = useState(true);
const fromISO = useMemo(() => { const d = new Date(date); d.setDate(1); d.setHours(0,0,0,0); d.setMonth(d.getMonth()-1); return d.toISOString(); }, [date]);
const toISO = useMemo(() => { const d = new Date(date); d.setMonth(d.getMonth()+2); d.setDate(0); d.setHours(23,59,59,999); return d.toISOString(); }, [date]);
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>( const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
["calendar-events", activeDomainId], ["calendar-events", activeDomainId, fromISO, toISO],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "") `/calendar/events?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>(
const events = eventsData?.items || []; ["calendar-unified", activeDomainId, fromISO, toISO],
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const events = useMemo(() => {
if (!unifiedData?.items) return eventsData?.items || [];
const base = unifiedData.items;
if (!showTasks && !showHabits) return base.filter(e => !e.entityType || (e.entityType!=="task" && e.entityType!=="habit"));
if (!showTasks) return base.filter(e => e.entityType!=="task");
if (!showHabits) return base.filter(e => e.entityType!=="habit");
return base;
}, [unifiedData, eventsData, showTasks, showHabits]);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/calendar/events/" + id), mutationFn: (id: string) => api.delete("/calendar/events/" + id),
@@ -223,11 +238,16 @@ function CalendarPage() {
const handleEventDrop = useCallback( const handleEventDrop = useCallback(
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => { ({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
api.patch(`/calendar/events/${event.id}`, { const isTask = event.entityType === "task" && String(event.id).startsWith("task-");
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(), const patchUrl = isTask ? `/tasks/${event.entityId}` : `/calendar/events/${event.id}`;
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(), const patchData = isTask ? { dueDate: (start instanceof Date ? start : new Date(start as string)).toISOString() } : {
}).then(() => { startTime: start instanceof Date ? start.toISOString() : new Date(start as string).toISOString(),
endTime: end instanceof Date ? end.toISOString() : new Date(end as string).toISOString(),
};
api.patch(patchUrl, patchData).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
queryClient.invalidateQueries({ queryKey: ["calendar-unified"] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
}); });
}, },
[queryClient] [queryClient]
@@ -235,11 +255,13 @@ function CalendarPage() {
const handleEventResize = useCallback( const handleEventResize = useCallback(
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => { ({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
if (String(event.id).startsWith("task-") || String(event.id).startsWith("habit-")) return;
api.patch(`/calendar/events/${event.id}`, { api.patch(`/calendar/events/${event.id}`, {
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(), startTime: start instanceof Date ? start.toISOString() : new Date(start as string).toISOString(),
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(), endTime: end instanceof Date ? end.toISOString() : new Date(end as string).toISOString(),
}).then(() => { }).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
queryClient.invalidateQueries({ queryKey: ["calendar-unified"] });
}); });
}, },
[queryClient] [queryClient]
@@ -268,7 +290,12 @@ function CalendarPage() {
</Dialog> </Dialog>
</div> </div>
<div className="flex items-center justify-end gap-1 mb-2"> <div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showTasks} onChange={e=>setShowTasks(e.target.checked)} /> Tasks</label>
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showHabits} onChange={e=>setShowHabits(e.target.checked)} /> Habits</label>
</div>
<div className="flex items-center gap-1">
{["month", "week", "day", "agenda"].map((name) => ( {["month", "week", "day", "agenda"].map((name) => (
<Button <Button
key={name} key={name}
@@ -280,6 +307,7 @@ function CalendarPage() {
{name} {name}
</Button> </Button>
))} ))}
</div>
</div> </div>
{eventsLoading && events.length === 0 ? ( {eventsLoading && events.length === 0 ? (
<LoadingState label="Loading events..." /> <LoadingState label="Loading events..." />
-481
View File
@@ -1,481 +0,0 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { Canvas, CanvasCard, PaginatedResponse } from "@/lib/types";
const BLOCK_TYPES = [
{ id: "text", label: "Text", icon: Type },
{ id: "heading1", label: "Heading 1", icon: Heading1 },
{ id: "heading2", label: "Heading 2", icon: Heading2 },
{ id: "heading3", label: "Heading 3", icon: Heading2 },
{ id: "bullet_list", label: "Bullet List", icon: List },
{ id: "todo", label: "Todo", icon: CheckSquare },
{ id: "code", label: "Code", icon: Code },
{ id: "image", label: "Image", icon: Image },
] as const;
// ─── Block Editor ────────────────────────────────────────────────────────
function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
block: { id: string; type: string; content: string };
onChange: (id: string, content: string) => void;
onDelete: (id: string) => void;
onMoveUp: () => void;
onMoveDown: () => void;
}) {
const [showSlash, setShowSlash] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "/" && block.content === "") {
setShowSlash(true);
}
if (e.key === "Escape") {
setShowSlash(false);
}
};
const handleChange = (value: string) => {
onChange(block.id, value);
if (value.startsWith("/")) {
setShowSlash(true);
} else {
setShowSlash(false);
}
};
const insertBlockType = (type: string) => {
onChange(block.id, "");
// We can't change the type directly in this simple model, so we signal via a custom event
const event = new CustomEvent("change-block-type", { detail: { id: block.id, type } });
window.dispatchEvent(event);
setShowSlash(false);
};
const renderEditor = () => {
switch (block.type) {
case "heading1":
return (
<input
ref={inputRef as any}
value={block.content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Heading 1..."
className="w-full text-2xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
/>
);
case "heading2":
return (
<input
ref={inputRef as any}
value={block.content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Heading 2..."
className="w-full text-xl font-semibold bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
/>
);
case "heading3":
return (
<input
ref={inputRef as any}
value={block.content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Heading 3..."
className="w-full text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
/>
);
case "todo":
return (
<div className="flex items-center gap-2">
<input type="checkbox" className="rounded border-muted-foreground/30" />
<input
value={block.content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Todo item..."
className="flex-1 bg-transparent border-none outline-none placeholder:text-muted-foreground/50"
/>
</div>
);
case "code":
return (
<textarea
value={block.content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Code..."
rows={4}
className="w-full font-mono text-sm bg-muted p-3 rounded border-none outline-none resize-none placeholder:text-muted-foreground/50"
/>
);
case "image":
return (
<div className="space-y-2">
<input
value={block.content}
onChange={(e) => handleChange(e.target.value)}
placeholder="Image URL..."
className="w-full bg-transparent border-b border-muted-foreground/20 outline-none text-sm placeholder:text-muted-foreground/50"
/>
{block.content && (
<img src={block.content} alt="" className="max-w-full h-auto rounded-lg" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
)}
</div>
);
default:
return (
<textarea
ref={inputRef as any}
value={block.content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type / for commands..."
rows={2}
className="w-full bg-transparent border-none outline-none resize-none placeholder:text-muted-foreground/50"
/>
);
}
};
return (
<div className="group relative flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pt-1 shrink-0">
<button onClick={onMoveUp} className="h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground"><ArrowUp className="h-3 w-3" /></button>
<button onClick={onMoveDown} className="h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground"><ArrowDown className="h-3 w-3" /></button>
</div>
<div className="flex-1 min-w-0">
{renderEditor()}
</div>
<button onClick={() => onDelete(block.id)} className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive shrink-0 pt-1">
<Trash2 className="h-3.5 w-3.5" />
</button>
{showSlash && (
<div className="absolute left-8 top-full z-50 mt-1 bg-popover border rounded-lg shadow-lg p-1 w-48">
{BLOCK_TYPES.map((bt) => {
const Icon = bt.icon;
return (
<button key={bt.id} onClick={() => insertBlockType(bt.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent transition-colors">
<Icon className="h-4 w-4" />
{bt.label}
</button>
);
})}
</div>
)}
</div>
);
}
// ─── Canvas Editor ────────────────────────────────────────────────────────
type Block = { id: string; type: string; content: string };
export function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
const queryClient = useQueryClient();
const [blocks, setBlocks] = useState<Block[]>(
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
);
const [title, setTitle] = useState(canvas.name);
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const blockIdCounter = useRef(blocks.length + 1);
const lastSavedTitle = useRef(canvas.name);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDirty = useRef(false);
const lastSeen = useRef<{ blocks: Block[]; title: string }>({ blocks, title });
// Keep latest values reachable from the debounced save without stale closures
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const titleRef = useRef(title);
titleRef.current = title;
// Listen for block type changes
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setBlocks((prev) => prev.map((b) => b.id === detail.id ? { ...b, type: detail.type } : b));
};
window.addEventListener("change-block-type", handler);
return () => window.removeEventListener("change-block-type", handler);
}, []);
// Persist the full block list via the bulk endpoint, then adopt the
// server-generated ids for newly created blocks (content stays local).
const persist = useCallback(async () => {
const currentBlocks = blocksRef.current;
const currentTitle = titleRef.current;
setSaveState("saving");
try {
const payload = {
cards: currentBlocks.map((b, i) => ({
...(b.id.startsWith("new-") || b.id.startsWith("block-") ? {} : { id: b.id }),
type: b.type,
content: b.content,
zIndex: i,
})),
};
const result = await api.put<{ cards: { id: string }[] }>("/canvas/" + canvas.id + "/cards", payload);
setBlocks((prev) => {
if (result.cards.length !== prev.length) return prev;
let changed = false;
const next = prev.map((b, i) => {
const newId = result.cards[i]?.id;
if (newId && b.id !== newId) {
changed = true;
return { ...b, id: newId };
}
return b;
});
return changed ? next : prev;
});
if (currentTitle !== lastSavedTitle.current) {
await api.patch("/canvas/" + canvas.id, { name: currentTitle });
lastSavedTitle.current = currentTitle;
}
queryClient.invalidateQueries({ queryKey: ["canvas"] });
isDirty.current = false;
setSaveState("saved");
} catch (error) {
console.error("[canvas] save failed:", error);
setSaveState("error");
}
}, [canvas.id, queryClient]);
// Debounced autosave: persist shortly after blocks/title stop changing
useEffect(() => {
if (lastSeen.current.blocks === blocks && lastSeen.current.title === title) {
return;
}
lastSeen.current = { blocks, title };
isDirty.current = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveTimer.current = null;
persist();
}, 800);
return () => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
};
}, [blocks, title, persist]);
// Flush any unsaved edits when leaving the editor
useEffect(() => {
return () => {
if (isDirty.current) {
persist();
}
};
}, [persist]);
const handleSave = () => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
persist();
};
const handleBlockChange = (id: string, content: string) => {
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
};
const handleDelete = (id: string) => {
setBlocks((prev) => prev.filter((b) => b.id !== id));
};
const handleMoveUp = (idx: number) => {
if (idx === 0) return;
setBlocks((prev) => {
const next = [...prev];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
return next;
});
};
const handleMoveDown = (idx: number) => {
if (idx >= blocks.length - 1) return;
setBlocks((prev) => {
const next = [...prev];
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
return next;
});
};
const addBlock = (type = "text") => {
blockIdCounter.current++;
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
};
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onBack}>&larr; Back</Button>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
/>
</div>
<div className="flex items-center gap-2">
{saveState === "saving" && <span className="text-xs text-muted-foreground">Saving&hellip;</span>}
{saveState === "saved" && <span className="text-xs text-muted-foreground">Saved</span>}
{saveState === "error" && <span className="text-xs text-destructive">Save failed</span>}
<Button size="sm" onClick={handleSave} disabled={saveState === "saving"}>Save</Button>
</div>
</div>
<Separator />
<div className="space-y-1">
{blocks.map((block, idx) => (
<BlockEditor
key={block.id}
block={block}
onChange={handleBlockChange}
onDelete={handleDelete}
onMoveUp={() => handleMoveUp(idx)}
onMoveDown={() => handleMoveDown(idx)}
/>
))}
</div>
<div className="flex gap-2 pt-2">
{BLOCK_TYPES.slice(0, 4).map((bt) => {
const Icon = bt.icon;
return (
<Button key={bt.id} variant="outline" size="sm" onClick={() => addBlock(bt.id)}>
<Icon className="h-4 w-4 mr-1" />{bt.label}
</Button>
);
})}
<Button variant="ghost" size="sm" onClick={() => addBlock("text")}>
<Plus className="h-4 w-4 mr-1" />Add Block
</Button>
</div>
</div>
);
}
// ─── Canvas List ──────────────────────────────────────────────────────────
function CanvasList() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const activeDomainId = useApiDomain();
const { data, isLoading } = useApiQuery<PaginatedResponse<Canvas>>(["canvas", activeDomainId], "/canvas" + (activeDomainId ? "?domain=" + activeDomainId : ""));
const canvases = data?.items || [];
const createMutation = useMutation({
mutationFn: (name: string) => api.post<Canvas>("/canvas", { name, ...(activeDomainId ? { domain: activeDomainId } : {}) }),
onSuccess: (canvas) => {
queryClient.invalidateQueries({ queryKey: ["canvas"] });
setCreateOpen(false);
setNewName("");
navigate({ to: "/canvas/$id", params: { id: canvas.id } });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/canvas/" + id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Canvas</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="h-4 w-4 mr-2" />New Canvas</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Canvas</DialogTitle></DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="canvas-name">Name</Label>
<Input id="canvas-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Canvas name" />
</div>
<Button onClick={() => createMutation.mutate(newName)} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading canvases...</div>
) : canvases.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No canvases yet. Create your first one!</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{canvases.map((c) => (
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow group" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 text-destructive"
onClick={(e) => e.stopPropagation()}
aria-label={"Delete canvas " + c.name}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete "{c.name}"? All blocks in it will be removed. This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(c.id); }} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-[10px]">{c.mode}</Badge>
<span className="text-xs text-muted-foreground">{c.cards?.length || 0} blocks</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "/canvas",
component: CanvasList,
});
-181
View File
@@ -1,181 +0,0 @@
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calendar, Clock, FileText, LayoutDashboard, Trash2 } from "lucide-react";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { InlineTextarea } from "@/components/entities/inline-edit";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import type { Canvas } from "@/lib/types";
import { CanvasEditor } from "../canvas";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
function formatCustomFieldValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function CanvasDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
const queryClient = useQueryClient();
useRealtime({ enabled: true });
const { data: canvas, isLoading, isError, error, refetch } = useApiQuery<Canvas>(
["canvas", id],
"/canvas/" + id
);
const { patch } = useOptimisticPatch<Canvas>({
entityKey: ["canvas", id],
listKeys: [["canvas"]],
patchUrl: (cid) => `/canvas/${cid}`,
applyPatch: (current, data) => ({ ...current, ...data }),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/canvas/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["canvas"] });
toast.success("Canvas deleted");
navigate({ to: "/canvas" });
},
onError: (err) => toast.error(errorMessage(err)),
});
if (isLoading) return <LoadingState label="Loading canvas..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!canvas) return <ErrorState message="Canvas not found" />;
const customFieldEntries = Object.entries(canvas.customFields ?? {});
return (
<div className="mx-auto max-w-5xl">
<div className="flex flex-col gap-6 lg:flex-row">
<div className="min-w-0 flex-1">
<CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />
</div>
<aside className="w-full shrink-0 space-y-6 lg:w-72">
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea
value={canvas.description ?? ""}
onSave={(description) =>
patch({ id, data: { description: description || null } })
}
placeholder="Add a description…"
/>
</div>
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Details</p>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<LayoutDashboard className="h-4 w-4 shrink-0 text-muted-foreground" />
<Badge variant="outline">{canvas.mode}</Badge>
</div>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{canvas.cards?.length ?? 0} blocks</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Created {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Updated {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
</div>
</div>
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Tags</p>
{canvas.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{canvas.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No tags</p>
)}
</div>
{customFieldEntries.length > 0 && (
<div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Custom fields</p>
<dl className="space-y-2">
{customFieldEntries.map(([key, value]) => (
<div key={key} className="flex items-baseline justify-between gap-2 text-sm">
<dt className="shrink-0 text-muted-foreground">{key}</dt>
<dd className="truncate text-right">{formatCustomFieldValue(value)}</dd>
</div>
))}
</dl>
</div>
)}
<div className="border-t pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
<Trash2 className="h-4 w-4" /> Delete Canvas
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Canvas</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{canvas.name}"? All blocks in it will be
removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</aside>
</div>
</div>
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "canvas/$id",
component: CanvasDetail,
});
+19 -6
View File
@@ -86,13 +86,22 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
); );
} }
// ─── Daily Note Editor ──────────────────────────────────────────────────── // ─── Daily Note Editor ────────────────────────────────────────────────────
function DailyNoteEditor({ date }: { date: Date }) { function DailyNoteEditor({ date }: { date: Date }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const dateStr = format(date, "yyyy-MM-dd"); const dateStr = format(date, "yyyy-MM-dd");
const { data: tasksData } = useApiQuery<{ items: Array<{ id:string; title:string; status:string; dueDate:string|null }> }>(["tasks-daily", dateStr, activeDomainId], "/tasks?limit=50" + (activeDomainId ? "&domain=" + activeDomainId : ""));
const { data: habitsData } = useApiQuery<{ items: Array<{ id:string; name:string }> }>(["habits-daily", activeDomainId], "/habits?limit=50" + (activeDomainId ? "&domain=" + activeDomainId : ""));
const tasksDue = (tasksData?.items || []).filter(t => t.dueDate && t.dueDate.slice(0,10) === dateStr && t.status !== "done");
const habitsToday = habitsData?.items || [];
const insertTemplate = () => {
const tpl = `# ${format(date, "EEEE, MMM d")}\n\n## Tasks Due Today\n${tasksDue.length ? tasksDue.map(t => `- [ ] ${t.title}`).join("\n") : "- No tasks due"}\n\n## Habits\n${habitsToday.slice(0,5).map(h => `- [ ] ${h.name}`).join("\n") || "- No habits"}\n\n## Notes\n`;
setContent(tpl);
autoSave(tpl, mood, energy);
};
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [mood, setMood] = useState<number | null>(null); const [mood, setMood] = useState<number | null>(null);
const [energy, setEnergy] = useState<number | null>(null); const [energy, setEnergy] = useState<number | null>(null);
@@ -319,11 +328,15 @@ function DailyNoteEditor({ date }: { date: Date }) {
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div> <div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>
) : isNew && !content ? ( ) : isNew && !content ? (
<div className="text-center py-12"> <div className="text-center py-8 space-y-4">
<p className="text-muted-foreground mb-4">No note for this day &mdash; click to start writing</p> <p className="text-muted-foreground">No note for this day</p>
<Button variant="outline" onClick={() => textareaRef.current?.focus()}> {tasksDue.length > 0 && <p className="text-xs text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
<Plus className="h-4 w-4 mr-2" />Start Writing <div className="flex gap-2 justify-center">
</Button> <Button variant="outline" onClick={() => textareaRef.current?.focus()}>
<Plus className="h-4 w-4 mr-2" />Start Writing
</Button>
<Button variant="secondary" onClick={insertTemplate}>Insert Daily Template</Button>
</div>
</div> </div>
) : ( ) : (
<textarea <textarea
+1 -1
View File
@@ -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?.category !== "done"); const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "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 -184
View File
@@ -10,10 +10,8 @@ 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";
@@ -30,7 +28,6 @@ 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,
@@ -55,12 +52,9 @@ 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 { GanttChart } from "@/components/gantt/gantt-chart"; import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
import type { TimelineData } from "@/components/gantt/gantt-utils"; import type { Project, Section, Task } from "@/lib/types";
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[] = [
@@ -232,16 +226,6 @@ 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",
@@ -358,8 +342,8 @@ function ProjectTasks({ project }: { project: Project }) {
}); });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: ({ taskId, statusId }: { taskId: string; statusId: string | null }) => mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${taskId}/status`, { statusId }), api.post<Task>(`/tasks/${taskId}/status`, { status }),
onMutate: (vars) => setPendingId(vars.taskId), onMutate: (vars) => setPendingId(vars.taskId),
onSettled: () => setPendingId(null), onSettled: () => setPendingId(null),
onSuccess: refresh, onSuccess: refresh,
@@ -454,7 +438,6 @@ 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)}
@@ -477,7 +460,6 @@ 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)}
@@ -493,43 +475,37 @@ 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; statusId: string | null }) => void; onToggle: (vars: { taskId: string; status: Task["status"] }) => 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={isDone} checked={task.status === "done"}
disabled={pending} disabled={pending}
onCheckedChange={() => onCheckedChange={() =>
onToggle({ onToggle({
taskId: task.id, taskId: task.id,
statusId: isDone status: task.status === "done" ? "todo" : "done",
? (statuses.find((s) => s.category === "todo")?.id ?? null)
: (statuses.find((s) => s.category === "done")?.id ?? null),
}) })
} }
aria-label={ aria-label={
"Mark " + task.title + " " + (isDone ? "as not done" : "as done") "Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done")
} }
/> />
<span className={cn("h-2 w-2 shrink-0 rounded-full", getStatusToken(task.status).dot)} /> <span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[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",
isDone && "text-muted-foreground line-through" task.status === "done" && "text-muted-foreground line-through"
)} )}
> >
{task.title} {task.title}
@@ -714,156 +690,6 @@ 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",
+22 -6
View File
@@ -1,17 +1,15 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useMemo, useRef } 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 } from "@tanstack/react-query"; import { useApiQuery } from "@/lib/api";
import { api, useApiQuery } from "@/lib/api";
import { Search as SearchIcon, X, Clock, ArrowRight } from "lucide-react"; import { Search as SearchIcon, X, Clock, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { SearchResult } from "@/lib/types"; import type { SearchResult } from "@/lib/types";
import Fuse from "fuse.js";
const SEARCH_TYPES = [ const SEARCH_TYPES = [
{ id: "task", label: "Tasks", color: "bg-blue-500" }, { id: "task", label: "Tasks", color: "bg-blue-500" },
@@ -62,7 +60,25 @@ function SearchPage() {
"/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "") "/search?q=" + encodeURIComponent(debouncedQuery) + "&types=" + Array.from(selectedTypes).join(",") + "&limit=50" + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
const results = searchData?.results || []; const serverResults = searchData?.results || [];
const results = useMemo(() => {
const rawQuery = debouncedQuery.trim();
const operatorMatch = rawQuery.match(/\b(in|status|tag):(\S+)/g);
let filtered = serverResults;
if (operatorMatch) {
for (const op of operatorMatch) {
const [key, val] = op.split(":");
if (key === "in") filtered = filtered.filter(r => r.type === val.toLowerCase());
if (key === "status") filtered = filtered.filter(r => r.snippet.toLowerCase().includes(val.toLowerCase()) || r.title.toLowerCase().includes(val.toLowerCase()));
}
}
const cleanQuery = rawQuery.replace(/\b(in|status|tag):\S+\s*/g, "").trim();
if (!cleanQuery) return filtered;
const fuse = new Fuse(filtered, { keys: ["title", "snippet"], threshold: 0.4, includeScore: true });
const fused = fuse.search(cleanQuery);
return fused.length > 0 ? fused.map(f => f.item) : filtered;
}, [serverResults, debouncedQuery]);
const toggleType = (typeId: string) => { const toggleType = (typeId: string) => {
const next = new Set(selectedTypes); const next = new Set(selectedTypes);
+56 -76
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useState, useCallback, useMemo } 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,9 +27,11 @@ 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 { getStatusLabel, getStatusToken, TASK_STATUS, PRIORITY } from "@/lib/status-colors"; import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { StatusDefinition, Task, TaskStatusCategory, PaginatedResponse } from "@/lib/types"; import type { Task, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
const STATUS_COLUMNS = [ const STATUS_COLUMNS = [
{ id: "todo", label: "Todo" }, { id: "todo", label: "Todo" },
@@ -99,27 +101,17 @@ 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 [statusId, setStatusId] = useState(task?.statusId ?? ""); const [status, setStatus] = useState(task?.status || "todo");
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 [recurrenceRule, setRecurrenceRule] = useState(task?.recurrenceRule || "");
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) })); const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
const parsed = !task ? parseTaskInput(title) : null;
// 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),
@@ -140,9 +132,20 @@ 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, priority }; let finalTitle = title.trim();
if (statusId) data.statusId = statusId; let finalDueDate = dueDate ? new Date(dueDate).toISOString() : null;
if (dueDate) data.dueDate = new Date(dueDate).toISOString(); let finalPriority = priority;
let tagNames: string[] = [];
if (!task) {
const p = parseTaskInput(title);
finalTitle = p.title;
if (p.dueDate && !dueDate) finalDueDate = p.dueDate;
if (p.priority) finalPriority = p.priority;
tagNames = p.tags;
}
const data: any = { title: finalTitle, description: description || null, status, priority: finalPriority, tagNames };
if (finalDueDate) data.dueDate = finalDueDate;
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
const customFields = { ...customFieldValues }; const customFields = { ...customFieldValues };
if (Object.keys(customFields).length > 0) data.customFields = customFields; if (Object.keys(customFields).length > 0) data.customFields = customFields;
if (task) { if (task) {
@@ -155,31 +158,33 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title <span className="text-xs text-muted-foreground"> try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Task title" required /> <Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
<div className="flex flex-wrap gap-1 mt-1.5">
{parsed.dueDate && <Badge variant="outline" className="text-[10px]">Due {new Date(parsed.dueDate).toLocaleDateString()} {new Date(parsed.dueDate).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</Badge>}
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
{parsed.tags.map(t => <Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>)}
</div>
)}
</div> </div>
<div> <div>
<Label htmlFor="desc">Description</Label> <Label htmlFor="desc">Description</Label>
<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">
{statusOptions.length > 0 && ( <div>
<div> <Label htmlFor="status">Status</Label>
<Label htmlFor="status">Status</Label> <Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
<Select <SelectTrigger id="status"><SelectValue /></SelectTrigger>
value={statusId || NO_STATUS} <SelectContent>
onValueChange={(v) => setStatusId(v === NO_STATUS ? "" : v)} <SelectItem value="todo">Todo</SelectItem>
> <SelectItem value="in_progress">In Progress</SelectItem>
<SelectTrigger id="status"><SelectValue /></SelectTrigger> <SelectItem value="done">Done</SelectItem>
<SelectContent> <SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value={NO_STATUS}>No status</SelectItem> </SelectContent>
{statusOptions.map((s) => ( </Select>
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem> </div>
))}
</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")}>
@@ -197,6 +202,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<Label htmlFor="dueDate">Due Date</Label> <Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} /> <Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
</div> </div>
<RecurrencePicker value={recurrenceRule || null} onChange={(v) => setRecurrenceRule(v || "")} />
<CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} /> <CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button> <Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
@@ -260,8 +266,8 @@ function TasksPage() {
}; };
const statusMutation = useMutation({ const statusMutation = useMutation({
mutationFn: ({ id, statusId }: { id: string; statusId: string }) => mutationFn: ({ id, status }: { id: string; status: string }) =>
api.post("/tasks/" + id + "/status", { statusId }), api.post("/tasks/" + id + "/status", { status }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] }); queryClient.invalidateQueries({ queryKey: ["tasks"] });
}, },
@@ -294,29 +300,11 @@ 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 = async (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null); setActiveId(null);
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return;
@@ -330,7 +318,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?.category === status) .filter((t) => t.status === 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:
@@ -344,7 +332,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?.category ?? "todo"; targetColumn = overTask.status;
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;
} }
@@ -366,24 +354,16 @@ 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 && targetStatus) { if (t.id === taskId && statusChanged) {
return { ...t, status: targetStatus, statusId: targetStatus.id, order: orderById.get(t.id) ?? t.order }; return { ...t, status: targetColumn as Task["status"], 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;
@@ -391,8 +371,8 @@ function TasksPage() {
}; };
}); });
if (statusChanged && targetStatus) { if (statusChanged) {
statusMutation.mutate({ id: taskId, statusId: targetStatus.id }); statusMutation.mutate({ id: taskId, status: targetColumn });
} }
reorderMutation.mutate({ orderedIds: targetIds }); reorderMutation.mutate({ orderedIds: targetIds });
}; };
@@ -411,7 +391,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?.category === col.id) .filter((t) => t.status === col.id)
.sort((a, b) => a.order - b.order), .sort((a, b) => a.order - b.order),
})); }));
}, [tasks]); }, [tasks]);
@@ -514,7 +494,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]", getStatusToken(task.status).badge)}>{getStatusLabel(task.status)}</Badge> <Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</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>
+58 -81
View File
@@ -55,10 +55,17 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { LoadingState, ErrorState } from "@/components/state"; import { LoadingState, ErrorState } from "@/components/state";
import { getStatusLabel, getStatusToken, PRIORITY } from "@/lib/status-colors"; import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { PaginatedResponse, Project, StatusDefinition, Task } from "@/lib/types"; import type { PaginatedResponse, Project, 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" },
@@ -105,18 +112,6 @@ 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,
@@ -125,13 +120,10 @@ function TaskDetail() {
}); });
const toggleComplete = useMutation({ const toggleComplete = useMutation({
mutationFn: () => { mutationFn: () =>
const isDone = task?.status?.category === "done"; api.post<Task>(`/tasks/${id}/status`, {
const target = projectStatuses.find((s) => s.category === (isDone ? "todo" : "done")); status: task?.status === "done" ? "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 });
@@ -160,7 +152,7 @@ function TaskDetail() {
} }
if (!task) return <ErrorState message="Task not found" />; if (!task) return <ErrorState message="Task not found" />;
const isDone = task.status?.category === "done"; const isDone = task.status === "done";
return ( return (
<EntityDetailPage <EntityDetailPage
@@ -176,17 +168,14 @@ function TaskDetail() {
badges={ badges={
<> <>
<InlineSelect <InlineSelect
value={task.status?.id ?? ""} value={task.status}
options={projectStatuses.map((s) => ({ value: s.id, label: s.label }))} options={STATUS_OPTIONS}
displayValue={(v) => { displayValue={(v) => (
const status = statusById.get(v); <Badge className={TASK_STATUS[v]?.badge}>
return ( {TASK_STATUS[v]?.label ?? v}
<Badge className={getStatusToken(status).badge}> </Badge>
{status ? status.label : "No status"} )}
</Badge> onSave={(status) => patch({ id, data: { status } })}
);
}}
onSave={(statusId) => patch({ id, data: { statusId: statusId || null } })}
/> />
<InlineSelect <InlineSelect
value={task.priority} value={task.priority}
@@ -375,16 +364,6 @@ 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"] });
@@ -402,8 +381,8 @@ function Subtasks({ task }: { task: Task }) {
}); });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: ({ subId, statusId }: { subId: string; statusId: string }) => mutationFn: ({ subId, status }: { subId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${subId}/status`, { statusId }), api.post<Task>(`/tasks/${subId}/status`, { status }),
onMutate: (vars) => setPendingId(vars.subId), onMutate: (vars) => setPendingId(vars.subId),
onSettled: () => setPendingId(null), onSettled: () => setPendingId(null),
onSuccess: refresh, onSuccess: refresh,
@@ -442,37 +421,35 @@ 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) => (
const subDone = sub.status?.category === "done"; <div
return ( key={sub.id}
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"
key={sub.id} >
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50" <Checkbox
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"
)}
> >
<Checkbox {sub.title}
checked={subDone} </button>
disabled={pendingId === sub.id} </div>
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>
@@ -546,7 +523,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", getStatusToken(dep.status).dot)} /> <span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[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 } })}
@@ -554,8 +531,8 @@ function Dependencies({ task }: { task: Task }) {
> >
{dep.title} {dep.title}
</button> </button>
<Badge className={cn("text-[10px]", getStatusToken(dep.status).badge)}> <Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{getStatusLabel(dep.status)} {TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge> </Badge>
<Button <Button
variant="ghost" variant="ghost"
@@ -605,7 +582,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", getStatusToken(dep.status).dot)} /> <span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[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 } })}
@@ -613,8 +590,8 @@ function Dependencies({ task }: { task: Task }) {
> >
{dep.title} {dep.title}
</button> </button>
<Badge className={cn("text-[10px]", getStatusToken(dep.status).badge)}> <Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{getStatusLabel(dep.status)} {TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge> </Badge>
<Button <Button
variant="ghost" variant="ghost"
-1
View File
@@ -19,6 +19,5 @@
} }
}, },
"include": ["src"], "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }
+5 -121
View File
@@ -1,5 +1,5 @@
import { db, sql, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks, statusDefinitions, projects, domains, notifications } from '@project-e/db'; import { db, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks } from '@project-e/db';
import { and, asc, eq, gte, lte, isNotNull, isNull, or } from 'drizzle-orm'; import { and, eq, lte, isNull, or } from 'drizzle-orm';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
import { RRule } from 'rrule'; import { RRule } from 'rrule';
@@ -22,10 +22,6 @@ 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.
@@ -207,75 +203,6 @@ 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> {
@@ -385,34 +312,11 @@ 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
const [spawned] = await db.insert(tasks).values({ await db.insert(tasks).values({
title: originalTask.title, title: originalTask.title,
description: originalTask.description, description: originalTask.description,
statusId, status: 'todo',
priority: originalTask.priority, priority: originalTask.priority,
domainId: originalTask.domainId, domainId: originalTask.domainId,
projectId: originalTask.projectId, projectId: originalTask.projectId,
@@ -422,28 +326,8 @@ 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
+4
View File
@@ -89,6 +89,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"fuse.js": "^7.5.0",
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-big-calendar": "^1.20.0", "react-big-calendar": "^1.20.0",
@@ -96,6 +97,7 @@
"react-force-graph-2d": "^1.29.1", "react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.84.0", "react-hook-form": "^7.84.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"rrule": "^2.8.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -1465,6 +1467,8 @@
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
"fuse.js": ["fuse.js@7.5.0", "", {}, "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
-121
View File
@@ -1,121 +0,0 @@
-- 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 $$;
+4
View File
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS "canvas_connections" CASCADE;--> statement-breakpoint
DROP TABLE IF EXISTS "canvas_cards" CASCADE;--> statement-breakpoint
DROP TABLE IF EXISTS "canvases" CASCADE;--> statement-breakpoint
DROP TABLE IF EXISTS "reports" CASCADE;
-14
View File
@@ -1,14 +0,0 @@
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");
-20
View File
@@ -1,20 +0,0 @@
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");
+2 -16
View File
@@ -54,22 +54,8 @@
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1786800000000, "when": 1786392464858,
"tag": "0007_custom_task_statuses", "tag": "0007_drop_canvas_reports",
"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 "breakpoints": true
} }
] ]
-37
View File
@@ -1,37 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { testCanvas } from './helpers/fixtures';
test.describe('Canvas', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/canvas');
await expect(page.getByRole('heading', { name: /canvas/i })).toBeVisible();
});
test('renders the canvas list', async ({ page }) => {
await expect(page.getByRole('button', { name: /new canvas/i })).toBeVisible();
});
test('creates a canvas and opens the editor', async ({ page }) => {
await page.getByRole('button', { name: /new canvas/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'New Canvas' })).toBeVisible();
await dialog.getByLabel('Name').fill(testCanvas.name);
const createResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/canvas') && resp.request().method() === 'POST',
);
await dialog.getByRole('button', { name: /^create$/i }).click();
expect((await createResponse).ok()).toBeTruthy();
// Creating a canvas navigates straight into the editor at /canvas/<id>.
await page.waitForURL('**/canvas/*', { timeout: 10_000 });
await expect(page.getByPlaceholder(/type \/ for commands/i)).toBeVisible({
timeout: 10_000,
});
});
});
-1
View File
@@ -18,7 +18,6 @@ test.describe('Navigation', () => {
{ url: '/search', check: page.getByPlaceholder(/search tasks, notes/i) }, { url: '/search', check: page.getByPlaceholder(/search tasks, notes/i) },
{ url: '/analytics', check: page.getByRole('heading', { name: /analytics/i }) }, { url: '/analytics', check: page.getByRole('heading', { name: /analytics/i }) },
{ url: '/agents/activity', check: page.getByRole('heading', { name: /agent activity/i }) }, { url: '/agents/activity', check: page.getByRole('heading', { name: /agent activity/i }) },
{ url: '/canvas', check: page.getByRole('heading', { name: /canvas/i }) },
{ url: '/daily', check: page.getByRole('button', { name: /today/i }) }, { url: '/daily', check: page.getByRole('button', { name: /today/i }) },
{ url: '/settings', check: page.getByRole('button', { name: 'Appearance' }) }, { url: '/settings', check: page.getByRole('button', { name: 'Appearance' }) },
]; ];
-1
View File
@@ -19,7 +19,6 @@
"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"
}, },
+3 -190
View File
@@ -23,7 +23,7 @@ export const tsvector = customType<{ data: string }>({
// ── Enums ────────────────────────────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────────────────────────────
export const statusCategoryEnum = pgEnum('status_category', ['todo', 'in_progress', 'done', 'cancelled']); export const taskStatusEnum = pgEnum('task_status', ['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,65 +119,6 @@ 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(
@@ -210,7 +151,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'),
statusId: uuid('status_id').references((): any => statusDefinitions.id, { onDelete: 'set null' }), status: taskStatusEnum('status').notNull().default('todo'),
priority: taskPriorityEnum('priority').notNull().default('medium'), priority: taskPriorityEnum('priority').notNull().default('medium'),
domainId: uuid('domain_id') domainId: uuid('domain_id')
.notNull() .notNull()
@@ -235,7 +176,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_id_idx').on(table.statusId), index('tasks_status_idx').on(table.status),
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),
@@ -475,36 +416,6 @@ 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
@@ -626,30 +537,6 @@ export const webhookDeliveries = pgTable(
// ── Reports ──────────────────────────────────────────────────────────────────
export const reports = pgTable(
'reports',
{
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull().default('Untitled report'),
content: text('content'),
reportType: text('report_type').notNull().default('custom'),
dateRangeStart: timestamp('date_range_start', { withTimezone: true }),
dateRangeEnd: timestamp('date_range_end', { withTimezone: true }),
domain: text('domain').notNull().default('personal'),
projectId: uuid('project_id').references((): any => projects.id, { onDelete: 'set null' }),
isDraft: boolean('is_draft').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('reports_domain_idx').on(table.domain),
index('reports_type_idx').on(table.reportType),
index('reports_created_at_idx').on(table.createdAt),
]
);
export const apiKeys = pgTable( export const apiKeys = pgTable(
'api_keys', 'api_keys',
{ {
@@ -748,81 +635,7 @@ export const agentTasks = pgTable(
] ]
); );
// ── Canvases ────────────────────────────────────────────────────────────────────
export const canvases = pgTable(
'canvases',
{
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
description: text('description'),
mode: text('mode').notNull().default('freeform'),
domainId: uuid('domain_id')
.notNull()
.references((): any => domains.id, { onDelete: 'cascade' }),
tags: text('tags').array().default([]),
viewport: jsonb('viewport').$type<{ x: number; y: number; zoom: number }>().default({ x: 0, y: 0, zoom: 1 }),
background: text('background'),
customFields: jsonb('custom_fields').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('canvases_domain_id_idx').on(table.domainId),
]
);
// ── Canvas Cards ─────────────────────────────────────────────────────────────────
export const canvasCards = pgTable(
'canvas_cards',
{
id: uuid('id').defaultRandom().primaryKey(),
canvasId: uuid('canvas_id')
.notNull()
.references((): any => canvases.id, { onDelete: 'cascade' }),
type: text('type').notNull().default('note'),
entityId: uuid('entity_id'),
title: text('title'),
content: text('content'),
x: integer('x').default(0),
y: integer('y').default(0),
width: integer('width').default(200),
height: integer('height').default(150),
rotation: integer('rotation').default(0),
color: text('color'),
zIndex: integer('z_index').default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('canvas_cards_canvas_id_idx').on(table.canvasId),
]
);
// ── Canvas Connections ───────────────────────────────────────────────────────────
export const canvasConnections = pgTable(
'canvas_connections',
{
id: uuid('id').defaultRandom().primaryKey(),
canvasId: uuid('canvas_id')
.notNull()
.references((): any => canvases.id, { onDelete: 'cascade' }),
sourceCardId: uuid('source_card_id')
.notNull()
.references((): any => canvasCards.id, { onDelete: 'cascade' }),
targetCardId: uuid('target_card_id')
.notNull()
.references((): any => canvasCards.id, { onDelete: 'cascade' }),
label: text('label'),
style: text('style').notNull().default('solid'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('canvas_connections_canvas_id_idx').on(table.canvasId),
]
);
// ── Calendar Events ────────────────────────────────────────────────────────────── // ── Calendar Events ──────────────────────────────────────────────────────────────
-3
View File
@@ -1,9 +1,6 @@
// ── 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 ────────────────────────────────────────────────────────────────────
-2
View File
@@ -5,8 +5,6 @@ export * from './schemas/habit';
export * from './schemas/project'; export * from './schemas/project';
export * from './schemas/milestone'; export * from './schemas/milestone';
export * from './schemas/note'; export * from './schemas/note';
export * from './schemas/report';
export * from './schemas/canvas';
export * from './schemas/agent'; export * from './schemas/agent';
export * from './schemas/webhook'; export * from './schemas/webhook';
-81
View File
@@ -1,81 +0,0 @@
import { z } from 'zod';
// ── Enums ────────────────────────────────────────────────────────────────────
export const canvasModeEnum = z.enum(['freeform', 'graph']);
export const canvasCardTypeEnum = z.enum(['note', 'task', 'image', 'entity']);
// ── Sub-schemas ──────────────────────────────────────────────────────────────
export const canvasCardSchema = z.object({
id: z.string(),
canvas_id: z.string(),
type: canvasCardTypeEnum,
entity_id: z.string().optional(),
title: z.string().optional(),
content: z.string().optional(),
x: z.number().default(0),
y: z.number().default(0),
width: z.number().positive().default(200),
height: z.number().positive().default(150),
rotation: z.number().default(0),
color: z.string().optional(),
z_index: z.number().int().default(0),
created: z.string().datetime(),
updated: z.string().datetime(),
});
export const createCanvasCardSchema = canvasCardSchema.omit({
id: true,
created: true,
updated: true,
});
export const updateCanvasCardSchema = createCanvasCardSchema.partial();
// ── Canvas Schema ────────────────────────────────────────────────────────────
export const canvasSchema = z.object({
id: z.string(),
name: z.string().min(1, 'Canvas name is required'),
description: z.string().optional(),
mode: canvasModeEnum.default('freeform'),
domain: z.string(),
tags: z.array(z.string()).default([]),
cards: z.array(canvasCardSchema).default([]),
connections: z.array(z.object({
id: z.string(),
source_card_id: z.string(),
target_card_id: z.string(),
label: z.string().optional(),
style: z.enum(['solid', 'dashed', 'dotted']).default('solid'),
})).default([]),
viewport: z.object({
x: z.number().default(0),
y: z.number().default(0),
zoom: z.number().positive().default(1),
}).optional(),
background: z.string().optional(),
custom_fields: z.record(z.string(), z.unknown()).optional(),
created: z.string().datetime(),
updated: z.string().datetime(),
});
export const createCanvasSchema = canvasSchema.omit({
id: true,
cards: true,
connections: true,
created: true,
updated: true,
});
export const updateCanvasSchema = createCanvasSchema.partial();
// ── Types ────────────────────────────────────────────────────────────────────
export type Canvas = z.infer<typeof canvasSchema>;
export type CreateCanvas = z.infer<typeof createCanvasSchema>;
export type UpdateCanvas = z.infer<typeof updateCanvasSchema>;
export type CanvasCard = z.infer<typeof canvasCardSchema>;
export type CreateCanvasCard = z.infer<typeof createCanvasCardSchema>;
export type UpdateCanvasCard = z.infer<typeof updateCanvasCardSchema>;
-72
View File
@@ -1,72 +0,0 @@
import { z } from 'zod';
// ── Enums ────────────────────────────────────────────────────────────────────
export const reportTypeEnum = z.enum(['weekly', 'monthly', 'project', 'habit', 'custom']);
// ── Sub-schemas ──────────────────────────────────────────────────────────────
export const reportTemplateSchema = z.object({
id: z.string(),
name: z.string().min(1, 'Template name is required'),
description: z.string().optional(),
type: reportTypeEnum,
sections: z.array(z.object({
title: z.string(),
type: z.enum(['summary', 'chart', 'table', 'list', 'text']).default('text'),
config: z.record(z.string(), z.unknown()).optional(),
sort_order: z.number().int().nonnegative().default(0),
})).default([]),
created: z.string().datetime(),
updated: z.string().datetime(),
});
export const createReportTemplateSchema = reportTemplateSchema.omit({
id: true,
created: true,
updated: true,
});
// ── Report Schema ────────────────────────────────────────────────────────────
export const reportSchema = z.object({
id: z.string(),
title: z.string().min(1, 'Report title is required'),
type: reportTypeEnum,
template_id: z.string().optional(),
domain: z.string(),
date_range: z.object({
start: z.string().datetime(),
end: z.string().datetime(),
}),
sections: z.array(z.object({
title: z.string(),
content: z.string().optional(),
data: z.record(z.string(), z.unknown()).optional(),
sort_order: z.number().int().nonnegative().default(0),
})).default([]),
summary: z.string().optional(),
is_draft: z.boolean().default(true),
generated_at: z.string().datetime().optional(),
tags: z.array(z.string()).default([]),
custom_fields: z.record(z.string(), z.unknown()).optional(),
created: z.string().datetime(),
updated: z.string().datetime(),
});
export const createReportSchema = reportSchema.omit({
id: true,
generated_at: true,
created: true,
updated: true,
});
export const updateReportSchema = createReportSchema.partial();
// ── Types ────────────────────────────────────────────────────────────────────
export type Report = z.infer<typeof reportSchema>;
export type CreateReport = z.infer<typeof createReportSchema>;
export type UpdateReport = z.infer<typeof updateReportSchema>;
export type ReportTemplate = z.infer<typeof reportTemplateSchema>;
export type CreateReportTemplate = z.infer<typeof createReportTemplateSchema>;
-8
View File
@@ -42,15 +42,7 @@ export type {
CreateNoteTaskLink, CreateNoteTaskLink,
} from '../schemas/note'; } from '../schemas/note';
// ── Report ───────────────────────────────────────────────────────────────────
export type { Report, CreateReport, UpdateReport } from '../schemas/report';
export type { ReportTemplate, CreateReportTemplate } from '../schemas/report';
// ── Canvas ───────────────────────────────────────────────────────────────────
export type { Canvas, CreateCanvas, UpdateCanvas } from '../schemas/canvas';
export type { CanvasCard, CreateCanvasCard, UpdateCanvasCard } from '../schemas/canvas';
// ── Agent ──────────────────────────────────────────────────────────────────── // ── Agent ────────────────────────────────────────────────────────────────────
-2
View File
@@ -74,8 +74,6 @@ 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
-22
View File
@@ -1,22 +0,0 @@
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);
}
+1 -1
View File
File diff suppressed because one or more lines are too long