Merge remote-tracking branch 'origin/feat/plane-lift-schema' into feat/pl-3-cycles

# Conflicts:
#	apps/api/src/index.ts
#	apps/api/src/routes/cycles.ts
This commit is contained in:
2026-09-07 17:11:02 -04:00
10 changed files with 1164 additions and 236 deletions
+4 -1
View File
@@ -29,6 +29,7 @@ import { notificationRoutes } from "./routes/notifications";
import { stateRoutes } from "./routes/states"; import { stateRoutes } from "./routes/states";
import { moduleRoutes } from "./routes/modules"; import { moduleRoutes } from "./routes/modules";
import { cycleRoutes } from "./routes/cycles"; import { cycleRoutes } from "./routes/cycles";
import { linkRoutes } from "./routes/links";
import { healthHandler } from "./routes/health"; import { healthHandler } from "./routes/health";
const app = new Hono(); const app = new Hono();
@@ -49,6 +50,8 @@ app.route("/api/auth", authRoutes);
app.route("/api/domains", domainRoutes); app.route("/api/domains", domainRoutes);
app.route("/api/projects/:projectId/modules", moduleRoutes); app.route("/api/projects/:projectId/modules", moduleRoutes);
app.route("/api/modules", moduleRoutes); app.route("/api/modules", moduleRoutes);
app.route("/api/projects/:projectId/cycles", cycleRoutes);
app.route("/api/cycles", cycleRoutes);
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);
@@ -68,7 +71,7 @@ app.route("/api/analytics", analyticsRoutes);
app.route("/api/activity", activityRoutes); app.route("/api/activity", activityRoutes);
app.route("/api/notifications", notificationRoutes); app.route("/api/notifications", notificationRoutes);
app.route("/api/states", stateRoutes); app.route("/api/states", stateRoutes);
app.route("/api/cycles", cycleRoutes); app.route("/api/links", linkRoutes);
app.route("/api", importExportRoutes); app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes); app.route("/api", realtimeRoutes);
app.route("/api/mcp", mcpRoutes); app.route("/api/mcp", mcpRoutes);
+4 -4
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db"; import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db";
import { and, eq, inArray, isNull } from "drizzle-orm"; import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -57,11 +57,11 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
for (const s of sectionRows) addNode(s.id, s.name, 'section'); for (const s of sectionRows) addNode(s.id, s.name, 'section');
for (const t of tagRows) addNode(t.id, t.name, 'tag'); for (const t of tagRows) addNode(t.id, t.name, 'tag');
// Read links from the canonical links table // Read links from the canonical links table (both directions)
const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)]; const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id), ...projectRows.map(p => p.id), ...sectionRows.map(s => s.id)];
if (allIds.length > 0) { if (allIds.length > 0) {
const linkRows = await db.select().from(links) const linkRows = await db.select().from(links)
.where(inArray(links.sourceId, allIds)); .where(or(inArray(links.sourceId, allIds), inArray(links.targetId, allIds)));
for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType); for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType);
} }
+173
View File
@@ -0,0 +1,173 @@
import { Hono } from "hono";
import { db, links, tasks, notes, projects } from "@project-e/db";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
export const linkRoutes = new Hono();
const createLinkSchema = z.object({
sourceType: z.string().min(1),
sourceId: z.string().uuid(),
targetType: z.string().min(1),
targetId: z.string().uuid(),
linkType: z.enum(["relates", "blocks", "parent-child", "created-from"]),
direction: z.string().optional().nullable(),
});
async function resolveWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
if (entityType === "task") {
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, entityId)).limit(1);
return row?.domainId ?? null;
}
if (entityType === "note") {
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, entityId)).limit(1);
return row?.domainId ?? null;
}
return null;
}
// GET /api/links — List links for an entity (either source OR target)
linkRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const entityType = url.searchParams.get("entityType");
const entityId = url.searchParams.get("entityId");
if (!entityType || !entityId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 400);
}
const workspaceId = await resolveWorkspaceId(entityType, entityId);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
const items = await db.select()
.from(links)
.where(or(
and(eq(links.sourceType, entityType), eq(links.sourceId, entityId)),
and(eq(links.targetType, entityType), eq(links.targetId, entityId)),
));
return c.json({ items });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[links] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list links" } }, 500);
}
});
// POST /api/links — Create a link between two entities
linkRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createLinkSchema.parse(body);
// Prevent self-links
if (data.sourceId === data.targetId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot link an entity to itself" } }, 400);
}
// Check for duplicate link
const [existing] = await db.select({ id: links.id })
.from(links)
.where(and(
eq(links.sourceId, data.sourceId),
eq(links.targetId, data.targetId),
eq(links.linkType, data.linkType),
))
.limit(1);
if (existing) {
return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 409);
}
const workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
const [link] = await db.insert(links).values({
sourceType: data.sourceType,
sourceId: data.sourceId,
targetType: data.targetType,
targetId: data.targetId,
linkType: data.linkType,
direction: data.direction ?? null,
}).returning();
if (workspaceId) {
await recordActivity({
actor: user.name,
action: "created",
entityType: "link",
entityId: link.id,
changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType },
workspaceId,
});
}
return c.json(link, 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("[links] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create link" } }, 500);
}
});
// DELETE /api/links/:id — Remove a link
linkRoutes.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(links)
.where(eq(links.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Link not found" } }, 404);
}
const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
await db.delete(links).where(eq(links.id, id));
if (workspaceId) {
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "link",
entityId: id,
changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId },
workspaceId,
});
}
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("[links] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete link" } }, 500);
}
});
+13 -6
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, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, states as statesTable, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
export const mcpRoutes = new Hono(); export const mcpRoutes = new Hono();
@@ -81,7 +81,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", enum: ["todo", "in_progress", "done", "cancelled"] }, state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by workflow state group" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
project_id: { type: "string" }, project_id: { type: "string" },
search: { type: "string" }, search: { type: "string" },
@@ -95,7 +95,16 @@ 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),
]; ];
// TODO(phase-2): filter by state_group / state_id instead of old status if (params.state_group) {
const groups = (params.state_group as string).split(",") as any[];
conditions.push(
exists(
db.select({ one: sql`1` })
.from(statesTable)
.where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups)))
)
);
}
if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.priority) conditions.push(eq(tasks.priority, params.priority as any));
if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string));
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
@@ -119,7 +128,6 @@ const tools: ToolDefinition[] = [
domain_id: { type: "string", description: "Workspace/domain ID" }, domain_id: { type: "string", description: "Workspace/domain ID" },
title: { type: "string" }, title: { type: "string" },
description: { type: "string" }, description: { type: "string" },
status: { type: "string" },
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" },
@@ -157,7 +165,6 @@ const tools: ToolDefinition[] = [
task_id: { type: "string" }, task_id: { type: "string" },
title: { type: "string" }, title: { type: "string" },
description: { type: "string" }, description: { type: "string" },
status: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" }, due_date: { type: "string" },
}, },
+59
View File
@@ -9,9 +9,13 @@ export interface Task {
domainId: string; domainId: string;
projectId: string | null; projectId: string | null;
sectionId: string | null; sectionId: string | null;
stateId: string | null;
moduleId: string | null;
cycleId: string | null;
parentId: string | null; parentId: string | null;
dueDate: string | null; dueDate: string | null;
estimatedMinutes: number | null; estimatedMinutes: number | null;
trackedMinutes: number | null;
recurrenceRule: string | null; recurrenceRule: string | null;
order: number; order: number;
completedAt: string | null; completedAt: string | null;
@@ -25,6 +29,61 @@ export interface Task {
dependents?: { id: string; title: string; status: string }[]; dependents?: { id: string; title: string; status: string }[];
} }
export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export interface State {
id: string;
name: string;
color: string | null;
group: StateGroup;
projectId: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export type ModuleStatus = "planned" | "in_progress" | "completed" | "cancelled";
export interface Module {
id: string;
name: string;
description: string | null;
projectId: string;
status: ModuleStatus;
startDate: string | null;
targetDate: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
tasks?: Task[];
}
export interface Cycle {
id: string;
name: string;
projectId: string;
startDate: string | null;
endDate: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
}
export type LinkType = "relates" | "blocks" | "parent-child" | "created-from";
export interface Link {
id: string;
sourceType: string;
sourceId: string;
targetType: string;
targetId: string;
linkType: LinkType;
direction: string | null;
createdAt: string;
}
export interface Habit { export interface Habit {
id: string; id: string;
name: string; name: string;
+26 -5
View File
@@ -20,7 +20,7 @@ import type { GraphNode, GraphEdge } from "@/lib/types";
import ForceGraph2D from "react-force-graph-2d"; import ForceGraph2D from "react-force-graph-2d";
const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"]; const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"];
const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"]; const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to", "relates", "blocks", "parent-child", "created-from", "task_project", "task_domain", "habit_domain", "project_domain", "note_domain", "section_project"];
const ENTITY_COLORS: Record<string, string> = { const ENTITY_COLORS: Record<string, string> = {
task: "#3b82f6", task: "#3b82f6",
@@ -32,6 +32,20 @@ const ENTITY_COLORS: Record<string, string> = {
domain: "#6366f1", domain: "#6366f1",
}; };
const LINK_TYPE_COLORS: Record<string, string> = {
relates: "#94a3b8",
blocks: "#ef4444",
"parent-child": "#8b5cf6",
"created-from": "#10b981",
depends_on: "#ef4444",
related_to: "#94a3b8",
part_of: "#8b5cf6",
references: "#f59e0b",
parent_of: "#8b5cf6",
child_of: "#10b981",
connects_to: "#3b82f6",
};
// Graph node types that have a detail page. section/tag/domain nodes appear in // Graph node types that have a detail page. section/tag/domain nodes appear in
// the graph but have no detail route, so they are intentionally absent. // the graph but have no detail route, so they are intentionally absent.
const NODE_TYPE_ROUTES: Record<string, string> = { const NODE_TYPE_ROUTES: Record<string, string> = {
@@ -265,11 +279,17 @@ function GraphPage() {
const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`); const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`);
const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale; const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale;
const opacity = isHighlighted ? 0.6 : 0.1; const opacity = isHighlighted ? 0.6 : 0.1;
const linkType = link.type || "relates";
const baseColor = LINK_TYPE_COLORS[linkType] || "#94a3b8";
const r = parseInt(baseColor.slice(1, 3), 16);
const g = parseInt(baseColor.slice(3, 5), 16);
const b = parseInt(baseColor.slice(5, 7), 16);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(link.source.x, link.source.y); ctx.moveTo(link.source.x, link.source.y);
ctx.lineTo(link.target.x, link.target.y); ctx.lineTo(link.target.x, link.target.y);
ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`; ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
ctx.lineWidth = width; ctx.lineWidth = width;
ctx.stroke(); ctx.stroke();
@@ -289,7 +309,7 @@ function GraphPage() {
ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5); ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5);
ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5); ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`; ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
ctx.fill(); ctx.fill();
} }
} }
@@ -425,8 +445,9 @@ function GraphPage() {
checked={enabledRelationships.has(type)} checked={enabledRelationships.has(type)}
onCheckedChange={() => toggleRelationship(type)} onCheckedChange={() => toggleRelationship(type)}
/> />
<Label htmlFor={"rel-" + type} className="text-sm cursor-pointer"> <Label htmlFor={"rel-" + type} className="flex items-center gap-2 text-sm cursor-pointer">
{type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} <div className="w-2 h-2 rounded-full" style={{ backgroundColor: LINK_TYPE_COLORS[type] || "#94a3b8" }} />
{type.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</Label> </Label>
</div> </div>
))} ))}
+509 -14
View File
@@ -8,8 +8,10 @@ import {
Clock, Clock,
Flag, Flag,
FolderKanban, FolderKanban,
LayoutGrid,
ListTodo, ListTodo,
Plus, Plus,
Repeat,
Trash2, Trash2,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -42,6 +44,7 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { import {
@@ -51,10 +54,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LoadingState, ErrorState } from "@/components/state"; import { LoadingState, ErrorState } from "@/components/state";
import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors"; import { PRIORITY, PROJECT_STATUS } from "@/lib/status-colors";
import type { Project, Section, Task } from "@/lib/types"; import type { Cycle, Module, PaginatedResponse, Project, Section, State, Task } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [ const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
@@ -70,14 +74,26 @@ const SECTION_STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "complete", label: "Complete" }, { value: "complete", label: "Complete" },
]; ];
/** Section lifecycle colors (no shared token exists for section statuses). */ const MODULE_STATUS_OPTIONS: InlineSelectOption[] = [
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
const MODULE_STATUS: Record<string, { label: string; badge: string; dot: string }> = {
planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" },
in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" },
completed: { label: "Completed", badge: "bg-green-500 text-white", dot: "bg-green-500" },
cancelled: { label: "Cancelled", badge: "bg-red-500 text-white", dot: "bg-red-400" },
};
const SECTION_STATUS: Record<string, { label: string; badge: string; dot: string }> = { const SECTION_STATUS: Record<string, { label: string; badge: string; dot: string }> = {
planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" }, planned: { label: "Planned", badge: "bg-slate-500 text-white", dot: "bg-slate-400" },
in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" }, in_progress: { label: "In Progress", badge: "bg-blue-500 text-white", dot: "bg-blue-500" },
complete: { label: "Complete", badge: "bg-green-500 text-white", dot: "bg-green-500" }, complete: { label: "Complete", badge: "bg-green-500 text-white", dot: "bg-green-500" },
}; };
/** Sentinel for the "No section" option in the task composer select. */
const NO_SECTION = "__none__"; const NO_SECTION = "__none__";
type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void; type PatchFn = (vars: { id: string; data: Record<string, unknown> }) => void;
@@ -226,6 +242,8 @@ 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: "modules", label: "Modules", content: <ProjectModules project={project} /> },
{ value: "cycles", label: "Cycles", content: <ProjectCycles project={project} /> },
{ {
value: "activity", value: "activity",
label: "Activity", label: "Activity",
@@ -319,6 +337,15 @@ function ProjectTasks({ project }: { project: Project }) {
const sections = project.sections || []; const sections = project.sections || [];
const tasks = project.tasks || []; const tasks = project.tasks || [];
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", project.id],
"/states?projectId=" + project.id,
{ enabled: !!project.id }
);
const projectStates = statesData?.items || [];
const completedStateId = projectStates.find((s) => s.group === "completed")?.id;
const uncompletedStateId = projectStates.find((s) => s.group !== "completed" && s.group !== "cancelled")?.id;
const refresh = () => { const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["project", project.id] }); queryClient.invalidateQueries({ queryKey: ["project", project.id] });
queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["projects"] });
@@ -342,8 +369,8 @@ function ProjectTasks({ project }: { project: Project }) {
}); });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) => mutationFn: ({ taskId, completed }: { taskId: string; completed: boolean }) =>
api.post<Task>(`/tasks/${taskId}/status`, { status }), api.patch<Task>(`/tasks/${taskId}`, { stateId: completed ? completedStateId || null : uncompletedStateId || null }),
onMutate: (vars) => setPendingId(vars.taskId), onMutate: (vars) => setPendingId(vars.taskId),
onSettled: () => setPendingId(null), onSettled: () => setPendingId(null),
onSuccess: refresh, onSuccess: refresh,
@@ -359,8 +386,6 @@ function ProjectTasks({ project }: { project: Project }) {
}); });
}; };
// Group tasks by section, keeping sections in API sort order. Tasks whose
// sectionId is null or points at a hard-deleted section land in Unassigned.
const sectionIdSet = new Set(sections.map((s) => s.id)); const sectionIdSet = new Set(sections.map((s) => s.id));
const tasksBySection = new Map<string, Task[]>(); const tasksBySection = new Map<string, Task[]>();
const unassigned: Task[] = []; const unassigned: Task[] = [];
@@ -439,6 +464,7 @@ function ProjectTasks({ project }: { project: Project }) {
key={task.id} key={task.id}
task={task} task={task}
pending={pendingId === task.id} pending={pendingId === task.id}
projectStates={projectStates}
onToggle={(vars) => toggleMutation.mutate(vars)} onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)} onOpen={() => openTask(task.id)}
/> />
@@ -461,6 +487,7 @@ function ProjectTasks({ project }: { project: Project }) {
key={task.id} key={task.id}
task={task} task={task}
pending={pendingId === task.id} pending={pendingId === task.id}
projectStates={projectStates}
onToggle={(vars) => toggleMutation.mutate(vars)} onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)} onOpen={() => openTask(task.id)}
/> />
@@ -476,36 +503,45 @@ function ProjectTasks({ project }: { project: Project }) {
function TaskRow({ function TaskRow({
task, task,
pending, pending,
projectStates,
onToggle, onToggle,
onOpen, onOpen,
}: { }: {
task: Task; task: Task;
pending: boolean; pending: boolean;
onToggle: (vars: { taskId: string; status: Task["status"] }) => void; projectStates: State[];
onToggle: (vars: { taskId: string; completed: boolean }) => void;
onOpen: () => void; onOpen: () => void;
}) { }) {
const state = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
const isCompleted = state?.group === "completed";
return ( return (
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50"> <div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Checkbox <Checkbox
checked={task.status === "done"} checked={isCompleted}
disabled={pending} disabled={pending}
onCheckedChange={() => onCheckedChange={() =>
onToggle({ onToggle({
taskId: task.id, taskId: task.id,
status: task.status === "done" ? "todo" : "done", completed: isCompleted,
}) })
} }
aria-label={ aria-label={
"Mark " + task.title + " " + (task.status === "done" ? "as not done" : "as done") "Mark " + task.title + " " + (isCompleted ? "as not done" : "as done")
} }
/> />
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[task.status]?.dot)} /> {state ? (
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: state.color || "#94a3b8" }} />
) : (
<span className="h-2 w-2 shrink-0 rounded-full bg-slate-300" />
)}
<button <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
className={cn( className={cn(
"min-w-0 flex-1 truncate text-left text-sm hover:underline", "min-w-0 flex-1 truncate text-left text-sm hover:underline",
task.status === "done" && "text-muted-foreground line-through" isCompleted && "text-muted-foreground line-through"
)} )}
> >
{task.title} {task.title}
@@ -690,6 +726,465 @@ function Sections({ project }: { project: Project }) {
); );
} }
function ProjectModules({ project }: { project: Project }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editModule, setEditModule] = useState<Module | null>(null);
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
const { data: modulesData, isLoading } = useApiQuery<PaginatedResponse<Module>>(
["modules", project.id],
"/projects/" + project.id + "/modules?limit=200"
);
const modules = modulesData?.items || [];
const { data: moduleDetail } = useApiQuery<Module & { tasks: Task[] }>(
["module", selectedModuleId || ""],
"/projects/" + project.id + "/modules/" + selectedModuleId,
{ enabled: !!selectedModuleId }
);
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", project.id],
"/states?projectId=" + project.id,
{ enabled: !!project.id }
);
const projectStates = statesData?.items || [];
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["modules", project.id] });
if (selectedModuleId) queryClient.invalidateQueries({ queryKey: ["module", selectedModuleId] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const createMutation = useMutation({
mutationFn: (data: { name: string; description?: string }) =>
api.post<Module>(`/projects/${project.id}/modules`, data),
onSuccess: () => {
setCreateOpen(false);
setNewName("");
setNewDescription("");
toast.success("Module created");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
api.patch<Module>(`/projects/${project.id}/modules/${id}`, data),
onSuccess: () => {
setEditModule(null);
toast.success("Module updated");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/projects/${project.id}/modules/${id}`),
onSuccess: () => {
toast.success("Module deleted");
if (selectedModuleId === editModule?.id) setSelectedModuleId(null);
setEditModule(null);
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const addTaskMutation = useMutation({
mutationFn: ({ moduleId, taskId }: { moduleId: string; taskId: string }) =>
api.post(`/projects/${project.id}/modules/${moduleId}/tasks`, { taskId }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const removeTaskMutation = useMutation({
mutationFn: ({ moduleId, taskId }: { moduleId: string; taskId: string }) =>
api.delete(`/projects/${project.id}/modules/${moduleId}/tasks/${taskId}`),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const tasks = project.tasks || [];
const moduleTasks = moduleDetail?.tasks || [];
const moduleTaskIds = new Set(moduleTasks.map((t) => t.id));
const unassignedTasks = tasks.filter((t) => !t.moduleId && !moduleTaskIds.has(t.id));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Modules</h3>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New Module
</Button>
</div>
{isLoading ? (
<LoadingState label="Loading modules..." />
) : modules.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No modules yet. Create one to organize tasks.</p>
) : (
<div className="space-y-2">
{modules.map((mod) => (
<div
key={mod.id}
className={cn(
"rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors",
selectedModuleId === mod.id && "bg-muted/50 ring-1 ring-primary/30"
)}
onClick={() => setSelectedModuleId(selectedModuleId === mod.id ? null : mod.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{mod.name}</span>
<Badge variant="secondary" className={cn("text-[10px]", MODULE_STATUS[mod.status]?.badge)}>
{MODULE_STATUS[mod.status]?.label ?? mod.status}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setEditModule(mod); }}>
<Flag className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(mod.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{mod.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{mod.description}</p>
)}
</div>
))}
</div>
)}
{selectedModuleId && moduleDetail && (
<div className="rounded-lg border p-4 space-y-3">
<h4 className="text-sm font-semibold">Tasks in {moduleDetail.name}</h4>
{moduleTasks.length === 0 ? (
<p className="text-xs text-muted-foreground">No tasks in this module.</p>
) : (
<div className="space-y-1">
{moduleTasks.map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: t.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{t.title}
</button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => removeTaskMutation.mutate({ moduleId: selectedModuleId, taskId: t.id })}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{unassignedTasks.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Add task:</p>
<div className="space-y-1">
{unassignedTasks.slice(0, 10).map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<span className="min-w-0 flex-1 truncate text-sm">{t.title}</span>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => addTaskMutation.mutate({ moduleId: selectedModuleId, taskId: t.id })}>
<Plus className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>New Module</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input placeholder="Module name" value={newName} onChange={(e) => setNewName(e.target.value)} />
<Textarea placeholder="Description (optional)" value={newDescription} onChange={(e) => setNewDescription(e.target.value)} rows={3} />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={() => createMutation.mutate({ name: newName, description: newDescription || undefined })} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!editModule} onOpenChange={(o) => { if (!o) setEditModule(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Module</DialogTitle></DialogHeader>
{editModule && (
<ModuleEditForm
module={editModule}
onSave={(data) => updateMutation.mutate({ id: editModule.id, data })}
onClose={() => setEditModule(null)}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
function ModuleEditForm({ module: mod, onSave, onClose }: { module: Module; onSave: (data: Record<string, unknown>) => void; onClose: () => void }) {
const [name, setName] = useState(mod.name);
const [description, setDescription] = useState(mod.description || "");
const [status, setStatus] = useState(mod.status);
return (
<div className="space-y-4">
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
<Select value={status} onValueChange={(v) => setStatus(v as Module["status"])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MODULE_STATUS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => onSave({ name, description: description || null, status })} disabled={!name.trim()}>Save</Button>
</div>
</div>
);
}
function ProjectCycles({ project }: { project: Project }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editCycle, setEditCycle] = useState<Cycle | null>(null);
const [newName, setNewName] = useState("");
const [selectedCycleId, setSelectedCycleId] = useState<string | null>(null);
const { data: cyclesData, isLoading } = useApiQuery<{ items: Cycle[] }>(
["cycles", project.id],
"/projects/" + project.id + "/cycles"
);
const cycles = cyclesData?.items || [];
const { data: cycleDetail } = useApiQuery<Cycle & { tasks: Task[] }>(
["cycle", selectedCycleId || ""],
"/projects/" + project.id + "/cycles/" + selectedCycleId,
{ enabled: !!selectedCycleId }
);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["cycles", project.id] });
if (selectedCycleId) queryClient.invalidateQueries({ queryKey: ["cycle", selectedCycleId] });
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const createMutation = useMutation({
mutationFn: (data: { name: string }) =>
api.post<Cycle>(`/projects/${project.id}/cycles`, data),
onSuccess: () => {
setCreateOpen(false);
setNewName("");
toast.success("Cycle created");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
api.patch<Cycle>(`/projects/${project.id}/cycles/${id}`, data),
onSuccess: () => {
setEditCycle(null);
toast.success("Cycle updated");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/projects/${project.id}/cycles/${id}`),
onSuccess: () => {
toast.success("Cycle deleted");
if (selectedCycleId === editCycle?.id) setSelectedCycleId(null);
setEditCycle(null);
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const addTaskMutation = useMutation({
mutationFn: ({ cycleId, taskId }: { cycleId: string; taskId: string }) =>
api.post(`/projects/${project.id}/cycles/${cycleId}/tasks`, { taskId }),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const removeTaskMutation = useMutation({
mutationFn: ({ cycleId, taskId }: { cycleId: string; taskId: string }) =>
api.delete(`/projects/${project.id}/cycles/${cycleId}/tasks/${taskId}`),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const tasks = project.tasks || [];
const cycleTasks = cycleDetail?.tasks || [];
const cycleTaskIds = new Set(cycleTasks.map((t) => t.id));
const backlogTasks = tasks.filter((t) => !t.cycleId && !cycleTaskIds.has(t.id));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Cycles</h3>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> New Cycle
</Button>
</div>
{isLoading ? (
<LoadingState label="Loading cycles..." />
) : cycles.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No cycles yet. Create a sprint cycle to time-box work.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{cycles.map((cycle) => (
<div
key={cycle.id}
className={cn(
"rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors",
selectedCycleId === cycle.id && "bg-muted/50 ring-1 ring-primary/30"
)}
onClick={() => setSelectedCycleId(selectedCycleId === cycle.id ? null : cycle.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{cycle.name}</span>
{cycle.active && <Badge className="text-[10px] bg-green-500 text-white">Active</Badge>}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setEditCycle(cycle); }}>
<Flag className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(cycle.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex gap-3 mt-2 text-xs text-muted-foreground">
{cycle.startDate && <span>Start: {format(parseISO(cycle.startDate), "MMM d")}</span>}
{cycle.endDate && <span>End: {format(parseISO(cycle.endDate), "MMM d")}</span>}
</div>
</div>
))}
</div>
)}
{selectedCycleId && cycleDetail && (
<div className="rounded-lg border p-4 space-y-3">
<h4 className="text-sm font-semibold">Tasks in {cycleDetail.name}</h4>
{cycleTasks.length === 0 ? (
<p className="text-xs text-muted-foreground">No tasks in this cycle.</p>
) : (
<div className="space-y-1">
{cycleTasks.map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: t.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{t.title}
</button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => removeTaskMutation.mutate({ cycleId: selectedCycleId, taskId: t.id })}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{backlogTasks.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Backlog add task:</p>
<div className="space-y-1">
{backlogTasks.slice(0, 10).map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
<span className="min-w-0 flex-1 truncate text-sm">{t.title}</span>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => addTaskMutation.mutate({ cycleId: selectedCycleId, taskId: t.id })}>
<Plus className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>New Cycle</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input placeholder="Cycle name" value={newName} onChange={(e) => setNewName(e.target.value)} />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={() => createMutation.mutate({ name: newName })} disabled={!newName.trim() || createMutation.isPending}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!editCycle} onOpenChange={(o) => { if (!o) setEditCycle(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Cycle</DialogTitle></DialogHeader>
{editCycle && (
<CycleEditForm
cycle={editCycle}
onSave={(data) => updateMutation.mutate({ id: editCycle.id, data })}
onClose={() => setEditCycle(null)}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
function CycleEditForm({ cycle, onSave, onClose }: { cycle: Cycle; onSave: (data: Record<string, unknown>) => void; onClose: () => void }) {
const [name, setName] = useState(cycle.name);
const [startDate, setStartDate] = useState(cycle.startDate ? cycle.startDate.slice(0, 10) : "");
const [endDate, setEndDate] = useState(cycle.endDate ? cycle.endDate.slice(0, 10) : "");
const [active, setActive] = useState(cycle.active);
return (
<div className="space-y-4">
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-muted-foreground">Start Date</label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div>
<label className="text-sm text-muted-foreground">End Date</label>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={active} onCheckedChange={(v) => setActive(!!v)} id="cycle-active" />
<label htmlFor="cycle-active" className="text-sm cursor-pointer">Active cycle</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => onSave({ name, startDate: startDate ? new Date(startDate).toISOString() : null, endDate: endDate ? new Date(endDate).toISOString() : null, active })} disabled={!name.trim()}>Save</Button>
</div>
</div>
);
}
export const Route = createRoute({ export const Route = createRoute({
getParentRoute: () => appRoute, getParentRoute: () => appRoute,
path: "projects/$id", path: "projects/$id",
+191 -102
View File
@@ -2,14 +2,14 @@ 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";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core"; import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Pencil, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react"; import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } 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";
@@ -20,27 +20,26 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel"; import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { LoadingState, EmptyState, ErrorState } from "@/components/state"; import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs"; import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors"; import { PRIORITY } from "@/lib/status-colors";
import type { Task, PaginatedResponse } from "@/lib/types"; import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp"; import { parseTaskInput } from "@/lib/nlp";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker"; import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
const STATUS_COLUMNS = [ const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "todo", label: "Todo" }, { id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
{ id: "in_progress", label: "In Progress" }, { id: "unstarted", label: "Unstarted", colorClass: "bg-slate-500" },
{ id: "done", label: "Done" }, { id: "started", label: "Started", colorClass: "bg-blue-500" },
{ id: "cancelled", label: "Cancelled" }, { id: "completed", label: "Completed", colorClass: "bg-green-500" },
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500" },
]; ];
function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () => void; onEdit?: () => void }) { function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; onClick: () => void; onEdit?: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style = { const style = {
@@ -58,6 +57,12 @@ function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () =
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{task.title}</p> <p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{stateName && (
<Badge variant="secondary" className="text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
{stateName}
</Badge>
)}
{task.dueDate && ( {task.dueDate && (
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
<Calendar className="h-3 w-3 mr-1" /> <Calendar className="h-3 w-3 mr-1" />
@@ -101,18 +106,28 @@ function ColumnDroppable({ id, className, children }: { id: string; className?:
); );
} }
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) { function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => void; projectId?: string | null }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const [title, setTitle] = useState(task?.title || ""); const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || ""); const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState(task?.status || "todo");
const [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 [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; const parsed = !task ? parseTaskInput(title) : null;
const effectiveProjectId = task?.projectId || projectId;
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", effectiveProjectId || ""],
"/states?projectId=" + effectiveProjectId,
{ enabled: !!effectiveProjectId }
);
const projectStates = statesData?.items || [];
const [selectedStateId, setSelectedStateId] = useState(task?.stateId || "");
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => api.post<Task>("/tasks", data), mutationFn: (data: any) => api.post<Task>("/tasks", data),
onSuccess: () => { onSuccess: () => {
@@ -143,7 +158,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
if (p.priority) finalPriority = p.priority; if (p.priority) finalPriority = p.priority;
tagNames = p.tags; tagNames = p.tags;
} }
const data: any = { title: finalTitle, description: description || null, status, priority: finalPriority, tagNames }; const data: any = { title: finalTitle, description: description || null, priority: finalPriority, tagNames };
if (selectedStateId) data.stateId = selectedStateId || null;
if (finalDueDate) data.dueDate = finalDueDate; if (finalDueDate) data.dueDate = finalDueDate;
if (recurrenceRule) data.recurrenceRule = recurrenceRule; if (recurrenceRule) data.recurrenceRule = recurrenceRule;
const customFields = { ...customFieldValues }; const customFields = { ...customFieldValues };
@@ -173,18 +189,25 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} /> <Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> {projectStates.length > 0 && (
<Label htmlFor="status">Status</Label> <div>
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}> <Label htmlFor="state">State</Label>
<SelectTrigger id="status"><SelectValue /></SelectTrigger> <Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectContent> <SelectTrigger id="state"><SelectValue placeholder="No state" /></SelectTrigger>
<SelectItem value="todo">Todo</SelectItem> <SelectContent>
<SelectItem value="in_progress">In Progress</SelectItem> <SelectItem value="">No state</SelectItem>
<SelectItem value="done">Done</SelectItem> {projectStates.map((s) => (
<SelectItem value="cancelled">Cancelled</SelectItem> <SelectItem key={s.id} value={s.id}>
</SelectContent> <span className="flex items-center gap-2">
</Select> <span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
</div> {s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div> <div>
<Label htmlFor="priority">Priority</Label> <Label htmlFor="priority">Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}> <Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
@@ -219,7 +242,7 @@ function TasksPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [view, setView] = useState<"board" | "list">("board"); const [view, setView] = useState<"board" | "list">("board");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [selectedStateId, setSelectedStateId] = useState("");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null); const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
@@ -231,16 +254,44 @@ function TasksPage() {
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const { data: projectsData } = useApiQuery<PaginatedResponse<{ id: string; name: string }>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const projects = projectsData?.items || [];
const [filterProjectId, setFilterProjectId] = useState("");
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", filterProjectId],
"/states?projectId=" + filterProjectId,
{ enabled: !!filterProjectId }
);
const projectStates = statesData?.items || [];
const stateGroupOf = useMemo(() => {
const map = new Map<string, StateGroup>();
for (const s of projectStates) map.set(s.id, s.group);
return map;
}, [projectStates]);
const stateById = useMemo(() => {
const map = new Map<string, State>();
for (const s of projectStates) map.set(s.id, s);
return map;
}, [projectStates]);
const taskQueryParams = () => const taskQueryParams = () =>
new URLSearchParams({ new URLSearchParams({
limit: "200", limit: "200",
...(activeDomainId ? { domain: activeDomainId } : {}), ...(activeDomainId ? { domain: activeDomainId } : {}),
...(search ? { search } : {}), ...(search ? { search } : {}),
...(statusFilter && statusFilter !== "all" ? { status: statusFilter } : {}), ...(filterProjectId ? { project_id: filterProjectId } : {}),
...(selectedStateId ? { state_id: selectedStateId } : {}),
}).toString(); }).toString();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>( const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, statusFilter], ["tasks", activeDomainId, search, filterProjectId, selectedStateId],
"/tasks?" + taskQueryParams() "/tasks?" + taskQueryParams()
); );
@@ -255,7 +306,7 @@ function TasksPage() {
const next = await api.get<PaginatedResponse<Task>>( const next = await api.get<PaginatedResponse<Task>>(
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString() "/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
); );
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => { queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
if (!old) return old; if (!old) return old;
const seen = new Set(old.items.map((t) => t.id)); const seen = new Set(old.items.map((t) => t.id));
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] }; return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
@@ -265,9 +316,9 @@ function TasksPage() {
} }
}; };
const statusMutation = useMutation({ const stateUpdateMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => mutationFn: ({ taskId, stateId }: { taskId: string; stateId: string }) =>
api.post("/tasks/" + id + "/status", { status }), api.patch<Task>("/tasks/" + taskId, { stateId }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] }); queryClient.invalidateQueries({ queryKey: ["tasks"] });
}, },
@@ -300,6 +351,17 @@ function TasksPage() {
useSensor(KeyboardSensor) useSensor(KeyboardSensor)
); );
const taskGroupOf = useCallback(
(task: Task): StateGroup => {
if (task.stateId) {
const group = stateGroupOf.get(task.stateId);
if (group) return group;
}
return "unstarted";
},
[stateGroupOf]
);
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string); setActiveId(event.active.id as string);
}; };
@@ -315,30 +377,25 @@ function TasksPage() {
const draggedTask = tasks.find((t) => t.id === taskId); const draggedTask = tasks.find((t) => t.id === taskId);
if (!draggedTask) return; if (!draggedTask) return;
// Tasks of a column in persisted order const columnTasks = (group: StateGroup) =>
const columnTasks = (status: string) =>
tasks tasks
.filter((t) => t.status === status) .filter((t) => taskGroupOf(t) === group)
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
// Decide the target column and insertion index: let targetGroup: StateGroup;
// - over a column id => drop at the end of that column (handles empty columns)
// - over a task id => drop at that task's position within its column
let targetColumn: string;
let insertIndex: number; let insertIndex: number;
if (STATUS_COLUMNS.some((c) => c.id === overId)) { if (STATE_GROUP_COLUMNS.some((c) => c.id === overId)) {
targetColumn = overId; targetGroup = overId as StateGroup;
insertIndex = -1; insertIndex = -1;
} 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; targetGroup = taskGroupOf(overTask);
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId); const overIndex = columnTasks(targetGroup).findIndex((t) => t.id === overId);
insertIndex = overIndex === -1 ? -1 : overIndex; insertIndex = overIndex === -1 ? -1 : overIndex;
} }
// Build the new ordered id list for the target column const targetIds = columnTasks(targetGroup)
const targetIds = columnTasks(targetColumn)
.map((t) => t.id) .map((t) => t.id)
.filter((id) => id !== taskId); .filter((id) => id !== taskId);
if (insertIndex === -1) { if (insertIndex === -1) {
@@ -347,32 +404,30 @@ function TasksPage() {
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId); targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
} }
// No-op when the task is already in that exact spot const currentIds = columnTasks(targetGroup).map((t) => t.id);
const currentIds = columnTasks(targetColumn).map((t) => t.id);
const unchanged = const unchanged =
currentIds.length === targetIds.length && currentIds.length === targetIds.length &&
currentIds.every((id, i) => id === targetIds[i]); currentIds.every((id, i) => id === targetIds[i]);
if (unchanged) return; if (unchanged) return;
// Optimistic local update so the board reorders immediately const groupChanged = taskGroupOf(draggedTask) !== targetGroup;
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, filterProjectId, selectedStateId], (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) {
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;
}), }),
}; };
}); });
if (statusChanged) { if (groupChanged && draggedTask.projectId) {
statusMutation.mutate({ id: taskId, status: targetColumn }); const firstStateInGroup = projectStates.find((s) => s.group === targetGroup && s.projectId === draggedTask.projectId);
if (firstStateInGroup) {
stateUpdateMutation.mutate({ taskId, stateId: firstStateInGroup.id });
}
} }
reorderMutation.mutate({ orderedIds: targetIds }); reorderMutation.mutate({ orderedIds: targetIds });
}; };
@@ -387,14 +442,13 @@ function TasksPage() {
}; };
const columns = useMemo(() => { const columns = useMemo(() => {
return STATUS_COLUMNS.map((col) => ({ return STATE_GROUP_COLUMNS.map((col) => ({
...col, ...col,
color: TASK_STATUS[col.id].dot,
tasks: tasks tasks: tasks
.filter((t) => t.status === col.id) .filter((t) => taskGroupOf(t) === col.id)
.sort((a, b) => a.order - b.order), .sort((a, b) => a.order - b.order),
})); }));
}, [tasks]); }, [tasks, taskGroupOf]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -415,27 +469,42 @@ function TasksPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>New Task</DialogTitle> <DialogTitle>New Task</DialogTitle>
</DialogHeader> </DialogHeader>
<TaskForm onClose={() => setCreateOpen(false)} /> <TaskForm onClose={() => setCreateOpen(false)} projectId={filterProjectId || undefined} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</div> </div>
{/* Search + filter bar */} <div className="flex gap-2 flex-wrap">
<div className="flex gap-2"> <div className="relative flex-1 min-w-[200px] max-w-sm">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" /> <Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
</div> </div>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={filterProjectId} onValueChange={setFilterProjectId}>
<SelectTrigger className="w-36"><SelectValue placeholder="All statuses" /></SelectTrigger> <SelectTrigger className="w-44"><SelectValue placeholder="All projects" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All statuses</SelectItem> <SelectItem value="">All projects</SelectItem>
{STATUS_COLUMNS.map((c) => ( {projects.map((p) => (
<SelectItem key={c.id} value={c.id}>{c.label}</SelectItem> <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{filterProjectId && projectStates.length > 0 && (
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectTrigger className="w-40"><SelectValue placeholder="All states" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All states</SelectItem>
{projectStates.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -444,21 +513,31 @@ function TasksPage() {
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} /> <ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
) : view === "board" ? ( ) : view === "board" ? (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{columns.map((col) => ( {columns.map((col) => (
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3"> <ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", col.color)} /> <div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
<h3 className="font-semibold text-sm">{col.label}</h3> <h3 className="font-semibold text-sm">{col.label}</h3>
<Badge variant="secondary" className="text-[10px]">{col.tasks.length}</Badge> <Badge variant="secondary" className="text-[10px]">{col.tasks.length}</Badge>
</div> </div>
</div> </div>
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}> <SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[100px]"> <div className="space-y-2 min-h-[100px]">
{col.tasks.map((task) => ( {col.tasks.map((task) => {
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} onEdit={() => openTaskPanel(task)} /> const st = task.stateId ? stateById.get(task.stateId) : undefined;
))} return (
<SortableTaskCard
key={task.id}
task={task}
stateName={st?.name}
stateColor={st?.color}
onClick={() => openTaskDetail(task)}
onEdit={() => openTaskPanel(task)}
/>
);
})}
{col.tasks.length === 0 && ( {col.tasks.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p> <p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
)} )}
@@ -477,7 +556,7 @@ function TasksPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>Status</TableHead> <TableHead>State</TableHead>
<TableHead>Priority</TableHead> <TableHead>Priority</TableHead>
<TableHead>Due Date</TableHead> <TableHead>Due Date</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
@@ -490,32 +569,42 @@ function TasksPage() {
<EmptyState title="No tasks found" /> <EmptyState title="No tasks found" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : tasks.map((task) => ( ) : tasks.map((task) => {
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}> const st = task.stateId ? stateById.get(task.stateId) : undefined;
<TableCell className="font-medium">{task.title}</TableCell> return (
<TableCell> <TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge> <TableCell className="font-medium">{task.title}</TableCell>
</TableCell> <TableCell>
<TableCell> {st ? (
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge> <Badge variant="secondary" className="text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
</TableCell> <span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
<TableCell className="text-sm text-muted-foreground"> {st.name}
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"} </Badge>
</TableCell> ) : (
<TableCell> <span className="text-xs text-muted-foreground"></span>
<DropdownMenu> )}
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> </TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button> <TableCell>
</DropdownMenuTrigger> <Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
<DropdownMenuContent align="end"> </TableCell>
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem> <TableCell className="text-sm text-muted-foreground">
<DropdownMenuSeparator /> {task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem> </TableCell>
</DropdownMenuContent> <TableCell>
</DropdownMenu> <DropdownMenu>
</TableCell> <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
</TableRow> <Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
))} </DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
+184 -103
View File
@@ -55,17 +55,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { LoadingState, ErrorState } from "@/components/state"; import { LoadingState, ErrorState } from "@/components/state";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors"; import { PRIORITY } from "@/lib/status-colors";
import type { PaginatedResponse, Project, Task } from "@/lib/types"; import type { PaginatedResponse, Project, State, 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" },
@@ -120,10 +113,17 @@ function TaskDetail() {
}); });
const toggleComplete = useMutation({ const toggleComplete = useMutation({
mutationFn: () => mutationFn: () => {
api.post<Task>(`/tasks/${id}/status`, { const completedStates = projectStates.filter((s) => s.group === "completed");
status: task?.status === "done" ? "todo" : "done", const uncompletedStates = projectStates.filter((s) => s.group !== "completed");
}), const isDone = task?.status === "done";
if (isDone && uncompletedStates.length > 0) {
return api.patch<Task>(`/tasks/${id}`, { stateId: uncompletedStates[0].id });
} else if (!isDone && completedStates.length > 0) {
return api.patch<Task>(`/tasks/${id}`, { stateId: completedStates[0].id });
}
return api.patch<Task>(`/tasks/${id}`, { stateId: null });
},
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 });
@@ -131,6 +131,14 @@ function TaskDetail() {
onError: (err) => toast.error(errorMessage(err)), onError: (err) => toast.error(errorMessage(err)),
}); });
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", task?.projectId || ""],
"/states?projectId=" + (task?.projectId || ""),
{ enabled: !!task?.projectId }
);
const projectStates = statesData?.items || [];
const currentState = task?.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => api.delete(`/tasks/${id}`), mutationFn: () => api.delete(`/tasks/${id}`),
onSuccess: () => { onSuccess: () => {
@@ -152,7 +160,7 @@ function TaskDetail() {
} }
if (!task) return <ErrorState message="Task not found" />; if (!task) return <ErrorState message="Task not found" />;
const isDone = task.status === "done"; const isDone = currentState?.group === "completed";
return ( return (
<EntityDetailPage <EntityDetailPage
@@ -167,16 +175,28 @@ function TaskDetail() {
icon={<ListTodo className="h-6 w-6" />} icon={<ListTodo className="h-6 w-6" />}
badges={ badges={
<> <>
<InlineSelect {projectStates.length > 0 ? (
value={task.status} <InlineSelect
options={STATUS_OPTIONS} value={task.stateId ?? ""}
displayValue={(v) => ( options={projectStates.map((s) => ({ value: s.id, label: s.name }))}
<Badge className={TASK_STATUS[v]?.badge}> displayValue={(v) => {
{TASK_STATUS[v]?.label ?? v} if (!v) return <Badge variant="secondary">No state</Badge>;
</Badge> const st = projectStates.find((s) => s.id === v);
)} if (!st) return <Badge variant="secondary">Unknown</Badge>;
onSave={(status) => patch({ id, data: { status } })} return (
/> <Badge variant="secondary" className="gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
{st.name}
</Badge>
);
}}
onSave={(stateId) => patch({ id, data: { stateId: stateId || null } })}
/>
) : (
<Badge variant="secondary">
{currentState?.name || task.status.replace("_", " ")}
</Badge>
)}
<InlineSelect <InlineSelect
value={task.priority} value={task.priority}
options={PRIORITY_OPTIONS} options={PRIORITY_OPTIONS}
@@ -265,6 +285,20 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
...projects.map((p) => ({ value: p.id, label: p.name })), ...projects.map((p) => ({ value: p.id, label: p.name })),
]; ];
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", task.projectId || ""],
"/states?projectId=" + (task.projectId || ""),
{ enabled: !!task.projectId }
);
const projectStates = statesData?.items || [];
const stateOptions: InlineSelectOption[] = [
{ value: "", label: "No state" },
...projectStates.map((s) => ({ value: s.id, label: s.name })),
];
const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -330,6 +364,29 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
} }
/> />
</div> </div>
{projectStates.length > 0 && (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect
value={task.stateId ?? ""}
options={stateOptions}
displayValue={(v) => {
if (!v) return <span className="text-muted-foreground/70">No state</span>;
const st = projectStates.find((s) => s.id === v);
if (!st) return <span>{v}</span>;
return (
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: st.color || "#94a3b8" }} />
{st.name}
</span>
);
}}
onSave={(stateId) =>
patch({ id: task.id, data: { stateId: stateId || null } })
}
/>
</div>
)}
{task.recurrenceRule ? ( {task.recurrenceRule ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -381,8 +438,8 @@ function Subtasks({ task }: { task: Task }) {
}); });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: ({ subId, status }: { subId: string; status: Task["status"] }) => mutationFn: ({ subId, completed }: { subId: string; completed: boolean }) =>
api.post<Task>(`/tasks/${subId}/status`, { status }), api.patch<Task>(`/tasks/${subId}`, { stateId: completed ? null : null }),
onMutate: (vars) => setPendingId(vars.subId), onMutate: (vars) => setPendingId(vars.subId),
onSettled: () => setPendingId(null), onSettled: () => setPendingId(null),
onSuccess: refresh, onSuccess: refresh,
@@ -432,12 +489,12 @@ function Subtasks({ task }: { task: Task }) {
onCheckedChange={() => onCheckedChange={() =>
toggleMutation.mutate({ toggleMutation.mutate({
subId: sub.id, subId: sub.id,
status: sub.status === "done" ? "todo" : "done", completed: sub.status === "done",
}) })
} }
aria-label={"Mark " + sub.title + " " + (sub.status === "done" ? "as not done" : "as 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)} /> <span className="h-2 w-2 shrink-0 rounded-full bg-slate-400" />
<button <button
type="button" type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })} onClick={() => navigate({ to: "/tasks/$id", params: { id: sub.id } })}
@@ -460,7 +517,13 @@ function Dependencies({ task }: { task: Task }) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const [depValue, setDepValue] = useState(""); const [targetEntityId, setTargetEntityId] = useState("");
const [linkType, setLinkType] = useState<string>("blocks");
const { data: linksData, isLoading: linksLoading } = useApiQuery<{ items: import("@/lib/types").Link[] }>(
["links", "task", task.id],
"/links?entityType=task&entityId=" + task.id
);
const { data: tasksData, isLoading: tasksLoading } = useApiQuery<PaginatedResponse<Task>>( const { data: tasksData, isLoading: tasksLoading } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, "dependency-picker"], ["tasks", activeDomainId, "dependency-picker"],
@@ -468,43 +531,55 @@ function Dependencies({ task }: { task: Task }) {
); );
const refresh = () => { const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["links", "task", task.id] });
queryClient.invalidateQueries({ queryKey: ["task", task.id] }); queryClient.invalidateQueries({ queryKey: ["task", task.id] });
queryClient.invalidateQueries({ queryKey: ["tasks"] }); queryClient.invalidateQueries({ queryKey: ["tasks"] });
}; };
const addDependency = useMutation({ const addLink = useMutation({
mutationFn: (dependsOnTaskId: string) => mutationFn: (vars: { sourceId: string; targetId: string; linkType: string }) =>
api.post(`/tasks/${task.id}/dependencies`, { dependsOnTaskId }), api.post("/links", {
sourceType: "task",
sourceId: vars.sourceId,
targetType: "task",
targetId: vars.targetId,
linkType: vars.linkType,
}),
onSuccess: () => { onSuccess: () => {
setDepValue(""); setTargetEntityId("");
toast.success("Dependency added"); toast.success("Link added");
refresh(); refresh();
}, },
onError: (err) => toast.error(errorMessage(err)), onError: (err) => toast.error(errorMessage(err)),
}); });
const removeDependency = useMutation({ const removeLink = useMutation({
mutationFn: ({ taskId, depId }: { taskId: string; depId: string }) => mutationFn: (linkId: string) => api.delete(`/links/${linkId}`),
api.delete(`/tasks/${taskId}/dependencies/${depId}`),
onSuccess: refresh, onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)), onError: (err) => toast.error(errorMessage(err)),
}); });
const dependencies = task.dependencies || []; const links = linksData?.items || [];
const dependents = task.dependents || [];
const availableTasks = (tasksData?.items ?? []).filter( const incomingLinks = links.filter((l) => l.targetId === task.id && l.sourceType === "task");
(t) => t.id !== task.id && !dependencies.some((d) => d.id === t.id) const outgoingLinks = links.filter((l) => l.sourceId === task.id && l.targetType === "task");
const allTaskIds = new Set((tasksData?.items || []).map((t) => t.id));
const linkedTaskIds = new Set([...incomingLinks.map((l) => l.sourceId), ...outgoingLinks.map((l) => l.targetId), task.id]);
const availableTasks = (tasksData?.items || []).filter(
(t) => t.id !== task.id && !linkedTaskIds.has(t.id)
); );
const depPlaceholder = tasksLoading const depPlaceholder = tasksLoading
? "Loading tasks..." ? "Loading tasks..."
: availableTasks.length === 0 : availableTasks.length === 0
? "No tasks to add" ? "No tasks to add"
: "Add dependency..."; : "Add link...";
const handleAddDependency = (value: string) => { const taskTitleById = new Map((tasksData?.items || []).map((t) => [t.id, t.title]));
const handleAddLink = (value: string) => {
if (!value) return; if (!value) return;
addDependency.mutate(value); addLink.mutate({ sourceId: task.id, targetId: value, linkType });
}; };
return ( return (
@@ -512,35 +587,32 @@ function Dependencies({ task }: { task: Task }) {
<div> <div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"> <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" /> <Link2 className="h-4 w-4 text-muted-foreground" />
Blocked by Links to this task
</h3> </h3>
{dependencies.length === 0 ? ( {incomingLinks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">Nothing blocks this task.</p> <p className="py-4 text-sm text-muted-foreground">No incoming links.</p>
) : ( ) : (
<div className="space-y-0.5"> <div className="space-y-0.5">
{dependencies.map((dep) => ( {incomingLinks.map((link) => (
<div <div
key={dep.id} key={link.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50" className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
> >
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
<button <button
type="button" type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })} onClick={() => navigate({ to: "/tasks/$id", params: { id: link.sourceId } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline" className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
> >
{dep.title} {taskTitleById.get(link.sourceId) || link.sourceId}
</button> </button>
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}> <Badge variant="outline" className="text-[10px]">{link.linkType}</Badge>
{TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeDependency.mutate({ taskId: task.id, depId: dep.id })} onClick={() => removeLink.mutate(link.id)}
aria-label={"Remove dependency on " + dep.title} aria-label="Remove link"
title="Remove dependency" title="Remove link"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
@@ -548,13 +620,63 @@ function Dependencies({ task }: { task: Task }) {
))} ))}
</div> </div>
)} )}
<div className="mt-3"> </div>
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Links from this task
</h3>
{outgoingLinks.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No outgoing links.</p>
) : (
<div className="space-y-0.5">
{outgoingLinks.map((link) => (
<div
key={link.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: link.targetId } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{taskTitleById.get(link.targetId) || link.targetId}
</button>
<Badge variant="outline" className="text-[10px]">{link.linkType}</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeLink.mutate(link.id)}
aria-label="Remove link"
title="Remove link"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
<div className="mt-3 flex gap-2">
<Select value={linkType} onValueChange={setLinkType}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blocks">Blocks</SelectItem>
<SelectItem value="relates">Relates to</SelectItem>
<SelectItem value="parent-child">Parent/Child</SelectItem>
<SelectItem value="created-from">Created from</SelectItem>
</SelectContent>
</Select>
<Select <Select
value={depValue} value={targetEntityId}
onValueChange={handleAddDependency} onValueChange={handleAddLink}
disabled={availableTasks.length === 0} disabled={availableTasks.length === 0}
> >
<SelectTrigger className="h-8 w-full text-sm" aria-label="Add dependency"> <SelectTrigger className="h-8 flex-1 text-sm" aria-label="Add link">
<SelectValue placeholder={depPlaceholder} /> <SelectValue placeholder={depPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -567,47 +689,6 @@ function Dependencies({ task }: { task: Task }) {
</Select> </Select>
</div> </div>
</div> </div>
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold">
<Link2 className="h-4 w-4 text-muted-foreground" />
Blocks
</h3>
{dependents.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">Nothing depends on this task.</p>
) : (
<div className="space-y-0.5">
{dependents.map((dep) => (
<div
key={dep.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<span className={cn("h-2 w-2 shrink-0 rounded-full", TASK_STATUS[dep.status]?.dot)} />
<button
type="button"
onClick={() => navigate({ to: "/tasks/$id", params: { id: dep.id } })}
className="min-w-0 flex-1 truncate text-left text-sm hover:underline"
>
{dep.title}
</button>
<Badge className={cn("text-[10px]", TASK_STATUS[dep.status]?.badge)}>
{TASK_STATUS[dep.status]?.label ?? dep.status}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeDependency.mutate({ taskId: dep.id, depId: task.id })}
aria-label={"Remove this task from " + dep.title + "'s dependencies"}
title="Remove dependency"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -1046,7 +1046,7 @@ Requests and responses use the JSON-RPC 2.0 envelope:
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "tools/call", "method": "tools/call",
"params": { "name": "tasks.list", "arguments": { "domain_id": "b2c3d4e5-...", "status": "todo" } }, "params": { "name": "tasks.list", "arguments": { "domain_id": "b2c3d4e5-...", "state_group": "unstarted" } },
"id": 1 "id": 1
} }
``` ```