From 78403be6d38b81eb1b1bbe7be7dd97418dae8313 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 28 Jul 2026 17:19:23 +0200 Subject: [PATCH 1/8] feat(metrics): add metric evidence drilldowns Signed-off-by: Aleksandr Barkhatov --- eslint.config.js | 60 ++-- package.json | 1 + pnpm-lock.yaml | 20 ++ src/api/metric-definitions-client.ts | 2 + src/api/metric-drilldown-client.ts | 102 +++++++ src/api/metric-results-client.ts | 13 + src/components/metric-evidence-context.ts | 32 +++ src/components/metric-evidence-dialog.tsx | 191 +++++++++++++ src/components/metric-evidence-provider.tsx | 75 +++++ src/components/metric-evidence-table.tsx | 229 +++++++++++++++ src/components/ui/table.tsx | 36 +-- .../widgets/metric-views/metric-breakdown.tsx | 29 +- .../metric-views/metric-card-actions.tsx | 52 ++++ .../metric-views/metric-summary-card.tsx | 41 ++- .../metric-views/metric-timeseries-chart.tsx | 20 ++ .../metric-views/metric-timeseries-chrome.tsx | 14 +- .../metric-views/metric-timeseries-model.ts | 7 +- .../metric-views/metric-timeseries-table.tsx | 48 +++- .../metric-views/metric-timeseries-view.tsx | 88 +++++- .../widgets/metric-views/peer-story.tsx | 54 ++-- .../widgets/v2/group-details-sheet.tsx | 259 +++++++++++++++++ .../widgets/v2/group-drilldown-sheet.tsx | 264 +----------------- .../widgets/v2/ic-needs-attention.tsx | 5 +- src/components/widgets/v2/kpi-tile.tsx | 13 +- src/components/widgets/v2/members-grid.tsx | 53 ++-- src/lib/insight/kpi-row.ts | 7 +- src/lib/metrics/collection.ts | 4 + src/lib/metrics/peer-story.ts | 21 +- src/routes/__root.tsx | 21 +- .../ic-dashboard/engineering-dashboard-v2.tsx | 9 +- src/screens/team-view-v2.tsx | 50 ++-- 31 files changed, 1391 insertions(+), 429 deletions(-) create mode 100644 src/api/metric-drilldown-client.ts create mode 100644 src/components/metric-evidence-context.ts create mode 100644 src/components/metric-evidence-dialog.tsx create mode 100644 src/components/metric-evidence-provider.tsx create mode 100644 src/components/metric-evidence-table.tsx create mode 100644 src/components/widgets/metric-views/metric-card-actions.tsx create mode 100644 src/components/widgets/v2/group-details-sheet.tsx diff --git a/eslint.config.js b/eslint.config.js index ffcde445..db4e4f9a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,21 +1,21 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ globalIgnores([ - 'dist', - 'coverage', - '.old', - '.v2-extras', - 'public', - 'src/routeTree.gen.ts', + "dist", + "coverage", + ".old", + ".v2-extras", + "public", + "src/routeTree.gen.ts", ]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -29,13 +29,13 @@ export default defineConfig([ rules: { // Underscore prefix is the project convention for intentionally // unused parameters / catch bindings / destructured holes. - '@typescript-eslint/no-unused-vars': [ - 'error', + "@typescript-eslint/no-unused-vars": [ + "error", { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - destructuredArrayIgnorePattern: '^_', + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", }, ], // TanStack Router file-routes export `Route = createFileRoute(...)({…})` @@ -44,13 +44,13 @@ export default defineConfig([ // warning about the colocated component. `allowExportNames` is NOT used // here because it short-circuits before the HOC check and would leave // the route's local component flagged. - 'react-refresh/only-export-components': [ - 'error', + "react-refresh/only-export-components": [ + "error", { allowConstantExport: true, // shadcn theme-provider co-exports the `useTheme` hook with the // provider component — explicit allow keeps the pattern intact. - allowExportNames: ['useTheme'], + allowExportNames: ["useTheme"], }, ], }, @@ -60,9 +60,9 @@ export default defineConfig([ // and helper hooks next to the component. Fast-Refresh restrictions // don't apply to these — they're auto-regenerated by `shadcn add`, // not hand-edited during HMR sessions. - files: ['src/components/ui/**/*.{ts,tsx}'], + files: ["src/components/ui/**/*.{ts,tsx}"], rules: { - 'react-refresh/only-export-components': 'off', + "react-refresh/only-export-components": "off", }, }, { @@ -73,9 +73,15 @@ export default defineConfig([ // maintainer warns it can degrade HMR over time. Disable the rule for // the routes directory instead; route files are small and visually // distinct, so the Fast-Refresh signal isn't pulling weight here. - files: ['src/routes/**/*.{ts,tsx}'], + files: ["src/routes/**/*.{ts,tsx}"], rules: { - 'react-refresh/only-export-components': 'off', + "react-refresh/only-export-components": "off", }, }, -]) + { + files: ["src/components/metric-evidence-table.tsx"], + rules: { + "react-hooks/incompatible-library": "off", + }, + }, +]); diff --git a/package.json b/package.json index 5afba9b4..44aa9246 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tailwindcss/vite": "4.2.4", "@tanstack/react-query": "5.100.9", "@tanstack/react-router": "1.169.2", + "@tanstack/react-virtual": "3.13.12", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "date-fns": "4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a8a1f16..1658c534 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@tanstack/react-router': specifier: 1.169.2 version: 1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: 3.13.12 + version: 3.13.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -1452,6 +1455,12 @@ packages: react: 19.2.6 react-dom: 19.2.6 + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: 19.2.6 + react-dom: 19.2.6 + '@tanstack/router-core@1.169.2': resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} engines: {node: '>=20.19'} @@ -1488,6 +1497,9 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tanstack/virtual-file-routes@1.161.7': resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} engines: {node: '>=20.19'} @@ -5318,6 +5330,12 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) + '@tanstack/react-virtual@3.13.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@tanstack/router-core@1.169.2': dependencies: '@tanstack/history': 1.161.6 @@ -5375,6 +5393,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.13.12': {} + '@tanstack/virtual-file-routes@1.161.7': {} '@testing-library/dom@10.4.1': diff --git a/src/api/metric-definitions-client.ts b/src/api/metric-definitions-client.ts index 582a2485..0602e9ae 100644 --- a/src/api/metric-definitions-client.ts +++ b/src/api/metric-definitions-client.ts @@ -11,6 +11,7 @@ import { AnalyticsApiError } from "@/api/analytics-client"; import { fetchWithAuth } from "@/api/fetch-with-auth"; import type { + MetricDrilldownCapability, MetricDirection, MetricFormat, } from "@/api/metric-results-client"; @@ -36,6 +37,7 @@ export interface MetricDefinition { schema_error_code: MetricSchemaErrorCode | null; /** ISO date of the newest observation ever seen; null = no data yet. */ last_observed_date: string | null; + drilldown?: MetricDrilldownCapability; } export type MetricSchemaErrorCode = diff --git a/src/api/metric-drilldown-client.ts b/src/api/metric-drilldown-client.ts new file mode 100644 index 00000000..3f86d180 --- /dev/null +++ b/src/api/metric-drilldown-client.ts @@ -0,0 +1,102 @@ +import { AnalyticsApiError } from "@/api/analytics-client"; +import { fetchWithAuth } from "@/api/fetch-with-auth"; +import type { + MetricCanonicalSelection, + MetricDimensionFilter, +} from "@/api/metric-results-client"; + +const BASE = + (import.meta.env.VITE_API_BASE as string | undefined) ?? "/api/analytics/v1"; + +export interface MetricEvidenceSelection { + metric_key: string; + entity: { type: "person"; id: string }; + period: { from: string; to: string }; + filters: MetricDimensionFilter[]; + display_dimensions: string[]; +} + +export interface MetricEvidenceColumn { + key: string; + label: string; + type: "string" | "number" | "date"; +} + +export interface MetricEvidenceRow { + values: Record; +} + +export interface MetricDrilldownResponse { + selection: MetricEvidenceSelection; + columns: MetricEvidenceColumn[]; + rows: MetricEvidenceRow[]; + next_cursor: string | null; +} + +export interface MetricDrilldownRequest extends MetricEvidenceSelection { + cursor?: string; + limit: number; +} + +async function errorFor(res: Response): Promise { + const body = await res.json().catch(() => null); + return new AnalyticsApiError(res.status, body); +} + +export async function queryMetricDrilldown( + request: MetricDrilldownRequest, + signal?: AbortSignal +): Promise { + const res = await fetchWithAuth(`${BASE}/metric-drilldown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal, + }); + if (!res.ok) throw await errorFor(res); + return (await res.json()) as MetricDrilldownResponse; +} + +export async function downloadMetricDrilldown( + selection: MetricEvidenceSelection, + format: "csv" | "xlsx", + signal?: AbortSignal +): Promise { + const res = await fetchWithAuth(`${BASE}/metric-drilldown/export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...selection, format }), + signal, + }); + if (!res.ok) throw await errorFor(res); + const blob = await res.blob(); + const disposition = res.headers.get("content-disposition"); + const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; + const plain = disposition?.match(/filename="?([^";]+)"?/i)?.[1]; + const filename = encoded + ? decodeURIComponent(encoded) + : (plain ?? `${selection.metric_key}.${format}`); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function evidenceSelection( + canonical: MetricCanonicalSelection | undefined, + entityId: string, + period?: { from: string; to: string }, + filters?: MetricDimensionFilter[], + displayDimensions: string[] = [] +): MetricEvidenceSelection | null { + if (!canonical) return null; + return { + metric_key: canonical.metric_key, + entity: { type: "person", id: entityId }, + period: period ?? canonical.period, + filters: filters ?? canonical.filters, + display_dimensions: [...new Set(displayDimensions)].sort(), + }; +} diff --git a/src/api/metric-results-client.ts b/src/api/metric-results-client.ts index b9c09b75..241fae08 100644 --- a/src/api/metric-results-client.ts +++ b/src/api/metric-results-client.ts @@ -36,6 +36,17 @@ export interface MetricDimensionFilter { values: string[]; } +export interface MetricDrilldownCapability { + granularity: Array<"event" | "source_summary" | "derived_population">; +} + +export interface MetricCanonicalSelection { + metric_key: string; + entity: { type: MetricEntityType; ids: string[] }; + period: { from: string; to: string }; + filters: MetricDimensionFilter[]; +} + export interface MetricGroupLimit { count: number; rank_by_metric?: string; @@ -80,6 +91,8 @@ interface MetricResultBase { format: MetricFormat; direction: MetricDirection; views: MetricResultView[]; + drilldown?: MetricDrilldownCapability; + selection?: MetricCanonicalSelection; } export interface SumMetricResult extends MetricResultBase { diff --git a/src/components/metric-evidence-context.ts b/src/components/metric-evidence-context.ts new file mode 100644 index 00000000..8486f430 --- /dev/null +++ b/src/components/metric-evidence-context.ts @@ -0,0 +1,32 @@ +import { createContext, useContext } from "react"; + +import type { MetricEvidenceSelection } from "@/api/metric-drilldown-client"; + +export interface EvidenceDialogState { + selection: MetricEvidenceSelection; + label: string; +} + +export interface EvidenceDialogContextValue { + openEvidence: (selection: MetricEvidenceSelection, label: string) => void; +} + +export const EvidenceDialogContext = createContext< + EvidenceDialogContextValue | undefined +>(undefined); + +export function useMetricEvidence(): EvidenceDialogContextValue { + const context = useContext(EvidenceDialogContext); + if (!context) { + throw new Error( + "useMetricEvidence must be used within MetricEvidenceProvider" + ); + } + return context; +} + +export function useMetricEvidenceOptional(): + | EvidenceDialogContextValue + | undefined { + return useContext(EvidenceDialogContext); +} diff --git a/src/components/metric-evidence-dialog.tsx b/src/components/metric-evidence-dialog.tsx new file mode 100644 index 00000000..3685d744 --- /dev/null +++ b/src/components/metric-evidence-dialog.tsx @@ -0,0 +1,191 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Download, FileSpreadsheet, FileText } from "lucide-react"; + +import { + downloadMetricDrilldown, + queryMetricDrilldown, + type MetricEvidenceSelection, +} from "@/api/metric-drilldown-client"; +import { AnalyticsApiError } from "@/api/analytics-client"; +import { useAuth } from "@/auth/use-auth"; +import type { EvidenceDialogState } from "@/components/metric-evidence-context"; +import { MetricEvidenceTable } from "@/components/metric-evidence-table"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Spinner } from "@/components/ui/spinner"; + +export function MetricEvidenceDialog({ + state, + onClose, +}: { + state: EvidenceDialogState | null; + onClose: () => void; +}) { + const { session } = useAuth(); + const tenantId = session?.tenantId ?? null; + const exportController = useRef(null); + const [exporting, setExporting] = useState(false); + const [exportFailure, setExportFailure] = useState(null); + const query = useInfiniteQuery({ + queryKey: ["metric-drilldown", tenantId, state?.selection], + queryFn: ({ pageParam, signal }) => + queryMetricDrilldown( + { + ...(state?.selection as MetricEvidenceSelection), + cursor: pageParam, + limit: 100, + }, + signal + ), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.next_cursor ?? undefined, + enabled: state != null && tenantId != null, + retry: (failureCount, error) => + failureCount < 1 && + (!(error instanceof AnalyticsApiError) || error.status >= 500), + }); + const rows = query.data?.pages.flatMap((page) => page.rows) ?? []; + const columns = useMemo(() => { + const columns = query.data?.pages[0]?.columns ?? []; + const order = new Map([ + ["ref", 0], + ["title", 1], + ["repository", 2], + ["author", 3], + ["date", 100], + ["value", 101], + ["numerator", 101], + ["denominator", 102], + ]); + return [...columns].sort( + (left, right) => + (order.get(left.key) ?? 50) - (order.get(right.key) ?? 50) || + left.label.localeCompare(right.label) + ); + }, [query.data?.pages]); + const { fetchNextPage, hasNextPage, isFetchingNextPage } = query; + const pageLimitReached = (query.data?.pages.length ?? 0) >= 50 && hasNextPage; + + useEffect( + () => () => { + exportController.current?.abort(); + }, + [] + ); + + async function exportRows(format: "csv" | "xlsx") { + if (!state) return; + exportController.current?.abort(); + const controller = new AbortController(); + exportController.current = controller; + setExporting(true); + setExportFailure(null); + try { + await downloadMetricDrilldown(state.selection, format, controller.signal); + } catch (error) { + if (!controller.signal.aborted) { + setExportFailure( + errorMessage(error, "Unable to export metric evidence") + ); + } + } finally { + if (exportController.current === controller) { + exportController.current = null; + setExporting(false); + } + } + } + + return ( + !open && onClose()}> + + +
+ {state?.label ?? "Metric evidence"} + + + {exporting ? : } + Export + + } + /> + + void exportRows("csv")}> + + CSV + + void exportRows("xlsx")}> + + Excel + + + +
+ {exportFailure ? ( +

+ {exportFailure} +

+ ) : null} +
+ {query.isPending ? ( +
+ +
+ ) : query.isError && !query.data ? ( +
+

+ {errorMessage(query.error, "Unable to load metric evidence")} +

+ +
+ ) : rows.length === 0 ? ( +
+ No supporting data for this selection +
+ ) : ( + + )} +
+
+ ); +} + +function errorMessage(error: unknown, fallback: string): string { + if ( + !(error instanceof AnalyticsApiError) || + !error.body || + typeof error.body !== "object" + ) { + return fallback; + } + const problem = error.body as { detail?: unknown; trace_id?: unknown }; + const detail = typeof problem.detail === "string" ? problem.detail : fallback; + return typeof problem.trace_id === "string" + ? `${detail} Trace: ${problem.trace_id}` + : detail; +} diff --git a/src/components/metric-evidence-provider.tsx b/src/components/metric-evidence-provider.tsx new file mode 100644 index 00000000..a63ca21f --- /dev/null +++ b/src/components/metric-evidence-provider.tsx @@ -0,0 +1,75 @@ +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useAuth } from "@/auth/use-auth"; +import { + EvidenceDialogContext, + type EvidenceDialogState, +} from "@/components/metric-evidence-context"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; + +const MetricEvidenceDialog = lazy(() => + import("@/components/metric-evidence-dialog").then((module) => ({ + default: module.MetricEvidenceDialog, + })) +); + +export function MetricEvidenceProvider({ children }: { children: ReactNode }) { + const { session } = useAuth(); + const tenantId = session?.tenantId ?? null; + const queryClient = useQueryClient(); + const previousTenant = useRef(tenantId); + const [state, setState] = useState(null); + useEffect(() => { + if (previousTenant.current !== tenantId) { + void queryClient.cancelQueries({ queryKey: ["metric-drilldown"] }); + queryClient.removeQueries({ queryKey: ["metric-drilldown"] }); + setState(null); + } + previousTenant.current = tenantId; + }, [queryClient, tenantId]); + const openEvidence = useCallback( + (selection: EvidenceDialogState["selection"], label: string) => + setState({ selection, label }), + [] + ); + const value = useMemo(() => ({ openEvidence }), [openEvidence]); + return ( + + {children} + {state ? ( + !open && setState(null)}> + + + {state.label} + +
+ +
+
+ + } + > + setState(null)} /> +
+ ) : null} +
+ ); +} diff --git a/src/components/metric-evidence-table.tsx b/src/components/metric-evidence-table.tsx new file mode 100644 index 00000000..ea278cad --- /dev/null +++ b/src/components/metric-evidence-table.tsx @@ -0,0 +1,229 @@ +import { useEffect, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Check, Copy } from "lucide-react"; +import { toast } from "sonner"; + +import type { + MetricEvidenceColumn, + MetricEvidenceRow, +} from "@/api/metric-drilldown-client"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatMetricNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +function cellText(value: unknown, type: MetricEvidenceColumn["type"]): string { + if (value == null) return "—"; + if (type === "number" && typeof value === "number") { + return formatMetricNumber(value, "decimal"); + } + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function columnLayout(column: MetricEvidenceColumn) { + if (column.key === "ref") return { basisRem: 7, grow: 0 }; + if (column.key === "title") return { basisRem: 24, grow: 2 }; + if (column.key === "repository") return { basisRem: 16, grow: 1.25 }; + if (column.key === "author") return { basisRem: 12, grow: 1 }; + if (column.key === "date") return { basisRem: 8, grow: 1 }; + return { basisRem: 9, grow: 1 }; +} + +function CopyValueButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef(null); + + useEffect( + () => () => { + if (resetTimer.current != null) window.clearTimeout(resetTimer.current); + }, + [] + ); + + async function copyValue(): Promise { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + if (resetTimer.current != null) window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + toast.error("Unable to copy ref"); + } + } + + return ( + + ); +} + +export function MetricEvidenceTable({ + rows, + columns, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + nextPageError, + pageLimitReached, +}: { + rows: MetricEvidenceRow[]; + columns: MetricEvidenceColumn[]; + fetchNextPage: () => Promise; + hasNextPage: boolean; + isFetchingNextPage: boolean; + nextPageError: boolean; + pageLimitReached: boolean; +}) { + const [viewport, setViewport] = useState(null); + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => viewport, + estimateSize: () => 44, + overscan: 8, + }); + const virtualRows = virtualizer.getVirtualItems(); + const virtualBodyHeight = virtualizer.getTotalSize(); + const last = virtualRows.at(-1)?.index ?? 0; + const minimumWidth = columns.reduce((total, column) => { + return total + columnLayout(column).basisRem; + }, 0); + + useEffect(() => { + if (last >= rows.length - 10 && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, last, rows.length]); + + return ( +
+ + + + {columns.map((column) => { + const layout = columnLayout(column); + return ( + + {column.label} + + ); + })} + + + + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + return ( + + {columns.map((column) => { + const layout = columnLayout(column); + const value = row.values[column.key]; + const text = cellText(value, column.type); + return ( + + {column.key === "ref" && value != null ? ( +
+ {text} + +
+ ) : ( + text + )} +
+ ); + })} +
+ ); + })} +
+
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} + {nextPageError ? ( +
+ Unable to load more rows + +
+ ) : pageLimitReached ? ( +
+ Showing the first 5,000 rows. Refine the selection or export the full + result. +
+ ) : null} +
+ ); +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 5d3d3ac9..0977b7da 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -1,21 +1,23 @@ -"use client" +"use client"; -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Table({ className, containerClassName, + containerRef, ...props -}: React.ComponentProps<"table"> & { containerClassName?: string }) { +}: React.ComponentProps<"table"> & { + containerClassName?: string; + containerRef?: React.Ref; +}) { return (
- ) + ); } function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { @@ -33,7 +35,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { className={cn("[&_tr]:border-b", className)} {...props} /> - ) + ); } function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { @@ -43,7 +45,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { className={cn("[&_tr:last-child]:border-0", className)} {...props} /> - ) + ); } function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { @@ -56,7 +58,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { )} {...props} /> - ) + ); } function TableRow({ className, ...props }: React.ComponentProps<"tr">) { @@ -69,7 +71,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { )} {...props} /> - ) + ); } function TableHead({ className, ...props }: React.ComponentProps<"th">) { @@ -82,7 +84,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { )} {...props} /> - ) + ); } function TableCell({ className, ...props }: React.ComponentProps<"td">) { @@ -95,7 +97,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { )} {...props} /> - ) + ); } function TableCaption({ @@ -108,7 +110,7 @@ function TableCaption({ className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} /> - ) + ); } export { @@ -120,4 +122,4 @@ export { TableRow, TableCell, TableCaption, -} +}; diff --git a/src/components/widgets/metric-views/metric-breakdown.tsx b/src/components/widgets/metric-views/metric-breakdown.tsx index 04d6f6f5..16f4e7bb 100644 --- a/src/components/widgets/metric-views/metric-breakdown.tsx +++ b/src/components/widgets/metric-views/metric-breakdown.tsx @@ -6,15 +6,20 @@ import { CardTitle, } from "@/components/ui/card"; import { ChartEmpty } from "@/components/widgets/metric-views/chart-empty"; +import { MetricCardActions } from "@/components/widgets/metric-views/metric-card-actions"; import { dimensionColorSeed, dimensionLabel, dimensionSeriesKey, } from "@/components/widgets/metric-views/dimension-series"; import { formatMetricValue } from "@/lib/format"; -import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; +import { + forEntity, + type NormalizedMetricResult, +} from "@/lib/metrics/collection"; import { percentShareLabels } from "@/lib/metrics/shares"; import { seriesColors } from "@/lib/series-colors"; +import { evidenceSelection } from "@/api/metric-drilldown-client"; export interface MetricBreakdownProps { metric: NormalizedMetricResult; @@ -27,6 +32,15 @@ function num(value: number | null | undefined): number { /** Proportional composition strip over the breakdown view's dimension groups. */ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { + const evidence = metric.drilldown + ? evidenceSelection( + metric.selection, + entityId, + undefined, + undefined, + metric.breakdown?.dimensions + ) + : null; const rows = forEntity(metric, entityId) .breakdown.filter((row) => num(row.value) > 0) .map((row) => ({ @@ -41,7 +55,9 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { return ( - {metric.label} + + {metric.label} + @@ -66,8 +82,9 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { })); return ( - - + + + {metric.label} {dimensions.length > 0 @@ -99,10 +116,10 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { {item.label} - + {item.formatted} - + {item.share}% diff --git a/src/components/widgets/metric-views/metric-card-actions.tsx b/src/components/widgets/metric-views/metric-card-actions.tsx new file mode 100644 index 00000000..197d0ac4 --- /dev/null +++ b/src/components/widgets/metric-views/metric-card-actions.tsx @@ -0,0 +1,52 @@ +import { Database, Ellipsis } from "lucide-react"; + +import type { MetricEvidenceSelection } from "@/api/metric-drilldown-client"; +import { useMetricEvidenceOptional } from "@/components/metric-evidence-context"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function MetricCardActions({ + evidence, + label, +}: { + evidence: MetricEvidenceSelection | null | undefined; + label: string; +}) { + const evidenceContext = useMetricEvidenceOptional(); + if (!evidence || !evidenceContext) return null; + + return ( + + event.stopPropagation()} + > + + + } + /> + + { + event.stopPropagation(); + evidenceContext.openEvidence(evidence, label); + }} + > + + View supporting data + + + + ); +} diff --git a/src/components/widgets/metric-views/metric-summary-card.tsx b/src/components/widgets/metric-views/metric-summary-card.tsx index 5ae5af74..7d106775 100644 --- a/src/components/widgets/metric-views/metric-summary-card.tsx +++ b/src/components/widgets/metric-views/metric-summary-card.tsx @@ -8,6 +8,7 @@ import { dimensionSeriesKey, } from "@/components/widgets/metric-views/dimension-series"; import { MetricSublabel } from "@/components/widgets/v2/metric-sublabel"; +import { MetricCardActions } from "@/components/widgets/metric-views/metric-card-actions"; import { useSettings } from "@/hooks/use-settings"; import { formatMetricNumber, @@ -15,7 +16,10 @@ import { metricDisplayUnit, } from "@/lib/format"; import { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; -import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; +import { + forEntity, + type NormalizedMetricResult, +} from "@/lib/metrics/collection"; import { derivePeerStanding } from "@/lib/metrics/peer-standing"; import { seriesColors } from "@/lib/series-colors"; import { @@ -24,22 +28,34 @@ import { applyFocusStatus, } from "@/lib/status"; import { cn } from "@/lib/utils"; +import { evidenceSelection } from "@/api/metric-drilldown-client"; export interface MetricSummaryCardProps { metric: NormalizedMetricResult; entityId: string; } - /** * Modality headline card: period total with peer status, plus a collapsible * proportional breakdown over the metric's dimension groups (ribbon + * legend). The breakdown section renders only when at least two groups have * data — a single-source metric reads as a plain summary card. */ -export function MetricSummaryCard({ metric, entityId }: MetricSummaryCardProps) { +export function MetricSummaryCard({ + metric, + entityId, +}: MetricSummaryCardProps) { const [open, setOpen] = useState(false); const { focusMode } = useSettings(); + const evidence = metric.drilldown + ? evidenceSelection( + metric.selection, + entityId, + undefined, + undefined, + metric.breakdown?.dimensions + ) + : null; const data = forEntity(metric, entityId); const value = data.value; @@ -69,14 +85,15 @@ export function MetricSummaryCard({ metric, entityId }: MetricSummaryCardProps) const displayUnit = metricDisplayUnit(metric.format, metric.unit); return ( - + + {/* KPI-tile line structure — label, sublabel slot, then the value on its own line — so narrow cards never truncate the label against the number, and all cards in a row share geometry (the sublabel reserves two lines whenever explanations are on). */}
- + {metric.label} {value == null @@ -98,9 +115,7 @@ export function MetricSummaryCard({ metric, entityId }: MetricSummaryCardProps) : formatMetricNumber(value, metric.format)} {value != null && displayUnit ? ( - - {displayUnit} - + {displayUnit} ) : null} @@ -150,8 +165,12 @@ export function MetricSummaryCard({ metric, entityId }: MetricSummaryCardProps) /> {row.label} - - {formatMetricValue(row.value, metric.format, metric.unit)} + + {formatMetricValue( + row.value, + metric.format, + metric.unit + )} ))} diff --git a/src/components/widgets/metric-views/metric-timeseries-chart.tsx b/src/components/widgets/metric-views/metric-timeseries-chart.tsx index ed19163c..4cb8e28d 100644 --- a/src/components/widgets/metric-views/metric-timeseries-chart.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-chart.tsx @@ -22,6 +22,11 @@ import { seriesColors } from "@/lib/series-colors"; export interface MetricTimeseriesChartProps { model: MetricTimeseriesModel; selectedMetricKey: string; + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; } function dateLabel(value: string, pattern: string): string { @@ -48,6 +53,7 @@ function IsolatedPoint({ export function MetricTimeseriesChart({ model, selectedMetricKey, + onEvidence, }: MetricTimeseriesChartProps) { const selectedMetric = model.metrics.find((metric) => metric.metric_key === selectedMetricKey) ?? @@ -111,6 +117,18 @@ export function MetricTimeseriesChart({ /> ); + const openPoint = (columnKey: string, state: unknown) => { + const point = state as { + payload?: { bucketStart?: string }; + }; + const bucketStart = point.payload?.bucketStart; + const column = model.columns.find( + (candidate) => candidate.key === columnKey + ); + if (bucketStart && column && !column.remainder) { + onEvidence?.(selectedMetric.metric_key, column.key, bucketStart); + } + }; return (
@@ -132,6 +150,7 @@ export function MetricTimeseriesChart({ fill={`var(--color-${column.key})`} name={column.label} radius={[2, 2, 0, 0]} + onClick={(point) => openPoint(column.key, point)} /> ))} @@ -148,6 +167,7 @@ export function MetricTimeseriesChart({ dataKey={column.key} stroke={`var(--color-${column.key})`} strokeWidth={2} + onClick={(point) => openPoint(column.key, point)} dot={IsolatedPoint} name={selectedMetric.label} /> diff --git a/src/components/widgets/metric-views/metric-timeseries-chrome.tsx b/src/components/widgets/metric-views/metric-timeseries-chrome.tsx index b8aba677..2ea53c78 100644 --- a/src/components/widgets/metric-views/metric-timeseries-chrome.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-chrome.tsx @@ -122,6 +122,7 @@ export function TimeseriesBody({ presentation, model, selectedMetricKey, + onEvidence, }: { isPending: boolean; isFetching: boolean; @@ -131,6 +132,11 @@ export function TimeseriesBody({ presentation: TimeseriesPresentation; model: MetricTimeseriesModel; selectedMetricKey: string; + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; }) { return (
@@ -154,9 +160,13 @@ export function TimeseriesBody({ ) : empty ? ( ) : presentation === "table" ? ( - + ) : ( - + )}
); diff --git a/src/components/widgets/metric-views/metric-timeseries-model.ts b/src/components/widgets/metric-views/metric-timeseries-model.ts index 4e7c094b..43d5f182 100644 --- a/src/components/widgets/metric-views/metric-timeseries-model.ts +++ b/src/components/widgets/metric-views/metric-timeseries-model.ts @@ -28,6 +28,7 @@ export interface MetricTimeseriesColumn { label: string; rank?: number; remainder: boolean; + dimensions?: MetricDimension[]; points: Map>; totals: Map; } @@ -54,10 +55,7 @@ function parseDate(value: string): Date | null { : null; } -export function bucketStarts( - range: DateRange, - bucket: MetricBucket -): string[] { +export function bucketStarts(range: DateRange, bucket: MetricBucket): string[] { const from = parseDate(range.from); const to = parseDate(range.to); if (!from || !to || from > to) return []; @@ -96,6 +94,7 @@ function columnFor( label: label ?? dimensionLabel(dimensions), rank, remainder, + dimensions, points: new Map(), totals: new Map(), }; diff --git a/src/components/widgets/metric-views/metric-timeseries-table.tsx b/src/components/widgets/metric-views/metric-timeseries-table.tsx index fabbc91f..ac7cd8c8 100644 --- a/src/components/widgets/metric-views/metric-timeseries-table.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-table.tsx @@ -13,6 +13,11 @@ import type { MetricTimeseriesModel } from "@/components/widgets/metric-views/me export interface MetricTimeseriesTableProps { model: MetricTimeseriesModel; + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; } const BUCKET_LABEL = { @@ -51,7 +56,10 @@ function MetricValues({ ); } -export function MetricTimeseriesTable({ model }: MetricTimeseriesTableProps) { +export function MetricTimeseriesTable({ + model, + onEvidence, +}: MetricTimeseriesTableProps) { return (
0 || metricIndex > 0) && "border-l" )} > - {value == null - ? "—" - : formatMetricNumber(value, metric.format)} + {value == null ? ( + "—" + ) : ( + + )} ); }) @@ -182,9 +205,20 @@ export function MetricTimeseriesTable({ model }: MetricTimeseriesTableProps) { (columnIndex > 0 || metricIndex > 0) && "border-l" )} > - {value == null - ? "—" - : formatMetricNumber(value, metric.format)} + {value == null ? ( + "—" + ) : ( + + )} ); }) diff --git a/src/components/widgets/metric-views/metric-timeseries-view.tsx b/src/components/widgets/metric-views/metric-timeseries-view.tsx index 61335065..2af26b2a 100644 --- a/src/components/widgets/metric-views/metric-timeseries-view.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-view.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { ListFilter, X } from "lucide-react"; +import { Database, ListFilter, X } from "lucide-react"; import type { DateRange } from "@/api/period-to-date-range"; import { Button } from "@/components/ui/button"; @@ -39,6 +39,11 @@ import { } from "@/lib/metrics/collection"; import { cn } from "@/lib/utils"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; +import { + evidenceSelection, + type MetricEvidenceSelection, +} from "@/api/metric-drilldown-client"; +import { useMetricEvidenceOptional } from "@/components/metric-evidence-context"; import { useMetricCollection, useMetricCollectionSet, @@ -218,6 +223,7 @@ export function MetricTimeseriesView({ defaultPresentation = "chart", groupBy, }: MetricTimeseriesViewProps) { + const evidenceContext = useMetricEvidenceOptional(); const [presentation, setPresentation] = useLocalStorageState({ key: `insight.timeseries.${id}.presentation`, defaultValue: defaultPresentation, @@ -319,6 +325,19 @@ export function MetricTimeseriesView({ const selectedMetric = model.metrics.find((metric) => metric.metric_key === selectedMetricKey) ?? model.metrics[0]; + const selectedDisplayDimensions = + selectedMetric?.computation !== "ratio" && selectedGroupBy + ? [selectedGroupBy] + : []; + const selectedEvidence = selectedMetric?.drilldown + ? evidenceSelection( + selectedMetric.selection, + entityId, + range, + filters, + selectedDisplayDimensions + ) + : null; const filterModels = dimensionOptions .filter((dimension) => dimension !== selectedGroupBy) .map((dimension) => { @@ -376,6 +395,54 @@ export function MetricTimeseriesView({ }); } + function openTimeseriesEvidence( + metricKey: string, + columnKey: string, + bucketStart: string | null + ): void { + const metric = model.metrics.find( + (candidate) => candidate.metric_key === metricKey + ); + const column = model.columns.find( + (candidate) => candidate.key === columnKey + ); + if (!metric?.drilldown || !column || column.remainder) return; + const exactFilters = new Map( + filters.map((filter) => [filter.dimension, filter]) + ); + for (const dimension of column.dimensions ?? []) { + exactFilters.set(dimension.key, { + dimension: dimension.key, + values: [dimension.value], + }); + } + let period = range; + if (bucketStart) { + const index = model.buckets.indexOf(bucketStart); + const next = model.buckets[index + 1]; + const to = next ? new Date(`${next}T00:00:00Z`) : null; + if (to) to.setUTCDate(to.getUTCDate() - 1); + period = { + from: bucketStart < range.from ? range.from : bucketStart, + to: to + ? to.toISOString().slice(0, 10) > range.to + ? range.to + : to.toISOString().slice(0, 10) + : range.to, + }; + } + const selection = evidenceSelection( + metric.selection, + entityId, + period, + [...exactFilters.values()].sort((a, b) => + a.dimension.localeCompare(b.dimension) + ), + metric.computation !== "ratio" && selectedGroupBy ? [selectedGroupBy] : [] + ) as MetricEvidenceSelection | null; + if (selection) evidenceContext?.openEvidence(selection, metric.label); + } + return ( ) : null} + {selectedEvidence ? ( + + ) : null} diff --git a/src/components/widgets/metric-views/peer-story.tsx b/src/components/widgets/metric-views/peer-story.tsx index 1f7b6814..8927379c 100644 --- a/src/components/widgets/metric-views/peer-story.tsx +++ b/src/components/widgets/metric-views/peer-story.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { PeerComparison } from "@/components/widgets/metric-views/peer-comparison"; +import { MetricCardActions } from "@/components/widgets/metric-views/metric-card-actions"; import { useSettings } from "@/hooks/use-settings"; import { formatMetricNumber, @@ -64,17 +65,18 @@ function HeroCard({ return ( +
-
+
{isBad ? "Top issue" : "Top win"} @@ -85,15 +87,17 @@ function HeroCard({ {entry.label} {entry.sublabel ? ( -

{entry.sublabel}

+

+ {entry.sublabel} +

) : null}
{entry.format === "percent" @@ -109,7 +113,7 @@ function HeroCard({ · - + {formatGap(entry) == null ? ( <>at the {cohortLabel} median ) : ( @@ -161,7 +165,7 @@ function SideCards({ "grid gap-3", stretchCards ? "h-full" : "content-start", entries.length === 2 && "grid-rows-2", - entries.length === 3 && "grid-rows-3", + entries.length === 3 && "grid-rows-3" )} > {entries.map((entry) => ( @@ -189,19 +193,20 @@ function SideCard({ return ( +
-
+
{entry.label}
@@ -232,7 +237,7 @@ function SideCard({ · - + {formatGap(entry) == null ? ( <>at the {cohortLabel} median ) : ( @@ -247,7 +252,11 @@ function SideCard({ )} - {formatMetricValue(entry.stats.p50, entry.format, entry.unit)} + {formatMetricValue( + entry.stats.p50, + entry.format, + entry.unit + )} @@ -310,11 +319,14 @@ function OutlierChips({ type="button" className={cn( "inline-flex cursor-help items-center gap-1 rounded-full border bg-transparent px-2.5 py-1 text-xs focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none", - PEER_TEXT[entry.status], + PEER_TEXT[entry.status] )} > {entry.label} @@ -371,8 +383,8 @@ function FlatGrid({ return (
{entries.map((entry) => ( @@ -396,7 +408,7 @@ function SupportingFold({ const [open, setOpen] = useState(false); if (entries.length === 0) return null; const neutralCount = entries.filter( - (entry) => entry.status === "neutral", + (entry) => entry.status === "neutral" ).length; const trueOnParCount = entries.length - neutralCount; const summaryLabel = @@ -504,7 +516,7 @@ export function PeerStory({ const { focusMode } = useSettings(); const { hero, sideCards, chips, folded } = partitionPeerStory( entries, - focusMode, + focusMode ); if (entries.length === 0) { diff --git a/src/components/widgets/v2/group-details-sheet.tsx b/src/components/widgets/v2/group-details-sheet.tsx new file mode 100644 index 00000000..63071a5b --- /dev/null +++ b/src/components/widgets/v2/group-details-sheet.tsx @@ -0,0 +1,259 @@ +import { Maximize2, Minimize2, XIcon } from "lucide-react"; + +import { ComingSoon } from "@/components/widgets/coming-soon"; +import { CountersBlock } from "@/components/widgets/v2/counters-block"; +import { DistributionStrip } from "@/components/widgets/v2/distribution-strip"; +import { CollectionDrilldown } from "@/components/widgets/metric-views/collection-drilldown"; +import { + TeamCollectionDrilldown, + type TeamMemberRef, +} from "@/components/widgets/metric-views/team-collection-drilldown"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import type { DateRange } from "@/api/period-to-date-range"; +import type { GroupDef } from "@/lib/insight/groups"; +import { partitionBullets } from "@/lib/insight/v2/partition"; +import { useIcDrilldownBatch } from "@/queries/v2/ic-extras"; +import type { PeerCohortLabel } from "@/lib/peers"; +import type { MetricCollectionResult } from "@/queries/metric-results"; +import { cn } from "@/lib/utils"; +import { + parseLocalStorageBoolean, + serializeLocalStorageBoolean, + useLocalStorageState, +} from "@/hooks/use-local-storage-state"; +import type { BulletMetric, PeriodValue } from "@/types/insight"; + +/** Data target for a metrics-backed group's drilldown body. */ +export type MetricDrilldownTarget = + | { + kind: "person"; + entityId: string; + data: MetricCollectionResult; + } + | { kind: "team"; members: TeamMemberRef[] }; + +export interface GroupDetailsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + def: GroupDef; + /** Legacy group rows; unused for `def.kind === "metrics"`. */ + rows: BulletMetric[]; + /** Required when `def.kind === "metrics"`. */ + metricTarget?: MetricDrilldownTarget; + personId?: string | null; + range?: DateRange; + period?: PeriodValue; + cohortLabel?: PeerCohortLabel; +} + +export function GroupDetailsSheet({ + open, + onOpenChange, + def, + rows, + metricTarget, + personId, + range, + period, + cohortLabel = "department", +}: GroupDetailsSheetProps) { + return ( + + + + + + ); +} + +function DrilldownPanel({ + def, + rows, + metricTarget, + personId, + range, + period, + cohortLabel, +}: { + def: GroupDef; + rows: BulletMetric[]; + metricTarget?: MetricDrilldownTarget; + personId?: string | null; + range?: DateRange; + period?: PeriodValue; + cohortLabel: PeerCohortLabel; +}) { + const [expanded, setExpanded] = useLocalStorageState({ + key: "insight.drilldown.expanded", + defaultValue: false, + parse: parseLocalStorageBoolean, + serialize: serializeLocalStorageBoolean, + }); + + return ( +
+ + {def.title} +
+ + + } + > + + +
+
+
+ {def.kind === "metrics" ? ( + metricTarget?.kind === "person" ? ( + + ) : metricTarget?.kind === "team" && range && period ? ( + + ) : ( +
+ +
+ ) + ) : ( + + )} +
+
+ ); +} + +function LegacyDrilldownBody({ + rows, + sectionId, + personId, + range, + period, + cohortLabel, +}: { + rows: BulletMetric[]; + sectionId: string; + personId?: string | null; + range?: DateRange; + period?: PeriodValue; + cohortLabel: PeerCohortLabel; +}) { + const { counters, distributions } = partitionBullets(rows); + + const batchQ = useIcDrilldownBatch({ + sectionId, + personId: personId ?? null, + range: range ?? null, + period: period ?? null, + }); + + const batch = batchQ.data; + const isFirstLoad = batchQ.isPending && batchQ.fetchStatus !== "idle"; + const isBodyEmpty = + Boolean(batch) && + counters.length === 0 && + distributions.length === 0 && + batch?.histograms.size === 0 && + !batch?.sectionTrend?.length; + const showFullSpinner = isFirstLoad || (isBodyEmpty && batchQ.isFetching); + + if (showFullSpinner) { + return ( +
+ +
+ ); + } + + return ( +
+ {counters.length > 0 ? ( + + ) : null} + {/* Histograms (ic_histogram) are per-person; a team aggregate has no + single person, so the team renders distributions as a compact + value-vs-expectation list (matches the sandbox's list layout). */} + {distributions.length > 0 ? ( + personId != null ? ( +
+ {distributions.map((r) => ( + + ))} +
+ ) : ( + + ) + ) : null} + {rows.length === 0 ? ( +

+ No data for this section in the selected period. +

+ ) : null} +
+ ); +} diff --git a/src/components/widgets/v2/group-drilldown-sheet.tsx b/src/components/widgets/v2/group-drilldown-sheet.tsx index f85ba46f..24d394d0 100644 --- a/src/components/widgets/v2/group-drilldown-sheet.tsx +++ b/src/components/widgets/v2/group-drilldown-sheet.tsx @@ -1,259 +1,5 @@ -import { Maximize2, Minimize2, XIcon } from "lucide-react"; - -import { ComingSoon } from "@/components/widgets/coming-soon"; -import { CountersBlock } from "@/components/widgets/v2/counters-block"; -import { DistributionStrip } from "@/components/widgets/v2/distribution-strip"; -import { CollectionDrilldown } from "@/components/widgets/metric-views/collection-drilldown"; -import { - TeamCollectionDrilldown, - type TeamMemberRef, -} from "@/components/widgets/metric-views/team-collection-drilldown"; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; -import type { DateRange } from "@/api/period-to-date-range"; -import type { GroupDef } from "@/lib/insight/groups"; -import { partitionBullets } from "@/lib/insight/v2/partition"; -import { useIcDrilldownBatch } from "@/queries/v2/ic-extras"; -import type { PeerCohortLabel } from "@/lib/peers"; -import type { MetricCollectionResult } from "@/queries/metric-results"; -import { cn } from "@/lib/utils"; -import { - parseLocalStorageBoolean, - serializeLocalStorageBoolean, - useLocalStorageState, -} from "@/hooks/use-local-storage-state"; -import type { BulletMetric, PeriodValue } from "@/types/insight"; - -/** Data target for a metrics-backed group's drilldown body. */ -export type MetricDrilldownTarget = - | { - kind: "person"; - entityId: string; - data: MetricCollectionResult; - } - | { kind: "team"; members: TeamMemberRef[] }; - -export interface GroupDrilldownSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; - def: GroupDef; - /** Legacy group rows; unused for `def.kind === "metrics"`. */ - rows: BulletMetric[]; - /** Required when `def.kind === "metrics"`. */ - metricTarget?: MetricDrilldownTarget; - personId?: string | null; - range?: DateRange; - period?: PeriodValue; - cohortLabel?: PeerCohortLabel; -} - -export function GroupDrilldownSheet({ - open, - onOpenChange, - def, - rows, - metricTarget, - personId, - range, - period, - cohortLabel = "department", -}: GroupDrilldownSheetProps) { - return ( - - - - - - ); -} - -function DrilldownPanel({ - def, - rows, - metricTarget, - personId, - range, - period, - cohortLabel, -}: { - def: GroupDef; - rows: BulletMetric[]; - metricTarget?: MetricDrilldownTarget; - personId?: string | null; - range?: DateRange; - period?: PeriodValue; - cohortLabel: PeerCohortLabel; -}) { - const [expanded, setExpanded] = useLocalStorageState({ - key: "insight.drilldown.expanded", - defaultValue: false, - parse: parseLocalStorageBoolean, - serialize: serializeLocalStorageBoolean, - }); - - return ( -
- - {def.title} -
- - - } - > - - -
-
-
- {def.kind === "metrics" ? ( - metricTarget?.kind === "person" ? ( - - ) : metricTarget?.kind === "team" && range && period ? ( - - ) : ( -
- -
- ) - ) : ( - - )} -
-
- ); -} - -function LegacyDrilldownBody({ - rows, - sectionId, - personId, - range, - period, - cohortLabel, -}: { - rows: BulletMetric[]; - sectionId: string; - personId?: string | null; - range?: DateRange; - period?: PeriodValue; - cohortLabel: PeerCohortLabel; -}) { - const { counters, distributions } = partitionBullets(rows); - - const batchQ = useIcDrilldownBatch({ - sectionId, - personId: personId ?? null, - range: range ?? null, - period: period ?? null, - }); - - const batch = batchQ.data; - const isFirstLoad = batchQ.isPending && batchQ.fetchStatus !== "idle"; - const isBodyEmpty = - Boolean(batch) && - counters.length === 0 && - distributions.length === 0 && - batch?.histograms.size === 0 && - !batch?.sectionTrend?.length; - const showFullSpinner = isFirstLoad || (isBodyEmpty && batchQ.isFetching); - - if (showFullSpinner) { - return ( -
- -
- ); - } - - return ( -
- {counters.length > 0 ? ( - - ) : null} - {/* Histograms (ic_histogram) are per-person; a team aggregate has no - single person, so the team renders distributions as a compact - value-vs-expectation list (matches the sandbox's list layout). */} - {distributions.length > 0 ? ( - personId != null ? ( -
- {distributions.map((r) => ( - - ))} -
- ) : ( - - ) - ) : null} - {rows.length === 0 ? ( -

- No data for this section in the selected period. -

- ) : null} -
- ); -} +export { + GroupDetailsSheet as GroupDrilldownSheet, + type GroupDetailsSheetProps as GroupDrilldownSheetProps, + type MetricDrilldownTarget, +} from "@/components/widgets/v2/group-details-sheet"; diff --git a/src/components/widgets/v2/ic-needs-attention.tsx b/src/components/widgets/v2/ic-needs-attention.tsx index a72620ab..74787ce1 100644 --- a/src/components/widgets/v2/ic-needs-attention.tsx +++ b/src/components/widgets/v2/ic-needs-attention.tsx @@ -76,7 +76,10 @@ export function IcNeedsAttention({ {item.gapText ? ( <> {item.gapText} {" "} diff --git a/src/components/widgets/v2/kpi-tile.tsx b/src/components/widgets/v2/kpi-tile.tsx index 4033016f..5c502a1a 100644 --- a/src/components/widgets/v2/kpi-tile.tsx +++ b/src/components/widgets/v2/kpi-tile.tsx @@ -29,21 +29,20 @@ const CARD_SURFACE = "@container/card"; */ export function KpiTile({ tile, onOpenGroup }: KpiTileProps) { const { showExplanations } = useSettings(); - const interactive = Boolean(onOpenGroup && tile.groupId); + const primaryGroup = onOpenGroup ? tile.groupId : null; + const interactive = primaryGroup != null; return ( { - if (tile.groupId) onOpenGroup?.(tile.groupId); - }} + onClick={() => onOpenGroup?.(primaryGroup)} aria-label={`Open ${tile.label} details`} /> ) : undefined @@ -73,7 +72,7 @@ export function KpiTile({ tile, onOpenGroup }: KpiTileProps) { className={cn( "text-2xl font-semibold tabular-nums @[250px]/card:text-3xl", tile.valueStatus !== "neutral" && - STATUS_TEXT_CLASS[tile.valueStatus], + STATUS_TEXT_CLASS[tile.valueStatus] )} > {tile.value} diff --git a/src/components/widgets/v2/members-grid.tsx b/src/components/widgets/v2/members-grid.tsx index 0a98f2bc..be65ee6a 100644 --- a/src/components/widgets/v2/members-grid.tsx +++ b/src/components/widgets/v2/members-grid.tsx @@ -54,6 +54,8 @@ import { } from "@/lib/peers"; import { applyFocusStatus, STATUS_TEXT_CLASS } from "@/lib/status"; import { cn } from "@/lib/utils"; +import { evidenceSelection } from "@/api/metric-drilldown-client"; +import { useMetricEvidenceOptional } from "@/components/metric-evidence-context"; export interface MembersGridMember { /** Metric entity id (normalized person id) — keys every lookup. */ @@ -103,6 +105,7 @@ interface Column { unit: string | null; format: MetricFormat; direction: MetricDirection; + metric: NormalizedMetricResult; } function columnFor(metric: NormalizedMetricResult): Column { @@ -113,6 +116,7 @@ function columnFor(metric: NormalizedMetricResult): Column { unit: metric.unit, format: metric.format, direction: metric.direction, + metric, }; } @@ -178,9 +182,7 @@ function fullDisplay(value: number | null, col: Column): string { /** True when the cell has nothing rankable — always sorts last, both ways. */ function cellMissing(cell: CellShape | undefined): boolean { - return ( - !cell?.observed || cell.value == null || !Number.isFinite(cell.value) - ); + return !cell?.observed || cell.value == null || !Number.isFinite(cell.value); } /** @@ -243,7 +245,12 @@ export function MembersGrid({ // (percentage points for percent ratios, relative % otherwise) — the // same helper the KPI tiles use. Only meaningful for observed members. const delta = standing.observed - ? computeDelta(data.value, previous, metric.computation, metric.format) + ? computeDelta( + data.value, + previous, + metric.computation, + metric.format + ) : null; return { col, @@ -378,7 +385,7 @@ export function MembersGrid({
{ + if (evidence) evidenceContext?.openEvidence(evidence, col.label); + }} aria-label={ observed ? `${memberName} — ${col.label}: ${displayWithUnit} — ${PEER_LABEL[focused]}` : `${memberName} — ${col.label}: not recorded` } className={cn( - "flex h-12 w-full items-center justify-center px-3 rounded-sm text-sm font-medium tabular-nums transition hover:brightness-95", + "flex h-12 w-full items-center justify-center rounded-sm px-3 text-sm font-medium tabular-nums transition hover:brightness-95", PEER_CELL[focused] )} > @@ -595,10 +612,7 @@ function GridCell({ {display} {showTrend && value != null ? ( ) : null} @@ -622,9 +636,11 @@ function GridCell({ {!observed ? ( // No value for this member — can't be "at the median". Show the // cohort median as context only. - median != null - ? `Not recorded · ${cohortLabel} median ${formatMetricValue(median, col.format, col.unit)}` - : "Not recorded" + median != null ? ( + `Not recorded · ${cohortLabel} median ${formatMetricValue(median, col.format, col.unit)}` + ) : ( + "Not recorded" + ) ) : median == null ? ( "No peer data" ) : gapText != null ? ( @@ -692,7 +708,7 @@ function ColumnHeader({ type="button" onClick={onClick} className={cn( - "flex h-9 w-full cursor-pointer items-center justify-center px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground", + "flex h-9 w-full cursor-pointer items-center justify-center px-4 text-xs font-medium tracking-wider text-muted-foreground uppercase transition-colors hover:text-foreground", active && "text-foreground" )} aria-label={`${col.label} — sort by this column`} @@ -701,7 +717,10 @@ function ColumnHeader({ like the value cell, rather than pinning to the column edge. */} {col.heading} - + } diff --git a/src/lib/insight/kpi-row.ts b/src/lib/insight/kpi-row.ts index e3a59419..f9e4e2ef 100644 --- a/src/lib/insight/kpi-row.ts +++ b/src/lib/insight/kpi-row.ts @@ -11,7 +11,11 @@ import { import { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; import { formatGapMagnitude } from "@/lib/metrics/gap"; import { derivePeerStanding } from "@/lib/metrics/peer-standing"; -import { computeDelta, deltaStatus, formatTileDelta } from "@/lib/metrics/delta"; +import { + computeDelta, + deltaStatus, + formatTileDelta, +} from "@/lib/metrics/delta"; import type { FocusMode } from "@/lib/peers"; import { applyFocusStatus, type Status } from "@/lib/status"; @@ -38,7 +42,6 @@ export interface KpiTileData { groupId: GroupId | null; } - /** Metric-collection results → tiles, in `KPI_ROW` order. */ export function metricKpiTiles( byKey: Map, diff --git a/src/lib/metrics/collection.ts b/src/lib/metrics/collection.ts index 0973757f..fcd1cfde 100644 --- a/src/lib/metrics/collection.ts +++ b/src/lib/metrics/collection.ts @@ -73,6 +73,8 @@ export type NormalizedMetricResult = { peer?: PeerView; breakdown?: BreakdownView; histogram?: HistogramView; + drilldown?: MetricResult["drilldown"]; + selection?: MetricResult["selection"]; }; export type PeerEntityStats = PeerView["values"][number]; @@ -146,6 +148,8 @@ export function normalizeMetricResult( direction: metric.direction, scale: metric.computation === "ratio" ? metric.scale : undefined, }; + if (metric.drilldown) normalized.drilldown = metric.drilldown; + if (metric.selection) normalized.selection = metric.selection; for (const view of metric.views) { switch (view.view) { diff --git a/src/lib/metrics/peer-story.ts b/src/lib/metrics/peer-story.ts index 65e75258..26f8a29f 100644 --- a/src/lib/metrics/peer-story.ts +++ b/src/lib/metrics/peer-story.ts @@ -6,11 +6,11 @@ import { type NormalizedMetricResult, } from "@/lib/metrics/collection"; import { derivePeerStanding } from "@/lib/metrics/peer-standing"; -import type { - FocusMode, - PeerStats, - PeerStatusWithNeutral, -} from "@/lib/peers"; +import type { FocusMode, PeerStats, PeerStatusWithNeutral } from "@/lib/peers"; +import { + evidenceSelection, + type MetricEvidenceSelection, +} from "@/api/metric-drilldown-client"; /** * The peer story ranks a collection's metrics by how far the person sits @@ -34,12 +34,14 @@ export type PeerStoryEntry = { gapPct: number | null; gapDelta: number; severity: number; + evidence?: MetricEvidenceSelection | null; }; function toStoryEntry( metric: NormalizedMetricResult, value: number, data: Pick, + entityId: string ): PeerStoryEntry { const standing = derivePeerStanding(metric.direction, data); return { @@ -57,6 +59,9 @@ function toStoryEntry( gapPct: standing.gapPct, gapDelta: standing.gapDelta, severity: standing.severity, + evidence: metric.drilldown + ? evidenceSelection(metric.selection, entityId) + : null, }; } @@ -69,14 +74,14 @@ function toStoryEntry( export function buildPeerStoryEntries( collection: MetricCollectionConfig, byKey: Map, - entityId: string, + entityId: string ): PeerStoryEntry[] { return collection.metrics.flatMap((metricConfig) => { const metric = byKey.get(metricConfig.key); if (!metric) return []; const data = forEntity(metric, entityId); if (data.value == null || !Number.isFinite(data.value)) return []; - return [toStoryEntry(metric, data.value, data)]; + return [toStoryEntry(metric, data.value, data, entityId)]; }); } @@ -104,7 +109,7 @@ export interface PeerStoryPartition { export function partitionPeerStory( entries: PeerStoryEntry[], - focusMode: FocusMode, + focusMode: FocusMode ): PeerStoryPartition { const bottom = entries .filter((entry) => entry.status === "bottom") diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e429edf3..46e05ebc 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -9,6 +9,7 @@ import { CenteredSpinner } from "@/components/widgets/centered-spinner"; import { MockBanner } from "@/components/mock-banner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { queryClient } from "@/query-client"; +import { MetricEvidenceProvider } from "@/components/metric-evidence-provider"; async function prefetchViewerIdentity(): Promise { const email = getViewerEmail(); @@ -48,15 +49,17 @@ function RootPending() { function RootLayout() { return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/src/screens/ic-dashboard/engineering-dashboard-v2.tsx b/src/screens/ic-dashboard/engineering-dashboard-v2.tsx index 6675a9ae..3baf44b8 100644 --- a/src/screens/ic-dashboard/engineering-dashboard-v2.tsx +++ b/src/screens/ic-dashboard/engineering-dashboard-v2.tsx @@ -4,12 +4,9 @@ import { CenteredSpinner } from "@/components/widgets/centered-spinner"; import { ComingSoon } from "@/components/widgets/coming-soon"; import { DashboardHeader } from "@/components/widgets/v2/dashboard-header"; import { IcNeedsAttention } from "@/components/widgets/v2/ic-needs-attention"; -import { - KpiTile, - KpiTilePlaceholder, -} from "@/components/widgets/v2/kpi-tile"; +import { KpiTile, KpiTilePlaceholder } from "@/components/widgets/v2/kpi-tile"; import { MetricGroupCard } from "@/components/widgets/metric-views/metric-group-card"; -import { GroupDrilldownSheet } from "@/components/widgets/v2/group-drilldown-sheet"; +import { GroupDrilldownSheet as GroupDetailsSheet } from "@/components/widgets/v2/group-drilldown-sheet"; import { usePeriod } from "@/hooks/use-period"; import { useSettings } from "@/hooks/use-settings"; import { metricAttentionItems } from "@/lib/insight/attention"; @@ -203,7 +200,7 @@ export function EngineeringDashboardV2({ {metricGroups().map((def) => ( - setOpenGroup(o ? def.id : null)} diff --git a/src/screens/team-view-v2.tsx b/src/screens/team-view-v2.tsx index e2e4be0d..3ca74f68 100644 --- a/src/screens/team-view-v2.tsx +++ b/src/screens/team-view-v2.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import { ComingSoon } from "@/components/widgets/coming-soon"; import { DashboardEmptyState } from "@/components/widgets/v2/dashboard-empty-state"; import { DashboardHeader } from "@/components/widgets/v2/dashboard-header"; -import { GroupDrilldownSheet } from "@/components/widgets/v2/group-drilldown-sheet"; +import { GroupDrilldownSheet as GroupDetailsSheet } from "@/components/widgets/v2/group-drilldown-sheet"; import { MembersOverview } from "@/components/widgets/v2/members-overview"; import { TeamMembersAttention } from "@/components/widgets/v2/team-members-attention"; import { TeamMetricGroupCard } from "@/components/widgets/metric-views/team-metric-group-card"; @@ -53,7 +53,7 @@ import type { BulletMetric } from "@/types/insight"; const LEGACY_GROUP_IDS = legacyGroups() .map((def) => def.id) .filter((id): id is Extract => - isTeamBulletSectionId(id), + isTeamBulletSectionId(id) ); /* v8 ignore stop */ @@ -70,7 +70,10 @@ export interface TeamViewV2ScreenProps { viewerEmail: string; } -export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) { +export function TeamViewV2Screen({ + teamId, + viewerEmail, +}: TeamViewV2ScreenProps) { const { period, dateRange, setPeriod } = usePeriod(); const [openGroup, setOpenGroup] = useState(null); const [directReportsOnly, setDirectReportsOnly] = useState(true); @@ -94,7 +97,7 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) const fullRoster = useMemo( () => (pivot ? flattenSubordinates(pivot) : null), - [pivot], + [pivot] ); // With no indirect reports, direct reports == the whole team, so the // toggle could never change the roster — hide it (#1756). @@ -105,9 +108,9 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) () => scopeRosterToDirectReports( fullRoster, - canScopeToDirectReports && directReportsOnly, + canScopeToDirectReports && directReportsOnly ), - [fullRoster, canScopeToDirectReports, directReportsOnly], + [fullRoster, canScopeToDirectReports, directReportsOnly] ); // Never fall back to the raw id (an email) — the shell prefetches the // viewer tree, so the pivot resolves synchronously in practice. @@ -128,7 +131,7 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) HEATMAP_COLLECTION, { type: "person", ids: memberEntityIds }, dateRange, - period, + period ); const sectionsQ = useTeamBulletSections( @@ -137,23 +140,22 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) teamSize, period, dateRange, - { keepPrevious: true, roster }, + { keepPrevious: true, roster } ); const metricGroupData = useMetricCollectionSet( TEAM_METRIC_COLLECTIONS, { type: "person", ids: memberEntityIds }, - dateRange, + dateRange ); const sectionData = sectionsQ.data; - const legacyRowsByGroup: Record = - Object.fromEntries( - LEGACY_GROUP_IDS.map((id) => [ - id, - orderRowsForSection(id, sectionData?.bySection[id] ?? []), - ]), - ); + const legacyRowsByGroup: Record = Object.fromEntries( + LEGACY_GROUP_IDS.map((id) => [ + id, + orderRowsForSection(id, sectionData?.bySection[id] ?? []), + ]) + ); const metricBelowByMember = new Map(); for (const def of metricGroups()) { @@ -162,11 +164,11 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) for (const [memberId, count] of metricBelowCounts( def, byKey, - memberEntityIds, + memberEntityIds )) { metricBelowByMember.set( memberId, - (metricBelowByMember.get(memberId) ?? 0) + count, + (metricBelowByMember.get(memberId) ?? 0) + count ); } } @@ -177,7 +179,7 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) const metricEntriesByPerson = memberMetricEntries( metricGroups(), (id) => metricGroupData.get(id)?.byKey, - memberEntityIds, + memberEntityIds ); // With no legacy groups the bullet query is disabled and never leaves @@ -199,7 +201,7 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) collectionSetPending(metricGroupData) || sectionsPending; const hasGroupData = Object.values(legacyRowsByGroup).some((rows) => - rows.some(hasBulletValue), + rows.some(hasBulletValue) ); const hasMembers = members.length > 0; const isAllEmpty = !isLoading && !hasGroupData && !hasMembers; @@ -222,13 +224,13 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) hasReports actions={ canScopeToDirectReports && fullRoster ? ( -