Files
fusion/packages/dashboard/src/command-center-csv.ts
gsxdsm 5117944371 FN-6720: add productivity task duration stats
Adds completed-task active-duration analytics to the Command Center Productivity surface.\n\n- Aggregate average, median, p90, total, and completed-task duration metrics from done tasks with recorded active time.\n- Render duration stat cards with unavailable dash sentinels and export matching CSV rows.\n- Cover analytics, dashboard, route, CSV, mobile/tablet layout, and documentation behavior.\n\nFiles changed:\n .changeset/soft-badgers-measure.md                 |   5 +\n docs/architecture.md                               |   4 +-\n docs/dashboard-guide.md                            |   2 +-\n docs/storage.md                                    |   2 +\n .../src/__tests__/productivity-analytics.test.ts   | 105 ++++++++++++++++++++-\n packages/core/src/productivity-analytics.ts        |  83 +++++++++++++++-\n .../__tests__/CommandCenter.mobile-scroll.test.tsx |  16 ++++\n .../__tests__/CommandCenter.tablet-layout.test.tsx |  19 ++++\n .../command-center/areas/ProductivityArea.tsx      |  65 ++++++++++++-\n .../command-center/areas/__tests__/areas.test.tsx  |  65 ++++++++++++-\n .../components/command-center/areas/areaShared.ts  |  12 +++\n .../src/__tests__/command-center-csv.test.ts       |   8 ++\n .../register-command-center-routes.test.ts         |  22 +++++\n packages/dashboard/src/command-center-csv.ts       |   5 +\n 14 files changed, 404 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-6720

Fusion-Task-Lineage: aa0980eb-d156-4dca-b6b8-1788570c166b
2026-06-19 17:25:35 -07:00

203 lines
6.8 KiB
TypeScript

import type {
TokenAnalytics,
ToolAnalytics,
ActivityAnalytics,
ProductivityAnalytics,
GithubIssueAnalytics,
} from "@fusion/core";
/**
* Command Center CSV serialization (U8).
*
* RFC-4180 serialization of the Phase-A analytics aggregator output so any
* analytics table can be exported as `text/csv`. Pure: these helpers take an
* already-aggregated, already-project-scoped result and emit a string. The
* route handler (`register-command-center-routes.ts`) is responsible for
* resolving the project-scoped store and running the aggregator first, exactly
* like the JSON path — there is no DB access here, so there is no scoping leak
* surface in this module.
*
* Format guarantees (RFC-4180):
* - A header row is always emitted, even for an empty result (header-only CSV,
* never a 204 / empty body).
* - Fields containing a comma, double-quote, CR, or LF are wrapped in double
* quotes; embedded double-quotes are doubled.
* - Records are terminated with CRLF (`\r\n`), consistently.
*/
const CRLF = "\r\n";
/** A scalar cell value. `null`/`undefined` serialize to an empty field. */
export type CsvCell = string | number | boolean | null | undefined;
/** A logical table: a fixed header plus zero or more rows of cells. */
export interface CsvTable {
header: readonly string[];
rows: readonly (readonly CsvCell[])[];
}
/** Quote a single field per RFC-4180 when (and only when) required. */
function quoteField(value: CsvCell): string {
if (value === null || value === undefined) return "";
const s = typeof value === "string" ? value : String(value);
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
/** Serialize one record (array of cells) to a CSV line (no terminator). */
function serializeRecord(record: readonly CsvCell[]): string {
return record.map(quoteField).join(",");
}
/**
* Serialize a {@link CsvTable} to an RFC-4180 string. Always emits the header
* row; an empty `rows` yields a header-only document. The document is
* CRLF-terminated, including a trailing CRLF after the final record (RFC-4180
* permits this and it keeps the empty/non-empty cases uniform).
*/
export function serializeCsv(table: CsvTable): string {
const lines: string[] = [serializeRecord(table.header)];
for (const row of table.rows) {
lines.push(serializeRecord(row));
}
return lines.join(CRLF) + CRLF;
}
// ---------------------------------------------------------------------------
// Aggregator → CsvTable converters
//
// Each analytics result is a small nested object; we flatten the
// developer-meaningful fields into a tabular shape. For token analytics with a
// groupBy, each group becomes a row; otherwise the grand total is a single row.
// ---------------------------------------------------------------------------
/** Token analytics → CSV. One row per group, or a single total row. */
export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable {
const header = [
"key",
"inputTokens",
"outputTokens",
"cachedTokens",
"cacheWriteTokens",
"totalTokens",
"nTasks",
"costUsd",
"costUnavailable",
];
if (result.groupBy && result.groups.length > 0) {
const rows = result.groups.map((g) => [
g.key,
g.inputTokens,
g.outputTokens,
g.cachedTokens,
g.cacheWriteTokens,
g.totalTokens,
g.nTasks,
g.cost.usd,
g.cost.unavailable,
]);
return { header, rows };
}
const t = result.totals;
return {
header,
rows: [
[
"(total)",
t.inputTokens,
t.outputTokens,
t.cachedTokens,
t.cacheWriteTokens,
t.totalTokens,
t.nTasks,
result.cost.usd,
result.cost.unavailable,
],
],
};
}
/** Tool analytics → CSV. One row per category plus a summary row. */
export function toolAnalyticsToTable(result: ToolAnalytics): CsvTable {
const header = ["category", "count"];
const rows: CsvCell[][] = result.byCategory.map((c) => [c.category, c.count]);
// Always include the headline metrics so an empty byCategory is not empty.
rows.push(["(toolCalls)", result.toolCalls]);
rows.push(["(sessions)", result.sessions]);
rows.push(["(interventions)", result.interventions.total]);
rows.push(["(autonomyRatio)", result.autonomyRatio]);
rows.push(["(fullyAutonomous)", result.fullyAutonomous]);
return { header, rows };
}
/** Activity analytics → CSV. One row per day plus summary rows. */
export function activityAnalyticsToTable(result: ActivityAnalytics): CsvTable {
const header = ["day", "messages", "activeNodes", "activeAgents", "agentRuns"];
const rows: CsvCell[][] = result.daily.map((d) => [
d.day,
d.messages,
d.activeNodes,
d.activeAgents,
d.agentRuns,
]);
rows.push([
"(total)",
result.messages,
result.activeNodes,
result.activeAgents,
result.agentRuns.total,
]);
rows.push(["(sessions)", result.sessions, "", "", ""]);
rows.push(["(stickiness)", result.stickiness, "", "", ""]);
rows.push(["(agentRuns.total)", result.agentRuns.total, "", "", ""]);
rows.push(["(agentRuns.active)", result.agentRuns.active, "", "", ""]);
rows.push(["(agentRuns.completed)", result.agentRuns.completed, "", "", ""]);
rows.push(["(agentRuns.failed)", result.agentRuns.failed, "", "", ""]);
return { header, rows };
}
/** Productivity analytics → CSV. One row per language plus summary rows. */
export function productivityAnalyticsToTable(
result: ProductivityAnalytics,
): CsvTable {
const header = ["metric", "count"];
const rows: CsvCell[][] = [];
for (const lang of result.byLanguage) {
rows.push([`language:${lang.language}`, lang.count]);
}
rows.push(["modifiedFiles", result.modifiedFiles]);
rows.push(["commits", result.commits]);
rows.push(["pullRequests", result.pullRequests]);
rows.push(["loc", result.loc.value ?? ""]);
rows.push(["hoursSaved", result.hoursSaved.value ?? ""]);
rows.push(["completedTasks", result.taskDuration.completedTasks]);
rows.push(["avgDurationMs", result.taskDuration.averageMs ?? ""]);
rows.push(["medianDurationMs", result.taskDuration.medianMs ?? ""]);
rows.push(["p90DurationMs", result.taskDuration.p90Ms ?? ""]);
rows.push(["totalDurationMs", result.taskDuration.totalMs ?? ""]);
return { header, rows };
}
/** GitHub issue analytics → CSV. Daily rows plus repo and summary rows. */
export function githubIssueAnalyticsToTable(
result: GithubIssueAnalytics,
): CsvTable {
const header = ["section", "key", "filed", "fixed", "net"];
const rows: CsvCell[][] = result.daily.map((d) => [
"daily",
d.date,
d.filed,
d.fixed,
d.filed - d.fixed,
]);
for (const repo of result.byRepo) {
rows.push(["repo", repo.repo, repo.filed, repo.fixed, repo.filed - repo.fixed]);
}
rows.push(["summary", "total", result.filed, result.fixed, result.net]);
return { header, rows };
}