From 6cb5702a2b4e04961cbff71992e64a35ebdd92be Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 21:46:22 +0530 Subject: [PATCH] feat: enhance task management with due date handling and mention support --- backend/app/api/metrics.py | 45 ++- backend/tests/test_error_handling.py | 15 + backend/tests/test_metrics_filters.py | 128 ++++++++ .../src/api/generated/model/approvalCreate.ts | 4 + .../src/api/generated/model/approvalRead.ts | 5 + ...rdMetricsApiV1MetricsDashboardGetParams.ts | 2 + .../src/app/board-groups/[groupId]/page.tsx | 22 +- frontend/src/app/boards/[boardId]/page.tsx | 104 ++++--- frontend/src/app/dashboard/page.tsx | 180 ++++++++++- frontend/src/components/BoardChatComposer.tsx | 226 +++++++++++++- frontend/src/components/atoms/Markdown.tsx | 5 +- .../src/components/molecules/TaskCard.tsx | 16 +- .../src/components/organisms/TaskBoard.tsx | 294 +++++++++--------- 13 files changed, 843 insertions(+), 203 deletions(-) create mode 100644 backend/tests/test_metrics_filters.py diff --git a/backend/app/api/metrics.py b/backend/app/api/metrics.py index ac3c2f9e..5b242d95 100644 --- a/backend/app/api/metrics.py +++ b/backend/app/api/metrics.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import DateTime, case from sqlalchemy import cast as sql_cast from sqlalchemy import func @@ -18,6 +18,7 @@ from app.core.time import utcnow from app.db.session import get_session from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.boards import Board from app.models.tasks import Task from app.schemas.metrics import ( DashboardBucketKey, @@ -38,6 +39,8 @@ router = APIRouter(prefix="/metrics", tags=["metrics"]) ERROR_EVENT_PATTERN = "%failed" _RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession) RANGE_QUERY = Query(default="24h") +BOARD_ID_QUERY = Query(default=None) +GROUP_ID_QUERY = Query(default=None) SESSION_DEP = Depends(get_session) ORG_MEMBER_DEP = Depends(require_org_member) @@ -385,16 +388,54 @@ async def _tasks_in_progress( return int(result) +async def _resolve_dashboard_board_ids( + session: AsyncSession, + *, + ctx: OrganizationContext, + board_id: UUID | None, + group_id: UUID | None, +) -> list[UUID]: + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) + if not board_ids: + return [] + allowed = set(board_ids) + + if board_id is not None and board_id not in allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + if group_id is None: + return [board_id] if board_id is not None else board_ids + + group_board_ids = list( + await session.exec( + select(Board.id) + .where(col(Board.organization_id) == ctx.member.organization_id) + .where(col(Board.board_group_id) == group_id) + .where(col(Board.id).in_(board_ids)), + ), + ) + if board_id is not None: + return [board_id] if board_id in set(group_board_ids) else [] + return group_board_ids + + @router.get("/dashboard", response_model=DashboardMetrics) async def dashboard_metrics( range_key: DashboardRangeKey = RANGE_QUERY, + board_id: UUID | None = BOARD_ID_QUERY, + group_id: UUID | None = GROUP_ID_QUERY, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> DashboardMetrics: """Return dashboard KPIs and time-series data for accessible boards.""" primary = _resolve_range(range_key) comparison = _comparison_range(primary) - board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) + board_ids = await _resolve_dashboard_board_ids( + session, + ctx=ctx, + board_id=board_id, + group_id=group_id, + ) throughput_primary = await _query_throughput(session, primary, board_ids) throughput_comparison = await _query_throughput(session, comparison, board_ids) diff --git a/backend/tests/test_error_handling.py b/backend/tests/test_error_handling.py index d8f1f5ac..9c7dda08 100644 --- a/backend/tests/test_error_handling.py +++ b/backend/tests/test_error_handling.py @@ -14,6 +14,7 @@ from app.core.error_handling import ( _error_payload, _get_request_id, _http_exception_exception_handler, + _json_safe, _request_validation_exception_handler, _response_validation_exception_handler, install_error_handling, @@ -209,6 +210,20 @@ def test_error_payload_omits_request_id_when_none() -> None: assert _error_payload(detail="x", request_id=None) == {"detail": "x"} +def test_json_safe_handles_binary_inputs() -> None: + assert _json_safe(b"\xf0\x9f\x92\xa1") == "đź’ˇ" + assert _json_safe(bytearray(b"hello")) == "hello" + assert _json_safe(memoryview(b"world")) == "world" + + +def test_json_safe_falls_back_to_string_for_unknown_objects() -> None: + class Weird: + def __str__(self) -> str: + return "weird-value" + + assert _json_safe(Weird()) == "weird-value" + + @pytest.mark.asyncio async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None: req = Request({"type": "http", "headers": [], "state": {}}) diff --git a/backend/tests/test_metrics_filters.py b/backend/tests/test_metrics_filters.py new file mode 100644 index 00000000..d9a0f3c2 --- /dev/null +++ b/backend/tests/test_metrics_filters.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api import metrics as metrics_api + + +class _FakeSession: + def __init__(self, exec_result: list[object]) -> None: + self._exec_result = exec_result + + async def exec(self, _statement: object) -> list[object]: + return self._exec_result + + +@pytest.mark.asyncio +async def test_resolve_dashboard_board_ids_returns_requested_board( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board_id = uuid4() + + async def _accessible(*_args: object, **_kwargs: object) -> list[object]: + return [board_id] + + monkeypatch.setattr( + metrics_api, + "list_accessible_board_ids", + _accessible, + ) + ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4())) + + resolved = await metrics_api._resolve_dashboard_board_ids( + _FakeSession([]), + ctx=ctx, + board_id=board_id, + group_id=None, + ) + + assert resolved == [board_id] + + +@pytest.mark.asyncio +async def test_resolve_dashboard_board_ids_rejects_inaccessible_board( + monkeypatch: pytest.MonkeyPatch, +) -> None: + accessible_board_id = uuid4() + requested_board_id = uuid4() + + async def _accessible(*_args: object, **_kwargs: object) -> list[object]: + return [accessible_board_id] + + monkeypatch.setattr( + metrics_api, + "list_accessible_board_ids", + _accessible, + ) + ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4())) + + with pytest.raises(HTTPException) as exc_info: + await metrics_api._resolve_dashboard_board_ids( + _FakeSession([]), + ctx=ctx, + board_id=requested_board_id, + group_id=None, + ) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_resolve_dashboard_board_ids_filters_by_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board_a = uuid4() + board_b = uuid4() + group_id = uuid4() + + async def _accessible(*_args: object, **_kwargs: object) -> list[object]: + return [board_a, board_b] + + monkeypatch.setattr( + metrics_api, + "list_accessible_board_ids", + _accessible, + ) + ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4())) + session = _FakeSession([board_b]) + + resolved = await metrics_api._resolve_dashboard_board_ids( + session, + ctx=ctx, + board_id=None, + group_id=group_id, + ) + + assert resolved == [board_b] + + +@pytest.mark.asyncio +async def test_resolve_dashboard_board_ids_returns_empty_when_board_not_in_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board_id = uuid4() + group_id = uuid4() + + async def _accessible(*_args: object, **_kwargs: object) -> list[object]: + return [board_id] + + monkeypatch.setattr( + metrics_api, + "list_accessible_board_ids", + _accessible, + ) + ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4())) + session = _FakeSession([]) + + resolved = await metrics_api._resolve_dashboard_board_ids( + session, + ctx=ctx, + board_id=board_id, + group_id=group_id, + ) + + assert resolved == [] diff --git a/frontend/src/api/generated/model/approvalCreate.ts b/frontend/src/api/generated/model/approvalCreate.ts index 6c4875c4..8d885162 100644 --- a/frontend/src/api/generated/model/approvalCreate.ts +++ b/frontend/src/api/generated/model/approvalCreate.ts @@ -16,6 +16,10 @@ export interface ApprovalCreate { task_id?: string | null; task_ids?: string[]; payload?: ApprovalCreatePayload; + /** + * @minimum 0 + * @maximum 100 + */ confidence: number; rubric_scores?: ApprovalCreateRubricScores; status?: ApprovalCreateStatus; diff --git a/frontend/src/api/generated/model/approvalRead.ts b/frontend/src/api/generated/model/approvalRead.ts index 86d3430a..48a9c348 100644 --- a/frontend/src/api/generated/model/approvalRead.ts +++ b/frontend/src/api/generated/model/approvalRead.ts @@ -16,11 +16,16 @@ export interface ApprovalRead { task_id?: string | null; task_ids?: string[]; payload?: ApprovalReadPayload; + /** + * @minimum 0 + * @maximum 100 + */ confidence: number; rubric_scores?: ApprovalReadRubricScores; status?: ApprovalReadStatus; id: string; board_id: string; + task_titles?: string[]; agent_id?: string | null; created_at: string; resolved_at?: string | null; diff --git a/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts b/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts index 83ffd4e8..bddc3079 100644 --- a/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts +++ b/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts @@ -8,4 +8,6 @@ import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "./dashboa export type DashboardMetricsApiV1MetricsDashboardGetParams = { range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey; + board_id?: string | null; + group_id?: string | null; }; diff --git a/frontend/src/app/board-groups/[groupId]/page.tsx b/frontend/src/app/board-groups/[groupId]/page.tsx index c81c924a..fb8db638 100644 --- a/frontend/src/app/board-groups/[groupId]/page.tsx +++ b/frontend/src/app/board-groups/[groupId]/page.tsx @@ -139,6 +139,7 @@ const SSE_RECONNECT_BACKOFF = { jitter: 0.2, maxMs: 5 * 60_000, } as const; +const HAS_ALL_MENTION_RE = /(^|\s)@all\b/i; type HeartbeatUnit = "s" | "m" | "h" | "d"; @@ -231,6 +232,17 @@ export default function BoardGroupDetailPage() { }); return ids; }, [boards]); + const groupMentionSuggestions = useMemo(() => { + const options = new Set(["lead", "all"]); + boards.forEach((item) => { + (item.tasks ?? []).forEach((task) => { + if (task.assignee) { + options.add(task.assignee); + } + }); + }); + return [...options]; + }, [boards]); const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< getMyMembershipApiV1OrganizationsMeMemberGetResponse, @@ -599,7 +611,9 @@ export default function BoardGroupDetailPage() { setIsChatSending(true); setChatError(null); try { - const tags = ["chat", ...(chatBroadcast ? ["broadcast"] : [])]; + const shouldBroadcast = + chatBroadcast || HAS_ALL_MENTION_RE.test(trimmed); + const tags = ["chat", ...(shouldBroadcast ? ["broadcast"] : [])]; const result = await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost( groupId, @@ -641,7 +655,9 @@ export default function BoardGroupDetailPage() { setIsNoteSending(true); setNoteSendError(null); try { - const tags = ["note", ...(notesBroadcast ? ["broadcast"] : [])]; + const shouldBroadcast = + notesBroadcast || HAS_ALL_MENTION_RE.test(trimmed); + const tags = ["note", ...(shouldBroadcast ? ["broadcast"] : [])]; const result = await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost( groupId, @@ -1156,6 +1172,7 @@ export default function BoardGroupDetailPage() { isSending={isChatSending} onSend={sendGroupChat} disabled={!canWriteGroup} + mentionSuggestions={groupMentionSuggestions} /> @@ -1242,6 +1259,7 @@ export default function BoardGroupDetailPage() { isSending={isNoteSending} onSend={sendGroupNote} disabled={!canWriteGroup} + mentionSuggestions={groupMentionSuggestions} /> diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index dd446153..6449ea63 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -92,7 +92,12 @@ import type { TaskRead, } from "@/api/generated/model"; import { createExponentialBackoff } from "@/lib/backoff"; -import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; +import { + apiDatetimeToMs, + localDateInputToUtcIso, + parseApiDatetime, + toLocalDateInput, +} from "@/lib/datetime"; import { cn } from "@/lib/utils"; import { usePageActive } from "@/hooks/usePageActive"; @@ -738,8 +743,6 @@ export default function BoardDetailPage() { const liveFeedHistoryLoadedRef = useRef(false); const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [commentsError, setCommentsError] = useState(null); - const [newComment, setNewComment] = useState(""); - const taskCommentInputRef = useRef(null); const [isPostingComment, setIsPostingComment] = useState(false); const [postCommentError, setPostCommentError] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); @@ -1001,6 +1004,7 @@ export default function BoardDetailPage() { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [priority, setPriority] = useState("medium"); + const [createDueDate, setCreateDueDate] = useState(""); const [createTagIds, setCreateTagIds] = useState([]); const [createError, setCreateError] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -1009,6 +1013,7 @@ export default function BoardDetailPage() { const [editDescription, setEditDescription] = useState(""); const [editStatus, setEditStatus] = useState("inbox"); const [editPriority, setEditPriority] = useState("medium"); + const [editDueDate, setEditDueDate] = useState(""); const [editAssigneeId, setEditAssigneeId] = useState(""); const [editTagIds, setEditTagIds] = useState([]); const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState( @@ -1484,6 +1489,7 @@ export default function BoardDetailPage() { setEditDescription(""); setEditStatus("inbox"); setEditPriority("medium"); + setEditDueDate(""); setEditAssigneeId(""); setEditTagIds([]); setEditDependsOnTaskIds([]); @@ -1494,6 +1500,7 @@ export default function BoardDetailPage() { setEditDescription(selectedTask.description ?? ""); setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); + setEditDueDate(toLocalDateInput(selectedTask.due_at)); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditTagIds(selectedTask.tag_ids ?? []); setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); @@ -1802,6 +1809,7 @@ export default function BoardDetailPage() { setTitle(""); setDescription(""); setPriority("medium"); + setCreateDueDate(""); setCreateTagIds([]); setCreateError(null); }; @@ -1821,6 +1829,7 @@ export default function BoardDetailPage() { description: description.trim() || null, status: "inbox", priority, + due_at: localDateInputToUtcIso(createDueDate), tag_ids: createTagIds, }); if (result.status !== 200) throw new Error("Unable to create task."); @@ -1973,6 +1982,15 @@ export default function BoardDetailPage() { () => agents.filter((agent) => !agent.is_board_lead), [agents], ); + const boardChatMentionSuggestions = useMemo(() => { + const options = new Set(["lead"]); + agents.forEach((agent) => { + if (agent.name) { + options.add(agent.name); + } + }); + return [...options]; + }, [agents]); const tagById = useMemo(() => { const map = new Map(); @@ -2045,6 +2063,7 @@ export default function BoardDetailPage() { const normalizedTitle = editTitle.trim(); const normalizedDescription = editDescription.trim(); const currentDescription = (selectedTask.description ?? "").trim(); + const currentDueDate = toLocalDateInput(selectedTask.due_at); const currentAssignee = selectedTask.assigned_agent_id ?? ""; const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|"); const nextTags = [...editTagIds].sort().join("|"); @@ -2057,12 +2076,14 @@ export default function BoardDetailPage() { normalizedDescription !== currentDescription || editStatus !== selectedTask.status || editPriority !== selectedTask.priority || + editDueDate !== currentDueDate || editAssigneeId !== currentAssignee || currentTags !== nextTags || currentDeps !== nextDeps ); }, [ editAssigneeId, + editDueDate, editTagIds, editDependsOnTaskIds, editDescription, @@ -2205,7 +2226,6 @@ export default function BoardDetailPage() { setSelectedTask(null); setComments([]); setCommentsError(null); - setNewComment(""); setPostCommentError(null); setIsEditDialogOpen(false); }; @@ -2237,12 +2257,12 @@ export default function BoardDetailPage() { setIsLiveFeedOpen(false); }; - const handlePostComment = async () => { - if (!selectedTask || !boardId || !isSignedIn) return; - const trimmed = newComment.trim(); + const handlePostComment = async (message: string): Promise => { + if (!selectedTask || !boardId || !isSignedIn) return false; + const trimmed = message.trim(); if (!trimmed) { setPostCommentError("Write a message before sending."); - return; + return false; } setIsPostingComment(true); setPostCommentError(null); @@ -2256,14 +2276,14 @@ export default function BoardDetailPage() { if (result.status !== 200) throw new Error("Unable to send message."); const created = result.data; setComments((prev) => [created, ...prev]); - setNewComment(""); + return true; } catch (err) { const message = formatActionError(err, "Unable to send message."); setPostCommentError(message); pushToast(message); + return false; } finally { setIsPostingComment(false); - taskCommentInputRef.current?.focus(); } }; @@ -2285,6 +2305,8 @@ export default function BoardDetailPage() { const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|"); const nextTags = [...editTagIds].sort().join("|"); const tagsChanged = currentTags !== nextTags; + const currentDueDate = toLocalDateInput(selectedTask.due_at); + const dueDateChanged = editDueDate !== currentDueDate; const updatePayload: Parameters< typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch @@ -2302,6 +2324,9 @@ export default function BoardDetailPage() { if (tagsChanged) { updatePayload.tag_ids = editTagIds; } + if (dueDateChanged) { + updatePayload.due_at = localDateInputToUtcIso(editDueDate); + } const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch( boardId, @@ -2362,6 +2387,7 @@ export default function BoardDetailPage() { setEditDescription(selectedTask.description ?? ""); setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); + setEditDueDate(toLocalDateInput(selectedTask.due_at)); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditTagIds(selectedTask.tag_ids ?? []); setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); @@ -3520,27 +3546,16 @@ export default function BoardDetailPage() { Comments

-