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
8 changes: 4 additions & 4 deletions src/api/metric-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ export const METRIC_REGISTRY = {
V2_IC_SECTION_TREND: "00000000-0000-0000-0001-000000000036",

// Per-person "member values" for the team heatmap + needs-attention widgets:
// long rows (person_id, metric_key, value) for a roster, no cohort.
// long rows (person_id, metric_key, value) for a roster, no cohort. Task
// delivery and collaboration both flipped to unified IC metrics groups, but
// their per-member values still feed the legacy team heatmap bullet
// columns + needs-attention until that surface migrates to unified metrics.
V2_MEMBER_VALUES_DELIVERY: "00000000-0000-0000-0001-000000000040",
// Collaboration flipped to a unified IC metrics group, but its per-member
// values still feed the legacy team heatmap meeting-hours bullet column +
// needs-attention until that surface migrates to unified metrics.
V2_MEMBER_VALUES_COLLAB: "00000000-0000-0000-0001-000000000041",

// Per-person PRs merged for a roster (period-bounded, from the weekly git
Expand Down
104 changes: 104 additions & 0 deletions src/components/ui/empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-lg border-dashed p-12 text-center text-balance",
className
)}
{...props}
/>
)
}

function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}

const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)

function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}

function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-lg font-medium tracking-tight",
className
)}
{...props}
/>
)
}

function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}

function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}

export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
26 changes: 26 additions & 0 deletions src/components/widgets/group-card-empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";

/**
* Empty body for a dashboard group card. `min-h` is the footprint floor so an
* empty card holds a populated card's height even when its grid row has no
* taller sibling to stretch against. It fills and centers within the card's
* content region (the parent `CardContent` carries `flex-1`); no padding of
* its own, so nothing biases the centered message downward.
*/
export function GroupCardEmpty() {
return (
<Empty className="min-h-28 gap-1 p-0">
<EmptyHeader>
<EmptyTitle className="text-sm">No data</EmptyTitle>
<EmptyDescription className="text-xs">
No metrics with data for this period.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
24 changes: 24 additions & 0 deletions src/components/widgets/metric-views/chart-empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty";
import { cn } from "@/lib/utils";

/**
* Placeholder for a chart with no data — centers the message in the plot area
* so an empty chart keeps its neighbours' footprint instead of collapsing to
* a line of text. No frame of its own: the enclosing card is the border.
* `className` carries the plot height so it matches the chart it stands in for.
*/
export function ChartEmpty({
message,
className,
}: {
message: string;
className?: string;
}) {
return (
<Empty className={cn("min-h-48 p-6", className)}>
<EmptyHeader>
<EmptyDescription>{message}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ describe("CollectionDrilldown", () => {
entityId="alice@example.com"
/>,
);
// Histograms live in a single labeled "Distributions" card, shaded vs the
// peer median (the subtitle proves the diverging render, not a bare chart).
expect(screen.getByText("Distributions")).toBeInTheDocument();
// Each histogram is its own card in the grid (no wrapping "Distributions"
// card), shaded vs the peer median (the subtitle proves the diverging
// render, not a bare chart).
expect(screen.getByText(/vs peer median/)).toBeInTheDocument();
});

Expand Down
59 changes: 27 additions & 32 deletions src/components/widgets/metric-views/collection-drilldown.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { ComingSoon } from "@/components/widgets/coming-soon";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { MetricBreakdown } from "@/components/widgets/metric-views/metric-breakdown";
import { MetricHistogram } from "@/components/widgets/metric-views/metric-histogram";
import { MetricSummaryCard } from "@/components/widgets/metric-views/metric-summary-card";
import { MetricTrend } from "@/components/widgets/metric-views/metric-trend";
import { PeerStory } from "@/components/widgets/metric-views/peer-story";
import type { DrilldownBlock, MetricGroup } from "@/lib/insight/groups";
import type { NormalizedMetricResult } from "@/lib/metrics/collection";
import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection";
import { buildPeerStoryEntries } from "@/lib/metrics/peer-story";
import type { PeerCohortLabel } from "@/lib/peers";
import type { MetricCollectionResult } from "@/queries/metric-results";
Expand Down Expand Up @@ -124,9 +118,18 @@ export function CollectionDrilldown({
!isSummaryBlock(block) &&
blockMetrics(block, data.byKey).length > 0,
);
const distributionMetrics = def.drilldown
// Populated distributions lead; those with no events for this entity in the
// period sort to the end so the section doesn't open on an empty placeholder.
// Stable partition keeps declared order within each group.
const declaredDistributions = def.drilldown
.filter((block) => block.view === "histogram")
.flatMap((block) => blockMetrics(block, data.byKey));
const hasDistribution = (metric: NormalizedMetricResult) =>
(forEntity(metric, entityId).histogram[0]?.bins?.length ?? 0) > 0;
const distributionMetrics = [
...declaredDistributions.filter(hasDistribution),
...declaredDistributions.filter((metric) => !hasDistribution(metric)),
];

return (
<div
Expand Down Expand Up @@ -174,30 +177,22 @@ export function CollectionDrilldown({
) : null}
<PeerStory entries={entries} cohortLabel={cohortLabel} />
{distributionMetrics.length > 0 ? (
// One labeled card holds every distribution; the charts inside are
// un-carded so there are no nested borders and "distribution" isn't
// repeated per chart.
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-semibold">
Distributions
</CardTitle>
</CardHeader>
<CardContent
className={cn(
"grid grid-cols-1 gap-x-8 gap-y-6",
distributionMetrics.length > 1 && "lg:grid-cols-2",
)}
>
{distributionMetrics.map((metric) => (
<MetricHistogram
key={metric.metric_key}
metric={metric}
entityId={entityId}
/>
))}
</CardContent>
</Card>
// Each histogram is its own card, dropped straight into the grid like
// the chart blocks above — no wrapping "Distributions" card.
<div
className={cn(
"grid grid-cols-1 gap-4",
distributionMetrics.length > 1 && "lg:grid-cols-2",
)}
>
{distributionMetrics.map((metric) => (
<MetricHistogram
key={metric.metric_key}
metric={metric}
entityId={entityId}
/>
))}
</div>
) : null}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ describe("MetricBreakdown", () => {
entityId="me@x.com"
/>,
);
expect(screen.getByText("No composition data yet.")).toBeInTheDocument();
expect(screen.getByText("No composition data yet")).toBeInTheDocument();
});
});
9 changes: 6 additions & 3 deletions src/components/widgets/metric-views/metric-breakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ChartEmpty } from "@/components/widgets/metric-views/chart-empty";
import {
dimensionColorSeed,
dimensionLabel,
Expand Down Expand Up @@ -38,11 +39,13 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) {

if (rows.length === 0) {
return (
<Card className="min-h-40 shrink-0">
<CardHeader>
<Card className="shrink-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{metric.label}</CardTitle>
<CardDescription>No composition data yet.</CardDescription>
</CardHeader>
<CardContent>
<ChartEmpty message="No composition data yet" className="min-h-32" />
</CardContent>
</Card>
);
}
Expand Down
49 changes: 45 additions & 4 deletions src/components/widgets/metric-views/metric-group-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,46 @@ describe("MetricGroupCard", () => {
expect(screen.getByText("3 days")).toBeInTheDocument();
});

it("shows a single empty state without a standing badge when no metric has data", () => {
render(
<MetricGroupCard
def={DEF}
data={result([
aiMetric("ai.active_days", null),
aiMetric("ai.cost", null),
])}
entityId="me@x.com"
onOpen={vi.fn()}
/>,
);
expect(
screen.getByText("No metrics with data for this period."),
).toBeInTheDocument();
// The badge would only restate the absence — one message, not two.
expect(screen.queryByText("no peer data")).not.toBeInTheDocument();
// Nothing to drill into — the card is not an interactive button.
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("keeps a fixed preview key with no value, rendering an em dash", () => {
render(
<MetricGroupCard
def={DEF}
data={result([
aiMetric("ai.active_days", 20),
aiMetric("ai.cost", null),
])}
entityId="me@x.com"
onOpen={vi.fn()}
/>,
);
// Both preview rows stay on the card — the valueless one shows "—", not
// dropped, so the card's identity is stable across periods.
expect(screen.getByText("ai.active_days")).toBeInTheDocument();
expect(screen.getByText("ai.cost")).toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
});

it("keeps the card name and shows a spinner while loading", () => {
render(
<MetricGroupCard
Expand Down Expand Up @@ -120,8 +160,8 @@ describe("MetricGroupCard", () => {
onOpen={vi.fn()}
/>,
);
// No metric is scorable → no "in top" tally.
expect(screen.getByText("No peer data")).toBeInTheDocument();
// No metric is scorable → no rankable peers.
expect(screen.getByText("no peer data")).toBeInTheDocument();
});

it("scores measured people against quartiles", () => {
Expand All @@ -136,8 +176,9 @@ describe("MetricGroupCard", () => {
onOpen={vi.fn()}
/>,
);
// active_days 20 ≥ p75 15 → top; cost 2 ≤ p25 5 → bottom → 1 of 2 in top.
expect(screen.getByText("1 of 2 in top")).toBeInTheDocument();
// active_days 20 ≥ p75 15 → top; cost 2 ≤ p25 5 → bottom → 1 bottom wins
// the phrase (behind beats ahead), single bottom below the red bar → amber.
expect(screen.getByText("1 behind peers")).toBeInTheDocument();
});

it("owns its error state with retry", () => {
Expand Down
Loading