feat(approvals): enhance approval model with task titles and confidence as float

This commit is contained in:
Abhimanyu Saharan
2026-02-12 19:57:04 +05:30
parent 8bd606a8dc
commit 032b77afb8
13 changed files with 370 additions and 40 deletions

View File

@@ -252,7 +252,8 @@ export default function DashboardPage() {
const searchParams = useSearchParams();
const selectedRangeParam = searchParams.get("range");
const selectedRange: RangeKey =
selectedRangeParam && DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
selectedRangeParam &&
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
? (selectedRangeParam as RangeKey)
: DEFAULT_RANGE;
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
@@ -401,10 +402,7 @@ export default function DashboardPage() {
</div>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<ChartCard
title="Completed Tasks"
subtitle="Throughput"
>
<ChartCard title="Completed Tasks" subtitle="Throughput">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={throughputSeries}
@@ -449,10 +447,7 @@ export default function DashboardPage() {
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Avg Hours to Review"
subtitle="Cycle time"
>
<ChartCard title="Avg Hours to Review" subtitle="Cycle time">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={cycleSeries}
@@ -501,10 +496,7 @@ export default function DashboardPage() {
</ResponsiveContainer>
</ChartCard>
<ChartCard
title="Failed Events"
subtitle="Error rate"
>
<ChartCard title="Failed Events" subtitle="Error rate">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={errorSeries}

View File

@@ -52,9 +52,14 @@ describe("BoardApprovalsPanel", () => {
linked_request: {
tasks: [
{
task_id: "task-1",
title: "Launch onboarding checklist",
description: "Create and validate the v1 onboarding checklist.",
},
{
task_id: "task-2",
title: "Publish onboarding checklist",
},
],
task_ids: ["task-1", "task-2"],
},
@@ -84,7 +89,46 @@ describe("BoardApprovalsPanel", () => {
expect(
screen.getByText("Needs explicit sign-off before rollout."),
).toBeInTheDocument();
expect(screen.getByText("62% score")).toBeInTheDocument();
expect(screen.getByText(/related tasks/i)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Launch onboarding checklist" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-1");
expect(
screen.getByRole("link", { name: "Publish onboarding checklist" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-2");
expect(screen.getByText(/rubric scores/i)).toBeInTheDocument();
expect(screen.getByText("Clarity")).toBeInTheDocument();
});
it("uses schema task_titles for related task links when payload titles are missing", () => {
const approval = {
id: "approval-2",
board_id: "board-1",
action_type: "task.update",
confidence: 88,
status: "pending",
task_id: "task-a",
task_ids: ["task-a", "task-b"],
task_titles: ["Prepare release notes", "Publish release notes"],
created_at: "2026-02-12T11:00:00Z",
resolved_at: null,
payload: {
task_ids: ["task-a", "task-b"],
reason: "Needs sign-off before publishing.",
},
rubric_scores: null,
} as ApprovalRead;
renderWithQueryClient(
<BoardApprovalsPanel boardId="board-1" approvals={[approval]} />,
);
expect(
screen.getByRole("link", { name: "Prepare release notes" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-a");
expect(
screen.getByRole("link", { name: "Publish release notes" }),
).toHaveAttribute("href", "/boards/board-1?taskId=task-b");
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
@@ -28,9 +29,16 @@ import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils";
type Approval = ApprovalRead & { status: string };
const normalizeScore = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return value;
};
const normalizeApproval = (approval: ApprovalRead): Approval => ({
...approval,
status: approval.status ?? "pending",
confidence: normalizeScore(approval.confidence),
});
type BoardApprovalsPanelProps = {
@@ -237,6 +245,79 @@ const approvalTaskIds = (approval: Approval) => {
return [...new Set(merged)];
};
type RelatedTaskSummary = {
id: string;
title: string;
};
const approvalRelatedTasks = (approval: Approval): RelatedTaskSummary[] => {
const payload = approval.payload ?? {};
const taskIds = approvalTaskIds(approval);
if (taskIds.length === 0) return [];
const apiTaskTitles = (
approval as Approval & { task_titles?: string[] | null }
).task_titles;
const titleByTaskId = new Map<string, string>();
const orderedTitles: string[] = [];
const collectTaskTitles = (path: string[]) => {
const tasks = payloadAtPath(payload, path);
if (!Array.isArray(tasks)) return;
for (const task of tasks) {
if (!isRecord(task)) continue;
const rawTitle = task["title"];
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
if (!title) continue;
orderedTitles.push(title);
const taskId =
typeof task["task_id"] === "string"
? task["task_id"]
: typeof task["taskId"] === "string"
? task["taskId"]
: typeof task["id"] === "string"
? task["id"]
: null;
if (taskId && taskId.trim()) {
titleByTaskId.set(taskId, title);
}
}
};
collectTaskTitles(["linked_request", "tasks"]);
collectTaskTitles(["linkedRequest", "tasks"]);
const indexedTitles = [
...(Array.isArray(apiTaskTitles) ? apiTaskTitles : []),
...orderedTitles,
...payloadValues(payload, "task_titles"),
...payloadValues(payload, "taskTitles"),
...payloadNestedValues(payload, ["linked_request", "task_titles"]),
...payloadNestedValues(payload, ["linked_request", "taskTitles"]),
...payloadNestedValues(payload, ["linkedRequest", "task_titles"]),
...payloadNestedValues(payload, ["linkedRequest", "taskTitles"]),
]
.map((value) => value.trim())
.filter((value) => value.length > 0);
const singleTitle =
payloadValue(payload, "title") ??
payloadNestedValue(payload, ["task", "title"]) ??
payloadFirstLinkedTaskValue(payload, "title");
return taskIds.map((taskId, index) => {
const resolvedTitle =
titleByTaskId.get(taskId) ??
indexedTitles[index] ??
(taskIds.length === 1 ? singleTitle : null) ??
"Untitled task";
return { id: taskId, title: resolvedTitle };
});
};
const taskHref = (boardId: string, taskId: string) =>
`/boards/${encodeURIComponent(boardId)}?taskId=${encodeURIComponent(taskId)}`;
const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
const payload = approval.payload ?? {};
const taskIds = approvalTaskIds(approval);
@@ -544,6 +625,9 @@ export function BoardApprovalsPanel({
</p>
) : null}
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-semibold text-slate-700">
{approval.confidence}% score
</span>
<Clock className="h-3.5 w-3.5 opacity-60" />
<span>{formatTimestamp(approval.created_at)}</span>
</div>
@@ -582,10 +666,12 @@ export function BoardApprovalsPanel({
const titleText = titleRow?.value?.trim() ?? "";
const descriptionText = summary.description?.trim() ?? "";
const reasoningText = summary.reason?.trim() ?? "";
const relatedTasks = approvalRelatedTasks(selectedApproval);
const extraRows = summary.rows.filter((row) => {
const normalized = row.label.toLowerCase();
if (normalized === "title") return false;
if (normalized === "task") return false;
if (normalized === "tasks") return false;
if (normalized === "assignee") return false;
return true;
});
@@ -733,6 +819,28 @@ export function BoardApprovalsPanel({
</div>
) : null}
{relatedTasks.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Related tasks
</p>
<div className="flex flex-wrap gap-2">
{relatedTasks.map((task) => (
<Link
key={`${selectedApproval.id}-task-${task.id}`}
href={taskHref(
selectedApproval.board_id,
task.id,
)}
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 underline-offset-2 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 hover:underline"
>
{task.title}
</Link>
))}
</div>
</div>
) : null}
{extraRows.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">

View File

@@ -57,10 +57,14 @@ export function DashboardSidebar() {
return (
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
<div className="flex-1 px-3 py-4">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">Navigation</p>
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
Navigation
</p>
<nav className="mt-3 space-y-4 text-sm">
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Overview</p>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Overview
</p>
<div className="mt-1 space-y-1">
<Link
href="/dashboard"
@@ -90,7 +94,9 @@ export function DashboardSidebar() {
</div>
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">Boards</p>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Boards
</p>
<div className="mt-1 space-y-1">
<Link
href="/board-groups"