From 09b4e3e652365c4609e54eaafbfbb09e4cdb262e Mon Sep 17 00:00:00 2001
From: Satya Patel
Date: Sat, 10 Jan 2026 14:31:12 -0800
Subject: [PATCH 01/18] WIP
---
apps/desktop/package.json | 1 +
.../components/ComponentsShowcase/index.tsx | 209 +++++++++++++
.../main/components/TasksView/TasksView.tsx | 293 +-----------------
.../components/StatusIcon/StatusIcon.tsx | 164 ++++++++++
.../components/StatusIcon/constants.ts | 10 +
.../TasksView/components/StatusIcon/index.ts | 2 +
.../TasksTableView/TasksTableView.tsx | 57 ++++
.../components/TasksTableView/index.ts | 1 +
.../cells/AssigneeCell/AssigneeCell.tsx | 156 ++++++++++
.../components/cells/AssigneeCell/index.ts | 1 +
.../cells/LabelsCell/LabelsCell.tsx | 134 ++++++++
.../components/cells/LabelsCell/index.ts | 1 +
.../cells/PriorityCell/PriorityCell.tsx | 154 +++++++++
.../cells/PriorityCell/constants.ts | 7 +
.../components/cells/PriorityCell/index.ts | 1 +
.../cells/StatusCell/StatusCell.tsx | 105 +++++++
.../components/cells/StatusCell/index.ts | 1 +
.../TasksView/hooks/useTasksTable/index.ts | 1 +
.../hooks/useTasksTable/useTasksTable.tsx | 178 +++++++++++
.../WorkspaceSidebarHeader.tsx | 38 +++
.../src/renderer/screens/main/index.tsx | 4 +
apps/desktop/src/renderer/stores/app-state.ts | 18 +-
bun.lock | 7 +-
23 files changed, 1255 insertions(+), 288 deletions(-)
create mode 100644 apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/constants.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/AssigneeCell.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/constants.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/index.ts
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 738586d1158..0bd431f8968 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -50,6 +50,7 @@
"@tanstack/electric-db-collection": "^0.2.20",
"@tanstack/react-db": "^0.1.60",
"@tanstack/react-query": "^5.90.10",
+ "@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.7.1",
"@trpc/react-query": "^11.7.1",
"@trpc/server": "^11.7.1",
diff --git a/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx b/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx
new file mode 100644
index 00000000000..593d504e6f0
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx
@@ -0,0 +1,209 @@
+import { ScrollArea } from "@superset/ui/scroll-area";
+import { StatusIcon, type StatusType } from "../TasksView/components/StatusIcon";
+
+const STATUS_VARIANTS: Array<{
+ type: StatusType;
+ color: string;
+ label: string;
+}> = [
+ { type: "backlog", color: "#6B7280", label: "Backlog (Gray)" },
+ { type: "unstarted", color: "#3B82F6", label: "Todo (Blue)" },
+ { type: "unstarted", color: "#EF4444", label: "Blocked (Red)" },
+ { type: "started", color: "#F59E0B", label: "In Progress (Orange)" },
+ { type: "started", color: "#10B981", label: "In Review (Green)" },
+ { type: "completed", color: "#8B5CF6", label: "Done (Purple)" },
+ { type: "cancelled", color: "#6B7280", label: "Canceled (Gray)" },
+ { type: "cancelled", color: "#EF4444", label: "Duplicate (Red)" },
+];
+
+export function ComponentsShowcase() {
+ return (
+
+ {/* Header */}
+
+
+
Components Showcase
+
+ View all component variants and states
+
+
+
+
+ {/* Content */}
+
+
+ {/* Status Icon Display */}
+
+ Status Icon Display
+
+ Linear-style status icons with support for different types and
+ colors
+
+
+
+ {/* All Variants Grid */}
+
+
All Variants
+
+ {STATUS_VARIANTS.map((variant, index) => (
+
+
+
+
{variant.label}
+
+ {variant.type}
+
+
+
+ ))}
+
+
+
+ {/* Type Breakdown */}
+
+
Type Breakdown
+
+ {/* Backlog */}
+
+
+
+ Backlog Type
+
+
+ Dashed circle outline
+
+
+
+ {/* Unstarted */}
+
+
+
+ Unstarted Type
+
+
+ Solid circle outline
+
+
+
+ {/* Started */}
+
+
+
+ Started Type
+
+
+ Filled circle with thin gap between fill and border
+
+
+
+ {/* Completed */}
+
+
+
+ Completed Type
+
+
+ Filled circle with hollow check icon
+
+
+
+ {/* Cancelled */}
+
+
+
+ Cancelled Type
+
+
+ Filled circle with hollow X icon
+
+
+
+
+
+ {/* Hover States */}
+
+
Hover States
+
+
+
+
+ Backlog
+
+
+
+
+
+ Unstarted
+
+
+
+
+
+ Started
+
+
+
+
+
+ Completed
+
+
+
+
+
+ Cancelled
+
+
+
+
+ Hover over any icon to see the brightness effect
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx
index 5e58414e87c..edaae24726b 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx
@@ -1,270 +1,10 @@
-import type { TaskPriority } from "@superset/db/enums";
-import type { SelectTask } from "@superset/db/schema";
-import { Badge } from "@superset/ui/badge";
-import { Button } from "@superset/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@superset/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@superset/ui/dialog";
-import { Input } from "@superset/ui/input";
-import { Label } from "@superset/ui/label";
import { ScrollArea } from "@superset/ui/scroll-area";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@superset/ui/select";
-import { toast } from "@superset/ui/sonner";
-import { Textarea } from "@superset/ui/textarea";
-import { useLiveQuery } from "@tanstack/react-db";
-import { useState } from "react";
-import {
- HiCalendar,
- HiCheckCircle,
- HiLink,
- HiPencil,
- HiUser,
-} from "react-icons/hi2";
-import { useCollections } from "renderer/contexts/CollectionsProvider";
+import { HiCheckCircle } from "react-icons/hi2";
+import { TasksTableView } from "./components/TasksTableView";
+import { useTasksTable } from "./hooks/useTasksTable";
-interface TaskEditDialogProps {
- task: SelectTask;
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-function TaskEditDialog({ task, open, onOpenChange }: TaskEditDialogProps) {
- const collections = useCollections();
- const [title, setTitle] = useState(task.title);
- const [description, setDescription] = useState(task.description || "");
- const [priority, setPriority] = useState(task.priority);
- const [isSaving, setIsSaving] = useState(false);
-
- const handleSave = async () => {
- setIsSaving(true);
- try {
- await collections.tasks.update(task.id, (draft: SelectTask) => {
- draft.title = title;
- draft.description = description || null;
- draft.priority = priority as
- | "urgent"
- | "high"
- | "medium"
- | "low"
- | "none";
- });
- toast.success("Task updated");
- onOpenChange(false);
- } catch (error) {
- console.error("[TaskEditDialog] Update failed:", error);
- toast.error(
- `Failed to update task: ${error instanceof Error ? error.message : String(error)}`,
- );
- } finally {
- setIsSaving(false);
- }
- };
-
- return (
-
- );
-}
-
-function TaskCard({
- task,
- onEdit,
-}: {
- task: SelectTask;
- onEdit: (task: SelectTask) => void;
-}) {
- const priorityColors: Record = {
- urgent: "bg-red-500",
- high: "bg-orange-500",
- medium: "bg-yellow-500",
- low: "bg-blue-500",
- none: "bg-gray-400",
- };
-
- const statusColors: Record = {
- backlog: "bg-gray-400",
- todo: "bg-blue-400",
- planning: "bg-purple-400",
- working: "bg-yellow-400",
- "needs-feedback": "bg-orange-400",
- "ready-to-merge": "bg-green-400",
- completed: "bg-green-600",
- canceled: "bg-red-400",
- };
-
- return (
-
-
-
-
- {task.externalKey && (
-
- {task.externalKey}
-
- )}
-
- {task.title}
-
-
-
-
- {task.priority !== "none" && (
-
- )}
-
- {task.status}
-
-
-
-
-
- {task.description && (
-
- {task.description}
-
- )}
-
- {task.assigneeId && (
-
-
- Assigned
-
- )}
- {task.dueDate && (
-
-
- {new Date(task.dueDate).toLocaleDateString()}
-
- )}
- {task.branch && (
-
- {task.branch}
-
- )}
- {task.externalUrl && (
-
-
- {task.externalProvider || "Link"}
-
- )}
-
- {task.labels && task.labels.length > 0 && (
-
- {(task.labels as string[]).map((label) => (
-
- {label}
-
- ))}
-
- )}
-
-
- );
-}
-
-function TasksList() {
- const collections = useCollections();
- const [editingTask, setEditingTask] = useState(null);
-
- const { data: allTasks, isLoading } = useLiveQuery(
- (q) => q.from({ tasks: collections.tasks }),
- [collections],
- );
-
- // Filter out deleted tasks in JavaScript
- const tasks = (allTasks?.filter((task) => task.deletedAt === null) ||
- []) as SelectTask[];
+export function TasksView() {
+ const { table, isLoading } = useTasksTable();
if (isLoading) {
return (
@@ -274,7 +14,7 @@ function TasksList() {
);
}
- if (tasks.length === 0) {
+ if (table.getRowModel().rows.length === 0) {
return (
@@ -285,29 +25,10 @@ function TasksList() {
);
}
- return (
- <>
-
- {tasks.map((task) => (
-
- ))}
-
- {editingTask && (
-
!open && setEditingTask(null)}
- />
- )}
- >
- );
-}
-
-export function TasksView() {
return (
-
+
);
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
new file mode 100644
index 00000000000..bf87a5015a6
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
@@ -0,0 +1,164 @@
+import { cn } from "@superset/ui/utils";
+
+export type StatusType =
+ | "backlog"
+ | "unstarted"
+ | "started"
+ | "completed"
+ | "cancelled";
+
+interface StatusIconProps {
+ type: StatusType;
+ color: string;
+ showHover?: boolean;
+ className?: string;
+}
+
+export function StatusIcon({
+ type,
+ color,
+ showHover = false,
+ className,
+}: StatusIconProps) {
+ const sizeClass = "w-3.5 h-3.5";
+
+ const containerClasses = cn(
+ "flex items-center justify-center rounded-full flex-shrink-0",
+ sizeClass,
+ showHover && "transition-all hover:brightness-120 duration-100",
+ className,
+ );
+
+ if (type === "backlog") {
+ return (
+
+
+
+ );
+ }
+
+ if (type === "unstarted") {
+ return (
+
+
+
+ );
+ }
+
+ if (type === "started") {
+ return (
+
+
+
+ );
+ }
+
+ if (type === "completed") {
+ return (
+
+ );
+ }
+
+ if (type === "cancelled") {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/constants.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/constants.ts
new file mode 100644
index 00000000000..eb35726c298
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/constants.ts
@@ -0,0 +1,10 @@
+export const STATUS_COLORS: Record = {
+ backlog: "text-gray-400",
+ todo: "text-blue-400",
+ planning: "text-purple-400",
+ working: "text-yellow-400",
+ "needs-feedback": "text-orange-400",
+ "ready-to-merge": "text-green-400",
+ completed: "text-green-600",
+ canceled: "text-red-400",
+};
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/index.ts
new file mode 100644
index 00000000000..328c2e4860a
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/index.ts
@@ -0,0 +1,2 @@
+export { StatusIcon, type StatusType } from "./StatusIcon";
+export { STATUS_COLORS } from "./constants";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
new file mode 100644
index 00000000000..4652a7c9088
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
@@ -0,0 +1,57 @@
+import { flexRender, type Table } from "@tanstack/react-table";
+import type { SelectTask } from "@superset/db/schema";
+
+interface TasksTableViewProps {
+ table: Table;
+}
+
+export function TasksTableView({ table }: TasksTableViewProps) {
+ return (
+
+ {table.getRowModel().rows.map((row) => {
+ // Group header rows have subRows, leaf rows don't
+ const isGroupHeader = row.subRows && row.subRows.length > 0;
+
+ if (isGroupHeader) {
+ // Group header - only render the status column (first cell)
+ // All other cells return null because they're placeholders
+ const firstCell = row.getVisibleCells()[0];
+ return (
+
+ {flexRender(firstCell.column.columnDef.cell, firstCell.getContext())}
+
+ );
+ }
+
+ // Leaf row - render all cells horizontally
+ // Layout: [ID] [Status Icon + Title .............. | Priority Assignee Labels Due]
+ // Note: Status column (index 0) returns null for leaf rows, so we skip it
+ const cells = row.getVisibleCells();
+ return (
+
+ {/* Left side: ID + Title (with inline status) - skip null status column */}
+ {cells.slice(1, 3).map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+ {/* Right side metadata: Priority, Assignee, Labels, Due */}
+
+ {cells.slice(3).map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/index.ts
new file mode 100644
index 00000000000..d6823901bbf
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/index.ts
@@ -0,0 +1 @@
+export { TasksTableView } from "./TasksTableView";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/AssigneeCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/AssigneeCell.tsx
new file mode 100644
index 00000000000..ffbd687e625
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/AssigneeCell.tsx
@@ -0,0 +1,156 @@
+import { useState, useMemo } from "react";
+import type { CellContext } from "@tanstack/react-table";
+import type { SelectTask } from "@superset/db/schema";
+import { useLiveQuery } from "@tanstack/react-db";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@superset/ui/dropdown-menu";
+import { Input } from "@superset/ui/input";
+import { Button } from "@superset/ui/button";
+import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar";
+import { useCollections } from "renderer/contexts/CollectionsProvider";
+
+interface AssigneeCellProps {
+ info: CellContext;
+}
+
+export function AssigneeCell({ info }: AssigneeCellProps) {
+ const collections = useCollections();
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const task = info.row.original;
+ const assigneeId = info.getValue();
+
+ // Lazy load users only when dropdown opens
+ const { data: allUsers } = useLiveQuery(
+ (q) => (open ? q.from({ users: collections.users }) : null),
+ [collections, open],
+ );
+
+ const users = useMemo(() => allUsers || [], [allUsers]);
+
+ // Find current assignee
+ const currentAssignee = useMemo(() => {
+ if (!assigneeId) return null;
+ return users.find((u) => u.id === assigneeId);
+ }, [assigneeId, users]);
+
+ // Filter users based on search query
+ const filteredUsers = useMemo(() => {
+ const query = searchQuery.toLowerCase();
+ return users.filter(
+ (user) =>
+ user.name.toLowerCase().includes(query) ||
+ user.email.toLowerCase().includes(query),
+ );
+ }, [searchQuery, users]);
+
+ const handleSelectUser = async (userId: string | null) => {
+ if (userId === assigneeId) {
+ setOpen(false);
+ return;
+ }
+
+ try {
+ await collections.tasks.update(task.id, (draft) => {
+ draft.assigneeId = userId;
+ });
+ setOpen(false);
+ setSearchQuery("");
+ } catch (error) {
+ console.error("[AssigneeCell] Failed to update assignee:", error);
+ }
+ };
+
+ const getInitials = (name: string) => {
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+ };
+
+ return (
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-8"
+ autoFocus
+ />
+
+
+
+
handleSelectUser(null)}
+ className="flex items-center gap-2"
+ >
+
+ Unassigned
+ {!assigneeId && (
+ ✓
+ )}
+
+ {filteredUsers.map((user) => (
+
handleSelectUser(user.id)}
+ className="flex items-center gap-2"
+ >
+
+ {user.image && }
+
+ {getInitials(user.name)}
+
+
+
+ {user.name}
+
+ {user.email}
+
+
+ {user.id === assigneeId && (
+
+ ✓
+
+ )}
+
+ ))}
+ {filteredUsers.length === 0 && searchQuery && (
+
+ No users found
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/index.ts
new file mode 100644
index 00000000000..045be14c7e1
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/AssigneeCell/index.ts
@@ -0,0 +1 @@
+export { AssigneeCell } from "./AssigneeCell";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx
new file mode 100644
index 00000000000..688ea21c3ad
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx
@@ -0,0 +1,134 @@
+import { useState, useMemo } from "react";
+import type { CellContext } from "@tanstack/react-table";
+import type { SelectTask } from "@superset/db/schema";
+import { useLiveQuery } from "@tanstack/react-db";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuCheckboxItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@superset/ui/dropdown-menu";
+import { Input } from "@superset/ui/input";
+import { Button } from "@superset/ui/button";
+import { Badge } from "@superset/ui/badge";
+import { useCollections } from "renderer/contexts/CollectionsProvider";
+import { HiPlus } from "react-icons/hi2";
+
+interface LabelsCellProps {
+ info: CellContext;
+}
+
+export function LabelsCell({ info }: LabelsCellProps) {
+ const collections = useCollections();
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const task = info.row.original;
+ const currentLabels = info.getValue() || [];
+
+ // Lazy load all tasks to get all unique labels
+ const { data: allTasks } = useLiveQuery(
+ (q) => (open ? q.from({ tasks: collections.tasks }) : null),
+ [collections, open],
+ );
+
+ // Get all unique labels across all tasks
+ const allLabels = useMemo(() => {
+ if (!allTasks) return [];
+ const labelsSet = new Set();
+ for (const t of allTasks) {
+ if (t.labels && Array.isArray(t.labels)) {
+ for (const label of t.labels) {
+ labelsSet.add(label);
+ }
+ }
+ }
+ return Array.from(labelsSet).sort();
+ }, [allTasks]);
+
+ // Filter labels based on search query
+ const filteredLabels = useMemo(() => {
+ const query = searchQuery.toLowerCase();
+ return allLabels.filter((label) => label.toLowerCase().includes(query));
+ }, [searchQuery, allLabels]);
+
+ const handleToggleLabel = async (label: string) => {
+ try {
+ await collections.tasks.update(task.id, (draft) => {
+ if (!draft.labels) {
+ draft.labels = [label];
+ } else if (draft.labels.includes(label)) {
+ draft.labels = draft.labels.filter((l) => l !== label);
+ } else {
+ draft.labels = [...draft.labels, label];
+ }
+ });
+ } catch (error) {
+ console.error("[LabelsCell] Failed to update labels:", error);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-8"
+ autoFocus
+ />
+
+
+
+ {filteredLabels.map((label) => (
+
handleToggleLabel(label)}
+ className="text-sm"
+ >
+ {label}
+
+ ))}
+ {filteredLabels.length === 0 && (
+
+ {searchQuery
+ ? "No labels found"
+ : "No labels yet. Add labels to tasks to see them here."}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/index.ts
new file mode 100644
index 00000000000..286fc5bed38
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/index.ts
@@ -0,0 +1 @@
+export { LabelsCell } from "./LabelsCell";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx
new file mode 100644
index 00000000000..ce000dc9400
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx
@@ -0,0 +1,154 @@
+import { useState, useMemo } from "react";
+import type { CellContext } from "@tanstack/react-table";
+import type { SelectTask } from "@superset/db/schema";
+import { taskPriorityValues, type TaskPriority } from "@superset/db/enums";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@superset/ui/dropdown-menu";
+import { Input } from "@superset/ui/input";
+import { Button } from "@superset/ui/button";
+import { useCollections } from "renderer/contexts/CollectionsProvider";
+import { PRIORITY_COLORS } from "./constants";
+
+interface PriorityCellProps {
+ info: CellContext;
+}
+
+export function PriorityCell({ info }: PriorityCellProps) {
+ const collections = useCollections();
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const task = info.row.original;
+ const currentPriority = info.getValue();
+
+ // Filter priorities based on search query
+ const filteredPriorities = useMemo(() => {
+ const query = searchQuery.toLowerCase();
+ return taskPriorityValues.filter((priority: TaskPriority) =>
+ priority.toLowerCase().includes(query),
+ );
+ }, [searchQuery]);
+
+ const handleSelectPriority = async (newPriority: TaskPriority) => {
+ if (newPriority === currentPriority) {
+ setOpen(false);
+ return;
+ }
+
+ try {
+ await collections.tasks.update(task.id, (draft) => {
+ draft.priority = newPriority;
+ });
+ setOpen(false);
+ setSearchQuery("");
+ } catch (error) {
+ console.error("[PriorityCell] Failed to update priority:", error);
+ }
+ };
+
+ // Don't render anything if priority is "none"
+ if (currentPriority === "none" && !open) {
+ return (
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-8"
+ autoFocus
+ />
+
+
+ {filteredPriorities.map((priority: TaskPriority) => (
+
handleSelectPriority(priority)}
+ className="flex items-center gap-2"
+ >
+
+ {priority}
+ {priority === currentPriority && (
+
+ ✓
+
+ )}
+
+ ))}
+ {filteredPriorities.length === 0 && (
+
+ No priority found
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-8"
+ autoFocus
+ />
+
+
+ {filteredPriorities.map((priority: TaskPriority) => (
+
handleSelectPriority(priority)}
+ className="flex items-center gap-2"
+ >
+
+ {priority}
+ {priority === currentPriority && (
+ ✓
+ )}
+
+ ))}
+ {filteredPriorities.length === 0 && (
+
+ No priority found
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/constants.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/constants.ts
new file mode 100644
index 00000000000..7feb29ff5a3
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/constants.ts
@@ -0,0 +1,7 @@
+export const PRIORITY_COLORS: Record = {
+ urgent: "bg-red-500",
+ high: "bg-orange-500",
+ medium: "bg-yellow-500",
+ low: "bg-blue-500",
+ none: "bg-gray-400",
+};
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/index.ts
new file mode 100644
index 00000000000..fdea06560e7
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/index.ts
@@ -0,0 +1 @@
+export { PriorityCell } from "./PriorityCell";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx
new file mode 100644
index 00000000000..2872c07ee9f
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx
@@ -0,0 +1,105 @@
+import { useState, useMemo } from "react";
+import type { CellContext } from "@tanstack/react-table";
+import type { SelectTask } from "@superset/db/schema";
+import { taskStatusEnumValues, type TaskStatus } from "@superset/db/enums";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@superset/ui/dropdown-menu";
+import { Input } from "@superset/ui/input";
+import { Button } from "@superset/ui/button";
+import { useCollections } from "renderer/contexts/CollectionsProvider";
+import { StatusIcon, STATUS_COLORS } from "../../StatusIcon";
+
+interface StatusCellProps {
+ info: CellContext;
+}
+
+export function StatusCell({ info }: StatusCellProps) {
+ const collections = useCollections();
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const task = info.row.original;
+ const currentStatus = info.getValue();
+
+ // Filter statuses based on search query
+ const filteredStatuses = useMemo(() => {
+ const query = searchQuery.toLowerCase();
+ return taskStatusEnumValues.filter((status: TaskStatus) =>
+ status.replace("-", " ").toLowerCase().includes(query),
+ );
+ }, [searchQuery]);
+
+ const handleSelectStatus = async (newStatus: TaskStatus) => {
+ if (newStatus === currentStatus) {
+ setOpen(false);
+ return;
+ }
+
+ try {
+ await collections.tasks.update(task.id, (draft) => {
+ draft.status = newStatus;
+ });
+ setOpen(false);
+ setSearchQuery("");
+ } catch (error) {
+ console.error("[StatusCell] Failed to update status:", error);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="h-8"
+ autoFocus
+ />
+
+
+ {filteredStatuses.map((status: TaskStatus) => (
+
handleSelectStatus(status)}
+ className="flex items-center gap-2"
+ >
+
+
+ {status.replace("-", " ")}
+
+ {status === currentStatus && (
+ ✓
+ )}
+
+ ))}
+ {filteredStatuses.length === 0 && (
+
+ No status found
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/index.ts
new file mode 100644
index 00000000000..9a2999036a2
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/index.ts
@@ -0,0 +1 @@
+export { StatusCell } from "./StatusCell";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/index.ts
new file mode 100644
index 00000000000..60a6208b821
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/index.ts
@@ -0,0 +1 @@
+export { useTasksTable } from "./useTasksTable";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
new file mode 100644
index 00000000000..e599f07c9c6
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
@@ -0,0 +1,178 @@
+import { useMemo, useState } from "react";
+import {
+ type ColumnDef,
+ type ExpandedState,
+ type Table,
+ createColumnHelper,
+ getCoreRowModel,
+ getExpandedRowModel,
+ getGroupedRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import type { SelectTask } from "@superset/db/schema";
+import { useLiveQuery } from "@tanstack/react-db";
+import { format } from "date-fns";
+import { Button } from "@superset/ui/button";
+import { HiChevronDown, HiChevronRight } from "react-icons/hi2";
+import { useCollections } from "renderer/contexts/CollectionsProvider";
+import { StatusIcon, STATUS_COLORS } from "../../components/StatusIcon";
+import { StatusCell } from "../../components/cells/StatusCell";
+import { PriorityCell } from "../../components/cells/PriorityCell";
+import { AssigneeCell } from "../../components/cells/AssigneeCell";
+import { LabelsCell } from "../../components/cells/LabelsCell";
+
+const columnHelper = createColumnHelper();
+
+export function useTasksTable(): {
+ table: Table;
+ isLoading: boolean;
+} {
+ const collections = useCollections();
+ const [grouping, setGrouping] = useState(["status"]);
+ const [expanded, setExpanded] = useState(true);
+
+ const { data: allTasks, isLoading } = useLiveQuery(
+ (q) => q.from({ tasks: collections.tasks }),
+ [collections],
+ );
+
+ const data = useMemo(
+ () => allTasks?.filter((task) => task.deletedAt === null) || [],
+ [allTasks],
+ );
+
+ // TODO: Add localStorage persistence for collapsed groups
+
+ // Define columns with useMemo (following official docs pattern)
+ const columns = useMemo[]>(
+ () => [
+ // Status column (grouped) - only shows for group headers
+ columnHelper.accessor("status", {
+ header: "Status",
+ cell: (info) => {
+ const { row, cell } = info;
+
+ if (cell.getIsGrouped()) {
+ // Group header row
+ return (
+
+ );
+ }
+
+ // For leaf rows, return null - status icon is shown in title column
+ return null;
+ },
+ }),
+
+ // Task ID - simple inline rendering
+ columnHelper.accessor("slug", {
+ header: "ID",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ return (
+
+ {info.getValue()}
+
+ );
+ },
+ }),
+
+ // Title - status icon + title inline (Linear style)
+ columnHelper.accessor("title", {
+ header: "Title",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ const task = info.row.original;
+ return (
+
+ task.status } as any} />
+
+ {info.getValue()}
+
+
+ );
+ },
+ }),
+
+ // Priority - clickable dropdown
+ columnHelper.accessor("priority", {
+ header: "Priority",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ return ;
+ },
+ }),
+
+ // Assignee - clickable dropdown
+ columnHelper.accessor("assigneeId", {
+ header: "Assignee",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ return ;
+ },
+ }),
+
+ // Labels - multi-select dropdown
+ columnHelper.accessor("labels", {
+ header: "Labels",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ return ;
+ },
+ }),
+
+ // Due date - simple inline rendering
+ columnHelper.accessor("dueDate", {
+ header: "Due",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ const date = info.getValue();
+ if (!date) return null;
+ return (
+
+ {format(new Date(date), "MMM d")}
+
+ );
+ },
+ }),
+ ],
+ [],
+ );
+
+ // Create table instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: { grouping, expanded },
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
+ getCoreRowModel: getCoreRowModel(),
+ getGroupedRowModel: getGroupedRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+ autoResetExpanded: false,
+ });
+
+ return { table, isLoading };
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
index ec8598c5c26..b4ae0a476b8 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
@@ -5,6 +5,7 @@ import { useFeatureFlagEnabled } from "posthog-js/react";
import { useState } from "react";
import { HiOutlineClipboardDocumentList } from "react-icons/hi2";
import {
+ LuBoxes,
LuLayers,
LuPanelLeft,
LuPanelLeftClose,
@@ -14,6 +15,7 @@ import { useWorkspaceSidebarStore } from "renderer/stores";
import {
useCloseWorkspacesList,
useCurrentView,
+ useOpenComponents,
useOpenTasks,
useOpenWorkspacesList,
} from "renderer/stores/app-state";
@@ -32,6 +34,7 @@ export function WorkspaceSidebarHeader({
const openWorkspacesList = useOpenWorkspacesList();
const closeWorkspacesList = useCloseWorkspacesList();
const openTasks = useOpenTasks();
+ const openComponents = useOpenComponents();
const { toggleCollapsed } = useWorkspaceSidebarStore();
const [isHovering, setIsHovering] = useState(false);
const hasTasksAccess = useFeatureFlagEnabled(
@@ -40,6 +43,7 @@ export function WorkspaceSidebarHeader({
const isWorkspacesListOpen = currentView === "workspaces-list";
const isTasksOpen = currentView === "tasks";
+ const isComponentsOpen = currentView === "components";
const handleClick = () => {
if (isWorkspacesListOpen) {
@@ -129,6 +133,24 @@ export function WorkspaceSidebarHeader({
)}
+
+
+
+
+ Components
+
+
);
@@ -197,6 +219,22 @@ export function WorkspaceSidebarHeader({
)}
+
+
);
diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx
index 2a20077abb7..57118b4a7a7 100644
--- a/apps/desktop/src/renderer/screens/main/index.tsx
+++ b/apps/desktop/src/renderer/screens/main/index.tsx
@@ -32,6 +32,7 @@ import { dragDropManager } from "../../lib/dnd";
import { AppFrame } from "./components/AppFrame";
import { Background } from "./components/Background";
import { ResizablePanel } from "./components/ResizablePanel";
+import { ComponentsShowcase } from "./components/ComponentsShowcase";
import { SettingsView } from "./components/SettingsView";
import { StartView } from "./components/StartView";
import { TasksView } from "./components/TasksView";
@@ -300,6 +301,9 @@ export function MainScreen() {
if (currentView === "workspaces-list") {
return ;
}
+ if (currentView === "components") {
+ return ;
+ }
return ;
};
diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts
index 47fe07d2763..7d3ab1a813a 100644
--- a/apps/desktop/src/renderer/stores/app-state.ts
+++ b/apps/desktop/src/renderer/stores/app-state.ts
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
-export type AppView = "workspace" | "settings" | "tasks" | "workspaces-list";
+export type AppView = "workspace" | "settings" | "tasks" | "workspaces-list" | "components";
export type SettingsSection =
| "account"
| "project"
@@ -18,6 +18,7 @@ interface AppState {
isSettingsTabOpen: boolean;
isTasksTabOpen: boolean;
isWorkspacesListOpen: boolean;
+ isComponentsOpen: boolean;
settingsSection: SettingsSection;
setView: (view: AppView) => void;
openSettings: (section?: SettingsSection) => void;
@@ -28,6 +29,8 @@ interface AppState {
closeTasks: () => void;
openWorkspacesList: () => void;
closeWorkspacesList: () => void;
+ openComponents: () => void;
+ closeComponents: () => void;
}
export const useAppStore = create()(
@@ -37,6 +40,7 @@ export const useAppStore = create()(
isSettingsTabOpen: false,
isTasksTabOpen: false,
isWorkspacesListOpen: false,
+ isComponentsOpen: false,
settingsSection: "project",
setView: (view) => {
@@ -78,6 +82,14 @@ export const useAppStore = create()(
closeWorkspacesList: () => {
set({ currentView: "workspace", isWorkspacesListOpen: false });
},
+
+ openComponents: () => {
+ set({ currentView: "components", isComponentsOpen: true });
+ },
+
+ closeComponents: () => {
+ set({ currentView: "workspace", isComponentsOpen: false });
+ },
}),
{ name: "AppStore" },
),
@@ -99,3 +111,7 @@ export const useOpenWorkspacesList = () =>
useAppStore((state) => state.openWorkspacesList);
export const useCloseWorkspacesList = () =>
useAppStore((state) => state.closeWorkspacesList);
+export const useOpenComponents = () =>
+ useAppStore((state) => state.openComponents);
+export const useCloseComponents = () =>
+ useAppStore((state) => state.closeComponents);
diff --git a/bun.lock b/bun.lock
index 5e423dced2d..6d96e86f4a9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -121,7 +121,7 @@
},
"apps/desktop": {
"name": "@superset/desktop",
- "version": "0.0.51",
+ "version": "0.0.52",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -142,6 +142,7 @@
"@tanstack/electric-db-collection": "^0.2.20",
"@tanstack/react-db": "^0.1.60",
"@tanstack/react-query": "^5.90.10",
+ "@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.7.1",
"@trpc/react-query": "^11.7.1",
"@trpc/server": "^11.7.1",
@@ -1337,10 +1338,14 @@
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="],
+ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
+
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.13", "", { "dependencies": { "@tanstack/virtual-core": "3.13.13" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
+ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
+
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.13", "", {}, "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA=="],
"@theguild/remark-mermaid": ["@theguild/remark-mermaid@0.3.0", "", { "dependencies": { "mermaid": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0" } }, "sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ=="],
From 43beb678a4e92ac3196378190f01941d16f325fa Mon Sep 17 00:00:00 2001
From: Satya Patel
Date: Sat, 10 Jan 2026 16:15:29 -0800
Subject: [PATCH 02/18] WIP'
---
.../src/app/api/electric/[...path]/utils.ts | 5 +
.../api/integrations/linear/callback/route.ts | 6 +-
.../api/integrations/linear/connect/route.ts | 3 +-
.../linear/jobs/initial-sync/route.ts | 42 +-
.../jobs/initial-sync/syncWorkflowStates.ts | 90 +
.../linear/jobs/initial-sync/utils.ts | 65 +-
.../linear/jobs/sync-task/route.ts | 16 +-
.../api/integrations/linear/webhook/route.ts | 22 +-
.../CollectionsProvider/collections.ts | 20 +-
.../components/ComponentsShowcase/index.tsx | 33 +
.../components/StatusIcon/StatusIcon.tsx | 41 +-
.../cells/StatusCell/StatusCell.tsx | 60 +-
.../hooks/useTasksTable/useTasksTable.tsx | 73 +-
.../0008_add_full_task_status_table.sql | 29 +
packages/db/drizzle/meta/0008_snapshot.json | 1659 +++++++++++++++++
packages/db/drizzle/meta/_journal.json | 7 +
packages/db/src/schema/relations.ts | 23 +-
packages/db/src/schema/schema.ts | 51 +-
packages/trpc/src/router/task/schema.ts | 4 +-
19 files changed, 2177 insertions(+), 72 deletions(-)
create mode 100644 apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts
create mode 100644 packages/db/drizzle/0008_add_full_task_status_table.sql
create mode 100644 packages/db/drizzle/meta/0008_snapshot.json
diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts
index ba2b1d05ff3..4a89029accf 100644
--- a/apps/api/src/app/api/electric/[...path]/utils.ts
+++ b/apps/api/src/app/api/electric/[...path]/utils.ts
@@ -4,6 +4,7 @@ import {
organizations,
repositories,
tasks,
+ taskStatuses,
users,
} from "@superset/db/schema";
import { eq, inArray, sql } from "drizzle-orm";
@@ -12,6 +13,7 @@ import { QueryBuilder } from "drizzle-orm/pg-core";
export type AllowedTable =
| "tasks"
+ | "task_statuses"
| "repositories"
| "auth.members"
| "auth.organizations"
@@ -42,6 +44,9 @@ export async function buildWhereClause(
case "tasks":
return build(tasks, tasks.organizationId, organizationId);
+ case "task_statuses":
+ return build(taskStatuses, taskStatuses.organizationId, organizationId);
+
case "repositories":
return build(repositories, repositories.organizationId, organizationId);
diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts
index f41e4954d00..8bae5376c85 100644
--- a/apps/api/src/app/api/integrations/linear/callback/route.ts
+++ b/apps/api/src/app/api/integrations/linear/callback/route.ts
@@ -49,7 +49,8 @@ export async function GET(request: Request) {
grant_type: "authorization_code",
client_id: env.LINEAR_CLIENT_ID,
client_secret: env.LINEAR_CLIENT_SECRET,
- redirect_uri: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/callback`,
+ // TODO: Revert to env.NEXT_PUBLIC_API_URL after testing
+ redirect_uri: "https://b02ef5887783.ngrok-free.app/api/integrations/linear/callback",
code,
}),
});
@@ -99,7 +100,8 @@ export async function GET(request: Request) {
},
});
- const qstashBaseUrl = env.NEXT_PUBLIC_API_URL;
+ // TODO: Revert to env.NEXT_PUBLIC_API_URL after testing
+ const qstashBaseUrl = "https://b02ef5887783.ngrok-free.app";
try {
await qstash.publishJSON({
url: `${qstashBaseUrl}/api/integrations/linear/jobs/initial-sync`,
diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts
index 6a8d123bbf8..59f3e60431d 100644
--- a/apps/api/src/app/api/integrations/linear/connect/route.ts
+++ b/apps/api/src/app/api/integrations/linear/connect/route.ts
@@ -45,7 +45,8 @@ export async function GET(request: Request) {
linearAuthUrl.searchParams.set("client_id", env.LINEAR_CLIENT_ID);
linearAuthUrl.searchParams.set(
"redirect_uri",
- `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/callback`,
+ // TODO: Revert to env.NEXT_PUBLIC_API_URL after testing
+ "https://b02ef5887783.ngrok-free.app/api/integrations/linear/callback",
);
linearAuthUrl.searchParams.set("response_type", "code");
linearAuthUrl.searchParams.set("scope", "read,write,issues:create");
diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts
index 1c2422749c9..67d239a218c 100644
--- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts
+++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts
@@ -1,11 +1,17 @@
import { LinearClient } from "@linear/sdk";
import { buildConflictUpdateColumns, db } from "@superset/db";
-import { integrationConnections, tasks, users } from "@superset/db/schema";
+import {
+ integrationConnections,
+ tasks,
+ taskStatuses,
+ users,
+} from "@superset/db/schema";
import { Receiver } from "@upstash/qstash";
import { and, eq, inArray } from "drizzle-orm";
import chunk from "lodash.chunk";
import { z } from "zod";
import { env } from "@/env";
+import { syncWorkflowStates } from "./syncWorkflowStates";
import { fetchAllIssues, mapIssueToTask } from "./utils";
const BATCH_SIZE = 100;
@@ -28,7 +34,8 @@ export async function POST(request: Request) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}
- const qstashBaseUrl = env.NEXT_PUBLIC_API_URL;
+ // TODO: Revert to env.NEXT_PUBLIC_API_URL after testing
+ const qstashBaseUrl = "https://b02ef5887783.ngrok-free.app";
const isValid = await receiver.verify({
body,
signature,
@@ -68,6 +75,25 @@ async function performInitialSync(
organizationId: string,
creatorUserId: string,
) {
+ // STEP 1: Sync workflow states FIRST
+ console.log("[initial-sync] Syncing workflow states");
+ await syncWorkflowStates({ client, organizationId });
+
+ // STEP 2: Load all statuses into a lookup map (avoid N+1)
+ console.log("[initial-sync] Loading status lookup map");
+ const statusByExternalId = new Map(); // externalId -> statusId
+ const statuses = await db.query.taskStatuses.findMany({
+ where: and(
+ eq(taskStatuses.organizationId, organizationId),
+ eq(taskStatuses.externalProvider, "linear"),
+ ),
+ });
+ for (const status of statuses) {
+ statusByExternalId.set(status.externalId!, status.id);
+ }
+
+ // STEP 3: Sync issues
+ console.log("[initial-sync] Syncing issues");
const issues = await fetchAllIssues(client);
if (issues.length === 0) {
@@ -90,7 +116,13 @@ async function performInitialSync(
const userByEmail = new Map(matchedUsers.map((u) => [u.email, u.id]));
const taskValues = issues.map((issue) =>
- mapIssueToTask(issue, organizationId, creatorUserId, userByEmail),
+ mapIssueToTask(
+ issue,
+ organizationId,
+ creatorUserId,
+ userByEmail,
+ statusByExternalId,
+ ),
);
const batches = chunk(taskValues, BATCH_SIZE);
@@ -106,9 +138,7 @@ async function performInitialSync(
"slug",
"title",
"description",
- "status",
- "statusColor",
- "statusType",
+ "statusId",
"priority",
"assigneeId",
"estimate",
diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts
new file mode 100644
index 00000000000..6c76cb71096
--- /dev/null
+++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts
@@ -0,0 +1,90 @@
+import type { LinearClient } from "@linear/sdk";
+import { and, eq } from "drizzle-orm";
+import { db } from "@superset/db/client";
+import { taskStatuses } from "@superset/db/schema";
+import { buildConflictUpdateColumns } from "@superset/db";
+import { calculateProgressForStates } from "./utils";
+
+/**
+ * Normalizes Linear's state type to our preferred US spelling
+ */
+function normalizeStateType(linearType: string): string {
+ if (linearType === "canceled") {
+ return "cancelled";
+ }
+ return linearType;
+}
+
+export async function syncWorkflowStates({
+ client,
+ organizationId,
+}: {
+ client: LinearClient;
+ organizationId: string;
+}): Promise {
+ console.log("[syncWorkflowStates] Fetching teams");
+
+ const teams = await client.teams();
+
+ for (const team of teams.nodes) {
+ console.log(`[syncWorkflowStates] Processing team: ${team.name}`);
+
+ const states = await team.states();
+
+ // Group by type for progress calculation
+ const statesByType = new Map();
+ for (const state of states.nodes) {
+ if (!statesByType.has(state.type)) {
+ statesByType.set(state.type, []);
+ }
+ statesByType.get(state.type)!.push(state);
+ }
+
+ // Calculate progress for "started" type
+ const startedStates = statesByType.get("started") || [];
+ const progressMap = calculateProgressForStates(
+ startedStates.map((s) => ({ name: s.name, position: s.position })),
+ );
+
+ // Prepare insert values
+ const values = states.nodes.map((state) => ({
+ organizationId,
+ name: state.name,
+ color: state.color,
+ type: normalizeStateType(state.type),
+ position: state.position,
+ progressPercent:
+ state.type === "started" ? progressMap.get(state.name) ?? null : null,
+ externalProvider: "linear" as const,
+ externalId: state.id,
+ }));
+
+ // Upsert workflow states
+ if (values.length > 0) {
+ await db
+ .insert(taskStatuses)
+ .values(values)
+ .onConflictDoUpdate({
+ target: [
+ taskStatuses.organizationId,
+ taskStatuses.externalProvider,
+ taskStatuses.externalId,
+ ],
+ set: {
+ ...buildConflictUpdateColumns(taskStatuses, [
+ "name",
+ "color",
+ "type",
+ "position",
+ "progressPercent",
+ ]),
+ updatedAt: new Date(),
+ },
+ });
+ }
+
+ console.log(
+ `[syncWorkflowStates] Synced ${values.length} states for team ${team.name}`,
+ );
+ }
+}
diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts
index e86c77c1093..17bebe604e8 100644
--- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts
+++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts
@@ -13,7 +13,7 @@ export interface LinearIssue {
startedAt: string | null;
completedAt: string | null;
assignee: { id: string; email: string } | null;
- state: { id: string; name: string; color: string; type: string };
+ state: { id: string; name: string; color: string; type: string; position: number };
labels: { nodes: Array<{ id: string; name: string }> };
}
@@ -24,6 +24,51 @@ interface IssuesQueryResponse {
};
}
+interface WorkflowStateWithPosition {
+ name: string;
+ position: number;
+}
+
+/**
+ * Calculates progress percentage for "started" type workflow states
+ * using Linear's rendering formula:
+ * - 1 state: 50%
+ * - 2 states: [50%, 75%]
+ * - 3+ states: evenly spaced using (index + 1) / (total + 1)
+ */
+export function calculateProgressForStates(
+ states: WorkflowStateWithPosition[],
+): Map {
+ const progressMap = new Map();
+
+ if (states.length === 0) {
+ return progressMap;
+ }
+
+ // Sort by position to get Linear's intended order
+ const sorted = [...states].sort((a, b) => a.position - b.position);
+
+ const total = sorted.length;
+
+ for (let i = 0; i < total; i++) {
+ const state = sorted[i];
+ let progress: number;
+
+ if (total === 1) {
+ progress = 50; // 50%
+ } else if (total === 2) {
+ progress = i === 0 ? 50 : 75; // [50%, 75%]
+ } else {
+ // 3+ states: evenly spaced (index + 1) / (total + 1)
+ progress = ((i + 1) / (total + 1)) * 100;
+ }
+
+ progressMap.set(state.name, Math.round(progress));
+ }
+
+ return progressMap;
+}
+
const ISSUES_QUERY = `
query Issues($first: Int!, $after: String, $filter: IssueFilter) {
issues(first: $first, after: $after, filter: $filter) {
@@ -51,6 +96,7 @@ const ISSUES_QUERY = `
name
color
type
+ position
}
labels {
nodes {
@@ -69,6 +115,10 @@ export async function fetchAllIssues(
const allIssues: LinearIssue[] = [];
let cursor: string | undefined;
+ // Fetch tasks updated in the last 3 months
+ const threeMonthsAgo = new Date();
+ threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+
do {
const response = await client.client.request<
IssuesQueryResponse,
@@ -76,7 +126,7 @@ export async function fetchAllIssues(
>(ISSUES_QUERY, {
first: 100,
after: cursor,
- filter: { state: { type: { nin: ["canceled", "completed"] } } },
+ filter: { updatedAt: { gte: threeMonthsAgo.toISOString() } },
});
allIssues.push(...response.issues.nodes);
cursor =
@@ -93,20 +143,25 @@ export function mapIssueToTask(
organizationId: string,
creatorId: string,
userByEmail: Map,
+ statusByExternalId: Map,
) {
const assigneeId = issue.assignee?.email
? (userByEmail.get(issue.assignee.email) ?? null)
: null;
+ // Look up statusId from pre-loaded map (no DB query)
+ const statusId = statusByExternalId.get(issue.state.id);
+ if (!statusId) {
+ throw new Error(`Status not found for state ${issue.state.id}`);
+ }
+
return {
organizationId,
creatorId,
slug: issue.identifier,
title: issue.title,
description: issue.description,
- status: issue.state.name,
- statusColor: issue.state.color,
- statusType: issue.state.type,
+ statusId, // FK to task_statuses
priority: mapPriorityFromLinear(issue.priority),
assigneeId,
estimate: issue.estimate,
diff --git a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts
index e1c3680afe0..593d7a2c6be 100644
--- a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts
+++ b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts
@@ -1,7 +1,7 @@
import type { LinearClient, WorkflowState } from "@linear/sdk";
import { db } from "@superset/db/client";
import type { LinearConfig, SelectTask } from "@superset/db/schema";
-import { integrationConnections, tasks } from "@superset/db/schema";
+import { integrationConnections, tasks, taskStatuses } from "@superset/db/schema";
import {
getLinearClient,
mapPriorityToLinear,
@@ -69,7 +69,16 @@ async function syncTaskToLinear(
}
try {
- const stateId = await findLinearState(client, teamId, task.status);
+ // Look up the status from task_statuses table
+ const taskStatus = await db.query.taskStatuses.findFirst({
+ where: eq(taskStatuses.id, task.statusId),
+ });
+
+ if (!taskStatus) {
+ return { success: false, error: "Task status not found" };
+ }
+
+ const stateId = await findLinearState(client, teamId, taskStatus.name);
if (task.externalProvider === "linear" && task.externalId) {
const result = await client.updateIssue(task.externalId, {
@@ -164,7 +173,8 @@ export async function POST(request: Request) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}
- const qstashBaseUrl = env.NEXT_PUBLIC_API_URL;
+ // TODO: Revert to env.NEXT_PUBLIC_API_URL after testing
+ const qstashBaseUrl = "https://b02ef5887783.ngrok-free.app";
const isValid = await receiver.verify({
body,
signature,
diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.ts b/apps/api/src/app/api/integrations/linear/webhook/route.ts
index d820c6a626e..e4521000eab 100644
--- a/apps/api/src/app/api/integrations/linear/webhook/route.ts
+++ b/apps/api/src/app/api/integrations/linear/webhook/route.ts
@@ -8,6 +8,7 @@ import type { SelectIntegrationConnection } from "@superset/db/schema";
import {
integrationConnections,
tasks,
+ taskStatuses,
users,
webhookEvents,
} from "@superset/db/schema";
@@ -92,6 +93,23 @@ async function processIssueEvent(
const issue = payload.data;
if (payload.action === "create" || payload.action === "update") {
+ // Look up statusId from task_statuses
+ const taskStatus = await db.query.taskStatuses.findFirst({
+ where: and(
+ eq(taskStatuses.organizationId, connection.organizationId),
+ eq(taskStatuses.externalProvider, "linear"),
+ eq(taskStatuses.externalId, issue.state.id),
+ ),
+ });
+
+ if (!taskStatus) {
+ // Status doesn't exist yet - need to sync workflow states
+ console.warn(
+ `[webhook] Status not found for state ${issue.state.id}, skipping update`,
+ );
+ return;
+ }
+
let assigneeId: string | null = null;
if (issue.assignee?.email) {
const matchedUser = await db.query.users.findFirst({
@@ -104,9 +122,7 @@ async function processIssueEvent(
slug: issue.identifier,
title: issue.title,
description: issue.description ?? null,
- status: issue.state.name,
- statusColor: issue.state.color,
- statusType: issue.state.type,
+ statusId: taskStatus.id, // FK reference
priority: mapPriorityFromLinear(issue.priority),
assigneeId,
estimate: issue.estimate ?? null,
diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts
index b8901878c20..0712242a1e0 100644
--- a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts
+++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts
@@ -4,6 +4,7 @@ import type {
SelectOrganization,
SelectRepository,
SelectTask,
+ SelectTaskStatus,
SelectUser,
} from "@superset/db/schema";
import type { AppRouter } from "@superset/trpc";
@@ -19,6 +20,7 @@ const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape`;
interface OrgCollections {
tasks: Collection;
+ taskStatuses: Collection;
repositories: Collection;
members: Collection;
users: Collection;
@@ -80,6 +82,22 @@ function createOrgCollections(
}),
);
+ const taskStatuses = createCollection(
+ electricCollectionOptions({
+ id: `task_statuses-${organizationId}`,
+ shapeOptions: {
+ url: electricUrl,
+ params: {
+ table: "task_statuses",
+ organization: organizationId,
+ },
+ headers,
+ columnMapper,
+ },
+ getKey: (item) => item.id,
+ }),
+ );
+
const repositories = createCollection(
electricCollectionOptions({
id: `repositories-${organizationId}`,
@@ -138,7 +156,7 @@ function createOrgCollections(
}),
);
- return { tasks, repositories, members, users };
+ return { tasks, taskStatuses, repositories, members, users };
}
function getOrCreateOrganizationsCollection(
diff --git a/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx b/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx
index 593d504e6f0..5c2b2a19083 100644
--- a/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/ComponentsShowcase/index.tsx
@@ -200,6 +200,39 @@ export function ComponentsShowcase() {
Hover over any icon to see the brightness effect
+
+ {/* Progress Variants for Started Type */}
+
+
Started Progress Variants
+
+ Visual progress indication for "started" type statuses
+
+
+
+
+ 25%
+
+
+
+ 50%
+
+
+
+ 75%
+
+
+
+ 83%
+
+
+
+ 100%
+
+
+
+ Progress is calculated from Linear workflow state positions during sync
+
+
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
index bf87a5015a6..8dbaf6d1585 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
@@ -12,6 +12,7 @@ interface StatusIconProps {
color: string;
showHover?: boolean;
className?: string;
+ progress?: number;
}
export function StatusIcon({
@@ -19,6 +20,7 @@ export function StatusIcon({
color,
showHover = false,
className,
+ progress,
}: StatusIconProps) {
const sizeClass = "w-3.5 h-3.5";
@@ -68,6 +70,15 @@ export function StatusIcon({
}
if (type === "started") {
+ // Progress fills counter-clockwise starting from 12 o'clock
+ const centerRadius = 2;
+ const centerCircumference = 2 * Math.PI * centerRadius;
+ const progressPercent = progress ?? 100;
+
+ // Dash length is the visible portion (progress%)
+ const dashLength = (progressPercent / 100) * centerCircumference;
+ const gapLength = centerCircumference - dashLength;
+
return (
);
@@ -121,6 +142,10 @@ export function StatusIcon({
}
if (type === "cancelled") {
+ // Middle ring is 50% filled (matches Linear)
+ const middleRadius = 3;
+ const middleCircumference = 2 * Math.PI * middleRadius;
+
return (
- {filteredStatuses.map((status: TaskStatus) => (
+ {filteredStatuses.map((status) => (
handleSelectStatus(status)}
className="flex items-center gap-2"
>
-
- {status.replace("-", " ")}
-
- {status === currentStatus && (
+ {status.name}
+ {status.id === currentStatus.id && (
✓
)}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
index e599f07c9c6..8ccce1a167d 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
@@ -9,48 +9,87 @@ import {
getGroupedRowModel,
useReactTable,
} from "@tanstack/react-table";
-import type { SelectTask } from "@superset/db/schema";
+import type { SelectTask, SelectTaskStatus } from "@superset/db/schema";
import { useLiveQuery } from "@tanstack/react-db";
import { format } from "date-fns";
import { Button } from "@superset/ui/button";
import { HiChevronDown, HiChevronRight } from "react-icons/hi2";
import { useCollections } from "renderer/contexts/CollectionsProvider";
-import { StatusIcon, STATUS_COLORS } from "../../components/StatusIcon";
+import { StatusIcon } from "../../components/StatusIcon";
import { StatusCell } from "../../components/cells/StatusCell";
import { PriorityCell } from "../../components/cells/PriorityCell";
import { AssigneeCell } from "../../components/cells/AssigneeCell";
import { LabelsCell } from "../../components/cells/LabelsCell";
-const columnHelper = createColumnHelper();
+// Task with joined status data
+type TaskWithStatus = SelectTask & {
+ status: SelectTaskStatus;
+};
+
+const columnHelper = createColumnHelper();
export function useTasksTable(): {
- table: Table;
+ table: Table;
isLoading: boolean;
} {
const collections = useCollections();
const [grouping, setGrouping] = useState(["status"]);
const [expanded, setExpanded] = useState(true);
- const { data: allTasks, isLoading } = useLiveQuery(
+ // Load tasks and statuses separately
+ const { data: allTasks, isLoading: tasksLoading } = useLiveQuery(
(q) => q.from({ tasks: collections.tasks }),
[collections],
);
- const data = useMemo(
- () => allTasks?.filter((task) => task.deletedAt === null) || [],
- [allTasks],
+ const { data: allStatuses, isLoading: statusesLoading } = useLiveQuery(
+ (q) => q.from({ taskStatuses: collections.taskStatuses }),
+ [collections],
);
+ // Client-side join: merge tasks with their status
+ const data = useMemo(() => {
+ if (!allTasks || !allStatuses) return [];
+
+ const statusMap = new Map(allStatuses.map((s) => [s.id, s]));
+
+ return allTasks
+ .filter((task) => task.deletedAt === null)
+ .map((task) => {
+ const status = statusMap.get(task.statusId);
+ if (!status) {
+ console.warn(`[useTasksTable] Status not found for task ${task.id}`);
+ // Provide a fallback status to prevent crashes
+ return {
+ ...task,
+ status: {
+ id: task.statusId,
+ name: "Unknown",
+ color: "#8B5CF6",
+ type: "unstarted",
+ position: 0,
+ progressPercent: null,
+ } as SelectTaskStatus,
+ };
+ }
+ return { ...task, status };
+ });
+ }, [allTasks, allStatuses]);
+
+ const isLoading = tasksLoading || statusesLoading;
+
// TODO: Add localStorage persistence for collapsed groups
// Define columns with useMemo (following official docs pattern)
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
// Status column (grouped) - only shows for group headers
- columnHelper.accessor("status", {
+ columnHelper.accessor((row) => row.status, {
+ id: "status",
header: "Status",
cell: (info) => {
const { row, cell } = info;
+ const status = info.getValue();
if (cell.getIsGrouped()) {
// Group header row
@@ -67,14 +106,15 @@ export function useTasksTable(): {
)}
- {info.getValue().replace("-", " ")}
+ {status.name}
- ({row.subRows.length})
+ {row.subRows.length}
@@ -84,6 +124,7 @@ export function useTasksTable(): {
// For leaf rows, return null - status icon is shown in title column
return null;
},
+ getGroupingValue: (row) => row.status.name,
}),
// Task ID - simple inline rendering
@@ -104,10 +145,10 @@ export function useTasksTable(): {
header: "Title",
cell: (info) => {
if (info.cell.getIsPlaceholder()) return null;
- const task = info.row.original;
+ const taskWithStatus = info.row.original;
return (
-
task.status } as any} />
+
{info.getValue()}
diff --git a/packages/db/drizzle/0008_add_full_task_status_table.sql b/packages/db/drizzle/0008_add_full_task_status_table.sql
new file mode 100644
index 00000000000..37b62d929e7
--- /dev/null
+++ b/packages/db/drizzle/0008_add_full_task_status_table.sql
@@ -0,0 +1,29 @@
+-- Clean up existing tasks before schema change
+DELETE FROM "tasks";
+--> statement-breakpoint
+CREATE TABLE "task_statuses" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "name" text NOT NULL,
+ "color" text NOT NULL,
+ "type" text NOT NULL,
+ "position" real NOT NULL,
+ "progress_percent" real,
+ "external_provider" "integration_provider",
+ "external_id" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "task_statuses_org_external_unique" UNIQUE("organization_id","external_provider","external_id")
+);
+--> statement-breakpoint
+DROP INDEX "tasks_status_idx";--> statement-breakpoint
+ALTER TABLE "tasks" ADD COLUMN "status_id" uuid NOT NULL;--> statement-breakpoint
+ALTER TABLE "task_statuses" ADD CONSTRAINT "task_statuses_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "task_statuses_organization_id_idx" ON "task_statuses" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "task_statuses_type_idx" ON "task_statuses" USING btree ("type");--> statement-breakpoint
+ALTER TABLE "tasks" ADD CONSTRAINT "tasks_status_id_task_statuses_id_fk" FOREIGN KEY ("status_id") REFERENCES "public"."task_statuses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "tasks_status_id_idx" ON "tasks" USING btree ("status_id");--> statement-breakpoint
+ALTER TABLE "tasks" DROP COLUMN "status";--> statement-breakpoint
+ALTER TABLE "tasks" DROP COLUMN "status_color";--> statement-breakpoint
+ALTER TABLE "tasks" DROP COLUMN "status_type";--> statement-breakpoint
+ALTER TABLE "tasks" DROP COLUMN "status_position";
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0008_snapshot.json b/packages/db/drizzle/meta/0008_snapshot.json
new file mode 100644
index 00000000000..d951561ab05
--- /dev/null
+++ b/packages/db/drizzle/meta/0008_snapshot.json
@@ -0,0 +1,1659 @@
+{
+ "id": "a303ef94-785d-41b4-ae28-defe0ec99e61",
+ "prevId": "fa87a301-b667-4c9c-9e1f-15b9d9130bb0",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.accounts": {
+ "name": "accounts",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "accounts_user_id_idx": {
+ "name": "accounts_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitations": {
+ "name": "invitations",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitations_organization_id_idx": {
+ "name": "invitations_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitations_email_idx": {
+ "name": "invitations_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitations_organization_id_organizations_id_fk": {
+ "name": "invitations_organization_id_organizations_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "organizations",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitations_inviter_id_users_id_fk": {
+ "name": "invitations_inviter_id_users_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.members": {
+ "name": "members",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "members_organization_id_idx": {
+ "name": "members_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "members_user_id_idx": {
+ "name": "members_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "members_organization_id_organizations_id_fk": {
+ "name": "members_organization_id_organizations_id_fk",
+ "tableFrom": "members",
+ "tableTo": "organizations",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "members_user_id_users_id_fk": {
+ "name": "members_user_id_users_id_fk",
+ "tableFrom": "members",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organizations": {
+ "name": "organizations",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "organizations_slug_idx": {
+ "name": "organizations_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organizations_slug_unique": {
+ "name": "organizations_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.sessions": {
+ "name": "sessions",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.users": {
+ "name": "users",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verifications": {
+ "name": "verifications",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verifications_identifier_idx": {
+ "name": "verifications_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "ingest.webhook_events": {
+ "name": "webhook_events",
+ "schema": "ingest",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "integration_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retry_count": {
+ "name": "retry_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "webhook_events_provider_status_idx": {
+ "name": "webhook_events_provider_status_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "webhook_events_provider_event_id_idx": {
+ "name": "webhook_events_provider_event_id_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "webhook_events_received_at_idx": {
+ "name": "webhook_events_received_at_idx",
+ "columns": [
+ {
+ "expression": "received_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration_connections": {
+ "name": "integration_connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_by_user_id": {
+ "name": "connected_by_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "integration_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_org_id": {
+ "name": "external_org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_org_name": {
+ "name": "external_org_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "integration_connections_org_idx": {
+ "name": "integration_connections_org_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integration_connections_organization_id_organizations_id_fk": {
+ "name": "integration_connections_organization_id_organizations_id_fk",
+ "tableFrom": "integration_connections",
+ "tableTo": "organizations",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_connections_connected_by_user_id_users_id_fk": {
+ "name": "integration_connections_connected_by_user_id_users_id_fk",
+ "tableFrom": "integration_connections",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "connected_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "integration_connections_unique": {
+ "name": "integration_connections_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "organization_id",
+ "provider"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.repositories": {
+ "name": "repositories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repo_url": {
+ "name": "repo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repo_owner": {
+ "name": "repo_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repo_name": {
+ "name": "repo_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'main'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "repositories_organization_id_idx": {
+ "name": "repositories_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "repositories_slug_idx": {
+ "name": "repositories_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "repositories_organization_id_organizations_id_fk": {
+ "name": "repositories_organization_id_organizations_id_fk",
+ "tableFrom": "repositories",
+ "tableTo": "organizations",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "repositories_org_slug_unique": {
+ "name": "repositories_org_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "organization_id",
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.task_statuses": {
+ "name": "task_statuses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "progress_percent": {
+ "name": "progress_percent",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_provider": {
+ "name": "external_provider",
+ "type": "integration_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "task_statuses_organization_id_idx": {
+ "name": "task_statuses_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "task_statuses_type_idx": {
+ "name": "task_statuses_type_idx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "task_statuses_organization_id_organizations_id_fk": {
+ "name": "task_statuses_organization_id_organizations_id_fk",
+ "tableFrom": "task_statuses",
+ "tableTo": "organizations",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "task_statuses_org_external_unique": {
+ "name": "task_statuses_org_external_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "organization_id",
+ "external_provider",
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tasks": {
+ "name": "tasks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_id": {
+ "name": "status_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "task_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignee_id": {
+ "name": "assignee_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "estimate": {
+ "name": "estimate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "due_date": {
+ "name": "due_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "labels": {
+ "name": "labels",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pr_url": {
+ "name": "pr_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_provider": {
+ "name": "external_provider",
+ "type": "integration_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_key": {
+ "name": "external_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_url": {
+ "name": "external_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_error": {
+ "name": "sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tasks_slug_idx": {
+ "name": "tasks_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_organization_id_idx": {
+ "name": "tasks_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_repository_id_idx": {
+ "name": "tasks_repository_id_idx",
+ "columns": [
+ {
+ "expression": "repository_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_assignee_id_idx": {
+ "name": "tasks_assignee_id_idx",
+ "columns": [
+ {
+ "expression": "assignee_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_creator_id_idx": {
+ "name": "tasks_creator_id_idx",
+ "columns": [
+ {
+ "expression": "creator_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_status_id_idx": {
+ "name": "tasks_status_id_idx",
+ "columns": [
+ {
+ "expression": "status_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_created_at_idx": {
+ "name": "tasks_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tasks_external_provider_idx": {
+ "name": "tasks_external_provider_idx",
+ "columns": [
+ {
+ "expression": "external_provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tasks_status_id_task_statuses_id_fk": {
+ "name": "tasks_status_id_task_statuses_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "task_statuses",
+ "columnsFrom": [
+ "status_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tasks_organization_id_organizations_id_fk": {
+ "name": "tasks_organization_id_organizations_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "organizations",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tasks_repository_id_repositories_id_fk": {
+ "name": "tasks_repository_id_repositories_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "repositories",
+ "columnsFrom": [
+ "repository_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tasks_assignee_id_users_id_fk": {
+ "name": "tasks_assignee_id_users_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "assignee_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tasks_creator_id_users_id_fk": {
+ "name": "tasks_creator_id_users_id_fk",
+ "tableFrom": "tasks",
+ "tableTo": "users",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "creator_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tasks_slug_unique": {
+ "name": "tasks_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ },
+ "tasks_external_unique": {
+ "name": "tasks_external_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_provider",
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.integration_provider": {
+ "name": "integration_provider",
+ "schema": "public",
+ "values": [
+ "linear"
+ ]
+ },
+ "public.task_priority": {
+ "name": "task_priority",
+ "schema": "public",
+ "values": [
+ "urgent",
+ "high",
+ "medium",
+ "low",
+ "none"
+ ]
+ },
+ "public.task_status": {
+ "name": "task_status",
+ "schema": "public",
+ "values": [
+ "backlog",
+ "todo",
+ "planning",
+ "working",
+ "needs-feedback",
+ "ready-to-merge",
+ "completed",
+ "canceled"
+ ]
+ }
+ },
+ "schemas": {
+ "auth": "auth",
+ "ingest": "ingest"
+ },
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index b6e6ceabfa2..17fb696a222 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -57,6 +57,13 @@
"when": 1767049518603,
"tag": "0007_add_created_at_default",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "7",
+ "when": 1768087327155,
+ "tag": "0008_add_full_task_status_table",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts
index 0528be97677..acff0f3fb0f 100644
--- a/packages/db/src/schema/relations.ts
+++ b/packages/db/src/schema/relations.ts
@@ -8,7 +8,12 @@ import {
sessions,
users,
} from "./auth";
-import { integrationConnections, repositories, tasks } from "./schema";
+import {
+ integrationConnections,
+ repositories,
+ tasks,
+ taskStatuses,
+} from "./schema";
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
@@ -39,6 +44,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({
invitations: many(invitations),
repositories: many(repositories),
tasks: many(tasks),
+ taskStatuses: many(taskStatuses),
integrations: many(integrationConnections),
}));
@@ -84,6 +90,10 @@ export const tasksRelations = relations(tasks, ({ one }) => ({
fields: [tasks.organizationId],
references: [organizations.id],
}),
+ status: one(taskStatuses, {
+ fields: [tasks.statusId],
+ references: [taskStatuses.id],
+ }),
assignee: one(users, {
fields: [tasks.assigneeId],
references: [users.id],
@@ -96,6 +106,17 @@ export const tasksRelations = relations(tasks, ({ one }) => ({
}),
}));
+export const taskStatusesRelations = relations(
+ taskStatuses,
+ ({ one, many }) => ({
+ organization: one(organizations, {
+ fields: [taskStatuses.organizationId],
+ references: [organizations.id],
+ }),
+ tasks: many(tasks),
+ }),
+);
+
export const integrationConnectionsRelations = relations(
integrationConnections,
({ one }) => ({
diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts
index c85b2d194fe..b4248566d1e 100644
--- a/packages/db/src/schema/schema.ts
+++ b/packages/db/src/schema/schema.ts
@@ -55,6 +55,48 @@ export const repositories = pgTable(
export type InsertRepository = typeof repositories.$inferInsert;
export type SelectRepository = typeof repositories.$inferSelect;
+export const taskStatuses = pgTable(
+ "task_statuses",
+ {
+ id: uuid().primaryKey().defaultRandom(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+
+ // Core fields from Linear WorkflowState
+ name: text().notNull(),
+ color: text().notNull(),
+ type: text().notNull(), // "backlog" | "unstarted" | "started" | "completed" | "canceled"
+ position: real().notNull(),
+
+ // Calculated progress for "started" type
+ progressPercent: real("progress_percent"), // 0-100, only for "started" type
+
+ // External sync
+ externalProvider: integrationProvider("external_provider"),
+ externalId: text("external_id"),
+
+ // Timestamps
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ updatedAt: timestamp("updated_at")
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ },
+ (table) => [
+ index("task_statuses_organization_id_idx").on(table.organizationId),
+ index("task_statuses_type_idx").on(table.type),
+ unique("task_statuses_org_external_unique").on(
+ table.organizationId,
+ table.externalProvider,
+ table.externalId,
+ ),
+ ],
+);
+
+export type InsertTaskStatus = typeof taskStatuses.$inferInsert;
+export type SelectTaskStatus = typeof taskStatuses.$inferSelect;
+
export const tasks = pgTable(
"tasks",
{
@@ -64,10 +106,9 @@ export const tasks = pgTable(
slug: text().notNull().unique(),
title: text().notNull(),
description: text(),
- status: text().notNull(), // Flexible text - stores any status name
- statusColor: text("status_color"),
- statusType: text("status_type"),
- statusPosition: real("status_position"),
+ statusId: uuid("status_id")
+ .notNull()
+ .references(() => taskStatuses.id),
priority: taskPriority().notNull().default("none"),
// Ownership
@@ -118,7 +159,7 @@ export const tasks = pgTable(
index("tasks_repository_id_idx").on(table.repositoryId),
index("tasks_assignee_id_idx").on(table.assigneeId),
index("tasks_creator_id_idx").on(table.creatorId),
- index("tasks_status_idx").on(table.status),
+ index("tasks_status_id_idx").on(table.statusId),
index("tasks_created_at_idx").on(table.createdAt),
index("tasks_external_provider_idx").on(table.externalProvider),
unique("tasks_external_unique").on(
diff --git a/packages/trpc/src/router/task/schema.ts b/packages/trpc/src/router/task/schema.ts
index 4a899c62303..cfab0785130 100644
--- a/packages/trpc/src/router/task/schema.ts
+++ b/packages/trpc/src/router/task/schema.ts
@@ -5,7 +5,7 @@ export const createTaskSchema = z.object({
slug: z.string().min(1),
title: z.string().min(1),
description: z.string().nullish(),
- status: z.string().min(1).default("Backlog"),
+ statusId: z.string().uuid(),
priority: z.enum(taskPriorityValues).default("none"),
repositoryId: z.string().uuid().nullish(),
organizationId: z.string().uuid(),
@@ -20,7 +20,7 @@ export const updateTaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).optional(),
description: z.string().nullish(),
- status: z.string().optional(),
+ statusId: z.string().uuid().optional(),
priority: z.enum(taskPriorityValues).optional(),
repositoryId: z.string().uuid().nullish(),
assigneeId: z.string().uuid().nullish(),
From 76264471c49ff8170dccce96fae542de3dbd2113 Mon Sep 17 00:00:00 2001
From: Satya Patel
Date: Sat, 10 Jan 2026 16:36:21 -0800
Subject: [PATCH 03/18] WIP'
---
.../main/components/TasksView/TasksView.tsx | 4 +-
.../TasksTableView/TasksTableView.tsx | 28 ++--
.../cells/LabelsCell/LabelsCell.tsx | 135 +++---------------
.../hooks/useTasksTable/useTasksTable.tsx | 48 +++++--
4 files changed, 64 insertions(+), 151 deletions(-)
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx
index edaae24726b..353d3cc3194 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx
@@ -4,7 +4,7 @@ import { TasksTableView } from "./components/TasksTableView";
import { useTasksTable } from "./hooks/useTasksTable";
export function TasksView() {
- const { table, isLoading } = useTasksTable();
+ const { table, isLoading, slugColumnWidth } = useTasksTable();
if (isLoading) {
return (
@@ -28,7 +28,7 @@ export function TasksView() {
return (
-
+
);
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
index 4652a7c9088..8ffbc7fd980 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
@@ -3,9 +3,10 @@ import type { SelectTask } from "@superset/db/schema";
interface TasksTableViewProps {
table: Table;
+ slugColumnWidth: string;
}
-export function TasksTableView({ table }: TasksTableViewProps) {
+export function TasksTableView({ table, slugColumnWidth }: TasksTableViewProps) {
return (
{table.getRowModel().rows.map((row) => {
@@ -26,29 +27,26 @@ export function TasksTableView({ table }: TasksTableViewProps) {
);
}
- // Leaf row - render all cells horizontally
- // Layout: [ID] [Status Icon + Title .............. | Priority Assignee Labels Due]
+ // Leaf row - render all cells horizontally with grid for consistent column widths
+ // Layout: [Priority] [ID] [Title] ... [Labels] [Assignee] [Due] [gap]
// Note: Status column (index 0) returns null for leaf rows, so we skip it
const cells = row.getVisibleCells();
return (
- {/* Left side: ID + Title (with inline status) - skip null status column */}
- {cells.slice(1, 3).map((cell) => (
-
+ {/* Skip status column (index 0), render priority, id, title, labels, assignee, due */}
+ {cells.slice(1).map((cell) => (
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
- {/* Right side metadata: Priority, Assignee, Labels, Due */}
-
- {cells.slice(3).map((cell) => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ))}
-
+ {/* Empty cell for right padding/growth space */}
+
);
})}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx
index 688ea21c3ad..85c8238d794 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/LabelsCell/LabelsCell.tsx
@@ -1,134 +1,31 @@
-import { useState, useMemo } from "react";
import type { CellContext } from "@tanstack/react-table";
import type { SelectTask } from "@superset/db/schema";
-import { useLiveQuery } from "@tanstack/react-db";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuCheckboxItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@superset/ui/dropdown-menu";
-import { Input } from "@superset/ui/input";
-import { Button } from "@superset/ui/button";
import { Badge } from "@superset/ui/badge";
-import { useCollections } from "renderer/contexts/CollectionsProvider";
-import { HiPlus } from "react-icons/hi2";
interface LabelsCellProps {
info: CellContext
;
}
export function LabelsCell({ info }: LabelsCellProps) {
- const collections = useCollections();
- const [open, setOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
-
- const task = info.row.original;
const currentLabels = info.getValue() || [];
- // Lazy load all tasks to get all unique labels
- const { data: allTasks } = useLiveQuery(
- (q) => (open ? q.from({ tasks: collections.tasks }) : null),
- [collections, open],
- );
-
- // Get all unique labels across all tasks
- const allLabels = useMemo(() => {
- if (!allTasks) return [];
- const labelsSet = new Set();
- for (const t of allTasks) {
- if (t.labels && Array.isArray(t.labels)) {
- for (const label of t.labels) {
- labelsSet.add(label);
- }
- }
- }
- return Array.from(labelsSet).sort();
- }, [allTasks]);
-
- // Filter labels based on search query
- const filteredLabels = useMemo(() => {
- const query = searchQuery.toLowerCase();
- return allLabels.filter((label) => label.toLowerCase().includes(query));
- }, [searchQuery, allLabels]);
-
- const handleToggleLabel = async (label: string) => {
- try {
- await collections.tasks.update(task.id, (draft) => {
- if (!draft.labels) {
- draft.labels = [label];
- } else if (draft.labels.includes(label)) {
- draft.labels = draft.labels.filter((l) => l !== label);
- } else {
- draft.labels = [...draft.labels, label];
- }
- });
- } catch (error) {
- console.error("[LabelsCell] Failed to update labels:", error);
- }
- };
+ // Don't render anything if there are no labels
+ if (currentLabels.length === 0) {
+ return null;
+ }
return (
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="h-8"
- autoFocus
- />
-
-
-
- {filteredLabels.map((label) => (
-
handleToggleLabel(label)}
- className="text-sm"
- >
- {label}
-
- ))}
- {filteredLabels.length === 0 && (
-
- {searchQuery
- ? "No labels found"
- : "No labels yet. Add labels to tasks to see them here."}
-
- )}
-
-
-
+
+ {currentLabels.slice(0, 2).map((label) => (
+
+ {label}
+
+ ))}
+ {currentLabels.length > 2 && (
+
+ +{currentLabels.length - 2}
+
+ )}
+
);
}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
index 8ccce1a167d..fab7961f300 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
@@ -31,6 +31,7 @@ const columnHelper = createColumnHelper();
export function useTasksTable(): {
table: Table;
isLoading: boolean;
+ slugColumnWidth: string;
} {
const collections = useCollections();
const [grouping, setGrouping] = useState(["status"]);
@@ -76,6 +77,23 @@ export function useTasksTable(): {
});
}, [allTasks, allStatuses]);
+ // Calculate optimal slug column width based on longest slug
+ const slugColumnWidth = useMemo(() => {
+ if (!data || data.length === 0) return "5rem"; // Default fallback
+
+ const longestSlug = data.reduce((longest, task) => {
+ return task.slug.length > longest.length ? task.slug : longest;
+ }, "");
+
+ // Monospace font-mono at text-xs (0.75rem = 12px)
+ // Each character is ~0.5em of the font size = 0.5 * 0.75rem = 0.375rem per char
+ const remPerChar = 0.5 * 0.75; // 0.375rem per character
+ const padding = 0.5; // rem for horizontal padding
+ const width = longestSlug.length * remPerChar + padding;
+
+ return `${Math.ceil(width * 10) / 10}rem`; // Round to 1 decimal
+ }, [data]);
+
const isLoading = tasksLoading || statusesLoading;
// TODO: Add localStorage persistence for collapsed groups
@@ -127,13 +145,22 @@ export function useTasksTable(): {
getGroupingValue: (row) => row.status.name,
}),
+ // Priority - clickable dropdown (FIRST COLUMN)
+ columnHelper.accessor("priority", {
+ header: "Priority",
+ cell: (info) => {
+ if (info.cell.getIsPlaceholder()) return null;
+ return ;
+ },
+ }),
+
// Task ID - simple inline rendering
columnHelper.accessor("slug", {
header: "ID",
cell: (info) => {
if (info.cell.getIsPlaceholder()) return null;
return (
-
+
{info.getValue()}
);
@@ -157,12 +184,12 @@ export function useTasksTable(): {
},
}),
- // Priority - clickable dropdown
- columnHelper.accessor("priority", {
- header: "Priority",
+ // Labels - multi-select dropdown
+ columnHelper.accessor("labels", {
+ header: "Labels",
cell: (info) => {
if (info.cell.getIsPlaceholder()) return null;
- return ;
+ return ;
},
}),
@@ -175,15 +202,6 @@ export function useTasksTable(): {
},
}),
- // Labels - multi-select dropdown
- columnHelper.accessor("labels", {
- header: "Labels",
- cell: (info) => {
- if (info.cell.getIsPlaceholder()) return null;
- return ;
- },
- }),
-
// Due date - simple inline rendering
columnHelper.accessor("dueDate", {
header: "Due",
@@ -215,5 +233,5 @@ export function useTasksTable(): {
autoResetExpanded: false,
});
- return { table, isLoading };
+ return { table, isLoading, slugColumnWidth };
}
From 5e65dbaf83e6f4f5c3892504ea23a6bc2e64e9ac Mon Sep 17 00:00:00 2001
From: Satya Patel
Date: Sat, 10 Jan 2026 18:24:39 -0800
Subject: [PATCH 04/18] WIP'
---
apps/api/package.json | 1 +
.../linear/jobs/initial-sync/utils.ts | 9 +-
.../api/integrations/linear/webhook/route.ts | 1 +
.../components/PriorityIcon/PriorityIcon.tsx | 119 ++++++++++++++
.../components/PriorityIcon/index.ts | 1 +
.../components/StatusIcon/StatusIcon.tsx | 6 +-
.../TasksTableView/TasksTableView.tsx | 14 +-
.../cells/PriorityCell/PriorityCell.tsx | 148 +++++-------------
.../cells/StatusCell/StatusCell.tsx | 70 ++++-----
.../hooks/useTasksTable/useTasksTable.tsx | 133 ++++++++++------
bun.lock | 1 +
packages/ui/src/components/ui/scroll-area.tsx | 2 +-
12 files changed, 299 insertions(+), 206 deletions(-)
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx
create mode 100644 apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/index.ts
diff --git a/apps/api/package.json b/apps/api/package.json
index 023421e1310..b4ad65cf7a0 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -23,6 +23,7 @@
"@upstash/qstash": "^2.8.4",
"@vercel/blob": "^2.0.0",
"better-auth": "^1.4.9",
+ "date-fns": "^4.1.0",
"drizzle-orm": "0.45.1",
"import-in-the-middle": "2.0.1",
"jose": "^6.1.3",
diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts
index 17bebe604e8..e6cb223592b 100644
--- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts
+++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts
@@ -1,4 +1,5 @@
import type { LinearClient } from "@linear/sdk";
+import { subMonths } from "date-fns";
import { mapPriorityFromLinear } from "@superset/trpc/integrations/linear";
export interface LinearIssue {
@@ -9,6 +10,7 @@ export interface LinearIssue {
priority: number;
estimate: number | null;
dueDate: string | null;
+ createdAt: string;
url: string;
startedAt: string | null;
completedAt: string | null;
@@ -84,6 +86,7 @@ const ISSUES_QUERY = `
priority
estimate
dueDate
+ createdAt
url
startedAt
completedAt
@@ -114,10 +117,7 @@ export async function fetchAllIssues(
): Promise {
const allIssues: LinearIssue[] = [];
let cursor: string | undefined;
-
- // Fetch tasks updated in the last 3 months
- const threeMonthsAgo = new Date();
- threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+ const threeMonthsAgo = subMonths(new Date(), 3);
do {
const response = await client.client.request<
@@ -169,6 +169,7 @@ export function mapIssueToTask(
labels: issue.labels.nodes.map((l) => l.name),
startedAt: issue.startedAt ? new Date(issue.startedAt) : null,
completedAt: issue.completedAt ? new Date(issue.completedAt) : null,
+ createdAt: new Date(issue.createdAt),
externalProvider: "linear" as const,
externalId: issue.id,
externalKey: issue.identifier,
diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.ts b/apps/api/src/app/api/integrations/linear/webhook/route.ts
index e4521000eab..f961d0c5299 100644
--- a/apps/api/src/app/api/integrations/linear/webhook/route.ts
+++ b/apps/api/src/app/api/integrations/linear/webhook/route.ts
@@ -143,6 +143,7 @@ async function processIssueEvent(
...taskData,
organizationId: connection.organizationId,
creatorId: connection.connectedByUserId,
+ createdAt: new Date(issue.createdAt),
})
.onConflictDoUpdate({
target: [tasks.externalProvider, tasks.externalId],
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx
new file mode 100644
index 00000000000..23d11bed170
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx
@@ -0,0 +1,119 @@
+import type { TaskPriority } from "@superset/db/enums";
+
+interface PriorityIconProps {
+ priority: TaskPriority;
+ statusType?: string;
+ className?: string;
+}
+
+export function PriorityIcon({
+ priority,
+ statusType,
+ className = "",
+}: PriorityIconProps) {
+ const sizeClass = className || "h-4 w-4";
+
+ // None: Three horizontal dashes with opacity
+ if (priority === "none") {
+ return (
+
+
+
+ );
+ }
+
+ // Urgent: Filled square with exclamation mark
+ // Orange for backlog/todo/in-progress, gray for completed/cancelled
+ if (priority === "urgent") {
+ const isActive =
+ statusType === "started" ||
+ statusType === "unstarted" ||
+ statusType === "backlog";
+ const fillColor = isActive ? "#F97316" : "currentColor";
+
+ return (
+
+ );
+ }
+
+ // High: 3 bars staircase pattern (all solid)
+ if (priority === "high") {
+ return (
+
+
+
+ );
+ }
+
+ // Medium: 3 bars staircase (last bar 40% opacity)
+ if (priority === "medium") {
+ return (
+
+
+
+ );
+ }
+
+ // Low: 3 bars staircase (middle and last bars 40% opacity)
+ if (priority === "low") {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/index.ts b/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/index.ts
new file mode 100644
index 00000000000..c6195a425fc
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/index.ts
@@ -0,0 +1 @@
+export { PriorityIcon } from "./PriorityIcon";
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
index 8dbaf6d1585..acddc3bc070 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/StatusIcon/StatusIcon.tsx
@@ -70,7 +70,7 @@ export function StatusIcon({
}
if (type === "started") {
- // Progress fills counter-clockwise starting from 12 o'clock
+ // Progress fills clockwise starting from 12 o'clock
const centerRadius = 2;
const centerCircumference = 2 * Math.PI * centerRadius;
const progressPercent = progress ?? 100;
@@ -97,7 +97,7 @@ export function StatusIcon({
strokeDasharray="3.14 0"
strokeDashoffset="-0.7"
/>
- {/* Center circle - radial progress, counter-clockwise from top */}
+ {/* Center circle - radial progress, clockwise from top */}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
index 8ffbc7fd980..404be56d3d6 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTableView/TasksTableView.tsx
@@ -16,11 +16,13 @@ export function TasksTableView({ table, slugColumnWidth }: TasksTableViewProps)
if (isGroupHeader) {
// Group header - only render the status column (first cell)
// All other cells return null because they're placeholders
+ // DOM order handles stacking - later headers naturally overlap earlier ones
const firstCell = row.getVisibleCells()[0];
+
return (
{flexRender(firstCell.column.columnDef.cell, firstCell.getContext())}
@@ -28,25 +30,23 @@ export function TasksTableView({ table, slugColumnWidth }: TasksTableViewProps)
}
// Leaf row - render all cells horizontally with grid for consistent column widths
- // Layout: [Priority] [ID] [Title] ... [Labels] [Assignee] [Due] [gap]
+ // Layout: [Checkbox] [Priority] [ID] [Title] ... [Labels] [Assignee] [Due] [gap]
// Note: Status column (index 0) returns null for leaf rows, so we skip it
const cells = row.getVisibleCells();
return (
- {/* Skip status column (index 0), render priority, id, title, labels, assignee, due */}
+ {/* Skip status column (index 0), render checkbox, priority, id, title+labels, assignee, created */}
{cells.slice(1).map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
- {/* Empty cell for right padding/growth space */}
-
);
})}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx
index ce000dc9400..f9010a28fa1 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/PriorityCell/PriorityCell.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo } from "react";
+import { useState } from "react";
import type { CellContext } from "@tanstack/react-table";
import type { SelectTask } from "@superset/db/schema";
import { taskPriorityValues, type TaskPriority } from "@superset/db/enums";
@@ -8,30 +8,36 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@superset/ui/dropdown-menu";
-import { Input } from "@superset/ui/input";
-import { Button } from "@superset/ui/button";
import { useCollections } from "renderer/contexts/CollectionsProvider";
-import { PRIORITY_COLORS } from "./constants";
+import { PriorityIcon } from "../../PriorityIcon";
interface PriorityCellProps {
info: CellContext
;
}
+const PRIORITY_LABELS: Record = {
+ none: "No priority",
+ urgent: "Urgent",
+ high: "High",
+ medium: "Medium",
+ low: "Low",
+};
+
+const PRIORITY_NUMBERS: Record = {
+ none: 0,
+ urgent: 1,
+ high: 2,
+ medium: 3,
+ low: 4,
+};
+
export function PriorityCell({ info }: PriorityCellProps) {
const collections = useCollections();
const [open, setOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
const task = info.row.original;
const currentPriority = info.getValue();
-
- // Filter priorities based on search query
- const filteredPriorities = useMemo(() => {
- const query = searchQuery.toLowerCase();
- return taskPriorityValues.filter((priority: TaskPriority) =>
- priority.toLowerCase().includes(query),
- );
- }, [searchQuery]);
+ const statusType = (task as any).status?.type;
const handleSelectPriority = async (newPriority: TaskPriority) => {
if (newPriority === currentPriority) {
@@ -44,110 +50,38 @@ export function PriorityCell({ info }: PriorityCellProps) {
draft.priority = newPriority;
});
setOpen(false);
- setSearchQuery("");
} catch (error) {
console.error("[PriorityCell] Failed to update priority:", error);
}
};
- // Don't render anything if priority is "none"
- if (currentPriority === "none" && !open) {
- return (
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="h-8"
- autoFocus
- />
-
-
- {filteredPriorities.map((priority: TaskPriority) => (
-
handleSelectPriority(priority)}
- className="flex items-center gap-2"
- >
-
- {priority}
- {priority === currentPriority && (
-
- ✓
-
- )}
-
- ))}
- {filteredPriorities.length === 0 && (
-
- No priority found
-
- )}
-
-
-
- );
- }
-
return (
-
+
+
-
-
- setSearchQuery(e.target.value)}
- className="h-8"
- autoFocus
- />
-
-
- {filteredPriorities.map((priority: TaskPriority) => (
-
handleSelectPriority(priority)}
- className="flex items-center gap-2"
- >
-
- {priority}
- {priority === currentPriority && (
- ✓
- )}
-
- ))}
- {filteredPriorities.length === 0 && (
-
- No priority found
-
- )}
-
+
+ {taskPriorityValues.map((priority: TaskPriority) => (
+ handleSelectPriority(priority)}
+ className="flex items-center gap-3 px-3 py-2"
+ >
+
+ {PRIORITY_LABELS[priority]}
+ {priority === currentPriority && (
+ ✓
+ )}
+
+ {PRIORITY_NUMBERS[priority]}
+
+
+ ))}
);
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx
index 41461654ac6..c054e9f5dfc 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/cells/StatusCell/StatusCell.tsx
@@ -7,8 +7,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@superset/ui/dropdown-menu";
-import { Input } from "@superset/ui/input";
-import { Button } from "@superset/ui/button";
import { useCollections } from "renderer/contexts/CollectionsProvider";
import { StatusIcon } from "../../StatusIcon";
@@ -17,6 +15,15 @@ type TaskWithStatus = SelectTask & {
status: SelectTaskStatus;
};
+// Status type ordering (Linear style: in progress → todo → backlog → done → cancelled)
+const STATUS_TYPE_ORDER: Record = {
+ started: 0,
+ unstarted: 1,
+ backlog: 2,
+ completed: 3,
+ cancelled: 4,
+};
+
interface StatusCellProps {
taskWithStatus: TaskWithStatus;
}
@@ -24,7 +31,6 @@ interface StatusCellProps {
export function StatusCell({ taskWithStatus }: StatusCellProps) {
const collections = useCollections();
const [open, setOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
// Lazy load statuses only when dropdown opens
const { data: allStatuses } = useLiveQuery(
@@ -35,26 +41,31 @@ export function StatusCell({ taskWithStatus }: StatusCellProps) {
const statuses = useMemo(() => allStatuses || [], [allStatuses]);
const currentStatus = taskWithStatus.status;
- // Filter statuses based on search query
- const filteredStatuses = useMemo(() => {
- const query = searchQuery.toLowerCase();
- return statuses.filter((status) =>
- status.name.toLowerCase().includes(query),
- );
- }, [searchQuery, statuses]);
+ // Sort statuses by type order and position
+ const sortedStatuses = useMemo(() => {
+ return statuses.sort((a, b) => {
+ // Sort by status type first (started → unstarted → backlog → completed → cancelled)
+ const typeOrderA = STATUS_TYPE_ORDER[a.type] ?? 999;
+ const typeOrderB = STATUS_TYPE_ORDER[b.type] ?? 999;
+ if (typeOrderA !== typeOrderB) {
+ return typeOrderA - typeOrderB;
+ }
+ // Within same type, sort by position
+ return a.position - b.position;
+ });
+ }, [statuses]);
- const handleSelectStatus = async (newStatus: SelectTaskStatus) => {
+ const handleSelectStatus = (newStatus: SelectTaskStatus) => {
if (newStatus.id === currentStatus.id) {
setOpen(false);
return;
}
try {
- await collections.tasks.update(taskWithStatus.id, (draft) => {
+ collections.tasks.update(taskWithStatus.id, (draft) => {
draft.statusId = newStatus.id;
});
setOpen(false);
- setSearchQuery("");
} catch (error) {
console.error("[StatusCell] Failed to update status:", error);
}
@@ -63,51 +74,34 @@ export function StatusCell({ taskWithStatus }: StatusCellProps) {
return (
-
-
-
- setSearchQuery(e.target.value)}
- className="h-8"
- autoFocus
- />
-
+
- {filteredStatuses.map((status) => (
+ {sortedStatuses.map((status) => (
handleSelectStatus(status)}
- className="flex items-center gap-2"
+ className="flex items-center gap-3 px-3 py-2"
>
- {status.name}
+ {status.name}
{status.id === currentStatus.id && (
- ✓
+ ✓
)}
))}
- {filteredStatuses.length === 0 && (
-
- No status found
-
- )}
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
index fab7961f300..c389ebc4c5c 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx
@@ -12,20 +12,28 @@ import {
import type { SelectTask, SelectTaskStatus } from "@superset/db/schema";
import { useLiveQuery } from "@tanstack/react-db";
import { format } from "date-fns";
-import { Button } from "@superset/ui/button";
-import { HiChevronDown, HiChevronRight } from "react-icons/hi2";
+import { HiChevronRight } from "react-icons/hi2";
+import { Badge } from "@superset/ui/badge";
import { useCollections } from "renderer/contexts/CollectionsProvider";
import { StatusIcon } from "../../components/StatusIcon";
import { StatusCell } from "../../components/cells/StatusCell";
import { PriorityCell } from "../../components/cells/PriorityCell";
import { AssigneeCell } from "../../components/cells/AssigneeCell";
-import { LabelsCell } from "../../components/cells/LabelsCell";
// Task with joined status data
type TaskWithStatus = SelectTask & {
status: SelectTaskStatus;
};
+// Status type ordering (Linear style: in progress → todo → backlog → done → cancelled)
+const STATUS_TYPE_ORDER: Record = {
+ started: 0,
+ unstarted: 1,
+ backlog: 2,
+ completed: 3,
+ cancelled: 4,
+};
+
const columnHelper = createColumnHelper();
export function useTasksTable(): {
@@ -48,7 +56,7 @@ export function useTasksTable(): {
[collections],
);
- // Client-side join: merge tasks with their status
+ // Client-side join: merge tasks with their status and sort
const data = useMemo(() => {
if (!allTasks || !allStatuses) return [];
@@ -74,6 +82,16 @@ export function useTasksTable(): {
};
}
return { ...task, status };
+ })
+ .sort((a, b) => {
+ // Sort by status type first (started → unstarted → backlog → completed → cancelled)
+ const typeOrderA = STATUS_TYPE_ORDER[a.status.type] ?? 999;
+ const typeOrderB = STATUS_TYPE_ORDER[b.status.type] ?? 999;
+ if (typeOrderA !== typeOrderB) {
+ return typeOrderA - typeOrderB;
+ }
+ // Within same type, sort by position
+ return a.status.position - b.status.position;
});
}, [allTasks, allStatuses]);
@@ -110,32 +128,38 @@ export function useTasksTable(): {
const status = info.getValue();
if (cell.getIsGrouped()) {
- // Group header row
+ // Group header row with subtle gradient (Linear style, 8% opacity)
return (
-
-
- {row.getIsExpanded() ? (
-
- ) : (
-
- )}
-
+
-
- {status.name}
-
-
- {row.subRows.length}
-
-
-
+
+
+
+ {status.name}
+
+
+ {row.subRows.length}
+
+
+
+
);
}
@@ -145,7 +169,16 @@ export function useTasksTable(): {
getGroupingValue: (row) => row.status.name,
}),
- // Priority - clickable dropdown (FIRST COLUMN)
+ // Checkbox column (placeholder for future selection)
+ columnHelper.display({
+ id: "checkbox",
+ header: "",
+ cell: () => {
+ return ;
+ },
+ }),
+
+ // Priority - clickable dropdown
columnHelper.accessor("priority", {
header: "Priority",
cell: (info) => {
@@ -160,39 +193,47 @@ export function useTasksTable(): {
cell: (info) => {
if (info.cell.getIsPlaceholder()) return null;
return (
-
+
{info.getValue()}
);
},
}),
- // Title - status icon + title inline (Linear style)
+ // Title + Labels - combined to handle overflow better
columnHelper.accessor("title", {
header: "Title",
cell: (info) => {
if (info.cell.getIsPlaceholder()) return null;
const taskWithStatus = info.row.original;
+ const labels = taskWithStatus.labels || [];
return (
-
+
-
- {info.getValue()}
-
+
+
+ {info.getValue()}
+
+ {labels.length > 0 && (
+
+ {labels.slice(0, 2).map((label) => (
+
+ {label}
+
+ ))}
+ {labels.length > 2 && (
+
+ +{labels.length - 2}
+
+ )}
+
+ )}
+
);
},
}),
- // Labels - multi-select dropdown
- columnHelper.accessor("labels", {
- header: "Labels",
- cell: (info) => {
- if (info.cell.getIsPlaceholder()) return null;
- return
;
- },
- }),
-
// Assignee - clickable dropdown
columnHelper.accessor("assigneeId", {
header: "Assignee",
@@ -202,9 +243,9 @@ export function useTasksTable(): {
},
}),
- // Due date - simple inline rendering
- columnHelper.accessor("dueDate", {
- header: "Due",
+ // Created date - simple inline rendering
+ columnHelper.accessor("createdAt", {
+ header: "Created",
cell: (info) => {
if (info.cell.getIsPlaceholder()) return null;
const date = info.getValue();
diff --git a/bun.lock b/bun.lock
index 6d96e86f4a9..19bccb4f16c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -70,6 +70,7 @@
"@upstash/qstash": "^2.8.4",
"@vercel/blob": "^2.0.0",
"better-auth": "^1.4.9",
+ "date-fns": "^4.1.0",
"drizzle-orm": "0.45.1",
"import-in-the-middle": "2.0.1",
"jose": "^6.1.3",
diff --git a/packages/ui/src/components/ui/scroll-area.tsx b/packages/ui/src/components/ui/scroll-area.tsx
index 28884095cf5..d80ac97404e 100644
--- a/packages/ui/src/components/ui/scroll-area.tsx
+++ b/packages/ui/src/components/ui/scroll-area.tsx
@@ -38,7 +38,7 @@ function ScrollBar({
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
- "flex touch-none p-px transition-colors select-none",
+ "flex touch-none p-px transition-colors select-none z-50",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
From ca4655b0fbe9f9574e2785ebf8dd00d92b6816bb Mon Sep 17 00:00:00 2001
From: Satya Patel
Date: Sat, 10 Jan 2026 21:16:26 -0800
Subject: [PATCH 05/18] WIP
---
.../CollectionsProvider/collections.ts | 7 +-
.../components/PriorityIcon/PriorityIcon.tsx | 26 +++---
.../cells/AssigneeCell/AssigneeCell.tsx | 93 ++++++-------------
.../cells/PriorityCell/PriorityCell.tsx | 33 ++++---
.../hooks/useTasksTable/useTasksTable.tsx | 2 +-
packages/ui/package.json | 1 +
packages/ui/src/atoms/Avatar/Avatar.tsx | 75 +++++++++++++++
packages/ui/src/atoms/Avatar/index.ts | 2 +
8 files changed, 143 insertions(+), 96 deletions(-)
create mode 100644 packages/ui/src/atoms/Avatar/Avatar.tsx
create mode 100644 packages/ui/src/atoms/Avatar/index.ts
diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts
index 0712242a1e0..d9a288fae32 100644
--- a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts
+++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts
@@ -70,8 +70,11 @@ function createOrgCollections(
return { txid: result.txid };
},
onUpdate: async ({ transaction }) => {
- const { modified } = transaction.mutations[0];
- const result = await apiClient.task.update.mutate(modified);
+ const { original, changes } = transaction.mutations[0];
+ const result = await apiClient.task.update.mutate({
+ ...changes,
+ id: original.id,
+ });
return { txid: result.txid };
},
onDelete: async ({ transaction }) => {
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx
index 23d11bed170..d7c9a1f2052 100644
--- a/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TasksView/components/PriorityIcon/PriorityIcon.tsx
@@ -1,17 +1,22 @@
import type { TaskPriority } from "@superset/db/enums";
+import colors from "tailwindcss/colors";
interface PriorityIconProps {
priority: TaskPriority;
statusType?: string;
className?: string;
+ showHover?: boolean;
}
export function PriorityIcon({
priority,
statusType,
className = "",
+ showHover = false,
}: PriorityIconProps) {
const sizeClass = className || "h-4 w-4";
+ const hoverClass = showHover ? "group-hover:brightness-150" : "";
+ const defaultColor = colors.neutral[500];
// None: Three horizontal dashes with opacity
if (priority === "none") {
@@ -19,9 +24,9 @@ export function PriorityIcon({