diff --git a/backend/alembic/versions/75e05e158ca9_normalize_task_statuses.py b/backend/alembic/versions/75e05e158ca9_normalize_task_statuses.py new file mode 100644 index 00000000..ad0741b2 --- /dev/null +++ b/backend/alembic/versions/75e05e158ca9_normalize_task_statuses.py @@ -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 diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4362b5c9..06748d3f 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -32,6 +32,15 @@ from app.services.activity_log import record_activity router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"]) 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: @@ -82,6 +91,11 @@ def list_tasks( if status_filter: statuses = [s.strip() for s in status_filter.split(",") if s.strip()] 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)) if assigned_agent_id is not None: statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id) @@ -99,6 +113,7 @@ def create_task( session: Session = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> Task: + validate_task_status(payload.status) task = Task.model_validate(payload) task.board_id = board.id 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): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if "status" in updates: + validate_task_status(updates["status"]) if updates["status"] == "inbox": task.assigned_agent_id = None task.in_progress_at = None @@ -143,6 +159,7 @@ def update_task( if updates["status"] == "in_progress": task.in_progress_at = datetime.utcnow() elif "status" in updates: + validate_task_status(updates["status"]) if updates["status"] == "inbox": task.assigned_agent_id = None task.in_progress_at = None diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 4dd0f647..c45593fa 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -280,46 +280,65 @@ export default function BoardDetailPage() { -
-
-
-

- {board?.slug ?? "board"} -

-

- {board?.name ?? "Board"} -

-

- Keep tasks moving through your workflow. -

+
+
+
+
+
+
+ {board?.name ?? "Board"} +
+

+ {board?.name ?? "Board"} +

+

+ Keep tasks moving through your workflow. +

+
+
+
+ + + +
+ +
+
-
- {error && ( -
- {error} -
- )} +
+ {error && ( +
+ {error} +
+ )} - {isLoading ? ( -
- Loading {titleLabel}… -
- ) : ( - setIsDialogOpen(true)} - isCreateDisabled={isCreating} - onTaskSelect={openComments} - /> - )} -
+ {isLoading ? ( +
+ Loading {titleLabel}… +
+ ) : ( + setIsDialogOpen(true)} + isCreateDisabled={isCreating} + onTaskSelect={openComments} + /> + )} +
+ { diff --git a/frontend/src/components/molecules/TaskCard.tsx b/frontend/src/components/molecules/TaskCard.tsx index 4ce193a5..4931b646 100644 --- a/frontend/src/components/molecules/TaskCard.tsx +++ b/frontend/src/components/molecules/TaskCard.tsx @@ -1,7 +1,6 @@ import { CalendarClock, UserCircle } from "lucide-react"; -import { StatusPill } from "@/components/atoms/StatusPill"; -import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; interface TaskCardProps { title: string; @@ -18,9 +17,58 @@ export function TaskCard({ due, onClick, }: 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 ( - - -
-
-

{title}

- -
+
+
+ + + {config.label} + +

{title}

-
+
+
+
+ + {assignee ?? "Unassigned"} +
+ {due ? (
- - {assignee ?? "Unassigned"} + + {due}
- {due ? ( -
- - {due} -
- ) : null} -
- - + ) : null} +
+
); } diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index fb043fd6..baf8e667 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -24,12 +24,34 @@ type TaskBoardProps = { }; const columns = [ - { title: "Inbox", status: "inbox" }, - { title: "Assigned", status: "assigned" }, - { title: "In Progress", status: "in_progress" }, - { title: "Testing", status: "testing" }, - { title: "Review", status: "review" }, - { title: "Done", status: "done" }, + { + title: "Inbox", + status: "inbox", + dot: "bg-slate-400", + accent: "hover:border-slate-400 hover:bg-slate-50", + 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) => { @@ -61,41 +83,53 @@ export function TaskBoard({ }, [tasks]); return ( -
+
{columns.map((column) => { const columnTasks = grouped[column.status] ?? []; return ( -
-
-

- {column.title} -

- {columnTasks.length} +
+
+
+
+ +

+ {column.title} +

+
+ + {columnTasks.length} + +
-
+
{column.status === "inbox" ? ( ) : null} - {columnTasks.map((task) => ( - onTaskSelect?.(task)} - /> - ))} +
+ {columnTasks.map((task) => ( + onTaskSelect?.(task)} + /> + ))} +
);