feat: enhance task management with due date handling and mention support

This commit is contained in:
Abhimanyu Saharan
2026-02-12 21:46:22 +05:30
parent 8e5fcd9243
commit 6cb5702a2b
13 changed files with 843 additions and 203 deletions

View File

@@ -82,14 +82,20 @@ const columns: Array<{
},
];
const formatDueDate = (value?: string | null) => {
if (!value) return undefined;
const date = parseApiDatetime(value);
if (!date) return undefined;
return date.toLocaleDateString(undefined, {
const resolveDueState = (
task: Task,
): { due: string | undefined; isOverdue: boolean } => {
const date = parseApiDatetime(task.due_at);
if (!date) return { due: undefined, isOverdue: false };
const dueLabel = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
return {
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
isOverdue,
};
};
type CardPosition = { left: number; top: number };
@@ -330,146 +336,156 @@ export const TaskBoard = memo(function TaskBoard({
ref={boardRef}
className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6"
>
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked")
return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 &&
!task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
return (
<div
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
>
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
</div>
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={formatDueDate(task.due_at)}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={readOnly ? undefined : handleDragStart(task)}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
return (
<div
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={
readOnly ? undefined : handleDragLeave(column.status)
}
>
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
))}
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
</div>
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => {
const dueState = resolveDueState(task);
return (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={dueState.due}
isOverdue={dueState.isOverdue}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={
readOnly ? undefined : handleDragStart(task)
}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
);
})}
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
);
});