diff --git a/frontend/src/components/bottlenecks/root-cause-cluster-list.tsx b/frontend/src/components/bottlenecks/root-cause-cluster-list.tsx index 0df2d3a..7129021 100644 --- a/frontend/src/components/bottlenecks/root-cause-cluster-list.tsx +++ b/frontend/src/components/bottlenecks/root-cause-cluster-list.tsx @@ -1,16 +1,12 @@ import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { formatPercent } from "@/lib/utils" +import { formatPercent, titleize } from "@/lib/utils" import type { RootCauseCluster } from "@/types/api" interface RootCauseClusterListProps { data: RootCauseCluster[] } -function titleize(value: string) { - return value.replaceAll("_", " ") -} - export function RootCauseClusterList({ data }: RootCauseClusterListProps) { if (data.length === 0) return null diff --git a/frontend/src/components/finops/__tests__/workflow-compare-card.test.tsx b/frontend/src/components/finops/__tests__/workflow-compare-card.test.tsx new file mode 100644 index 0000000..932a407 --- /dev/null +++ b/frontend/src/components/finops/__tests__/workflow-compare-card.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { WorkflowCompareCard } from "@/components/finops/workflow-compare-card" + +describe("WorkflowCompareCard", () => { + it("renders an empty state when fewer than two workflows are available", () => { + render( + , + ) + + expect(screen.getByText("Need at least two workflows with cost data to compare them.")).toBeInTheDocument() + }) + + it("compares workflow metrics and allows dimension switching", () => { + render( + edit -> ship", + session_count: 1, + total_estimated_cost: 4.2, + avg_cost_per_session: 4.2, + cost_per_successful_outcome: 4.2, + }, + { + dimension: "workflow_fingerprint", + label: "refactor: search -> read -> edit -> ship", + session_count: 2, + total_estimated_cost: 5.3, + avg_cost_per_session: 2.65, + cost_per_successful_outcome: null, + }, + ]} + qualityRows={[ + { + dimension: "workflow_archetype", + label: "debugging", + linked_sessions: 2, + linked_prs: 2, + merge_rate: 1, + avg_review_comments_per_pr: 1.5, + avg_findings_per_pr: 0.5, + high_severity_findings_per_pr: 0, + avg_time_to_merge_hours: 4, + findings_fix_rate: 1, + }, + { + dimension: "workflow_archetype", + label: "refactor", + linked_sessions: 3, + linked_prs: 1, + merge_rate: 0.5, + avg_review_comments_per_pr: 2, + avg_findings_per_pr: 1, + high_severity_findings_per_pr: 0.5, + avg_time_to_merge_hours: 6, + findings_fix_rate: 0.5, + }, + { + dimension: "workflow_fingerprint", + label: "bug fix: read -> edit -> ship", + linked_sessions: 1, + linked_prs: 1, + merge_rate: 1, + avg_review_comments_per_pr: 1, + avg_findings_per_pr: 0, + high_severity_findings_per_pr: 0, + avg_time_to_merge_hours: 3, + findings_fix_rate: null, + }, + ]} + />, + ) + + expect(screen.getByText("Workflow Archetype")).toBeInTheDocument() + expect(screen.getAllByText("Debugging").length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText("Refactor").length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText("$12.50").length).toBeGreaterThanOrEqual(1) + + fireEvent.change(screen.getByLabelText("Dimension"), { + target: { value: "workflow_fingerprint" }, + }) + + expect(screen.getByText("Workflow Fingerprint")).toBeInTheDocument() + expect(screen.getAllByText("bug fix: read -> edit -> ship").length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText("refactor: search -> read -> edit -> ship").length).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/frontend/src/components/finops/overview-tab.tsx b/frontend/src/components/finops/overview-tab.tsx index ac13996..fc7eee9 100644 --- a/frontend/src/components/finops/overview-tab.tsx +++ b/frontend/src/components/finops/overview-tab.tsx @@ -1,7 +1,8 @@ -import { useCostAnalytics, useProductivity } from "@/hooks/use-api-queries" +import { useCostAnalytics, useProductivity, useQualityMetrics } from "@/hooks/use-api-queries" import { DailyCostChart } from "@/components/dashboard/daily-cost-chart" import { CostBreakdownChart } from "@/components/dashboard/cost-breakdown-chart" import { WorkflowCostTable } from "@/components/finops/workflow-cost-table" +import { WorkflowCompareCard } from "@/components/finops/workflow-compare-card" import { CardSkeleton, ChartSkeleton } from "@/components/shared/loading-skeleton" import { formatCost, getModelPricing } from "@/lib/utils" @@ -14,6 +15,7 @@ interface OverviewTabProps { export function OverviewTab({ teamId, startDate, endDate }: OverviewTabProps) { const costs = useCostAnalytics(teamId, startDate, endDate) const productivity = useProductivity(teamId, startDate, endDate) + const quality = useQualityMetrics(teamId, startDate, endDate) if (costs.isLoading || productivity.isLoading) { return ( @@ -80,6 +82,11 @@ export function OverviewTab({ teamId, startDate, endDate }: OverviewTabProps) { + + ) } diff --git a/frontend/src/components/finops/workflow-compare-card.tsx b/frontend/src/components/finops/workflow-compare-card.tsx new file mode 100644 index 0000000..593f109 --- /dev/null +++ b/frontend/src/components/finops/workflow-compare-card.tsx @@ -0,0 +1,211 @@ +import { useState } from "react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { formatCost, formatLabel, formatMetric, formatPercent } from "@/lib/utils" +import type { QualityAttributionRow, WorkflowCostBreakdown } from "@/types/api" + +type CompareDimension = "workflow_archetype" | "workflow_fingerprint" + +interface WorkflowCompareCardProps { + workflowCosts: WorkflowCostBreakdown[] + qualityRows: QualityAttributionRow[] +} + +const DIMENSION_LABELS: Record = { + workflow_archetype: "Workflow Archetype", + workflow_fingerprint: "Workflow Fingerprint", +} + +function displayLabel(value: string, dimension: CompareDimension) { + return dimension === "workflow_archetype" ? formatLabel(value) : value +} + +export function WorkflowCompareCard({ + workflowCosts, + qualityRows, +}: WorkflowCompareCardProps) { + const [dimension, setDimension] = useState("workflow_archetype") + const [leftLabel, setLeftLabel] = useState("") + const [rightLabel, setRightLabel] = useState("") + + const options = workflowCosts.filter((row) => row.dimension === dimension) + const labels = options.map((row) => row.label) + const effectiveLeftLabel = labels.includes(leftLabel) ? leftLabel : (labels[0] ?? "") + const effectiveRightLabel = + labels.includes(rightLabel) && rightLabel !== effectiveLeftLabel + ? rightLabel + : (labels.find((label) => label !== effectiveLeftLabel) ?? "") + + const dimensionSelector = ( + + ) + + if (options.length < 2) { + return ( + + + Workflow Compare + + +
{dimensionSelector}
+

+ Need at least two workflows with cost data to compare them. +

+
+
+ ) + } + + const costMap = Object.fromEntries( + workflowCosts + .filter((row) => row.dimension === dimension) + .map((row) => [row.label, row]), + ) + const qualityMap = Object.fromEntries( + qualityRows + .filter((row) => row.dimension === dimension) + .map((row) => [row.label, row]), + ) + + const leftCost = costMap[effectiveLeftLabel] + const rightCost = costMap[effectiveRightLabel] + const leftQuality = qualityMap[effectiveLeftLabel] + const rightQuality = qualityMap[effectiveRightLabel] + + if (!leftCost || !rightCost) { + return null + } + + return ( + + + Workflow Compare + + +
+ {dimensionSelector} + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Metric{displayLabel(effectiveLeftLabel, dimension)}{displayLabel(effectiveRightLabel, dimension)}
Sessions{leftCost.session_count}{rightCost.session_count}
Total Cost{formatCost(leftCost.total_estimated_cost)}{formatCost(rightCost.total_estimated_cost)}
Avg Cost / Session + {leftCost.avg_cost_per_session != null ? formatCost(leftCost.avg_cost_per_session) : "-"} + + {rightCost.avg_cost_per_session != null ? formatCost(rightCost.avg_cost_per_session) : "-"} +
Cost / Success + {leftCost.cost_per_successful_outcome != null + ? formatCost(leftCost.cost_per_successful_outcome) + : "-"} + + {rightCost.cost_per_successful_outcome != null + ? formatCost(rightCost.cost_per_successful_outcome) + : "-"} +
Linked PRs{leftQuality?.linked_prs ?? "-"}{rightQuality?.linked_prs ?? "-"}
Merge Rate{formatPercent(leftQuality?.merge_rate)}{formatPercent(rightQuality?.merge_rate)}
Reviews / PR + {formatMetric(leftQuality?.avg_review_comments_per_pr)} + + {formatMetric(rightQuality?.avg_review_comments_per_pr)} +
Findings / PR{formatMetric(leftQuality?.avg_findings_per_pr)}{formatMetric(rightQuality?.avg_findings_per_pr)}
Merge Time (h) + {formatMetric(leftQuality?.avg_time_to_merge_hours)} + + {formatMetric(rightQuality?.avg_time_to_merge_hours)} +
+
+
+
+ ) +} diff --git a/frontend/src/components/growth/bright-spot-cards.tsx b/frontend/src/components/growth/bright-spot-cards.tsx index 2dc8615..3e86200 100644 --- a/frontend/src/components/growth/bright-spot-cards.tsx +++ b/frontend/src/components/growth/bright-spot-cards.tsx @@ -2,7 +2,7 @@ import { Link } from "react-router-dom" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { formatDuration, formatLabel, formatPercent } from "@/lib/utils" +import { formatDuration, formatPercent, titleize } from "@/lib/utils" import type { BrightSpot } from "@/types/api" interface BrightSpotCardsProps { @@ -29,7 +29,7 @@ export function BrightSpotCards({ spots }: BrightSpotCardsProps) {

{spot.summary}

- {formatLabel(spot.cluster_type)} + {titleize(spot.cluster_type)} diff --git a/frontend/src/components/growth/workflow-playbook-cards.tsx b/frontend/src/components/growth/workflow-playbook-cards.tsx index 8a7d8b6..4e7e8c1 100644 --- a/frontend/src/components/growth/workflow-playbook-cards.tsx +++ b/frontend/src/components/growth/workflow-playbook-cards.tsx @@ -1,6 +1,6 @@ import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { formatDuration, formatPercent } from "@/lib/utils" +import { formatDuration, formatPercent, titleize } from "@/lib/utils" import type { WorkflowPlaybook } from "@/types/api" interface WorkflowPlaybookCardsProps { @@ -13,10 +13,6 @@ const adoptionVariant: Record = { already_using: "outline", } -function titleize(value: string) { - return value.replaceAll("_", " ") -} - export function WorkflowPlaybookCards({ playbooks }: WorkflowPlaybookCardsProps) { if (playbooks.length === 0) { return ( diff --git a/frontend/src/components/projects/project-workflow-section.tsx b/frontend/src/components/projects/project-workflow-section.tsx index 5903ea3..9695491 100644 --- a/frontend/src/components/projects/project-workflow-section.tsx +++ b/frontend/src/components/projects/project-workflow-section.tsx @@ -1,16 +1,12 @@ import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { formatDuration, formatNumber, formatPercent } from "@/lib/utils" +import { formatDuration, formatNumber, formatPercent, titleize } from "@/lib/utils" import type { ProjectWorkflowSummary } from "@/types/api" interface ProjectWorkflowSectionProps { workflowSummary: ProjectWorkflowSummary } -function titleize(value: string) { - return value.replaceAll("_", " ") -} - export function ProjectWorkflowSection({ workflowSummary }: ProjectWorkflowSectionProps) { return (
diff --git a/frontend/src/components/quality/claude-pr-comparison.tsx b/frontend/src/components/quality/claude-pr-comparison.tsx index 4d53336..c440b8a 100644 --- a/frontend/src/components/quality/claude-pr-comparison.tsx +++ b/frontend/src/components/quality/claude-pr-comparison.tsx @@ -1,7 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { CardSkeleton } from "@/components/shared/loading-skeleton" import { useClaudePRComparison } from "@/hooks/use-api-queries" -import { cn } from "@/lib/utils" +import { cn, formatMetric } from "@/lib/utils" import { GitPullRequest } from "lucide-react" import type { PRGroupMetrics } from "@/types/api" @@ -11,9 +11,9 @@ interface ClaudePRComparisonProps { endDate?: string } -function formatMetric(value: number | null, suffix = ""): string { - if (value == null) return "-" - return `${value.toFixed(1)}${suffix}` +function formatMetricWithSuffix(value: number | null, suffix = ""): string { + const base = formatMetric(value) + return base === "-" ? base : `${base}${suffix}` } function DeltaIndicator({ value }: { value: number | null }) { @@ -56,8 +56,8 @@ function MetricRow({ return (
{label}
-
{formatMetric(claudeValue, suffix)}
-
{formatMetric(otherValue, suffix)}
+
{formatMetricWithSuffix(claudeValue, suffix)}
+
{formatMetricWithSuffix(otherValue, suffix)}
diff --git a/frontend/src/components/quality/quality-attribution-table.tsx b/frontend/src/components/quality/quality-attribution-table.tsx index 1db6c30..e490cd5 100644 --- a/frontend/src/components/quality/quality-attribution-table.tsx +++ b/frontend/src/components/quality/quality-attribution-table.tsx @@ -1,4 +1,4 @@ -import { formatLabel } from "@/lib/utils" +import { formatLabel, formatMetric } from "@/lib/utils" import type { QualityAttributionRow } from "@/types/api" interface Props { @@ -18,10 +18,6 @@ function formatPercent(value: number | null): string { return value == null ? "-" : `${(value * 100).toFixed(0)}%` } -function formatMetric(value: number | null, digits = 1): string { - return value == null ? "-" : value.toFixed(digits) -} - export function QualityAttributionTable({ rows }: Props) { if (rows.length === 0) { return ( diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 5a23952..45a20de 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -27,12 +27,20 @@ export function formatCost(dollars: number): string { return `$${dollars.toFixed(2)}` } +export function titleize(value: string): string { + return value.replaceAll("_", " ") +} + export function formatLabel(value: string): string { return value .replaceAll("_", " ") .replace(/\b\w/g, (char) => char.toUpperCase()) } +export function formatMetric(value: number | null | undefined, digits = 1): string { + return value == null ? "-" : value.toFixed(digits) +} + export function formatPercent(value: number | null | undefined): string { if (value == null) return "-" return `${(value * 100).toFixed(0)}%`