Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<WorkflowCompareCard
workflowCosts={[
{
dimension: "workflow_archetype",
label: "debugging",
session_count: 2,
total_estimated_cost: 12.5,
avg_cost_per_session: 6.25,
cost_per_successful_outcome: 12.5,
},
]}
qualityRows={[]}
/>,
)

expect(screen.getByText("Need at least two workflows with cost data to compare them.")).toBeInTheDocument()
})

it("compares workflow metrics and allows dimension switching", () => {
render(
<WorkflowCompareCard
workflowCosts={[
{
dimension: "workflow_archetype",
label: "debugging",
session_count: 2,
total_estimated_cost: 12.5,
avg_cost_per_session: 6.25,
cost_per_successful_outcome: 12.5,
},
{
dimension: "workflow_archetype",
label: "refactor",
session_count: 3,
total_estimated_cost: 9.0,
avg_cost_per_session: 3,
cost_per_successful_outcome: 4.5,
},
{
dimension: "workflow_fingerprint",
label: "bug fix: read -> 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)
})
})
9 changes: 8 additions & 1 deletion frontend/src/components/finops/overview-tab.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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 (
Expand Down Expand Up @@ -80,6 +82,11 @@ export function OverviewTab({ teamId, startDate, endDate }: OverviewTabProps) {
</div>

<WorkflowCostTable rows={costData.workflow_breakdown} />

<WorkflowCompareCard
workflowCosts={costData.workflow_breakdown}
qualityRows={quality.data?.attribution ?? []}
/>
</div>
)
}
211 changes: 211 additions & 0 deletions frontend/src/components/finops/workflow-compare-card.tsx
Original file line number Diff line number Diff line change
@@ -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<CompareDimension, string> = {
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<CompareDimension>("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 = (
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Dimension</span>
<select
aria-label="Dimension"
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 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>
</CardContent>
</Card>
)
}

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 (
<Card>
<CardHeader className="pb-4">
<CardTitle>Workflow Compare</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{dimensionSelector}

<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Workflow A</span>
<select
value={effectiveLeftLabel}
onChange={(e) => setLeftLabel(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{options.map((row) => (
<option key={`left-${row.label}`} value={row.label}>
{displayLabel(row.label, dimension)}
</option>
))}
</select>
</label>

<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Workflow B</span>
<select
value={effectiveRightLabel}
onChange={(e) => setRightLabel(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
>
{options.map((row) => (
<option key={`right-${row.label}`} value={row.label}>
{displayLabel(row.label, dimension)}
</option>
))}
</select>
</label>
</div>

<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50 text-left text-xs font-medium text-muted-foreground">
<th className="px-3 py-2">Metric</th>
<th className="px-3 py-2">{displayLabel(effectiveLeftLabel, dimension)}</th>
<th className="px-3 py-2">{displayLabel(effectiveRightLabel, dimension)}</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Sessions</td>
<td className="px-3 py-2">{leftCost.session_count}</td>
<td className="px-3 py-2">{rightCost.session_count}</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Total Cost</td>
<td className="px-3 py-2">{formatCost(leftCost.total_estimated_cost)}</td>
<td className="px-3 py-2">{formatCost(rightCost.total_estimated_cost)}</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Avg Cost / Session</td>
<td className="px-3 py-2">
{leftCost.avg_cost_per_session != null ? formatCost(leftCost.avg_cost_per_session) : "-"}
</td>
<td className="px-3 py-2">
{rightCost.avg_cost_per_session != null ? formatCost(rightCost.avg_cost_per_session) : "-"}
</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Cost / Success</td>
<td className="px-3 py-2">
{leftCost.cost_per_successful_outcome != null
? formatCost(leftCost.cost_per_successful_outcome)
: "-"}
</td>
<td className="px-3 py-2">
{rightCost.cost_per_successful_outcome != null
? formatCost(rightCost.cost_per_successful_outcome)
: "-"}
</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Linked PRs</td>
<td className="px-3 py-2">{leftQuality?.linked_prs ?? "-"}</td>
<td className="px-3 py-2">{rightQuality?.linked_prs ?? "-"}</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Merge Rate</td>
<td className="px-3 py-2">{formatPercent(leftQuality?.merge_rate)}</td>
<td className="px-3 py-2">{formatPercent(rightQuality?.merge_rate)}</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Reviews / PR</td>
<td className="px-3 py-2">
{formatMetric(leftQuality?.avg_review_comments_per_pr)}
</td>
<td className="px-3 py-2">
{formatMetric(rightQuality?.avg_review_comments_per_pr)}
</td>
</tr>
<tr className="border-b border-border/40">
<td className="px-3 py-2 font-medium">Findings / PR</td>
<td className="px-3 py-2">{formatMetric(leftQuality?.avg_findings_per_pr)}</td>
<td className="px-3 py-2">{formatMetric(rightQuality?.avg_findings_per_pr)}</td>
</tr>
<tr>
<td className="px-3 py-2 font-medium">Merge Time (h)</td>
<td className="px-3 py-2">
{formatMetric(leftQuality?.avg_time_to_merge_hours)}
</td>
<td className="px-3 py-2">
{formatMetric(rightQuality?.avg_time_to_merge_hours)}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}
4 changes: 2 additions & 2 deletions frontend/src/components/growth/bright-spot-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,7 +29,7 @@ export function BrightSpotCards({ spots }: BrightSpotCardsProps) {
<p className="text-sm text-muted-foreground">{spot.summary}</p>
</div>
<Badge variant="success" className="capitalize">
{formatLabel(spot.cluster_type)}
{titleize(spot.cluster_type)}
</Badge>
</div>
</CardHeader>
Expand Down
Loading
Loading