Skip to content

feat: add workflow compare mode#114

Merged
ccf merged 3 commits intomainfrom
feat/workflow-compare-mode
Mar 16, 2026
Merged

feat: add workflow compare mode#114
ccf merged 3 commits intomainfrom
feat/workflow-compare-mode

Conversation

@ccf
Copy link
Owner

@ccf ccf commented Mar 16, 2026

Summary

  • add workflow compare mode on top of workflow cost and workflow quality attribution
  • surface the compare experience in FinOps Overview with workflow archetype and fingerprint dimensions
  • let users compare two workflows side-by-side across spend, cost efficiency, and quality outcomes

Validation

  • PYTHONPATH=/Users/ccf/git/primer/src pytest tests/test_analytics.py -q
  • cd frontend && /Users/ccf/git/primer/frontend/node_modules/.bin/vitest run --run src/components/finops/__tests__/workflow-cost-table.test.tsx src/components/finops/__tests__/workflow-compare-card.test.tsx

Note

Medium Risk
Adds new comparison UI that joins cost and quality datasets and introduces a new useQualityMetrics fetch in the FinOps overview, which could affect loading/empty-state behavior and displayed metrics.

Overview
Adds a new Workflow Compare card to the FinOps Overview, letting users pick a dimension (workflow_archetype or workflow_fingerprint) and compare two workflows side-by-side across spend, cost-efficiency, and quality outcomes.

Introduces shared formatting helpers in lib/utils (titleize, formatMetric) and refactors several components to use them, plus a new WorkflowCompareCard test covering empty-state and dimension switching.

Written by Cursor Bugbot for commit 142689f. This will update automatically on new commits. Configure here.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Empty state hides dimension selector, blocking valid comparisons
    • Extracted the dimension selector into a shared JSX variable rendered in both the empty state and the normal comparison view, so users can always switch dimensions.
  • ✅ Fixed: Quality fetch failure blanks entire FinOps overview tab
    • Removed quality from the loading and data guard conditions so the tab renders with cost/productivity data regardless of the quality query state, passing quality.data?.attribution ?? [] to the compare card.
  • ✅ Fixed: Duplicated titleize utility across three component files
    • Added a shared titleize() export to @/lib/utils and replaced all 4 duplicate local definitions with imports from the shared utility.

Create PR

Or push these changes by commenting:

@cursor push 2722a5f987
Preview (2722a5f987)
diff --git a/frontend/src/components/bottlenecks/root-cause-cluster-list.tsx b/frontend/src/components/bottlenecks/root-cause-cluster-list.tsx
--- 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/overview-tab.tsx b/frontend/src/components/finops/overview-tab.tsx
--- a/frontend/src/components/finops/overview-tab.tsx
+++ b/frontend/src/components/finops/overview-tab.tsx
@@ -17,7 +17,7 @@
   const productivity = useProductivity(teamId, startDate, endDate)
   const quality = useQualityMetrics(teamId, startDate, endDate)
 
-  if (costs.isLoading || productivity.isLoading || quality.isLoading) {
+  if (costs.isLoading || productivity.isLoading) {
     return (
       <div className="space-y-4">
         <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@@ -32,7 +32,7 @@
     )
   }
 
-  if (!costs.data || !productivity.data || !quality.data) return null
+  if (!costs.data || !productivity.data) return null
 
   const costData = costs.data
   const prod = productivity.data
@@ -85,7 +85,7 @@
 
       <WorkflowCompareCard
         workflowCosts={costData.workflow_breakdown}
-        qualityRows={quality.data.attribution}
+        qualityRows={quality.data?.attribution ?? []}
       />
     </div>
   )

diff --git a/frontend/src/components/finops/workflow-compare-card.tsx b/frontend/src/components/finops/workflow-compare-card.tsx
--- a/frontend/src/components/finops/workflow-compare-card.tsx
+++ b/frontend/src/components/finops/workflow-compare-card.tsx
@@ -40,13 +40,28 @@
       ? rightLabel
       : (labels.find((label) => label !== effectiveLeftLabel) ?? "")
 
+  const dimensionSelector = (
+    <label className="space-y-1">
+      <span className="text-xs font-medium text-muted-foreground">Dimension</span>
+      <select
+        value={dimension}
+        onChange={(e) => setDimension(e.target.value as CompareDimension)}
+        className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
+      >
+        <option value="workflow_archetype">{DIMENSION_LABELS.workflow_archetype}</option>
+        <option value="workflow_fingerprint">{DIMENSION_LABELS.workflow_fingerprint}</option>
+      </select>
+    </label>
+  )
+
   if (options.length < 2) {
     return (
       <Card>
         <CardHeader>
           <CardTitle>Workflow Compare</CardTitle>
         </CardHeader>
-        <CardContent>
+        <CardContent className="space-y-4">
+          <div className="max-w-xs">{dimensionSelector}</div>
           <p className="text-sm text-muted-foreground">
             Need at least two workflows with cost data to compare them.
           </p>
@@ -82,17 +97,7 @@
       </CardHeader>
       <CardContent className="space-y-4">
         <div className="grid gap-4 md:grid-cols-3">
-          <label className="space-y-1">
-            <span className="text-xs font-medium text-muted-foreground">Dimension</span>
-            <select
-              value={dimension}
-              onChange={(e) => setDimension(e.target.value as CompareDimension)}
-              className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
-            >
-              <option value="workflow_archetype">{DIMENSION_LABELS.workflow_archetype}</option>
-              <option value="workflow_fingerprint">{DIMENSION_LABELS.workflow_fingerprint}</option>
-            </select>
-          </label>
+          {dimensionSelector}
 
           <label className="space-y-1">
             <span className="text-xs font-medium text-muted-foreground">Workflow A</span>

diff --git a/frontend/src/components/growth/bright-spot-cards.tsx b/frontend/src/components/growth/bright-spot-cards.tsx
--- a/frontend/src/components/growth/bright-spot-cards.tsx
+++ b/frontend/src/components/growth/bright-spot-cards.tsx
@@ -2,17 +2,13 @@
 
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
 import { Badge } from "@/components/ui/badge"
-import { formatDuration, formatPercent } from "@/lib/utils"
+import { formatDuration, formatPercent, titleize } from "@/lib/utils"
 import type { BrightSpot } from "@/types/api"
 
 interface BrightSpotCardsProps {
   spots: BrightSpot[]
 }
 
-function titleize(value: string) {
-  return value.replaceAll("_", " ")
-}
-
 export function BrightSpotCards({ spots }: BrightSpotCardsProps) {
   if (spots.length === 0) {
     return (

diff --git a/frontend/src/components/growth/workflow-playbook-cards.tsx b/frontend/src/components/growth/workflow-playbook-cards.tsx
--- 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 @@
   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
--- 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 (
     <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">

diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -27,6 +27,10 @@
   return `$${dollars.toFixed(2)}`
 }
 
+export function titleize(value: string): string {
+  return value.replaceAll("_", " ")
+}
+
 export function formatLabel(value: string): string {
   return value
     .replaceAll("_", " ")

@ccf ccf force-pushed the feat/workflow-compare-mode branch from 04bbdfe to bce1585 Compare March 16, 2026 17:49
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicate formatMetric utility across multiple components
    • Centralized formatMetric into utils.ts and updated all three component files to import from the single source, eliminating the duplicated null-safe numeric formatting logic.

Create PR

Or push these changes by commenting:

@cursor push bb3d80a44b
Preview (bb3d80a44b)
diff --git a/frontend/src/components/finops/workflow-compare-card.tsx b/frontend/src/components/finops/workflow-compare-card.tsx
--- a/frontend/src/components/finops/workflow-compare-card.tsx
+++ b/frontend/src/components/finops/workflow-compare-card.tsx
@@ -1,7 +1,7 @@
 import { useState } from "react"
 
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { formatCost, formatLabel, formatPercent } from "@/lib/utils"
+import { formatCost, formatLabel, formatMetric, formatPercent } from "@/lib/utils"
 import type { QualityAttributionRow, WorkflowCostBreakdown } from "@/types/api"
 
 type CompareDimension = "workflow_archetype" | "workflow_fingerprint"
@@ -20,10 +20,6 @@
   return dimension === "workflow_archetype" ? formatLabel(value) : value
 }
 
-function formatMetric(value: number | null | undefined, digits = 1): string {
-  return value == null ? "-" : value.toFixed(digits)
-}
-
 export function WorkflowCompareCard({
   workflowCosts,
   qualityRows,

diff --git a/frontend/src/components/quality/claude-pr-comparison.tsx b/frontend/src/components/quality/claude-pr-comparison.tsx
--- 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 @@
   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 @@
   return (
     <div className="grid grid-cols-4 gap-4 border-b border-border py-3 last:border-0">
       <div className="text-sm text-muted-foreground">{label}</div>
-      <div className="text-sm font-medium">{formatMetric(claudeValue, suffix)}</div>
-      <div className="text-sm font-medium">{formatMetric(otherValue, suffix)}</div>
+      <div className="text-sm font-medium">{formatMetricWithSuffix(claudeValue, suffix)}</div>
+      <div className="text-sm font-medium">{formatMetricWithSuffix(otherValue, suffix)}</div>
       <div className="flex items-center">
         <DeltaIndicator value={displayDelta ?? null} />
       </div>

diff --git a/frontend/src/components/quality/quality-attribution-table.tsx b/frontend/src/components/quality/quality-attribution-table.tsx
--- 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 @@
   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
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -33,6 +33,10 @@
     .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)}%`

@ccf
Copy link
Owner Author

ccf commented Mar 16, 2026

@cursor push 2722a5f

@cursor
Copy link

cursor bot commented Mar 16, 2026

Could not push Autofix changes. The PR branch may have changed since the Autofix ran, or the Autofix commit may no longer exist.

@ccf ccf merged commit 3f45bc7 into main Mar 16, 2026
6 checks passed
@ccf ccf deleted the feat/workflow-compare-mode branch March 16, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants