diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 0adcb341..1e82307f 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -54,6 +54,7 @@ export const zh: Record = { "Cancel run": "取消运行", "Show older runs": "显示更早的运行", "Target date": "目标日期", + "{n} others": "其余 {n} 人", "Work accepted (7 days)": "工作验收(近 7 天)", "{accepted} done · {returned} sent back · sessions: {ok} ended, {failed} failed": "{accepted} 项完成 · {returned} 项退回 · 会话:{ok} 次结束,{failed} 次失败", "no target date": "未设目标日期", diff --git a/src/renderer/src/pages/home/DashboardCharts.tsx b/src/renderer/src/pages/home/DashboardCharts.tsx index dd200ce6..650851c6 100644 --- a/src/renderer/src/pages/home/DashboardCharts.tsx +++ b/src/renderer/src/pages/home/DashboardCharts.tsx @@ -1,7 +1,7 @@ +import { workloadBars } from "./workload-bars"; import type { UsageStatsRecord } from "../../../../shared/usage-stats"; import { useState, type ReactNode } from "react"; import type { AgentRecord, TaskRecord } from "../../../../shared/domain"; -import { TASK_STATUS } from "../../../../shared/domain"; import { t } from "../../i18n/i18n"; const CHART_H = 116; @@ -22,16 +22,15 @@ function ChartCard({ title, readout, children }: { title: string; readout: strin ); } +/** Agents beyond this share one bar, so a large team still fits and is still counted. */ +const MAX_WORKLOAD_BARS = 8; + export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; tasks: TaskRecord[] }) { - const data = agents.slice(0, 8).map((a) => ({ - name: a.name.split(/\s+/)[0] ?? a.name, - count: tasks.filter((t) => t.assigneeAgentId === a.id && t.status !== TASK_STATUS.DONE).length, - })); - const total = data.reduce((s, d) => s + d.count, 0); + const { bars: data, total } = workloadBars(agents, tasks, MAX_WORKLOAD_BARS); const max = Math.max(1, ...data.map((d) => d.count)); const barW = data.length > 0 ? (CHART_W - (data.length - 1) * BAR_GAP) / data.length : 0; const [hover, setHover] = useState(null); - const readout = hover !== null && data[hover] ? `${data[hover]!.name} · ${data[hover]!.count}` : t("{n} open", { n: total }); + const readout = hover !== null && data[hover] ? `${data[hover]!.label} · ${data[hover]!.count}` : t("{n} open", { n: total }); return ( @@ -40,7 +39,7 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t const h = (d.count / max) * (CHART_H - 22); const x = i * (barW + BAR_GAP); return ( - setHover(i)} onMouseLeave={() => setHover(null)}> + setHover(i)} onMouseLeave={() => setHover(null)}> - {d.name} + {d.label} ); })} diff --git a/src/renderer/src/pages/home/workload-bars.ts b/src/renderer/src/pages/home/workload-bars.ts new file mode 100644 index 00000000..bbf62e34 --- /dev/null +++ b/src/renderer/src/pages/home/workload-bars.ts @@ -0,0 +1,18 @@ +import { TASK_STATUS, type AgentRecord, type TaskRecord } from "../../../../shared/domain"; +import { t } from "../../i18n/i18n"; + +/** Open work per agent for the chart: the busiest get their own bar, the rest share one labelled bar, and the total counts everyone. */ +export function workloadBars(agents: AgentRecord[], tasks: TaskRecord[], maxBars: number): { bars: Array<{ key: string; label: string; count: number }>; total: number } { + const counts = agents.map((agent) => ({ + key: agent.id, + label: agent.name.split(/\s+/)[0] ?? agent.name, + count: tasks.filter((task) => task.assigneeAgentId === agent.id && task.status !== TASK_STATUS.DONE).length, + })); + const total = counts.reduce((sum, item) => sum + item.count, 0); + if (counts.length <= maxBars) return { bars: counts, total }; + const ranked = [...counts].sort((a, b) => b.count - a.count); + const shown = ranked.slice(0, maxBars - 1); + const rest = ranked.slice(maxBars - 1); + const others = { key: "others", label: t("{n} others", { n: rest.length }), count: rest.reduce((sum, item) => sum + item.count, 0) }; + return { bars: [...shown, others], total }; +} diff --git a/tests/unit/workload-bars.test.ts b/tests/unit/workload-bars.test.ts new file mode 100644 index 00000000..56964088 --- /dev/null +++ b/tests/unit/workload-bars.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { workloadBars } from "../../src/renderer/src/pages/home/workload-bars"; +import type { AgentRecord, TaskRecord } from "../../src/shared/domain"; + +const agent = (id: string, name: string) => ({ id, name }) as AgentRecord; +const open = (assignee: string, count: number) => Array.from({ length: count }, () => ({ assigneeAgentId: assignee, status: "in_progress" }) as TaskRecord); + +describe("team workload", () => { + test("counts every agent: the busiest show, the rest are one labelled bar, and the total is everyone's", () => { + const agents = Array.from({ length: 10 }, (_, index) => agent(`agt_${index}`, `Agent ${index}`)); + const tasks = agents.flatMap((item, index) => open(item.id, index)); + const { bars, total } = workloadBars(agents, tasks, 8); + expect(total).toBe(45); + expect(bars.map((bar) => [bar.label, bar.count])).toEqual([ + ["Agent", 9], ["Agent", 8], ["Agent", 7], ["Agent", 6], ["Agent", 5], ["Agent", 4], ["Agent", 3], ["3 others", 3], + ]); + expect(new Set(bars.map((bar) => bar.key)).size).toBe(8); + }); + + test("a small team shows everyone", () => { + const { bars, total } = workloadBars([agent("a", "Ada Lovelace"), agent("b", "Ben")], [...open("a", 2)], 8); + expect([bars.map((bar) => bar.label), total]).toEqual([["Ada", "Ben"], 2]); + }); +});