feat(tasks): project-centric task management redesign

- Task board uses per-project workflow state columns with status
  fallback when no project is selected; adds calendar view tab
- Chip-based filter bar (search, project, state, priority, due date)
  with quick-add bar and slide-over task detail panel
- Project detail switches to left-nav layout with progress summary
  and per-section counts; projects list gains search, status
  filter, and richer cards
- Sidebar gains expandable active-projects sub-nav; calendar
  unified view gains project filter
- API: task due_after/due_before filters, GET /tasks/grouped,
  GET /projects/:id/stats, calendar unified project_id filter
- Apply Buzzbee design tokens; remove accidentally committed
  apps/web/node_modules self-symlink
This commit is contained in:
2026-09-10 10:49:41 +00:00
parent f3b1fd709a
commit ddcb707190
15 changed files with 1394 additions and 317 deletions
+4 -1
View File
@@ -237,9 +237,12 @@ calendarRoutes.get("/unified", async (c) => {
await requireWorkspaceAccess(c, domainId);
const from = fromStr ? new Date(fromStr) : new Date(new Date().setDate(new Date().getDate() - 7));
const to = toStr ? new Date(toStr) : new Date(new Date().setDate(new Date().getDate() + 30));
const projectId = url.searchParams.get("project_id");
const taskConds: any[] = [eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to)];
if (projectId) taskConds.push(eq(tasks.projectId, projectId));
const [events, domainTasks, domainHabits] = await Promise.all([
db.select().from(calendarEvents).where(and(eq(calendarEvents.domainId, domainId), gte(calendarEvents.startTime, from), lte(calendarEvents.startTime, to))).orderBy(asc(calendarEvents.startTime)),
db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to))).orderBy(asc(tasks.dueDate)),
db.select().from(tasks).where(and(...taskConds)).orderBy(asc(tasks.dueDate)),
db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
]);
const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({
+54
View File
@@ -316,6 +316,60 @@ projectRoutes.get("/:id", async (c) => {
}
});
// GET /api/projects/:id/stats — Task counts by state group for board headers/progress
projectRoutes.get("/:id/stats", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [project] = await db.select().from(projects)
.where(and(eq(projects.id, id), isNull(projects.deletedAt))).limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const projectStates = await db.select().from(states)
.where(and(eq(states.projectId, id), isNull(states.deletedAt)))
.orderBy(asc(states.sortOrder));
const projectTasks = await db.select({ id: tasks.id, stateId: tasks.stateId, completedAt: tasks.completedAt, dueDate: tasks.dueDate })
.from(tasks).where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)));
const stateById = new Map(projectStates.map((s) => [s.id, s]));
const byGroup: Record<string, number> = { backlog: 0, unstarted: 0, started: 0, completed: 0, cancelled: 0 };
const byState: Record<string, number> = {};
for (const t of projectTasks) {
byState[t.stateId || "__none__"] = (byState[t.stateId || "__none__"] || 0) + 1;
const group = t.stateId ? stateById.get(t.stateId)?.group || "unstarted" : "unstarted";
byGroup[group] = (byGroup[group] || 0) + 1;
}
const now = new Date();
const overdue = projectTasks.filter((t) => t.dueDate && new Date(t.dueDate) < now && !t.completedAt).length;
const dueSoon = projectTasks.filter((t) => {
if (!t.dueDate || t.completedAt) return false;
const d = new Date(t.dueDate).getTime() - now.getTime();
return d >= 0 && d <= 7 * 24 * 60 * 60 * 1000;
}).length;
const total = projectTasks.length;
const completed = byGroup.completed || 0;
return c.json({
total, completed, overdue, dueSoon,
progress: total > 0 ? Math.round((completed / total) * 100) : 0,
byGroup, byState,
states: projectStates.map((s) => ({ id: s.id, name: s.name, color: s.color, group: s.group, count: byState[s.id] || 0 })),
});
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[projects] GET /:id/stats error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project stats" } }, 500);
}
});
// PATCH /api/projects/:id — Update a project
projectRoutes.patch("/:id", async (c) => {
try {
+119 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
@@ -95,6 +95,8 @@ taskRoutes.get("/", async (c) => {
const parentId = url.searchParams.get("parent_id");
const projectId = url.searchParams.get("project_id");
const sectionId = url.searchParams.get("section_id");
const dueAfter = url.searchParams.get("due_after");
const dueBefore = url.searchParams.get("due_before");
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
const offset = parseInt(url.searchParams.get("offset") || "0");
const order = url.searchParams.get("order") || "asc";
@@ -139,6 +141,14 @@ taskRoutes.get("/", async (c) => {
if (sectionId) {
conditions.push(eq(tasks.sectionId, sectionId));
}
if (dueAfter) {
const d = new Date(dueAfter);
if (!Number.isNaN(d.getTime())) conditions.push(gte(tasks.dueDate, d));
}
if (dueBefore) {
const d = new Date(dueBefore);
if (!Number.isNaN(d.getTime())) conditions.push(lte(tasks.dueDate, d));
}
if (stateId) {
conditions.push(eq(tasks.stateId, stateId));
}
@@ -249,6 +259,114 @@ taskRoutes.get("/", async (c) => {
}
});
// GET /api/tasks/grouped — Board-ready grouping (by state, project, or priority)
taskRoutes.get("/grouped", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const groupBy = url.searchParams.get("group_by") || "state";
const projectId = url.searchParams.get("project_id");
const priority = url.searchParams.get("priority");
const search = url.searchParams.get("search");
const dueAfter = url.searchParams.get("due_after");
const dueBefore = url.searchParams.get("due_before");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(tasks.domainId, domainId), isNull(tasks.deletedAt)];
if (projectId) conditions.push(eq(tasks.projectId, projectId));
if (priority) conditions.push(inArray(tasks.priority, priority.split(",") as any));
if (search) conditions.push(ilike(tasks.title, `%${search}%`));
if (dueAfter) {
const d = new Date(dueAfter);
if (!Number.isNaN(d.getTime())) conditions.push(gte(tasks.dueDate, d));
}
if (dueBefore) {
const d = new Date(dueBefore);
if (!Number.isNaN(d.getTime())) conditions.push(lte(tasks.dueDate, d));
}
const items = await db.select().from(tasks)
.where(and(...conditions))
.orderBy(asc(tasks.order))
.limit(500);
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (items.length > 0) {
const tagRows = await db.select({
taskId: taskTags.taskId, id: tagsTable.id, name: tagsTable.name, color: tagsTable.color,
}).from(taskTags).innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(inArray(taskTags.taskId, items.map((t) => t.id)));
for (const row of tagRows) {
if (!taskTagMap.has(row.taskId)) taskTagMap.set(row.taskId, []);
taskTagMap.get(row.taskId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const withTags = items.map((t) => ({ ...t, tags: taskTagMap.get(t.id) || [] }));
if (groupBy === "state") {
const stateIds = [...new Set(withTags.map((t) => t.stateId).filter(Boolean))] as string[];
let stateList: typeof statesTable.$inferSelect[] = [];
if (projectId) {
stateList = await db.select().from(statesTable)
.where(and(eq(statesTable.projectId, projectId), isNull(statesTable.deletedAt)))
.orderBy(asc(statesTable.sortOrder));
} else if (stateIds.length > 0) {
stateList = await db.select().from(statesTable).where(inArray(statesTable.id, stateIds));
}
const byState = new Map<string | null, typeof withTags>();
for (const t of withTags) {
const key = t.stateId ?? null;
if (!byState.has(key)) byState.set(key, []);
byState.get(key)!.push(t);
}
const groups = stateList.map((s) => ({
id: s.id, name: s.name, color: s.color, group: s.group,
tasks: (byState.get(s.id) || []).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
}));
const unassigned = (byState.get(null) || []).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
if (unassigned.length > 0) groups.unshift({ id: "__none__", name: "No state", color: null, group: "unstarted", tasks: unassigned } as any);
return c.json({ groups, totalItems: withTags.length });
}
if (groupBy === "priority") {
const order = ["urgent", "high", "medium", "low"] as const;
return c.json({
groups: order.map((p) => ({ id: p, name: p[0].toUpperCase() + p.slice(1), tasks: withTags.filter((t) => t.priority === p) })),
totalItems: withTags.length,
});
}
// group_by=project
const projectIds = [...new Set(withTags.map((t) => t.projectId).filter(Boolean))] as string[];
let projectList: { id: string; name: string; color: string | null }[] = [];
if (projectIds.length > 0) {
projectList = await db.select({ id: projects.id, name: projects.name, color: projects.color })
.from(projects).where(inArray(projects.id, projectIds));
}
const byProject = new Map<string | null, typeof withTags>();
for (const t of withTags) {
const key = t.projectId ?? null;
if (!byProject.has(key)) byProject.set(key, []);
byProject.get(key)!.push(t);
}
const groups = projectList.map((p) => ({ id: p.id, name: p.name, color: p.color, tasks: byProject.get(p.id) || [] }));
const inbox = byProject.get(null) || [];
if (inbox.length > 0) groups.unshift({ id: "__none__", name: "No project", color: null, tasks: inbox });
return c.json({ groups, totalItems: withTags.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] GET /grouped error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to group tasks" } }, 500);
}
});
// POST /api/tasks — Create a task
taskRoutes.post("/", async (c) => {
try {
-1
View File
@@ -1 +0,0 @@
/home/user/projects/dev/ProjectE/apps/web/node_modules
@@ -0,0 +1,190 @@
import { CalendarClock, Flag, FolderKanban, Search, SlidersHorizontal, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { State } from "@/lib/types";
export type DueFilter = "all" | "overdue" | "today" | "week" | "none";
export const DUE_FILTER_LABELS: Record<DueFilter, string> = {
all: "Any due date",
overdue: "Overdue",
today: "Due today",
week: "Due this week",
none: "No due date",
};
interface FilterBarProps {
search: string;
onSearchChange: (v: string) => void;
projects: { id: string; name: string; color?: string | null }[];
projectId: string;
onProjectChange: (v: string) => void;
states: State[];
stateId: string;
onStateChange: (v: string) => void;
priority: string;
onPriorityChange: (v: string) => void;
dueFilter: DueFilter;
onDueChange: (v: DueFilter) => void;
onClearAll: () => void;
}
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<Badge
className="gap-1 border-[#91caff] bg-[#e6f4ff] pl-2 pr-1 text-[11px] font-medium text-[#002c8c] hover:bg-[#bae0ff]"
>
{label}
<button
type="button"
onClick={onRemove}
className="rounded-full p-0.5 hover:bg-[#91caff]/40"
aria-label={`Remove ${label} filter`}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
}
export function FilterBar({
search,
onSearchChange,
projects,
projectId,
onProjectChange,
states,
stateId,
onStateChange,
priority,
onPriorityChange,
dueFilter,
onDueChange,
onClearAll,
}: FilterBarProps) {
const projectName = projects.find((p) => p.id === projectId)?.name;
const stateName = states.find((s) => s.id === stateId)?.name;
const activeCount =
(projectId ? 1 : 0) + (stateId ? 1 : 0) + (priority ? 1 : 0) + (dueFilter !== "all" ? 1 : 0) + (search ? 1 : 0);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tasks..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 pl-8"
aria-label="Search tasks"
/>
</div>
<Select value={projectId || "__all__"} onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-8 w-44" aria-label="Filter by project">
<FolderKanban className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: p.color || "#1677ff" }} />
{p.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{states.length > 0 && (
<Select value={stateId || "__all__"} onValueChange={(v) => onStateChange(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-8 w-40" aria-label="Filter by state">
<SelectValue placeholder="All states" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All states</SelectItem>
{states.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={priority || "__all__"} onValueChange={(v) => onPriorityChange(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-8 w-36" aria-label="Filter by priority">
<Flag className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Any priority</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5">
<CalendarClock className="h-3.5 w-3.5" />
{dueFilter === "all" ? "Due date" : DUE_FILTER_LABELS[dueFilter]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Due date</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(DUE_FILTER_LABELS) as DueFilter[]).map((d) => (
<DropdownMenuItem key={d} onClick={() => onDueChange(d)}>
{DUE_FILTER_LABELS[d]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{activeCount > 0 && (
<Button variant="ghost" size="sm" className="h-8 gap-1 text-muted-foreground" onClick={onClearAll}>
<X className="h-3.5 w-3.5" /> Clear ({activeCount})
</Button>
)}
</div>
{activeCount > 0 && (
<div className="flex flex-wrap items-center gap-1.5" aria-label="Active filters">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<SlidersHorizontal className="h-3 w-3" /> Active:
</span>
{projectName && <Chip label={projectName} onRemove={() => onProjectChange("")} />}
{stateName && <Chip label={stateName} onRemove={() => onStateChange("")} />}
{priority && <Chip label={`Priority: ${priority}`} onRemove={() => onPriorityChange("")} />}
{dueFilter !== "all" && <Chip label={DUE_FILTER_LABELS[dueFilter]} onRemove={() => onDueChange("all")} />}
{search && <Chip label={`"${search}"`} onRemove={() => onSearchChange("")} />}
</div>
)}
</div>
);
}
+69 -1
View File
@@ -16,6 +16,7 @@ import {
Bot,
PenLine,
Settings,
ChevronDown,
ChevronLeft,
ChevronRight,
LogOut,
@@ -24,6 +25,9 @@ import {
FileBarChart,
FileStack,
} from "lucide-react";
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { PaginatedResponse, Project } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
@@ -115,6 +119,13 @@ export function Sidebar() {
? "right"
: "left"
);
const [projectsExpanded, setProjectsExpanded] = useState(true);
const activeDomainId = useApiDomain();
const { data: sidebarProjectsData } = useApiQuery<PaginatedResponse<Project>>(
["projects", activeDomainId, "sidebar"],
"/projects?limit=50&status=active" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const sidebarProjects = sidebarProjectsData?.items || [];
useEffect(() => {
const onSidebarPositionChange = (event: Event) => {
@@ -182,7 +193,64 @@ export function Sidebar() {
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
<ScrollArea className="flex-1 py-2">
<nav className="flex flex-col gap-0.5 px-2" aria-label="Primary">
{navItems.map((item) => renderNavLink(item, isCollapsed, onNavigate))}
{navItems.map((item) => {
if (item.href === "/projects" && !isCollapsed) {
const projectsActive = isActive("/projects");
return (
<div key={item.href}>
<div className="flex items-center gap-1">
<div className="flex-1">{renderNavLink(item, isCollapsed, onNavigate)}</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground"
onClick={() => setProjectsExpanded((v) => !v)}
aria-label={projectsExpanded ? "Collapse projects" : "Expand projects"}
aria-expanded={projectsExpanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform", !projectsExpanded && "-rotate-90")} />
</Button>
</div>
{projectsExpanded && (
<div className="mb-1 ml-6 flex flex-col gap-0.5 border-l border-sidebar-border pl-2" aria-label="Projects">
{sidebarProjects.slice(0, 8).map((p) => {
const current = location.pathname === `/projects/${p.id}`;
return (
<Link
key={p.id}
to="/projects/$id"
params={{ id: p.id }}
onClick={onNavigate}
className={cn(
"flex items-center gap-2 truncate rounded-md px-2 py-1.5 text-[13px] transition-colors",
current ? "bg-accent/50 font-semibold text-accent-foreground" : "text-muted-foreground hover:bg-accent/30 hover:text-accent-foreground"
)}
title={p.name}
>
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: p.color || "#1677ff" }} />
<span className="flex-1 truncate">{p.name}</span>
</Link>
);
})}
{sidebarProjects.length === 0 && (
<p className="px-2 py-1 text-xs text-muted-foreground">No active projects</p>
)}
{(projectsActive || sidebarProjects.length > 8) && (
<Link
to="/projects"
onClick={onNavigate}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent/30 hover:text-accent-foreground"
>
View all projects
</Link>
)}
</div>
)}
</div>
);
}
return renderNavLink(item, isCollapsed, onNavigate);
})}
</nav>
<Separator className="my-2" />
<nav className="flex flex-col gap-0.5 px-2" aria-label="Workspace">
@@ -0,0 +1,100 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Settings2 } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { parseTaskInput } from "@/lib/nlp";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Task } from "@/lib/types";
interface QuickAddBarProps {
projectId?: string | null;
sectionId?: string | null;
onMoreOptions?: () => void;
onCreated?: (task: Task) => void;
placeholder?: string;
}
/**
* Linear-style quick-add: type a title, press Enter, the task appears.
* Natural-language parsing (due dates, priorities, tags) runs live.
*/
export function QuickAddBar({ projectId, sectionId, onMoreOptions, onCreated, placeholder }: QuickAddBarProps) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState("");
const parsed = title.trim() ? parseTaskInput(title) : null;
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post<Task>("/tasks", data),
onSuccess: (task) => {
setTitle("");
queryClient.invalidateQueries({ queryKey: ["tasks"] });
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
queryClient.invalidateQueries({ queryKey: ["project"] });
onCreated?.(task);
},
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to create task"),
});
const submit = () => {
const raw = title.trim();
if (!raw || createMutation.isPending) return;
const p = parseTaskInput(raw);
createMutation.mutate({
title: p.title,
priority: p.priority || "medium",
...(p.dueDate ? { dueDate: p.dueDate } : {}),
...(p.tags.length > 0 ? { tagNames: p.tags } : {}),
...(projectId ? { projectId } : {}),
...(sectionId ? { sectionId } : {}),
...(activeDomainId ? { domain: activeDomainId } : {}),
});
};
return (
<div className="rounded-lg border border-[#d9dee7] bg-white shadow-[0_1px_2px_#fafafa]">
<div className="flex items-center gap-2 px-3 py-2">
<Plus className="h-4 w-4 shrink-0 text-[#1677ff]" />
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
placeholder={placeholder || 'Add a task — try "Report due tomorrow 5pm #work p1"'}
className="h-8 border-0 px-0 shadow-none focus-visible:ring-0"
aria-label="Quick add task"
/>
{onMoreOptions && (
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={onMoreOptions} title="More options" aria-label="More task options">
<Settings2 className="h-4 w-4" />
</Button>
)}
<Button size="sm" className="h-7 shrink-0" disabled={!title.trim() || createMutation.isPending} onClick={submit}>
Add
</Button>
</div>
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
<div className="flex flex-wrap gap-1 border-t border-[#f1f1f1] px-3 py-1.5">
{parsed.dueDate && (
<Badge variant="outline" className="border-[#91caff] bg-[#e6f4ff] text-[10px] text-[#002c8c]">
Due {new Date(parsed.dueDate).toLocaleDateString()}{" "}
{new Date(parsed.dueDate).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</Badge>
)}
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
{parsed.tags.map((t) => (
<Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,285 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowUpRight, Calendar, Flag, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { TagManager } from "@/components/entities/tag-manager";
import { PRIORITY } from "@/lib/status-colors";
import { cn } from "@/lib/utils";
import type { State, Task } from "@/lib/types";
interface TaskDetailPanelProps {
taskId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenFullPage?: (task: Task) => void;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Something went wrong";
}
export function TaskDetailPanel({ taskId, open, onOpenChange, onOpenFullPage }: TaskDetailPanelProps) {
const queryClient = useQueryClient();
const [newSubtask, setNewSubtask] = useState("");
const { data: task, isLoading } = useApiQuery<Task>(["task", taskId || ""], "/tasks/" + taskId, {
enabled: open && !!taskId,
});
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", task?.projectId || ""],
"/states?projectId=" + (task?.projectId || ""),
{ enabled: open && !!task?.projectId }
);
const projectStates = statesData?.items || [];
const currentState = task?.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
if (taskId) queryClient.invalidateQueries({ queryKey: ["task", taskId] });
if (task?.projectId) queryClient.invalidateQueries({ queryKey: ["project", task.projectId] });
};
const patchMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.patch<Task>(`/tasks/${taskId}`, data),
onSuccess: refresh,
onError: (err) => toast.error(errorMessage(err)),
});
const addSubtaskMutation = useMutation({
mutationFn: (title: string) =>
api.post<Task>("/tasks", { title, parentId: taskId, projectId: task?.projectId || null, domain: task?.domainId }),
onSuccess: () => {
setNewSubtask("");
refresh();
},
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task deleted");
refresh();
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err)),
});
const subtasks = task?.subtasks || [];
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full p-0 sm:max-w-md">
<SheetHeader className="border-b border-[#f1f1f1] px-4 py-3">
<SheetTitle className="text-sm text-muted-foreground">Task details</SheetTitle>
</SheetHeader>
{isLoading || !task ? (
<p className="p-4 text-sm text-muted-foreground">{isLoading ? "Loading..." : "Select a task"}</p>
) : (
<ScrollArea className="h-[calc(100vh-8rem)] px-4 py-3">
<div className="space-y-4">
<Input
defaultValue={task.title}
key={task.id + task.title}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== task.title) patchMutation.mutate({ title: v });
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="h-9 text-base font-semibold"
aria-label="Task title"
/>
<div className="flex flex-wrap items-center gap-1.5">
{currentState ? (
<Badge
variant="secondary"
className="gap-1 text-[11px]"
style={currentState.color ? { backgroundColor: currentState.color + "20", color: currentState.color } : undefined}
>
<span className="h-1.5 w-1.5 rounded-full" style={currentState.color ? { backgroundColor: currentState.color } : undefined} />
{currentState.name}
</Badge>
) : (
<Badge variant="secondary" className="text-[11px]">No state</Badge>
)}
<Badge variant="outline" className={cn("text-[11px]", PRIORITY[task.priority]?.badge)}>
{PRIORITY[task.priority]?.label ?? task.priority}
</Badge>
{task.dueDate && (
<Badge variant="outline" className="text-[11px]">
<Calendar className="mr-1 h-3 w-3" />
{format(parseISO(task.dueDate), "MMM d, yyyy")}
</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="mb-1 block text-xs text-muted-foreground">State</Label>
<Select
value={task.stateId || "__none__"}
onValueChange={(v) => patchMutation.mutate({ stateId: v === "__none__" ? null : v })}
disabled={projectStates.length === 0}
>
<SelectTrigger className="h-8"><SelectValue placeholder="No state" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No state</SelectItem>
{projectStates.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground">Priority</Label>
<Select value={task.priority} onValueChange={(v) => patchMutation.mutate({ priority: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" /> Due date
</Label>
<Input
type="date"
className="h-8"
defaultValue={task.dueDate ? task.dueDate.slice(0, 10) : ""}
key={task.id + (task.dueDate || "")}
onChange={(e) => patchMutation.mutate({ dueDate: e.target.value ? new Date(e.target.value).toISOString() : null })}
/>
</div>
<div>
<Label className="mb-1 flex items-center gap-1 text-xs text-muted-foreground">
<Flag className="h-3 w-3" /> Estimate
</Label>
<Input
type="number"
min={0}
className="h-8"
defaultValue={task.estimatedMinutes ?? ""}
key={task.id + (task.estimatedMinutes ?? "")}
placeholder="Minutes"
onBlur={(e) => {
const v = e.target.value ? parseInt(e.target.value, 10) : null;
patchMutation.mutate({ estimatedMinutes: v });
}}
/>
</div>
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground">Description</Label>
<Textarea
defaultValue={task.description || ""}
key={task.id + (task.description || "")}
rows={3}
placeholder="Add a description…"
onBlur={(e) => {
if (e.target.value !== (task.description || "")) {
patchMutation.mutate({ description: e.target.value || null });
}
}}
/>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold text-muted-foreground">
Subtasks {subtasks.length > 0 && `(${subtasks.length})`}
</p>
{subtasks.length === 0 ? (
<p className="text-xs text-muted-foreground">No subtasks yet.</p>
) : (
<div className="space-y-0.5">
{subtasks.map((s) => (
<TaskSubtaskRow key={s.id} subtask={s} projectStates={projectStates} onChanged={refresh} />
))}
</div>
)}
<div className="mt-1.5 flex gap-1.5">
<Input
value={newSubtask}
onChange={(e) => setNewSubtask(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (newSubtask.trim()) addSubtaskMutation.mutate(newSubtask.trim());
}
}}
placeholder="Add subtask…"
className="h-8"
aria-label="New subtask title"
/>
<Button size="sm" className="h-8" disabled={!newSubtask.trim() || addSubtaskMutation.isPending} onClick={() => addSubtaskMutation.mutate(newSubtask.trim())}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="border-t border-[#f1f1f1] pt-3">
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
</div>
<div className="flex items-center justify-between border-t border-[#f1f1f1] pt-3">
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
</Button>
{onOpenFullPage && (
<Button variant="outline" size="sm" onClick={() => onOpenFullPage(task)}>
Open full page <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</ScrollArea>
)}
</SheetContent>
</Sheet>
);
}
function TaskSubtaskRow({ subtask, projectStates, onChanged }: { subtask: Task; projectStates: State[]; onChanged: () => void }) {
const queryClient = useQueryClient();
const state = subtask.stateId ? projectStates.find((s) => s.id === subtask.stateId) : null;
const isDone = state?.group === "completed";
const toggle = async () => {
const completedState = projectStates.find((s) => s.group === "completed");
const openState = projectStates.find((s) => s.group !== "completed" && s.group !== "cancelled");
await api.patch(`/tasks/${subtask.id}`, {
stateId: isDone ? openState?.id || null : completedState?.id || null,
});
queryClient.invalidateQueries({ queryKey: ["task", subtask.parentId || ""] });
onChanged();
};
return (
<div className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted/50">
<Checkbox checked={!!isDone} onCheckedChange={toggle} aria-label={"Toggle " + subtask.title} />
<span className={cn("min-w-0 flex-1 truncate text-sm", isDone && "text-muted-foreground line-through")}>
{subtask.title}
</span>
</div>
);
}
+20 -16
View File
@@ -10,10 +10,10 @@
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code", "Source Code Pro",
Menlo, Consolas, "DejaVu Sans Mono", monospace;
/* Control heights & sizing */
--control-height: 40px;
--control-height-sm: 32px;
--control-height-lg: 48px;
/* Control heights & sizing — Buzzbee 32px control grid */
--control-height: 32px;
--control-height-sm: 24px;
--control-height-lg: 40px;
--size: 16px;
--size-sm: 12px;
--size-md: 20px;
@@ -27,30 +27,34 @@
--duration-slow: 0.3s;
--ease-default: cubic-bezier(0.645, 0.045, 0.355, 1);
/* Light mode — Buzzbee palette */
/* Light mode — Buzzbee palette (#1677ff accent, #01bec6 teal) */
--background: 0 0% 100%;
--foreground: 0 0% 6.7%;
--card: 220 13% 97%;
--card: 220 14% 97%;
--card-foreground: 0 0% 6.7%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 6.7%;
--primary: 217 91% 60%;
--primary: 216 100% 54%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary: 220 14% 97%;
--secondary-foreground: 0 0% 18%;
--muted: 210 40% 96%;
--muted: 220 14% 97%;
--muted-foreground: 220 9% 45%;
--accent: 210 100% 95%;
--accent-foreground: 222 100% 27%;
--accent: 214 100% 95%;
--accent-foreground: 224 100% 27%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 88%;
--input: 214 32% 88%;
--ring: 210 100% 75%;
--border: 214 24% 87%;
--input: 214 24% 87%;
--ring: 213 100% 77%;
--radius: 0.5rem;
/* Interactive accent — pinned vibrant blue */
--accent-hsl: 217 91% 60%;
/* Interactive accent — Buzzbee primary #1677ff */
--accent-hsl: 216 100% 54%;
--brand-teal: 184 99% 39%;
--brand-primary-bg: 214 100% 95%;
--brand-primary-border: 213 100% 77%;
--brand-focus-ring: 0 0 0 3px #e6f4ff;
/* Shell surfaces */
--sidebar-bg: 0 0% 100%;
+11 -11
View File
@@ -13,12 +13,12 @@ export interface StatusToken {
badge: string;
}
/** Task workflow statuses (board column dots + badges). */
/** Task workflow statuses (board column dots + badges) — Buzzbee tints. */
export const TASK_STATUS: Record<string, StatusToken> = {
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
todo: { label: "Todo", dot: "bg-slate-400", badge: "bg-[#f1f1f1] text-[#2e2e2e] border border-[#dbdbdb]" },
in_progress: { label: "In Progress", dot: "bg-[#1677ff]", badge: "bg-[#e6f4ff] text-[#002c8c] border border-[#91caff]" },
done: { label: "Done", dot: "bg-[#52c41a]", badge: "bg-[#f6ffed] text-[#135200] border border-[#b6eb8f]" },
cancelled: { label: "Cancelled", dot: "bg-[#ff4d4f]", badge: "bg-[#fff2f0] text-[#820014] border border-[#ffccc7]" },
};
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
@@ -29,12 +29,12 @@ export const PRIORITY: Record<string, { label: string; badge: string }> = {
urgent: { label: "Urgent", badge: "text-red-600 bg-red-500/15" },
};
/** Project lifecycle statuses. */
/** Project lifecycle statuses — Buzzbee tints. */
export const PROJECT_STATUS: Record<string, StatusToken> = {
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
active: { label: "Active", dot: "bg-[#52c41a]", badge: "bg-[#f6ffed] text-[#135200] border border-[#b6eb8f]" },
paused: { label: "Paused", dot: "bg-[#faad14]", badge: "bg-[#fffbe6] text-[#874c00] border border-[#ffe58f]" },
completed: { label: "Completed", dot: "bg-[#1677ff]", badge: "bg-[#e6f4ff] text-[#002c8c] border border-[#91caff]" },
archived: { label: "Archived", dot: "bg-slate-400", badge: "bg-[#f1f1f1] text-[#646464] border border-[#dbdbdb]" },
};
/**
@@ -62,7 +62,7 @@ export const ENTITY: Record<string, string> = {
* pick up the user's chosen accent color.
*/
export const CALENDAR_EVENT: Record<string, string> = {
task: "var(--accent-hsl, 217 91% 60%)",
task: "var(--accent-hsl, 216 100% 54%)",
habit: "142 71% 45%",
project: "271 81% 56%",
note: "24 95% 53%",
+19 -2
View File
@@ -187,6 +187,12 @@ function CalendarPage() {
const activeDomainId = useApiDomain();
const [showTasks, setShowTasks] = useState(true);
const [showHabits, setShowHabits] = useState(true);
const [filterProjectId, setFilterProjectId] = useState("");
const { data: calendarProjectsData } = useApiQuery<{ items: { id: string; name: string }[] }>(
["projects", activeDomainId, "calendar"],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const fromISO = useMemo(() => { const d = new Date(date); d.setDate(1); d.setHours(0,0,0,0); d.setMonth(d.getMonth()-1); return d.toISOString(); }, [date]);
const toISO = useMemo(() => { const d = new Date(date); d.setMonth(d.getMonth()+2); d.setDate(0); d.setHours(23,59,59,999); return d.toISOString(); }, [date]);
@@ -196,8 +202,8 @@ function CalendarPage() {
`/calendar/events?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const { data: unifiedData } = useApiQuery<{ items: CalendarEvent[] }>(
["calendar-unified", activeDomainId, fromISO, toISO],
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "")
["calendar-unified", activeDomainId, fromISO, toISO, filterProjectId],
`/calendar/unified?from=${encodeURIComponent(fromISO)}&to=${encodeURIComponent(toISO)}` + (activeDomainId ? "&domain=" + activeDomainId : "") + (filterProjectId ? "&project_id=" + filterProjectId : "")
);
const events = useMemo(() => {
if (!unifiedData?.items) return eventsData?.items || [];
@@ -352,6 +358,17 @@ function CalendarPage() {
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showTasks} onChange={e=>setShowTasks(e.target.checked)} /> Tasks</label>
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={showHabits} onChange={e=>setShowHabits(e.target.checked)} /> Habits</label>
<select
value={filterProjectId}
onChange={(e) => setFilterProjectId(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-2 text-xs"
aria-label="Filter calendar by project"
>
<option value="">All projects</option>
{(calendarProjectsData?.items || []).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-1">
{["month", "week", "day", "agenda"].map((name) => (
+60 -10
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -6,7 +6,7 @@ import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { Plus, Pencil, Trash2, Calendar, LayoutGrid, List } from "lucide-react";
import { Plus, Pencil, Trash2, Calendar, LayoutGrid, List, Search, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -117,15 +117,16 @@ function ProjectCard({
onOpenPanel: (p: Project) => void;
}) {
const status = PROJECT_STATUS[project.status];
const overdue = project.targetDate && project.status === "active" && new Date(project.targetDate) < new Date();
return (
<Card
className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20"
className="cursor-pointer rounded-lg border border-[#d9dee7] bg-white shadow-[0_1px_2px_#fafafa] transition-colors hover:bg-[#f7f8fa]"
onClick={() => onOpenDetail(project)}
>
<CardHeader className="pb-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
<CardTitle className="text-base truncate">{project.name}</CardTitle>
<div className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: project.color || "#1677ff" }} />
<CardTitle className="truncate text-base">{project.name}</CardTitle>
<div className="ml-auto flex items-center gap-1.5">
{status && (
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5">
@@ -155,9 +156,16 @@ function ProjectCard({
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-mono">{project.completedCount}/{project.taskCount} tasks</span>
{project.targetDate && (
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}</span>
<span className={`flex items-center gap-1 ${overdue ? "font-semibold text-destructive" : ""}`}>
<Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}
</span>
)}
</div>
{project.updatedAt && (
<p className="mt-1.5 flex items-center gap-1 font-mono text-[10px] text-muted-foreground">
<Clock className="h-3 w-3" /> Updated {new Date(project.updatedAt).toLocaleDateString()}
</p>
)}
</CardContent>
</Card>
);
@@ -185,8 +193,19 @@ function ProjectsPage() {
projectQueryUrl()
);
const projects = projectsData?.items || [];
const hasMoreProjects = projects.length < (projectsData?.totalItems || 0);
const allProjects = projectsData?.items || [];
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const projects = useMemo(
() =>
allProjects.filter(
(p) =>
(!search || p.name.toLowerCase().includes(search.toLowerCase())) &&
(!statusFilter || p.status === statusFilter)
),
[allProjects, search, statusFilter]
);
const hasMoreProjects = allProjects.length < (projectsData?.totalItems || 0);
const [loadingMoreProjects, setLoadingMoreProjects] = useState(false);
const loadMoreProjects = async () => {
@@ -246,14 +265,45 @@ function ProjectsPage() {
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[180px] flex-1 sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search projects..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 pl-8" aria-label="Search projects" />
</div>
<Select value={statusFilter || "__all__"} onValueChange={(v) => setStatusFilter(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-8 w-40" aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
{(search || statusFilter) && (
<Button variant="ghost" size="sm" className="h-8" onClick={() => { setSearch(""); setStatusFilter(""); }}>
Clear
</Button>
)}
<span className="ml-auto font-mono text-xs text-muted-foreground">{projects.length} projects</span>
</div>
{isLoading ? (
<LoadingState label="Loading projects..." />
) : isError ? (
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
) : projects.length === 0 ? (
<EmptyState title="No projects yet" description="Create your first project to get started." />
<EmptyState title={search || statusFilter ? "No matching projects" : "No projects yet"} description={search || statusFilter ? "Try clearing your filters." : "Create your first project to get started."} />
) : view === "grid" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
type="button"
onClick={() => setCreateOpen(true)}
className="flex min-h-[148px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-[#91caff] bg-[#e6f4ff]/40 text-sm font-medium text-[#002c8c] transition-colors hover:bg-[#e6f4ff]"
>
<Plus className="h-5 w-5" />
New project
</button>
{projects.map((project) => (
<ProjectCard
key={project.id}
+89 -39
View File
@@ -4,12 +4,16 @@ import { Route as appRoute } from "../../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Activity,
Boxes,
Calendar,
Clock,
Flag,
FolderKanban,
Layers,
LayoutGrid,
ListTodo,
MessageSquare,
Plus,
Repeat,
Trash2,
@@ -19,7 +23,6 @@ import { differenceInCalendarDays, format, parseISO } from "date-fns";
import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { useOptimisticPatch } from "@/hooks/use-optimistic-patch";
import { EntityDetailPage } from "@/components/entities/detail-page";
import {
InlineDate,
InlineEdit,
@@ -153,25 +156,49 @@ function ProjectDetail() {
onError: (err) => toast.error(errorMessage(err)),
});
const [section, setSection] = useState<"tasks" | "overview" | "sections" | "modules" | "cycles" | "activity" | "comments">("tasks");
const { data: stats } = useApiQuery<{
total: number; completed: number; overdue: number; dueSoon: number; progress: number;
}>(["project-stats", id], "/projects/" + id + "/stats", { enabled: !!id });
if (isLoading) return <LoadingState label="Loading project..." />;
if (isError) {
return <ErrorState message={errorMessage(error)} onRetry={() => refetch()} />;
}
if (!project) return <ErrorState message="Project not found" />;
const taskCount = project.taskCount ?? project.tasks?.length ?? 0;
const sectionCount = project.sections?.length ?? 0;
const navItems: { value: typeof section; label: string; icon: React.ReactNode; count?: number }[] = [
{ value: "tasks", label: "Tasks", icon: <ListTodo className="h-4 w-4" />, count: taskCount },
{ value: "sections", label: "Sections", icon: <Layers className="h-4 w-4" />, count: sectionCount },
{ value: "modules", label: "Modules", icon: <Boxes className="h-4 w-4" /> },
{ value: "cycles", label: "Cycles", icon: <Repeat className="h-4 w-4" /> },
{ value: "overview", label: "Overview", icon: <LayoutGrid className="h-4 w-4" /> },
{ value: "activity", label: "Activity", icon: <Activity className="h-4 w-4" /> },
{ value: "comments", label: "Comments", icon: <MessageSquare className="h-4 w-4" /> },
];
return (
<EntityDetailPage
backTo={{ to: "/projects", label: "Back to Projects" }}
title={
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/projects" })}>
Projects
</Button>
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-[#d9dee7] bg-[#f7f8fa]">
<FolderKanban className="h-5 w-5" style={{ color: project.color || "#1677ff" }} />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-bold">
<InlineText
value={project.name}
onSave={(name) => patch({ id, data: { name } })}
placeholder="Untitled project"
/>
}
icon={<FolderKanban className="h-6 w-6" />}
badges={
<>
</h3>
</div>
<InlineSelect
value={project.status}
options={PROJECT_STATUS_OPTIONS}
@@ -189,8 +216,8 @@ function ProjectDetail() {
title="Edit color"
display={() => (
<span
className="h-4 w-4 rounded-full"
style={{ backgroundColor: project.color || "#3b82f6" }}
className="h-4 w-4 rounded-full border border-[#d9dee7]"
style={{ backgroundColor: project.color || "#1677ff" }}
/>
)}
renderEdit={(v, onChange, commit) => (
@@ -198,7 +225,7 @@ function ProjectDetail() {
type="color"
autoFocus
className="h-8 w-12"
value={v || "#3b82f6"}
value={v || "#1677ff"}
onChange={(e) => {
onChange(e.target.value);
commit(e.target.value);
@@ -206,13 +233,10 @@ function ProjectDetail() {
/>
)}
/>
</>
}
actions={
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4" /> Delete
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -234,29 +258,55 @@ function ProjectDetail() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
tabs={[
{
value: "overview",
label: "Overview",
content: <Overview project={project} patch={patch} />,
},
{ value: "tasks", label: "Tasks", content: <ProjectTasks project={project} /> },
{ value: "sections", label: "Sections", content: <Sections project={project} /> },
{ value: "modules", label: "Modules", content: <ProjectModules project={project} /> },
{ value: "cycles", label: "Cycles", content: <ProjectCycles project={project} /> },
{
value: "activity",
label: "Activity",
content: <EntityActivity entityType="project" entityId={id} />,
},
{
value: "comments",
label: "Comments",
content: <EntityComments entityType="project" entityId={id} />,
},
]}
/>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<nav className="w-full shrink-0 space-y-1 md:w-52" aria-label="Project sections">
<div className="rounded-lg border border-[#d9dee7] bg-white p-3">
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-semibold">Progress</p>
<span className="ml-auto font-mono text-xs font-medium">{stats?.progress ?? project.progress ?? 0}%</span>
</div>
<Progress value={stats?.progress ?? project.progress ?? 0} className="mt-2 h-1.5" />
<p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
{stats?.completed ?? project.completedCount ?? 0} of {stats?.total ?? taskCount} done
{(stats?.overdue ?? 0) > 0 && <span className="text-destructive"> · {stats?.overdue} overdue</span>}
</p>
</div>
{navItems.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setSection(item.value)}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors",
section === item.value
? "border-b-2 border-b-[#1677ff] bg-[#e6f4ff]/60 font-semibold text-[#002c8c]"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)}
aria-current={section === item.value ? "page" : undefined}
>
{item.icon}
<span className="flex-1 text-left">{item.label}</span>
{item.count !== undefined && (
<Badge variant="secondary" className="font-mono text-[10px]">{item.count}</Badge>
)}
</button>
))}
</nav>
<div className="min-w-0 flex-1 rounded-lg border border-[#d9dee7] bg-white p-4">
{section === "tasks" && <ProjectTasks project={project} />}
{section === "overview" && <Overview project={project} patch={patch} />}
{section === "sections" && <Sections project={project} />}
{section === "modules" && <ProjectModules project={project} />}
{section === "cycles" && <ProjectCycles project={project} />}
{section === "activity" && <EntityActivity entityType="project" entityId={id} />}
{section === "comments" && <EntityComments entityType="project" entityId={id} />}
</div>
</div>
</div>
);
}
+314 -183
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -9,7 +9,7 @@ import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal, Bookmark, X } from "lucide-react";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, MoreHorizontal, Bookmark, X, CalendarDays } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -21,30 +21,33 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { PRIORITY } from "@/lib/status-colors";
import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import type { Task, State, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
import { FilterBar, type DueFilter } from "@/components/filters/filter-bar";
import { QuickAddBar } from "@/components/tasks/quick-add-bar";
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
import { format, parseISO, startOfDay, endOfDay, addDays, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
{ id: "unstarted", label: "Unstarted", colorClass: "bg-slate-500" },
{ id: "started", label: "Started", colorClass: "bg-blue-500" },
{ id: "completed", label: "Completed", colorClass: "bg-green-500" },
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500" },
/** Fallback columns when no project is selected (group by task status). */
const FALLBACK_COLUMNS: { id: string; label: string; colorClass: string; statuses: string[] }[] = [
{ id: "todo", label: "Todo", colorClass: "bg-slate-400", statuses: ["todo"] },
{ id: "in_progress", label: "In Progress", colorClass: "bg-blue-500", statuses: ["in_progress"] },
{ id: "done", label: "Done", colorClass: "bg-green-500", statuses: ["done"] },
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500", statuses: ["cancelled"] },
];
function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; onClick: () => void; onEdit?: () => void }) {
function SortableTaskCard({ task, stateName, stateColor, projectName, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; projectName?: string; onClick: () => void; onEdit?: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style = {
@@ -53,42 +56,47 @@ function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { ta
opacity: isDragging ? 0.5 : 1,
};
const overdue = task.dueDate && !task.completedAt && new Date(task.dueDate) < new Date();
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Card className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20" onClick={onClick}>
<Card className="cursor-pointer rounded-lg border border-[#d9dee7] bg-white transition-colors hover:bg-[#f7f8fa]" onClick={onClick}>
<CardContent className="p-2.5">
<div className="flex items-start gap-2">
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
<GripVertical className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{task.title}</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{stateName && (
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
<Badge variant="secondary" className="gap-1 font-mono text-[10px]" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
{stateName}
</Badge>
)}
{task.dueDate && (
<Badge variant="outline" className="font-mono text-[10px]">
<Calendar className="h-3 w-3 mr-1" />
<Badge variant="outline" className={cn("font-mono text-[10px]", overdue && "border-[#ffccc7] bg-[#fff2f0] text-[#820014]")}>
<Calendar className="mr-1 h-3 w-3" />
{new Date(task.dueDate).toLocaleDateString()}
</Badge>
)}
<Badge variant="secondary" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>
{PRIORITY[task.priority]?.label ?? task.priority}
</Badge>
{task.tags?.slice(0, 2).map((tag) => (
{task.tags?.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="font-mono text-[10px]" style={{ borderColor: tag.color || undefined }}>
{tag.name}
</Badge>
))}
</div>
{projectName && (
<p className="mt-1 truncate text-[11px] text-muted-foreground">{projectName}</p>
)}
</div>
{onEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 -mt-1 -mr-1 shrink-0"
className="-mr-1 -mt-1 h-7 w-7 shrink-0"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
aria-label={"Edit " + task.title}
>
@@ -169,6 +177,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
const customFields = { ...customFieldValues };
if (Object.keys(customFields).length > 0) data.customFields = customFields;
if (projectId && !task) data.projectId = projectId;
if (task) {
updateMutation.mutate(data);
} else {
@@ -182,8 +191,8 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground"> try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
{parsed && (parsed.dueDate || parsed.priority || parsed.tags.length > 0) && (
<div className="flex flex-wrap gap-1 mt-1.5">
{parsed.dueDate && <Badge variant="outline" className="text-[10px]">Due {new Date(parsed.dueDate).toLocaleDateString()} {new Date(parsed.dueDate).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</Badge>}
<div className="mt-1.5 flex flex-wrap gap-1">
{parsed.dueDate && <Badge variant="outline" className="border-[#91caff] bg-[#e6f4ff] text-[10px] text-[#002c8c]">Due {new Date(parsed.dueDate).toLocaleDateString()} {new Date(parsed.dueDate).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</Badge>}
{parsed.priority && <Badge variant="secondary" className="text-[10px]">Priority {parsed.priority}</Badge>}
{parsed.tags.map(t => <Badge key={t} variant="outline" className="text-[10px]">#{t}</Badge>)}
</div>
@@ -242,14 +251,106 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
);
}
function applyDueFilter(tasks: Task[], dueFilter: DueFilter): Task[] {
if (dueFilter === "all") return tasks;
const now = new Date();
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
const weekEnd = endOfDay(addDays(now, 7));
return tasks.filter((t) => {
if (dueFilter === "none") return !t.dueDate;
if (!t.dueDate) return false;
const d = new Date(t.dueDate);
if (dueFilter === "overdue") return d < now && !t.completedAt;
if (dueFilter === "today") return d >= todayStart && d <= todayEnd;
if (dueFilter === "week") return d >= todayStart && d <= weekEnd;
return true;
});
}
function TasksCalendar({ tasks, onSelectTask }: { tasks: Task[]; onSelectTask: (t: Task) => void }) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const days = useMemo(() => {
const start = startOfWeek(startOfMonth(month));
const end = endOfWeek(endOfMonth(month));
return eachDayOfInterval({ start, end });
}, [month]);
const byDay = useMemo(() => {
const map = new Map<string, Task[]>();
for (const t of tasks) {
if (!t.dueDate) continue;
const key = format(parseISO(t.dueDate), "yyyy-MM-dd");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(t);
}
return map;
}, [tasks]);
return (
<div className="rounded-lg border border-[#d9dee7] bg-white">
<div className="flex items-center justify-between border-b border-[#f1f1f1] px-3 py-2">
<h3 className="font-mono text-sm font-semibold">{format(month, "MMMM yyyy")}</h3>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7" onClick={() => setMonth(startOfMonth(new Date()))}>Today</Button>
<Button variant="outline" size="sm" className="h-7" onClick={() => setMonth(addDays(startOfMonth(month), -15))}></Button>
<Button variant="outline" size="sm" className="h-7" onClick={() => setMonth(addDays(endOfMonth(month), 15))}></Button>
</div>
</div>
<div className="grid grid-cols-7 border-b border-[#f1f1f1] bg-[#fafafa]">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d) => (
<div key={d} className="px-2 py-1 text-center text-[11px] font-semibold text-muted-foreground">{d}</div>
))}
</div>
<div className="grid grid-cols-7">
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = byDay.get(key) || [];
return (
<div
key={key}
className={cn(
"min-h-[84px] border-b border-r border-[#f1f1f1] p-1 [&:nth-child(7n)]:border-r-0",
!isSameMonth(day, month) && "bg-[#fafafa]/60 text-muted-foreground"
)}
>
<p className={cn("px-1 font-mono text-[11px]", isToday(day) && "font-bold text-[#1677ff]")}>
{format(day, "d")}
</p>
<div className="space-y-0.5">
{dayTasks.slice(0, 3).map((t) => (
<button
key={t.id}
type="button"
onClick={() => onSelectTask(t)}
className="block w-full truncate rounded border border-[#91caff] bg-[#e6f4ff] px-1 py-0.5 text-left text-[11px] text-[#002c8c] hover:bg-[#bae0ff]"
>
{t.title}
</button>
))}
{dayTasks.length > 3 && (
<p className="px-1 text-[10px] text-muted-foreground">+{dayTasks.length - 3} more</p>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
function TasksPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [view, setView] = useState<"board" | "list">("board");
const [view, setView] = useState<"board" | "list" | "calendar">("board");
const [search, setSearch] = useState("");
const [priority, setPriority] = useState("");
const [dueFilter, setDueFilter] = useState<DueFilter>("all");
const [selectedStateId, setSelectedStateId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [panelTaskId, setPanelTaskId] = useState<string | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
@@ -264,11 +365,12 @@ function TasksPage() {
const activeDomainId = useApiDomain();
const { data: projectsData } = useApiQuery<PaginatedResponse<{ id: string; name: string }>>(
const { data: projectsData } = useApiQuery<PaginatedResponse<{ id: string; name: string; color?: string | null }>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const projects = projectsData?.items || [];
const projectById = useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]);
const [filterProjectId, setFilterProjectId] = useState("");
const [saveViewOpen, setSaveViewOpen] = useState(false);
@@ -282,13 +384,7 @@ function TasksPage() {
"/states?projectId=" + filterProjectId,
{ enabled: !!filterProjectId }
);
const projectStates = statesData?.items || [];
const stateGroupOf = useMemo(() => {
const map = new Map<string, StateGroup>();
for (const s of projectStates) map.set(s.id, s.group);
return map;
}, [projectStates]);
const projectStates = useMemo(() => (statesData?.items || []).sort((a, b) => a.sortOrder - b.sortOrder), [statesData]);
const stateById = useMemo(() => {
const map = new Map<string, State>();
@@ -296,6 +392,15 @@ function TasksPage() {
return map;
}, [projectStates]);
const groupedQuery = useQuery({
queryKey: ["task-groups", activeDomainId, filterProjectId],
queryFn: () =>
api.get<{ groups: { id: string; name: string; color: string | null; group?: string; tasks: Task[] }[]; totalItems: number }>(
"/tasks/grouped?group_by=state" + (activeDomainId ? "&domain=" + activeDomainId : "") + (filterProjectId ? "&project_id=" + filterProjectId : "")
),
enabled: !!filterProjectId,
});
const taskQueryParams = () =>
new URLSearchParams({
limit: "200",
@@ -303,15 +408,17 @@ function TasksPage() {
...(search ? { search } : {}),
...(filterProjectId ? { project_id: filterProjectId } : {}),
...(selectedStateId ? { state_id: selectedStateId } : {}),
...(priority ? { priority } : {}),
}).toString();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, filterProjectId, selectedStateId],
["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority],
"/tasks?" + taskQueryParams()
);
const tasks = tasksData?.items || [];
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
const allTasks = tasksData?.items || [];
const tasks = useMemo(() => applyDueFilter(allTasks, dueFilter), [allTasks, dueFilter]);
const hasMoreTasks = allTasks.length < (tasksData?.totalItems || 0);
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
// Keyboard navigation for list view
@@ -358,36 +465,6 @@ function TasksPage() {
});
}
break;
case "J":
if (e.shiftKey) {
e.preventDefault();
if (tasks.length > 1) {
const newOrder = tasks.map((t) => t.id);
const idx = currentIdx;
const nextIdx = Math.min(idx + 1, tasks.length - 1);
if (idx !== nextIdx) {
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
reorderMutation.mutate({ orderedIds: newOrder });
setSelectedRowIndex(nextIdx);
}
}
}
break;
case "K":
if (e.shiftKey) {
e.preventDefault();
if (tasks.length > 1) {
const newOrder = tasks.map((t) => t.id);
const idx = currentIdx;
const nextIdx = Math.max(idx - 1, 0);
if (idx !== nextIdx) {
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
reorderMutation.mutate({ orderedIds: newOrder });
setSelectedRowIndex(nextIdx);
}
}
}
break;
case "Enter":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
@@ -407,7 +484,6 @@ function TasksPage() {
e.preventDefault();
const task = tasks[selectedRowIndex];
const newStatus = task.status === "done" ? "todo" : "done";
stateUpdateMutation.mutate({ taskId: task.id, stateId: task.stateId || "" });
api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
});
@@ -425,7 +501,7 @@ function TasksPage() {
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [view, tasks, selectedRowIndex, selectedTaskIds]);
}, [view, tasks, selectedRowIndex, selectedTaskIds, queryClient]);
// Hide kbd hint after 5 seconds
useEffect(() => {
@@ -439,9 +515,9 @@ function TasksPage() {
setLoadingMoreTasks(true);
try {
const next = await api.get<PaginatedResponse<Task>>(
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(allTasks.length) }).toString()
);
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((t) => t.id));
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
@@ -456,12 +532,21 @@ function TasksPage() {
api.patch<Task>("/tasks/" + taskId, { stateId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
queryClient.invalidateQueries({ queryKey: ["task-groups"] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const statusUpdateMutation = useMutation({
mutationFn: ({ taskId, status }: { taskId: string; status: string }) =>
api.patch<Task>("/tasks/" + taskId, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const reorderMutation = useMutation({
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
api.post("/tasks/reorder", { orderedIds }),
@@ -548,15 +633,12 @@ function TasksPage() {
useSensor(KeyboardSensor)
);
const taskGroupOf = useCallback(
(task: Task): StateGroup => {
if (task.stateId) {
const group = stateGroupOf.get(task.stateId);
if (group) return group;
}
return "unstarted";
const columnOfTask = useCallback(
(task: Task): string => {
if (filterProjectId) return task.stateId || "__none__";
return task.status || "todo";
},
[stateGroupOf]
[filterProjectId]
);
const handleDragStart = (event: DragStartEvent) => {
@@ -574,25 +656,26 @@ function TasksPage() {
const draggedTask = tasks.find((t) => t.id === taskId);
if (!draggedTask) return;
const columnTasks = (group: StateGroup) =>
const columnTasks = (colId: string) =>
tasks
.filter((t) => taskGroupOf(t) === group)
.filter((t) => columnOfTask(t) === colId)
.sort((a, b) => a.order - b.order);
let targetGroup: StateGroup;
const allColumnIds = columns.map((c) => c.id);
let targetColumn: string;
let insertIndex: number;
if (STATE_GROUP_COLUMNS.some((c) => c.id === overId)) {
targetGroup = overId as StateGroup;
if (allColumnIds.includes(overId)) {
targetColumn = overId;
insertIndex = -1;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
targetGroup = taskGroupOf(overTask);
const overIndex = columnTasks(targetGroup).findIndex((t) => t.id === overId);
targetColumn = columnOfTask(overTask);
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
insertIndex = overIndex === -1 ? -1 : overIndex;
}
const targetIds = columnTasks(targetGroup)
const targetIds = columnTasks(targetColumn)
.map((t) => t.id)
.filter((id) => id !== taskId);
if (insertIndex === -1) {
@@ -601,15 +684,15 @@ function TasksPage() {
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
}
const currentIds = columnTasks(targetGroup).map((t) => t.id);
const currentIds = columnTasks(targetColumn).map((t) => t.id);
const unchanged =
currentIds.length === targetIds.length &&
currentIds.every((id, i) => id === targetIds[i]);
if (unchanged) return;
const groupChanged = taskGroupOf(draggedTask) !== targetGroup;
const groupChanged = columnOfTask(draggedTask) !== targetColumn;
const orderById = new Map(targetIds.map((id, i) => [id, i]));
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, filterProjectId, selectedStateId, priority], (old) => {
if (!old) return old;
return {
...old,
@@ -620,10 +703,12 @@ function TasksPage() {
};
});
if (groupChanged && draggedTask.projectId) {
const firstStateInGroup = projectStates.find((s) => s.group === targetGroup && s.projectId === draggedTask.projectId);
if (firstStateInGroup) {
stateUpdateMutation.mutate({ taskId, stateId: firstStateInGroup.id });
if (groupChanged) {
if (filterProjectId && targetColumn !== "__none__") {
stateUpdateMutation.mutate({ taskId, stateId: targetColumn });
} else if (!filterProjectId) {
const statusMap: Record<string, string> = { todo: "todo", in_progress: "in_progress", done: "done", cancelled: "cancelled" };
if (statusMap[targetColumn]) statusUpdateMutation.mutate({ taskId, status: statusMap[targetColumn] });
}
}
reorderMutation.mutate({ orderedIds: targetIds });
@@ -635,32 +720,66 @@ function TasksPage() {
const openTaskPanel = (task: Task) => {
setSelectedTask(task);
setPanelTaskId(task.id);
setPanelOpen(true);
};
const clearFilters = () => {
setSearch("");
setFilterProjectId("");
setSelectedStateId("");
setPriority("");
setDueFilter("all");
};
const columns = useMemo(() => {
return STATE_GROUP_COLUMNS.map((col) => ({
...col,
tasks: tasks
.filter((t) => taskGroupOf(t) === col.id)
.sort((a, b) => a.order - b.order),
if (filterProjectId) {
const groups = groupedQuery.data?.groups;
if (groups) {
return groups.map((g) => ({
id: g.id,
label: g.name,
color: g.color,
tasks: applyDueFilter(
g.tasks.filter((t) =>
(!search || t.title.toLowerCase().includes(search.toLowerCase())) &&
(!selectedStateId || t.stateId === selectedStateId) &&
(!priority || t.priority === priority)
),
dueFilter
).sort((a, b) => a.order - b.order),
}));
}, [tasks, taskGroupOf]);
}
return projectStates.map((s) => ({
id: s.id,
label: s.name,
color: s.color,
tasks: tasks.filter((t) => t.stateId === s.id).sort((a, b) => a.order - b.order),
}));
}
return FALLBACK_COLUMNS.map((col) => ({
id: col.id,
label: col.label,
colorClass: col.colorClass,
tasks: tasks.filter((t) => col.statuses.includes(t.status)).sort((a, b) => a.order - b.order),
}));
}, [filterProjectId, groupedQuery.data, projectStates, tasks, search, selectedStateId, priority, dueFilter]);
return (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Tasks</h1>
<div className="flex items-center gap-2">
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list" | "calendar")}>
<TabsList>
<TabsTrigger value="board" aria-label="Board view"><LayoutIcon className="h-4 w-4" /></TabsTrigger>
<TabsTrigger value="list" aria-label="List view"><ListTodo className="h-4 w-4" /></TabsTrigger>
<TabsTrigger value="calendar" aria-label="Calendar view"><CalendarDays className="h-4 w-4" /></TabsTrigger>
</TabsList>
</Tabs>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button aria-label="New task"><Plus className="h-4 w-4 mr-2" />New Task</Button>
<Button aria-label="New task"><Plus className="mr-2 h-4 w-4" />New Task</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
@@ -672,67 +791,46 @@ function TasksPage() {
</div>
</div>
<div className="flex gap-1.5 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
</div>
<Select value={filterProjectId} onValueChange={setFilterProjectId}>
<SelectTrigger className="w-44"><SelectValue placeholder="All projects" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
{filterProjectId && projectStates.length > 0 && (
<Select value={selectedStateId} onValueChange={setSelectedStateId}>
<SelectTrigger className="w-40"><SelectValue placeholder="All states" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All states</SelectItem>
{projectStates.map((s) => (
<SelectItem key={s.id} value={s.id}>
<span className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color || "#94a3b8" }} />
{s.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<QuickAddBar projectId={filterProjectId || undefined} onMoreOptions={() => setCreateOpen(true)} />
{/* Saved views */}
<FilterBar
search={search}
onSearchChange={setSearch}
projects={projects}
projectId={filterProjectId}
onProjectChange={(v) => { setFilterProjectId(v); setSelectedStateId(""); }}
states={projectStates}
stateId={selectedStateId}
onStateChange={setSelectedStateId}
priority={priority}
onPriorityChange={setPriority}
dueFilter={dueFilter}
onDueChange={setDueFilter}
onClearAll={clearFilters}
/>
<div className="flex flex-wrap items-center gap-1.5">
{savedViews.length > 0 && (
<Select
value=""
onValueChange={(id) => {
const view = savedViews.find((v) => v.id === id);
if (view) {
setSearch(view.filters.search);
setFilterProjectId(view.filters.projectId);
setSelectedStateId(view.filters.stateId);
onValueChange={(vid) => {
const v = savedViews.find((sv) => sv.id === vid);
if (v) {
setSearch(v.filters.search);
setFilterProjectId(v.filters.projectId);
setSelectedStateId(v.filters.stateId);
setPriority(v.filters.priority);
}
}}
>
<SelectTrigger className="w-44"><SelectValue placeholder="Saved views" /></SelectTrigger>
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Saved views" /></SelectTrigger>
<SelectContent>
{savedViews.map((v) => (
<SelectItem key={v.id} value={v.id} className="flex items-center justify-between">
<SelectItem key={v.id} value={v.id}>
<span className="flex items-center gap-2">
<Bookmark className="h-3 w-3" />
{v.name}
</span>
<button
className="ml-auto hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeSavedView(v.id);
}}
>
<X className="h-3 w-3" />
</button>
</SelectItem>
))}
</SelectContent>
@@ -741,7 +839,7 @@ function TasksPage() {
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs">
<Bookmark className="h-3.5 w-3.5" /> Save View
</Button>
</DialogTrigger>
@@ -756,7 +854,7 @@ function TasksPage() {
onChange={(e) => setViewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && viewName.trim()) {
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority });
setViewName("");
setSaveViewOpen(false);
}
@@ -767,7 +865,7 @@ function TasksPage() {
<Button
disabled={!viewName.trim()}
onClick={() => {
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority });
setViewName("");
setSaveViewOpen(false);
}}
@@ -778,6 +876,22 @@ function TasksPage() {
</div>
</DialogContent>
</Dialog>
{savedViews.length > 0 && (
<div className="flex flex-wrap gap-1">
{savedViews.map((v) => (
<Badge key={v.id} variant="outline" className="gap-1 text-[10px]">
{v.name}
<button
className="ml-0.5 hover:text-destructive"
onClick={() => removeSavedView(v.id)}
aria-label={`Remove saved view ${v.name}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{isLoading ? (
@@ -786,18 +900,22 @@ function TasksPage() {
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
) : view === "board" ? (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
{columns.map((col) => (
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<ColumnDroppable key={col.id} id={col.id} className="rounded-lg border border-[#d9dee7] bg-[#f7f8fa] p-2.5">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
<h3 className="font-semibold text-sm">{col.label}</h3>
{"color" in col && col.color ? (
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: col.color as string }} />
) : (
<div className={cn("h-2 w-2 rounded-full", (col as any).colorClass || "bg-slate-400")} />
)}
<h3 className="text-sm font-semibold">{col.label}</h3>
<Badge variant="secondary" className="font-mono text-[10px]">{col.tasks.length}</Badge>
</div>
</div>
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1.5 min-h-[100px]">
<div className="min-h-[100px] space-y-1.5">
{col.tasks.map((task) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined;
return (
@@ -806,13 +924,14 @@ function TasksPage() {
task={task}
stateName={st?.name}
stateColor={st?.color}
onClick={() => openTaskDetail(task)}
projectName={!filterProjectId ? projectById.get(task.projectId || "")?.name : undefined}
onClick={() => openTaskPanel(task)}
onEdit={() => openTaskPanel(task)}
/>
);
})}
{col.tasks.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
<p className="py-4 text-center text-xs text-muted-foreground">No tasks</p>
)}
</div>
</SortableContext>
@@ -820,17 +939,19 @@ function TasksPage() {
))}
</div>
<DragOverlay>
{activeId ? <div className="p-3 bg-card rounded-lg shadow-lg border opacity-80">Moving...</div> : null}
{activeId ? <div className="rounded-lg border bg-card p-3 opacity-80 shadow-lg">Moving...</div> : null}
</DragOverlay>
</DndContext>
) : view === "calendar" ? (
<TasksCalendar tasks={tasks} onSelectTask={openTaskPanel} />
) : (
<div className="border rounded-lg relative">
<div className="relative rounded-lg border border-[#d9dee7] bg-white">
{view === "list" && showKbdHint && (
<div className="absolute top-0 right-0 text-[10px] text-muted-foreground flex gap-3 p-2 z-10">
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">j</kbd>/<kbd className="px-1 py-0.5 rounded border bg-muted font-mono">k</kbd> navigate</span>
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">Enter</kbd> open</span>
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">e</kbd> edit</span>
<span><kbd className="px-1 py-0.5 rounded border bg-muted font-mono">x</kbd> toggle</span>
<div className="absolute right-0 top-0 z-10 flex gap-3 p-2 text-[10px] text-muted-foreground">
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">j</kbd>/<kbd className="rounded border bg-muted px-1 py-0.5 font-mono">k</kbd> navigate</span>
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">Enter</kbd> open</span>
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">e</kbd> edit</span>
<span><kbd className="rounded border bg-muted px-1 py-0.5 font-mono">x</kbd> toggle</span>
</div>
)}
<Table>
@@ -865,7 +986,7 @@ function TasksPage() {
key={task.id}
data-row-index={idx}
className={cn(
"cursor-pointer hover:bg-muted/30 py-2",
"cursor-pointer py-2 hover:bg-muted/30",
idx === selectedRowIndex && "bg-accent/50 ring-1 ring-[hsl(var(--accent-hsl))]"
)}
onClick={(e) => {
@@ -880,7 +1001,7 @@ function TasksPage() {
return next;
});
} else {
openTaskDetail(task);
openTaskPanel(task);
}
}}
>
@@ -902,7 +1023,7 @@ function TasksPage() {
/>
</TableCell>
)}
<TableCell className="font-medium py-2">
<TableCell className="py-2 font-medium">
{editingTaskId === task.id ? (
<Input
autoFocus
@@ -927,7 +1048,7 @@ function TasksPage() {
</TableCell>
<TableCell className="py-2">
{st ? (
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
<Badge variant="secondary" className="gap-1 font-mono text-[10px]" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
{st.name}
</Badge>
@@ -938,7 +1059,7 @@ function TasksPage() {
<TableCell className="py-2">
<Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
</TableCell>
<TableCell className="font-mono text-[10px] text-muted-foreground py-2">
<TableCell className="py-2 font-mono text-[10px] text-muted-foreground">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
</TableCell>
<TableCell className="py-2">
@@ -947,7 +1068,8 @@ function TasksPage() {
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Quick view</DropdownMenuItem>
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Open full page</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
@@ -972,7 +1094,7 @@ function TasksPage() {
</div>
)}
{hasMoreTasks && (
{hasMoreTasks && view !== "calendar" && (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={loadMoreTasks} disabled={loadingMoreTasks}>
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
@@ -980,14 +1102,26 @@ function TasksPage() {
</div>
)}
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
{selectedTask && (
<div className="space-y-3">
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
<div className="pt-4 border-t">
<TaskDetailPanel
taskId={panelTaskId}
open={panelOpen}
onOpenChange={(o) => {
setPanelOpen(o);
if (!o) {
setPanelTaskId(null);
setSelectedTask(null);
}
}}
onOpenFullPage={(t) => {
setPanelOpen(false);
openTaskDetail(t);
}}
/>
{selectedTask && !panelOpen && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Task</Button>
<span className="hidden" />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -1000,10 +1134,7 @@ function TasksPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
</EntityDetailPanel>
</div>
);
}
+10 -2
View File
@@ -34,7 +34,7 @@ test.describe('Tasks', () => {
});
});
test('opens the task detail page when a task card is clicked', async ({ page }) => {
test('opens the task detail panel when a task card is clicked', async ({ page }) => {
// Make sure a task exists before trying to open it.
if (!(await page.getByText(testTasks.title, { exact: true }).isVisible().catch(() => false))) {
await page.getByRole('button', { name: /new task/i }).click();
@@ -46,6 +46,11 @@ test.describe('Tasks', () => {
await page.getByText(testTasks.title, { exact: true }).first().click();
// Clicking a card opens the slide-over detail panel.
await expect(page.getByText('Task details')).toBeVisible({ timeout: 10_000 });
// The panel offers a link to the full detail page.
await page.getByRole('button', { name: /open full page/i }).click();
await page.waitForURL('**/tasks/*', { timeout: 10_000 });
// The detail page renders the task title as a heading.
@@ -54,10 +59,13 @@ test.describe('Tasks', () => {
).toBeVisible({ timeout: 10_000 });
});
test('switches between board and list views', async ({ page }) => {
test('switches between board, list, and calendar views', async ({ page }) => {
await page.getByRole('tab', { name: /list view/i }).click();
await expect(page.getByRole('columnheader', { name: 'Title' })).toBeVisible();
await page.getByRole('tab', { name: /calendar view/i }).click();
await expect(page.getByText(/today/i).first()).toBeVisible();
await page.getByRole('tab', { name: /board view/i }).click();
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
});