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

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import UUID 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 DateTime, case
from sqlalchemy import cast as sql_cast from sqlalchemy import cast as sql_cast
from sqlalchemy import func from sqlalchemy import func
@@ -18,6 +18,7 @@ from app.core.time import utcnow
from app.db.session import get_session from app.db.session import get_session
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
from app.models.agents import Agent from app.models.agents import Agent
from app.models.boards import Board
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.metrics import ( from app.schemas.metrics import (
DashboardBucketKey, DashboardBucketKey,
@@ -38,6 +39,8 @@ router = APIRouter(prefix="/metrics", tags=["metrics"])
ERROR_EVENT_PATTERN = "%failed" ERROR_EVENT_PATTERN = "%failed"
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession) _RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
RANGE_QUERY = Query(default="24h") RANGE_QUERY = Query(default="24h")
BOARD_ID_QUERY = Query(default=None)
GROUP_ID_QUERY = Query(default=None)
SESSION_DEP = Depends(get_session) SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member) ORG_MEMBER_DEP = Depends(require_org_member)
@@ -385,16 +388,54 @@ async def _tasks_in_progress(
return int(result) 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) @router.get("/dashboard", response_model=DashboardMetrics)
async def dashboard_metrics( async def dashboard_metrics(
range_key: DashboardRangeKey = RANGE_QUERY, range_key: DashboardRangeKey = RANGE_QUERY,
board_id: UUID | None = BOARD_ID_QUERY,
group_id: UUID | None = GROUP_ID_QUERY,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DashboardMetrics: ) -> DashboardMetrics:
"""Return dashboard KPIs and time-series data for accessible boards.""" """Return dashboard KPIs and time-series data for accessible boards."""
primary = _resolve_range(range_key) primary = _resolve_range(range_key)
comparison = _comparison_range(primary) 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_primary = await _query_throughput(session, primary, board_ids)
throughput_comparison = await _query_throughput(session, comparison, board_ids) throughput_comparison = await _query_throughput(session, comparison, board_ids)

View File

@@ -14,6 +14,7 @@ from app.core.error_handling import (
_error_payload, _error_payload,
_get_request_id, _get_request_id,
_http_exception_exception_handler, _http_exception_exception_handler,
_json_safe,
_request_validation_exception_handler, _request_validation_exception_handler,
_response_validation_exception_handler, _response_validation_exception_handler,
install_error_handling, 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"} 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 @pytest.mark.asyncio
async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None: async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None:
req = Request({"type": "http", "headers": [], "state": {}}) req = Request({"type": "http", "headers": [], "state": {}})

View File

@@ -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 == []

View File

@@ -16,6 +16,10 @@ export interface ApprovalCreate {
task_id?: string | null; task_id?: string | null;
task_ids?: string[]; task_ids?: string[];
payload?: ApprovalCreatePayload; payload?: ApprovalCreatePayload;
/**
* @minimum 0
* @maximum 100
*/
confidence: number; confidence: number;
rubric_scores?: ApprovalCreateRubricScores; rubric_scores?: ApprovalCreateRubricScores;
status?: ApprovalCreateStatus; status?: ApprovalCreateStatus;

View File

@@ -16,11 +16,16 @@ export interface ApprovalRead {
task_id?: string | null; task_id?: string | null;
task_ids?: string[]; task_ids?: string[];
payload?: ApprovalReadPayload; payload?: ApprovalReadPayload;
/**
* @minimum 0
* @maximum 100
*/
confidence: number; confidence: number;
rubric_scores?: ApprovalReadRubricScores; rubric_scores?: ApprovalReadRubricScores;
status?: ApprovalReadStatus; status?: ApprovalReadStatus;
id: string; id: string;
board_id: string; board_id: string;
task_titles?: string[];
agent_id?: string | null; agent_id?: string | null;
created_at: string; created_at: string;
resolved_at?: string | null; resolved_at?: string | null;

View File

@@ -8,4 +8,6 @@ import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "./dashboa
export type DashboardMetricsApiV1MetricsDashboardGetParams = { export type DashboardMetricsApiV1MetricsDashboardGetParams = {
range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey; range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey;
board_id?: string | null;
group_id?: string | null;
}; };

View File

@@ -139,6 +139,7 @@ const SSE_RECONNECT_BACKOFF = {
jitter: 0.2, jitter: 0.2,
maxMs: 5 * 60_000, maxMs: 5 * 60_000,
} as const; } as const;
const HAS_ALL_MENTION_RE = /(^|\s)@all\b/i;
type HeartbeatUnit = "s" | "m" | "h" | "d"; type HeartbeatUnit = "s" | "m" | "h" | "d";
@@ -231,6 +232,17 @@ export default function BoardGroupDetailPage() {
}); });
return ids; return ids;
}, [boards]); }, [boards]);
const groupMentionSuggestions = useMemo(() => {
const options = new Set<string>(["lead", "all"]);
boards.forEach((item) => {
(item.tasks ?? []).forEach((task) => {
if (task.assignee) {
options.add(task.assignee);
}
});
});
return [...options];
}, [boards]);
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse, getMyMembershipApiV1OrganizationsMeMemberGetResponse,
@@ -599,7 +611,9 @@ export default function BoardGroupDetailPage() {
setIsChatSending(true); setIsChatSending(true);
setChatError(null); setChatError(null);
try { try {
const tags = ["chat", ...(chatBroadcast ? ["broadcast"] : [])]; const shouldBroadcast =
chatBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
const tags = ["chat", ...(shouldBroadcast ? ["broadcast"] : [])];
const result = const result =
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost( await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
groupId, groupId,
@@ -641,7 +655,9 @@ export default function BoardGroupDetailPage() {
setIsNoteSending(true); setIsNoteSending(true);
setNoteSendError(null); setNoteSendError(null);
try { try {
const tags = ["note", ...(notesBroadcast ? ["broadcast"] : [])]; const shouldBroadcast =
notesBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
const tags = ["note", ...(shouldBroadcast ? ["broadcast"] : [])];
const result = const result =
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost( await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
groupId, groupId,
@@ -1156,6 +1172,7 @@ export default function BoardGroupDetailPage() {
isSending={isChatSending} isSending={isChatSending}
onSend={sendGroupChat} onSend={sendGroupChat}
disabled={!canWriteGroup} disabled={!canWriteGroup}
mentionSuggestions={groupMentionSuggestions}
/> />
</div> </div>
</div> </div>
@@ -1242,6 +1259,7 @@ export default function BoardGroupDetailPage() {
isSending={isNoteSending} isSending={isNoteSending}
onSend={sendGroupNote} onSend={sendGroupNote}
disabled={!canWriteGroup} disabled={!canWriteGroup}
mentionSuggestions={groupMentionSuggestions}
/> />
</div> </div>
</div> </div>

View File

@@ -92,7 +92,12 @@ import type {
TaskRead, TaskRead,
} from "@/api/generated/model"; } from "@/api/generated/model";
import { createExponentialBackoff } from "@/lib/backoff"; 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 { cn } from "@/lib/utils";
import { usePageActive } from "@/hooks/usePageActive"; import { usePageActive } from "@/hooks/usePageActive";
@@ -738,8 +743,6 @@ export default function BoardDetailPage() {
const liveFeedHistoryLoadedRef = useRef(false); const liveFeedHistoryLoadedRef = useRef(false);
const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null); const [commentsError, setCommentsError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
const [isPostingComment, setIsPostingComment] = useState(false); const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState<string | null>(null); const [postCommentError, setPostCommentError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
@@ -1001,6 +1004,7 @@ export default function BoardDetailPage() {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium"); const [priority, setPriority] = useState("medium");
const [createDueDate, setCreateDueDate] = useState("");
const [createTagIds, setCreateTagIds] = useState<string[]>([]); const [createTagIds, setCreateTagIds] = useState<string[]>([]);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
@@ -1009,6 +1013,7 @@ export default function BoardDetailPage() {
const [editDescription, setEditDescription] = useState(""); const [editDescription, setEditDescription] = useState("");
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox"); const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
const [editPriority, setEditPriority] = useState("medium"); const [editPriority, setEditPriority] = useState("medium");
const [editDueDate, setEditDueDate] = useState("");
const [editAssigneeId, setEditAssigneeId] = useState(""); const [editAssigneeId, setEditAssigneeId] = useState("");
const [editTagIds, setEditTagIds] = useState<string[]>([]); const [editTagIds, setEditTagIds] = useState<string[]>([]);
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>( const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
@@ -1484,6 +1489,7 @@ export default function BoardDetailPage() {
setEditDescription(""); setEditDescription("");
setEditStatus("inbox"); setEditStatus("inbox");
setEditPriority("medium"); setEditPriority("medium");
setEditDueDate("");
setEditAssigneeId(""); setEditAssigneeId("");
setEditTagIds([]); setEditTagIds([]);
setEditDependsOnTaskIds([]); setEditDependsOnTaskIds([]);
@@ -1494,6 +1500,7 @@ export default function BoardDetailPage() {
setEditDescription(selectedTask.description ?? ""); setEditDescription(selectedTask.description ?? "");
setEditStatus(selectedTask.status); setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority); setEditPriority(selectedTask.priority);
setEditDueDate(toLocalDateInput(selectedTask.due_at));
setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditTagIds(selectedTask.tag_ids ?? []); setEditTagIds(selectedTask.tag_ids ?? []);
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
@@ -1802,6 +1809,7 @@ export default function BoardDetailPage() {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setPriority("medium"); setPriority("medium");
setCreateDueDate("");
setCreateTagIds([]); setCreateTagIds([]);
setCreateError(null); setCreateError(null);
}; };
@@ -1821,6 +1829,7 @@ export default function BoardDetailPage() {
description: description.trim() || null, description: description.trim() || null,
status: "inbox", status: "inbox",
priority, priority,
due_at: localDateInputToUtcIso(createDueDate),
tag_ids: createTagIds, tag_ids: createTagIds,
}); });
if (result.status !== 200) throw new Error("Unable to create task."); 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.filter((agent) => !agent.is_board_lead),
[agents], [agents],
); );
const boardChatMentionSuggestions = useMemo(() => {
const options = new Set<string>(["lead"]);
agents.forEach((agent) => {
if (agent.name) {
options.add(agent.name);
}
});
return [...options];
}, [agents]);
const tagById = useMemo(() => { const tagById = useMemo(() => {
const map = new Map<string, TagRead>(); const map = new Map<string, TagRead>();
@@ -2045,6 +2063,7 @@ export default function BoardDetailPage() {
const normalizedTitle = editTitle.trim(); const normalizedTitle = editTitle.trim();
const normalizedDescription = editDescription.trim(); const normalizedDescription = editDescription.trim();
const currentDescription = (selectedTask.description ?? "").trim(); const currentDescription = (selectedTask.description ?? "").trim();
const currentDueDate = toLocalDateInput(selectedTask.due_at);
const currentAssignee = selectedTask.assigned_agent_id ?? ""; const currentAssignee = selectedTask.assigned_agent_id ?? "";
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|"); const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
const nextTags = [...editTagIds].sort().join("|"); const nextTags = [...editTagIds].sort().join("|");
@@ -2057,12 +2076,14 @@ export default function BoardDetailPage() {
normalizedDescription !== currentDescription || normalizedDescription !== currentDescription ||
editStatus !== selectedTask.status || editStatus !== selectedTask.status ||
editPriority !== selectedTask.priority || editPriority !== selectedTask.priority ||
editDueDate !== currentDueDate ||
editAssigneeId !== currentAssignee || editAssigneeId !== currentAssignee ||
currentTags !== nextTags || currentTags !== nextTags ||
currentDeps !== nextDeps currentDeps !== nextDeps
); );
}, [ }, [
editAssigneeId, editAssigneeId,
editDueDate,
editTagIds, editTagIds,
editDependsOnTaskIds, editDependsOnTaskIds,
editDescription, editDescription,
@@ -2205,7 +2226,6 @@ export default function BoardDetailPage() {
setSelectedTask(null); setSelectedTask(null);
setComments([]); setComments([]);
setCommentsError(null); setCommentsError(null);
setNewComment("");
setPostCommentError(null); setPostCommentError(null);
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
}; };
@@ -2237,12 +2257,12 @@ export default function BoardDetailPage() {
setIsLiveFeedOpen(false); setIsLiveFeedOpen(false);
}; };
const handlePostComment = async () => { const handlePostComment = async (message: string): Promise<boolean> => {
if (!selectedTask || !boardId || !isSignedIn) return; if (!selectedTask || !boardId || !isSignedIn) return false;
const trimmed = newComment.trim(); const trimmed = message.trim();
if (!trimmed) { if (!trimmed) {
setPostCommentError("Write a message before sending."); setPostCommentError("Write a message before sending.");
return; return false;
} }
setIsPostingComment(true); setIsPostingComment(true);
setPostCommentError(null); setPostCommentError(null);
@@ -2256,14 +2276,14 @@ export default function BoardDetailPage() {
if (result.status !== 200) throw new Error("Unable to send message."); if (result.status !== 200) throw new Error("Unable to send message.");
const created = result.data; const created = result.data;
setComments((prev) => [created, ...prev]); setComments((prev) => [created, ...prev]);
setNewComment(""); return true;
} catch (err) { } catch (err) {
const message = formatActionError(err, "Unable to send message."); const message = formatActionError(err, "Unable to send message.");
setPostCommentError(message); setPostCommentError(message);
pushToast(message); pushToast(message);
return false;
} finally { } finally {
setIsPostingComment(false); setIsPostingComment(false);
taskCommentInputRef.current?.focus();
} }
}; };
@@ -2285,6 +2305,8 @@ export default function BoardDetailPage() {
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|"); const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
const nextTags = [...editTagIds].sort().join("|"); const nextTags = [...editTagIds].sort().join("|");
const tagsChanged = currentTags !== nextTags; const tagsChanged = currentTags !== nextTags;
const currentDueDate = toLocalDateInput(selectedTask.due_at);
const dueDateChanged = editDueDate !== currentDueDate;
const updatePayload: Parameters< const updatePayload: Parameters<
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
@@ -2302,6 +2324,9 @@ export default function BoardDetailPage() {
if (tagsChanged) { if (tagsChanged) {
updatePayload.tag_ids = editTagIds; updatePayload.tag_ids = editTagIds;
} }
if (dueDateChanged) {
updatePayload.due_at = localDateInputToUtcIso(editDueDate);
}
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch( const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
boardId, boardId,
@@ -2362,6 +2387,7 @@ export default function BoardDetailPage() {
setEditDescription(selectedTask.description ?? ""); setEditDescription(selectedTask.description ?? "");
setEditStatus(selectedTask.status); setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority); setEditPriority(selectedTask.priority);
setEditDueDate(toLocalDateInput(selectedTask.due_at));
setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditTagIds(selectedTask.tag_ids ?? []); setEditTagIds(selectedTask.tag_ids ?? []);
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
@@ -3520,27 +3546,16 @@ export default function BoardDetailPage() {
Comments Comments
</p> </p>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Textarea <BoardChatComposer
ref={taskCommentInputRef}
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
if (!canWrite) return;
event.preventDefault();
if (isPostingComment) return;
if (!newComment.trim()) return;
void handlePostComment();
}}
placeholder={ placeholder={
canWrite canWrite
? "Write a message for the assigned agent" ? "Write a message for the assigned agent. Tag @lead or @name."
: "Read-only access. Comments are disabled." : "Read-only access. Comments are disabled."
} }
className="min-h-[80px] bg-white" isSending={isPostingComment}
disabled={!canWrite || isPostingComment} onSend={handlePostComment}
disabled={!canWrite}
mentionSuggestions={boardChatMentionSuggestions}
/> />
{postCommentError ? ( {postCommentError ? (
<p className="text-xs text-rose-600">{postCommentError}</p> <p className="text-xs text-rose-600">{postCommentError}</p>
@@ -3550,18 +3565,6 @@ export default function BoardDetailPage() {
Read-only access. You cannot post comments on this board. Read-only access. You cannot post comments on this board.
</p> </p>
) : null} ) : null}
<div className="flex justify-end">
<Button
size="sm"
onClick={handlePostComment}
disabled={
!canWrite || isPostingComment || !newComment.trim()
}
title={canWrite ? "Send message" : "Read-only access"}
>
{isPostingComment ? "Sending…" : "Send message"}
</Button>
</div>
</div> </div>
{isCommentsLoading ? ( {isCommentsLoading ? (
<p className="text-sm text-slate-500">Loading comments</p> <p className="text-sm text-slate-500">Loading comments</p>
@@ -3638,6 +3641,7 @@ export default function BoardDetailPage() {
isSending={isChatSending} isSending={isChatSending}
onSend={handleSendChat} onSend={handleSendChat}
disabled={!canWrite} disabled={!canWrite}
mentionSuggestions={boardChatMentionSuggestions}
placeholder={ placeholder={
canWrite canWrite
? "Message the board lead. Tag agents with @name." ? "Message the board lead. Tag agents with @name."
@@ -3803,6 +3807,17 @@ export default function BoardDetailPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Due date
</label>
<Input
type="date"
value={editDueDate}
onChange={(event) => setEditDueDate(event.target.value)}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
@@ -4094,6 +4109,17 @@ export default function BoardDetailPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Due date
</label>
<Input
type="date"
value={createDueDate}
onChange={(event) => setCreateDueDate(event.target.value)}
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium text-strong">Tags</label> <label className="text-sm font-medium text-strong">Tags</label>

View File

@@ -3,6 +3,7 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { useMemo } from "react"; import { useMemo } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
@@ -24,9 +25,19 @@ import { Activity, PenSquare, Timer, Users } from "lucide-react";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import DropdownSelect from "@/components/ui/dropdown-select"; import DropdownSelect, {
type DropdownSelectOption,
} from "@/components/ui/dropdown-select";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel"; import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import { import {
type dashboardMetricsApiV1MetricsDashboardGetResponse, type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet, useDashboardMetricsApiV1MetricsDashboardGet,
@@ -85,6 +96,7 @@ const DASHBOARD_RANGE_OPTIONS: Array<{ value: RangeKey; label: string }> = [
const DASHBOARD_RANGE_SET = new Set<RangeKey>( const DASHBOARD_RANGE_SET = new Set<RangeKey>(
DASHBOARD_RANGE_OPTIONS.map((option) => option.value), DASHBOARD_RANGE_OPTIONS.map((option) => option.value),
); );
const ALL_FILTER_VALUE = "all";
const DEFAULT_RANGE: RangeKey = "7d"; const DEFAULT_RANGE: RangeKey = "7d";
const formatPeriod = (value: string, bucket: BucketKey) => { const formatPeriod = (value: string, bucket: BucketKey) => {
@@ -251,16 +263,111 @@ export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const selectedRangeParam = searchParams.get("range"); const selectedRangeParam = searchParams.get("range");
const selectedGroupParam = searchParams.get("group");
const selectedBoardParam = searchParams.get("board");
const selectedRange: RangeKey = const selectedRange: RangeKey =
selectedRangeParam && selectedRangeParam &&
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey) DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
? (selectedRangeParam as RangeKey) ? (selectedRangeParam as RangeKey)
: DEFAULT_RANGE; : DEFAULT_RANGE;
const selectedGroupId =
selectedGroupParam && selectedGroupParam !== ALL_FILTER_VALUE
? selectedGroupParam
: null;
const selectedBoardId =
selectedBoardParam && selectedBoardParam !== ALL_FILTER_VALUE
? selectedBoardParam
: null;
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
},
},
);
const boardGroupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
listBoardGroupsApiV1BoardGroupsGetResponse,
ApiError
>(
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
},
},
);
const boards = useMemo(
() =>
boardsQuery.data?.status === 200
? [...(boardsQuery.data.data.items ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
: [],
[boardsQuery.data],
);
const boardGroups = useMemo(
() =>
boardGroupsQuery.data?.status === 200
? [...(boardGroupsQuery.data.data.items ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
: [],
[boardGroupsQuery.data],
);
const filteredBoards = useMemo(
() =>
selectedGroupId
? boards.filter((board) => board.board_group_id === selectedGroupId)
: boards,
[boards, selectedGroupId],
);
const selectedBoard = useMemo(
() => boards.find((board) => board.id === selectedBoardId) ?? null,
[boards, selectedBoardId],
);
const selectedGroup = useMemo(
() => boardGroups.find((group) => group.id === selectedGroupId) ?? null,
[boardGroups, selectedGroupId],
);
const boardGroupOptions = useMemo<DropdownSelectOption[]>(
() => [
{ value: ALL_FILTER_VALUE, label: "All groups" },
...boardGroups.map((group) => ({ value: group.id, label: group.name })),
],
[boardGroups],
);
const boardOptions = useMemo<DropdownSelectOption[]>(
() => [
{ value: ALL_FILTER_VALUE, label: "All boards" },
...filteredBoards.map((board) => ({
value: board.id,
label: board.name,
})),
],
[filteredBoards],
);
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet< const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
dashboardMetricsApiV1MetricsDashboardGetResponse, dashboardMetricsApiV1MetricsDashboardGetResponse,
ApiError ApiError
>( >(
{ range_key: selectedRange }, {
range_key: selectedRange,
board_id: selectedBoardId ?? undefined,
group_id: selectedGroupId ?? undefined,
},
{ {
query: { query: {
enabled: Boolean(isSignedIn), enabled: Boolean(isSignedIn),
@@ -356,6 +463,75 @@ export default function DashboardPage() {
triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100" triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
contentClassName="rounded-lg border border-slate-200" contentClassName="rounded-lg border border-slate-200"
/> />
<DropdownSelect
value={selectedGroupId ?? ALL_FILTER_VALUE}
onValueChange={(value) => {
const nextGroupId =
value === ALL_FILTER_VALUE ? null : value;
const params = new URLSearchParams(searchParams.toString());
if (nextGroupId) {
params.set("group", nextGroupId);
} else {
params.delete("group");
}
if (selectedBoardId) {
const selectedBoardRecord = boards.find(
(board) => board.id === selectedBoardId,
);
const boardVisibleInScope = nextGroupId
? selectedBoardRecord?.board_group_id === nextGroupId
: true;
if (!boardVisibleInScope) {
params.delete("board");
}
}
router.replace(`${pathname}?${params.toString()}`);
}}
options={boardGroupOptions}
ariaLabel="Dashboard board group filter"
placeholder="All groups"
triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
contentClassName="rounded-lg border border-slate-200"
searchEnabled={false}
disabled={boardGroupsQuery.isLoading}
/>
<DropdownSelect
value={selectedBoardId ?? ALL_FILTER_VALUE}
onValueChange={(value) => {
const nextBoardId =
value === ALL_FILTER_VALUE ? null : value;
const params = new URLSearchParams(searchParams.toString());
if (nextBoardId) {
params.set("board", nextBoardId);
} else {
params.delete("board");
}
router.replace(`${pathname}?${params.toString()}`);
}}
options={boardOptions}
ariaLabel="Dashboard board filter"
placeholder="All boards"
triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
contentClassName="rounded-lg border border-slate-200"
searchEnabled={false}
disabled={boardsQuery.isLoading || boardOptions.length <= 1}
/>
{selectedGroup ? (
<Link
href={`/board-groups/${selectedGroup.id}`}
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
Open group
</Link>
) : null}
{selectedBoard ? (
<Link
href={`/boards/${selectedBoard.id}`}
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
Open board
</Link>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,27 +1,90 @@
"use client"; "use client";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
const MENTION_MAX_OPTIONS = 8;
const MENTION_PATTERN = /(?:^|\s)@([A-Za-z0-9_-]{0,31})$/;
type MentionTarget = {
start: number;
end: number;
query: string;
};
type BoardChatComposerProps = { type BoardChatComposerProps = {
placeholder?: string; placeholder?: string;
isSending?: boolean; isSending?: boolean;
disabled?: boolean; disabled?: boolean;
mentionSuggestions?: string[];
onSend: (content: string) => Promise<boolean>; onSend: (content: string) => Promise<boolean>;
}; };
const normalizeMentionHandle = (raw: string): string | null => {
const trimmed = raw.trim().replace(/^@+/, "");
if (!trimmed) return null;
const token = trimmed.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_-]/g, "") ?? "";
if (!token) return null;
if (!/^[A-Za-z]/.test(token)) return null;
return token.slice(0, 32).toLowerCase();
};
const findMentionTarget = (
text: string,
caret: number,
): MentionTarget | null => {
if (caret < 0 || caret > text.length) return null;
const prefix = text.slice(0, caret);
const match = prefix.match(MENTION_PATTERN);
if (!match) return null;
const query = (match[1] ?? "").toLowerCase();
const start = caret - query.length - 1;
return { start, end: caret, query };
};
function BoardChatComposerImpl({ function BoardChatComposerImpl({
placeholder = "Message the board lead. Tag agents with @name.", placeholder = "Message the board lead. Tag agents with @name.",
isSending = false, isSending = false,
disabled = false, disabled = false,
mentionSuggestions,
onSend, onSend,
}: BoardChatComposerProps) { }: BoardChatComposerProps) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [mentionTarget, setMentionTarget] = useState<MentionTarget | null>(
null,
);
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const closeMenuTimeoutRef = useRef<number | null>(null);
const shouldFocusAfterSendRef = useRef(false); const shouldFocusAfterSendRef = useRef(false);
const mentionOptions = useMemo(() => {
const handles = new Set<string>(["lead"]);
(mentionSuggestions ?? []).forEach((candidate) => {
const handle = normalizeMentionHandle(candidate);
if (handle) {
handles.add(handle);
}
});
return [...handles];
}, [mentionSuggestions]);
const filteredMentionOptions = useMemo(() => {
if (!mentionTarget) return [];
const query = mentionTarget.query;
const startsWithMatches = mentionOptions.filter((option) =>
option.startsWith(query),
);
return startsWithMatches.slice(0, MENTION_MAX_OPTIONS);
}, [mentionOptions, mentionTarget]);
const activeIndex =
filteredMentionOptions.length > 0
? Math.min(activeMentionIndex, filteredMentionOptions.length - 1)
: 0;
useEffect(() => { useEffect(() => {
if (isSending) return; if (isSending) return;
if (!shouldFocusAfterSendRef.current) return; if (!shouldFocusAfterSendRef.current) return;
@@ -29,6 +92,43 @@ function BoardChatComposerImpl({
textareaRef.current?.focus(); textareaRef.current?.focus();
}, [isSending]); }, [isSending]);
useEffect(() => {
return () => {
if (closeMenuTimeoutRef.current !== null) {
window.clearTimeout(closeMenuTimeoutRef.current);
}
};
}, []);
const refreshMentionTarget = useCallback(
(nextValue: string, caret: number) => {
const nextTarget = findMentionTarget(nextValue, caret);
setMentionTarget(nextTarget);
},
[],
);
const applyMentionSelection = useCallback(
(handle: string) => {
const textarea = textareaRef.current;
if (!textarea || !mentionTarget) return;
const replacement = `@${handle} `;
const nextValue =
value.slice(0, mentionTarget.start) +
replacement +
value.slice(mentionTarget.end);
setValue(nextValue);
setMentionTarget(null);
setActiveMentionIndex(0);
window.requestAnimationFrame(() => {
const nextCaret = mentionTarget.start + replacement.length;
textarea.focus();
textarea.setSelectionRange(nextCaret, nextCaret);
});
},
[mentionTarget, value],
);
const send = useCallback(async () => { const send = useCallback(async () => {
if (isSending || disabled) return; if (isSending || disabled) return;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -37,16 +137,85 @@ function BoardChatComposerImpl({
shouldFocusAfterSendRef.current = true; shouldFocusAfterSendRef.current = true;
if (ok) { if (ok) {
setValue(""); setValue("");
setMentionTarget(null);
setActiveMentionIndex(0);
} }
}, [disabled, isSending, onSend, value]); }, [disabled, isSending, onSend, value]);
return ( return (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<div className="relative">
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => {
const nextValue = event.target.value;
setValue(nextValue);
refreshMentionTarget(
nextValue,
event.target.selectionStart ?? nextValue.length,
);
}}
onClick={(event) => {
refreshMentionTarget(
value,
event.currentTarget.selectionStart ?? value.length,
);
}}
onKeyUp={(event) => {
refreshMentionTarget(
value,
event.currentTarget.selectionStart ?? value.length,
);
}}
onBlur={() => {
if (closeMenuTimeoutRef.current !== null) {
window.clearTimeout(closeMenuTimeoutRef.current);
}
closeMenuTimeoutRef.current = window.setTimeout(() => {
setMentionTarget(null);
setActiveMentionIndex(0);
}, 120);
}}
onFocus={(event) => {
refreshMentionTarget(
value,
event.currentTarget.selectionStart ?? value.length,
);
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if (filteredMentionOptions.length > 0 && mentionTarget) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveMentionIndex(
(prev) => (prev + 1) % filteredMentionOptions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveMentionIndex(
(prev) =>
(prev - 1 + filteredMentionOptions.length) %
filteredMentionOptions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const selected = filteredMentionOptions[activeIndex];
if (selected) {
applyMentionSelection(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMentionTarget(null);
setActiveMentionIndex(0);
return;
}
}
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return; if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return; if (event.shiftKey) return;
@@ -57,6 +226,31 @@ function BoardChatComposerImpl({
className="min-h-[120px]" className="min-h-[120px]"
disabled={isSending || disabled} disabled={isSending || disabled}
/> />
{mentionTarget && filteredMentionOptions.length > 0 ? (
<div className="absolute bottom-full left-0 z-20 mb-2 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
<div className="max-h-52 overflow-y-auto py-1">
{filteredMentionOptions.map((option, index) => (
<button
key={option}
type="button"
onMouseDown={(event) => {
event.preventDefault();
applyMentionSelection(option);
}}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition ${
index === activeIndex
? "bg-slate-100 text-slate-900"
: "text-slate-700 hover:bg-slate-50"
}`}
>
<span className="font-mono">@{option}</span>
<span className="text-xs text-slate-400">mention</span>
</button>
))}
</div>
</div>
) : null}
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={() => void send()} onClick={() => void send()}

View File

@@ -67,7 +67,10 @@ const renderMentionsInText = (text: string, keyPrefix: string): ReactNode => {
return nodes; return nodes;
}; };
const renderMentions = (content: ReactNode, keyPrefix = "mention"): ReactNode => { const renderMentions = (
content: ReactNode,
keyPrefix = "mention",
): ReactNode => {
if (typeof content === "string") { if (typeof content === "string") {
return renderMentionsInText(content, keyPrefix); return renderMentionsInText(content, keyPrefix);
} }

View File

@@ -10,6 +10,7 @@ interface TaskCardProps {
priority?: string; priority?: string;
assignee?: string; assignee?: string;
due?: string; due?: string;
isOverdue?: boolean;
approvalsPendingCount?: number; approvalsPendingCount?: number;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
isBlocked?: boolean; isBlocked?: boolean;
@@ -27,6 +28,7 @@ export function TaskCard({
priority, priority,
assignee, assignee,
due, due,
isOverdue = false,
approvalsPendingCount = 0, approvalsPendingCount = 0,
tags = [], tags = [],
isBlocked = false, isBlocked = false,
@@ -157,8 +159,18 @@ export function TaskCard({
<span>{assignee ?? "Unassigned"}</span> <span>{assignee ?? "Unassigned"}</span>
</div> </div>
{due ? ( {due ? (
<div className="flex items-center gap-2"> <div
<CalendarClock className="h-4 w-4 text-slate-400" /> className={cn(
"flex items-center gap-2",
isOverdue && "font-semibold text-rose-600",
)}
>
<CalendarClock
className={cn(
"h-4 w-4",
isOverdue ? "text-rose-500" : "text-slate-400",
)}
/>
<span>{due}</span> <span>{due}</span>
</div> </div>
) : null} ) : null}

View File

@@ -82,14 +82,20 @@ const columns: Array<{
}, },
]; ];
const formatDueDate = (value?: string | null) => { const resolveDueState = (
if (!value) return undefined; task: Task,
const date = parseApiDatetime(value); ): { due: string | undefined; isOverdue: boolean } => {
if (!date) return undefined; const date = parseApiDatetime(task.due_at);
return date.toLocaleDateString(undefined, { if (!date) return { due: undefined, isOverdue: false };
const dueLabel = date.toLocaleDateString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
return {
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
isOverdue,
};
}; };
type CardPosition = { left: number; top: number }; type CardPosition = { left: number; top: number };
@@ -359,10 +365,12 @@ export const TaskBoard = memo(function TaskBoard({
const filteredTasks = const filteredTasks =
column.status === "review" && reviewBucket !== "all" column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => { ? columnTasks.filter((task) => {
if (reviewBucket === "blocked") return Boolean(task.is_blocked); if (reviewBucket === "blocked")
return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed") if (reviewBucket === "approval_needed")
return ( return (
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked (task.approvals_pending_count ?? 0) > 0 &&
!task.is_blocked
); );
if (reviewBucket === "waiting_lead") if (reviewBucket === "waiting_lead")
return ( return (
@@ -384,7 +392,9 @@ export const TaskBoard = memo(function TaskBoard({
)} )}
onDrop={readOnly ? undefined : handleDrop(column.status)} onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)} onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(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="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 justify-between">
@@ -445,14 +455,17 @@ export const TaskBoard = memo(function TaskBoard({
</div> </div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3"> <div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3"> <div className="space-y-3">
{filteredTasks.map((task) => ( {filteredTasks.map((task) => {
const dueState = resolveDueState(task);
return (
<div key={task.id} ref={setCardRef(task.id)}> <div key={task.id} ref={setCardRef(task.id)}>
<TaskCard <TaskCard
title={task.title} title={task.title}
status={task.status} status={task.status}
priority={task.priority} priority={task.priority}
assignee={task.assignee ?? undefined} assignee={task.assignee ?? undefined}
due={formatDueDate(task.due_at)} due={dueState.due}
isOverdue={dueState.isOverdue}
approvalsPendingCount={task.approvals_pending_count} approvalsPendingCount={task.approvals_pending_count}
tags={task.tags} tags={task.tags}
isBlocked={task.is_blocked} isBlocked={task.is_blocked}
@@ -460,11 +473,14 @@ export const TaskBoard = memo(function TaskBoard({
onClick={() => onTaskSelect?.(task)} onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked} draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id} isDragging={draggingId === task.id}
onDragStart={readOnly ? undefined : handleDragStart(task)} onDragStart={
readOnly ? undefined : handleDragStart(task)
}
onDragEnd={readOnly ? undefined : handleDragEnd} onDragEnd={readOnly ? undefined : handleDragEnd}
/> />
</div> </div>
))} );
})}
</div> </div>
</div> </div>
</div> </div>