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
+10 -184
View File
@@ -10,10 +10,8 @@ import {
FolderKanban,
ListTodo,
Plus,
Pencil,
Trash2,
X,
Zap,
} from "lucide-react";
import { differenceInCalendarDays, format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
@@ -30,7 +28,6 @@ import {
} from "@/components/entities/inline-edit";
import { EntityActivity } from "@/components/entities/entity-activity";
import { EntityComments } from "@/components/entities/entity-comments";
import { AutomationRuleBuilder, TRIGGER_OPTIONS, summarizeActions } from "@/components/automation-rule-builder";
import {
AlertDialog,
AlertDialogAction,
@@ -55,12 +52,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Switch } from "@/components/ui/switch";
import { LoadingState, ErrorState } from "@/components/state";
import { GanttChart } from "@/components/gantt/gantt-chart";
import type { TimelineData } from "@/components/gantt/gantt-utils";
import { getStatusToken, PRIORITY, PROJECT_STATUS } from "@/lib/status-colors";
import type { AutomationRule, Project, Section, Task } from "@/lib/types";
import { PRIORITY, PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
import type { Project, Section, Task } from "@/lib/types";
import { cn } from "@/lib/utils";
const PROJECT_STATUS_OPTIONS: InlineSelectOption[] = [
@@ -232,16 +226,6 @@ function ProjectDetail() {
},
{ value: "tasks", label: "Tasks", content: <ProjectTasks 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",
label: "Activity",
@@ -358,8 +342,8 @@ function ProjectTasks({ project }: { project: Project }) {
});
const toggleMutation = useMutation({
mutationFn: ({ taskId, statusId }: { taskId: string; statusId: string | null }) =>
api.post<Task>(`/tasks/${taskId}/status`, { statusId }),
mutationFn: ({ taskId, status }: { taskId: string; status: Task["status"] }) =>
api.post<Task>(`/tasks/${taskId}/status`, { status }),
onMutate: (vars) => setPendingId(vars.taskId),
onSettled: () => setPendingId(null),
onSuccess: refresh,
@@ -454,7 +438,6 @@ function ProjectTasks({ project }: { project: Project }) {
<TaskRow
key={task.id}
task={task}
project={project}
pending={pendingId === task.id}
onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)}
@@ -477,7 +460,6 @@ function ProjectTasks({ project }: { project: Project }) {
<TaskRow
key={task.id}
task={task}
project={project}
pending={pendingId === task.id}
onToggle={(vars) => toggleMutation.mutate(vars)}
onOpen={() => openTask(task.id)}
@@ -493,43 +475,37 @@ function ProjectTasks({ project }: { project: Project }) {
function TaskRow({
task,
project,
pending,
onToggle,
onOpen,
}: {
task: Task;
project: Project;
pending: boolean;
onToggle: (vars: { taskId: string; statusId: string | null }) => void;
onToggle: (vars: { taskId: string; status: Task["status"] }) => void;
onOpen: () => void;
}) {
const isDone = task.status?.category === "done";
const statuses = project.statuses ?? [];
return (
<div className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Checkbox
checked={isDone}
checked={task.status === "done"}
disabled={pending}
onCheckedChange={() =>
onToggle({
taskId: task.id,
statusId: isDone
? (statuses.find((s) => s.category === "todo")?.id ?? null)
: (statuses.find((s) => s.category === "done")?.id ?? null),
status: task.status === "done" ? "todo" : "done",
})
}
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
type="button"
onClick={onOpen}
className={cn(
"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}
@@ -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({
getParentRoute: () => appRoute,
path: "projects/$id",