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
@@ -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>
);
}