mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
perf(panel): kanban virtualization + row memoization + scorecards batch endpoint (#594)
* perf(panel): kanban virtualization + row memoization + scorecards batch endpoint The audit's remaining phases: the kanban card lists render through @tanstack/react-virtual windows (columns are the dnd drop targets, cards only drag — no sortable conflict) with memoized columns/cards and a stabilized handleAction; the task table's desktop row and mobile card are extracted and memoized (the table itself already client-paginates to 100). Backend: the per-member scorecard N+1 (~20 requests x 3 queries per poll) collapses into GET /dashboard/metrics/members backed by get_all_member_scorecards with grouped rollup/overlay SQL shared with the single-agent path. * test(metrics): shared-DB-safe scorecard tests — unique seeds, delta assertions The two new batch-scorecard tests assumed a private DB: fixed ceo/system slugs collided with other tests' seeds (ix_agents_slug) and a global exactly-one CEO lookup + exact roster count broke in the one-process suite. Unique slugs, subset/disjoint assertions, dead count constant dropped. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -14,7 +14,7 @@ const { mockOrg, mockCeo, mockMember, mockAgents } = vi.hoisted(() => ({
|
||||
vi.mock("@/hooks/use-observability", () => ({
|
||||
useOrgScorecard: mockOrg,
|
||||
useCeoScorecard: mockCeo,
|
||||
useMemberScorecard: mockMember,
|
||||
useAllMemberScorecards: mockMember,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-agents", () => ({
|
||||
@@ -96,18 +96,21 @@ describe("ScorecardsTabContent", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
mockMember.mockReturnValue({
|
||||
data: member({
|
||||
id: "dev1",
|
||||
name: "be-dev-1",
|
||||
tasks_completed: 7,
|
||||
first_pass_yield: 0.8,
|
||||
active_runtime_hours: 4.2,
|
||||
qa_pass_rate: 0.9,
|
||||
escalations: 1,
|
||||
blocked_others: 2,
|
||||
utilization: 0.6,
|
||||
}),
|
||||
data: [
|
||||
member({
|
||||
id: "dev1",
|
||||
name: "be-dev-1",
|
||||
tasks_completed: 7,
|
||||
first_pass_yield: 0.8,
|
||||
active_runtime_hours: 4.2,
|
||||
qa_pass_rate: 0.9,
|
||||
escalations: 1,
|
||||
blocked_others: 2,
|
||||
utilization: 0.6,
|
||||
}),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockAgents.mockReturnValue({
|
||||
data: [
|
||||
@@ -164,8 +167,10 @@ describe("ScorecardsTabContent", () => {
|
||||
expect(
|
||||
screen.getByText(/failed to load organization metrics/i),
|
||||
).toBeInTheDocument();
|
||||
// The member row shows a failed marker rather than a perpetual skeleton
|
||||
// (exact lowercase text, distinct from the org card's message).
|
||||
expect(screen.getByText("failed to load")).toBeInTheDocument();
|
||||
// The batched member-scorecard fetch surfaces one table-level banner
|
||||
// rather than a per-row error (or an endless per-row skeleton).
|
||||
expect(
|
||||
screen.getByText(/failed to load member scorecards/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -13,12 +14,12 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
useAllMemberScorecards,
|
||||
useCeoScorecard,
|
||||
useMemberScorecard,
|
||||
useOrgScorecard,
|
||||
} from "@/hooks/use-observability";
|
||||
import { useAgents } from "@/hooks/use-agents";
|
||||
import { AgentRole, type Agent } from "@/types";
|
||||
import { AgentRole, type Agent, type MemberScorecard } from "@/types";
|
||||
|
||||
function pctOrNa(rate: number | null): string {
|
||||
return rate === null ? "n/a" : (rate * 100).toFixed(0) + "%";
|
||||
@@ -32,20 +33,18 @@ function hoursOrDash(seconds: number): string {
|
||||
return (seconds / 3600).toFixed(1) + "h";
|
||||
}
|
||||
|
||||
/** One member's row — each row self-fetches its rollup scorecard. */
|
||||
function MemberRow({ agent }: { agent: Agent }) {
|
||||
const { data, isLoading, isError } = useMemberScorecard(agent.id);
|
||||
if (isError) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{agent.name || agent.slug}</TableCell>
|
||||
<TableCell colSpan={8} className="text-muted-foreground text-xs">
|
||||
failed to load
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (isLoading || !data) {
|
||||
/** One member's row. Data comes from the batched useAllMemberScorecards
|
||||
* fetch (one request for the whole table) rather than each row self-fetching
|
||||
* — ~20 agents used to mean ~20 parallel `/metrics/member/{id}` requests
|
||||
* (each 3 DB queries) on every table poll. */
|
||||
function MemberRow({
|
||||
agent,
|
||||
data,
|
||||
}: {
|
||||
agent: Agent;
|
||||
data: MemberScorecard | undefined;
|
||||
}) {
|
||||
if (!data) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{agent.name || agent.slug}</TableCell>
|
||||
@@ -172,6 +171,12 @@ export function ScorecardsTabContent() {
|
||||
const members = (agents ?? []).filter(
|
||||
(a) => a.role !== AgentRole.CEO && a.role !== AgentRole.SYSTEM,
|
||||
);
|
||||
const { data: scorecards, isError: scorecardsError } =
|
||||
useAllMemberScorecards();
|
||||
const scorecardById = useMemo(
|
||||
() => new Map((scorecards ?? []).map((s) => [s.id, s])),
|
||||
[scorecards],
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -197,6 +202,11 @@ export function ScorecardsTabContent() {
|
||||
<CardTitle>Members</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{scorecardsError && (
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
Failed to load member scorecards.
|
||||
</p>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -245,7 +255,7 @@ export function ScorecardsTabContent() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((a) => (
|
||||
<MemberRow key={a.id} agent={a} />
|
||||
<MemberRow key={a.id} agent={a} data={scorecardById.get(a.id)} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
Reference in New Issue
Block a user