feat(tasks): Normalize task statuses and enhance UI for task management

This commit is contained in:
Abhimanyu Saharan
2026-02-05 00:18:54 +05:30
parent 8f224910b5
commit 79155e9067
5 changed files with 236 additions and 84 deletions

View File

@@ -0,0 +1,27 @@
"""normalize task statuses
Revision ID: 75e05e158ca9
Revises: 4b2a5e2dbb6e
Create Date: 2026-02-05 00:16:48.958679
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = '75e05e158ca9'
down_revision = '4b2a5e2dbb6e'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"UPDATE tasks SET status='in_progress' WHERE status IN ('assigned','testing')"
)
def downgrade() -> None:
pass

View File

@@ -32,6 +32,15 @@ from app.services.activity_log import record_activity
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"]) router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:") REQUIRED_COMMENT_FIELDS = ("summary:", "details:", "next:")
ALLOWED_STATUSES = {"inbox", "in_progress", "review", "done"}
def validate_task_status(status_value: str) -> None:
if status_value not in ALLOWED_STATUSES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported task status.",
)
def is_valid_markdown_comment(message: str) -> bool: def is_valid_markdown_comment(message: str) -> bool:
@@ -82,6 +91,11 @@ def list_tasks(
if status_filter: if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()] statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
if statuses: if statuses:
if any(status not in ALLOWED_STATUSES for status in statuses):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported task status filter.",
)
statement = statement.where(col(Task.status).in_(statuses)) statement = statement.where(col(Task.status).in_(statuses))
if assigned_agent_id is not None: if assigned_agent_id is not None:
statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id) statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id)
@@ -99,6 +113,7 @@ def create_task(
session: Session = Depends(get_session), session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> Task: ) -> Task:
validate_task_status(payload.status)
task = Task.model_validate(payload) task = Task.model_validate(payload)
task.board_id = board.id task.board_id = board.id
if task.created_by_user_id is None and auth.user is not None: if task.created_by_user_id is None and auth.user is not None:
@@ -135,6 +150,7 @@ def update_task(
if not set(updates).issubset(allowed_fields): if not set(updates).issubset(allowed_fields):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates: if "status" in updates:
validate_task_status(updates["status"])
if updates["status"] == "inbox": if updates["status"] == "inbox":
task.assigned_agent_id = None task.assigned_agent_id = None
task.in_progress_at = None task.in_progress_at = None
@@ -143,6 +159,7 @@ def update_task(
if updates["status"] == "in_progress": if updates["status"] == "in_progress":
task.in_progress_at = datetime.utcnow() task.in_progress_at = datetime.utcnow()
elif "status" in updates: elif "status" in updates:
validate_task_status(updates["status"])
if updates["status"] == "inbox": if updates["status"] == "inbox":
task.assigned_agent_id = None task.assigned_agent_id = None
task.in_progress_at = None task.in_progress_at = None

View File

@@ -280,46 +280,65 @@ export default function BoardDetailPage() {
</SignedOut> </SignedOut>
<SignedIn> <SignedIn>
<DashboardSidebar /> <DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8"> <main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="sticky top-0 z-30 border-b border-slate-200 bg-white shadow-sm">
<div className="space-y-2"> <div className="px-8 py-6">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet"> <div className="flex flex-wrap items-center justify-between gap-4">
{board?.slug ?? "board"} <div>
</p> <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
<h1 className="text-2xl font-semibold text-strong"> <span>{board?.name ?? "Board"}</span>
{board?.name ?? "Board"} </div>
</h1> <h1 className="mt-2 text-2xl font-semibold text-slate-900 tracking-tight">
<p className="text-sm text-muted"> {board?.name ?? "Board"}
Keep tasks moving through your workflow. </h1>
</p> <p className="mt-1 text-sm text-slate-500">
Keep tasks moving through your workflow.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1">
<button className="rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white">
Board
</button>
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
List
</button>
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
Timeline
</button>
</div>
<Button
variant="outline"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div>
</div>
</div> </div>
<Button
variant="outline"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div> </div>
{error && ( <div className="p-6">
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted"> {error && (
{error} <div className="mb-4 rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
</div> {error}
)} </div>
)}
{isLoading ? ( {isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted"> <div className="flex min-h-[50vh] items-center justify-center text-sm text-slate-500">
Loading {titleLabel} Loading {titleLabel}
</div> </div>
) : ( ) : (
<TaskBoard <TaskBoard
tasks={displayTasks} tasks={displayTasks}
onCreateTask={() => setIsDialogOpen(true)} onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating} isCreateDisabled={isCreating}
onTaskSelect={openComments} onTaskSelect={openComments}
/> />
)} )}
</div> </div>
</main>
</SignedIn> </SignedIn>
<Dialog open={isCommentsOpen} onOpenChange={(open) => { <Dialog open={isCommentsOpen} onOpenChange={(open) => {

View File

@@ -1,7 +1,6 @@
import { CalendarClock, UserCircle } from "lucide-react"; import { CalendarClock, UserCircle } from "lucide-react";
import { StatusPill } from "@/components/atoms/StatusPill"; import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
interface TaskCardProps { interface TaskCardProps {
title: string; title: string;
@@ -18,9 +17,58 @@ export function TaskCard({
due, due,
onClick, onClick,
}: TaskCardProps) { }: TaskCardProps) {
const statusConfig: Record<
string,
{ label: string; dot: string; badge: string; text: string }
> = {
inbox: {
label: "Inbox",
dot: "bg-slate-400",
badge: "bg-slate-100",
text: "text-slate-600",
},
assigned: {
label: "Assigned",
dot: "bg-blue-500",
badge: "bg-blue-50",
text: "text-blue-700",
},
in_progress: {
label: "In progress",
dot: "bg-purple-500",
badge: "bg-purple-50",
text: "text-purple-700",
},
testing: {
label: "Testing",
dot: "bg-amber-500",
badge: "bg-amber-50",
text: "text-amber-700",
},
review: {
label: "Review",
dot: "bg-indigo-500",
badge: "bg-indigo-50",
text: "text-indigo-700",
},
done: {
label: "Done",
dot: "bg-green-500",
badge: "bg-green-50",
text: "text-green-700",
},
};
const config = statusConfig[status] ?? {
label: status,
dot: "bg-slate-400",
badge: "bg-slate-100",
text: "text-slate-600",
};
return ( return (
<Card <div
className="cursor-pointer border border-[color:var(--border)] bg-[color:var(--surface)] transition hover:border-[color:var(--border-strong)]" className="group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md"
onClick={onClick} onClick={onClick}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -31,26 +79,33 @@ export function TaskCard({
} }
}} }}
> >
<CardContent className="space-y-4"> <div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3"> <div className="space-y-2">
<div className="space-y-2"> <span
<p className="text-sm font-semibold text-strong">{title}</p> className={cn(
<StatusPill status={status} /> "inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide",
</div> config.badge,
config.text,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
{config.label}
</span>
<p className="text-sm font-medium text-slate-900">{title}</p>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted"> </div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">
<UserCircle className="h-4 w-4 text-slate-400" />
<span>{assignee ?? "Unassigned"}</span>
</div>
{due ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserCircle className="h-4 w-4" /> <CalendarClock className="h-4 w-4 text-slate-400" />
<span>{assignee ?? "Unassigned"}</span> <span>{due}</span>
</div> </div>
{due ? ( ) : null}
<div className="flex items-center gap-2"> </div>
<CalendarClock className="h-4 w-4" /> </div>
<span>{due}</span>
</div>
) : null}
</div>
</CardContent>
</Card>
); );
} }

View File

@@ -24,12 +24,34 @@ type TaskBoardProps = {
}; };
const columns = [ const columns = [
{ title: "Inbox", status: "inbox" }, {
{ title: "Assigned", status: "assigned" }, title: "Inbox",
{ title: "In Progress", status: "in_progress" }, status: "inbox",
{ title: "Testing", status: "testing" }, dot: "bg-slate-400",
{ title: "Review", status: "review" }, accent: "hover:border-slate-400 hover:bg-slate-50",
{ title: "Done", status: "done" }, text: "group-hover:text-slate-700 text-slate-500",
},
{
title: "In Progress",
status: "in_progress",
dot: "bg-purple-500",
accent: "hover:border-purple-400 hover:bg-purple-50",
text: "group-hover:text-purple-600 text-slate-500",
},
{
title: "Review",
status: "review",
dot: "bg-indigo-500",
accent: "hover:border-indigo-400 hover:bg-indigo-50",
text: "group-hover:text-indigo-600 text-slate-500",
},
{
title: "Done",
status: "done",
dot: "bg-green-500",
accent: "hover:border-green-400 hover:bg-green-50",
text: "group-hover:text-green-600 text-slate-500",
},
]; ];
const formatDueDate = (value?: string | null) => { const formatDueDate = (value?: string | null) => {
@@ -61,41 +83,53 @@ export function TaskBoard({
}, [tasks]); }, [tasks]);
return ( return (
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-6 overflow-x-auto pb-4"> <div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
{columns.map((column) => { {columns.map((column) => {
const columnTasks = grouped[column.status] ?? []; const columnTasks = grouped[column.status] ?? [];
return ( return (
<div key={column.title} className="space-y-4"> <div key={column.title} className="kanban-column min-h-[calc(100vh-260px)]">
<div className="flex items-center justify-between"> <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">
<h3 className="text-sm font-semibold text-strong"> <div className="flex items-center justify-between">
{column.title} <div className="flex items-center gap-2">
</h3> <span className={cn("h-2 w-2 rounded-full", column.dot)} />
<span className="text-xs text-quiet">{columnTasks.length}</span> <h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
{columnTasks.length}
</span>
</div>
</div> </div>
<div className="space-y-3"> <div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
{column.status === "inbox" ? ( {column.status === "inbox" ? (
<button <button
type="button" type="button"
onClick={onCreateTask} onClick={onCreateTask}
disabled={isCreateDisabled} disabled={isCreateDisabled}
className={cn( className={cn(
"flex w-full items-center justify-center rounded-2xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-[11px] font-semibold uppercase tracking-[0.2em] text-quiet transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface)]", "group mb-3 flex w-full items-center justify-center rounded-lg border-2 border-dashed border-slate-300 px-4 py-4 text-sm font-medium transition",
column.accent,
isCreateDisabled && "cursor-not-allowed opacity-60" isCreateDisabled && "cursor-not-allowed opacity-60"
)} )}
> >
New task <div className={cn("flex items-center gap-2", column.text)}>
<span className="text-sm font-medium">New task</span>
</div>
</button> </button>
) : null} ) : null}
{columnTasks.map((task) => ( <div className="space-y-3">
<TaskCard {columnTasks.map((task) => (
key={task.id} <TaskCard
title={task.title} key={task.id}
status={column.status} title={task.title}
assignee={task.assignee} status={column.status}
due={formatDueDate(task.due_at)} assignee={task.assignee}
onClick={() => onTaskSelect?.(task)} due={formatDueDate(task.due_at)}
/> onClick={() => onTaskSelect?.(task)}
))} />
))}
</div>
</div> </div>
</div> </div>
); );