diff --git a/eslint.config.js b/eslint.config.js index ffcde44..db4e4f9 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 bcae509..9056894 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@fontsource-variable/inter": "5.2.8", "@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 11442ad..200e4dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,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 @@ -1449,6 +1452,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'} @@ -1485,6 +1494,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'} @@ -5307,6 +5319,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 @@ -5364,6 +5382,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 e04f467..b65cb6b 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; } type MetricSchemaErrorCode = diff --git a/src/api/metric-drilldown-client.test.ts b/src/api/metric-drilldown-client.test.ts new file mode 100644 index 0000000..ad4f9b2 --- /dev/null +++ b/src/api/metric-drilldown-client.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalyticsApiError } from "@/api/analytics-client"; +import { + downloadMetricDrilldown, + evidenceSelection, + queryMetricDrilldown, + type MetricDrilldownRequest, +} from "@/api/metric-drilldown-client"; + +const mocks = vi.hoisted(() => ({ + fetchWithAuth: vi.fn(), + downloadBlob: vi.fn(), +})); + +vi.mock("@/api/fetch-with-auth", () => ({ + fetchWithAuth: mocks.fetchWithAuth, +})); + +vi.mock("@/lib/download", () => ({ + downloadBlob: mocks.downloadBlob, +})); + +const selection = { + metric_key: "git.commits", + entity: { type: "person" as const, id: "person@example.com" }, + period: { from: "2026-07-01", to: "2026-07-31" }, + filters: [], + display_dimensions: [], +}; + +function response({ + ok = true, + status = 200, + body, + disposition, +}: { + ok?: boolean; + status?: number; + body?: unknown; + disposition?: string | null; +}) { + return { + ok, + status, + json: + body instanceof Error + ? vi.fn().mockRejectedValue(body) + : vi.fn().mockResolvedValue(body), + blob: vi.fn().mockResolvedValue(new Blob(["export"])), + headers: new Headers( + disposition == null ? undefined : { "content-disposition": disposition } + ), + } as unknown as Response; +} + +describe("metric drilldown client", () => { + beforeEach(() => { + mocks.fetchWithAuth.mockReset(); + mocks.downloadBlob.mockReset(); + }); + + it("queries evidence and forwards cancellation", async () => { + const payload = { ...selection, columns: [], rows: [], next_cursor: null }; + mocks.fetchWithAuth.mockResolvedValue(response({ body: payload })); + const controller = new AbortController(); + const request: MetricDrilldownRequest = { ...selection, limit: 100 }; + + await expect( + queryMetricDrilldown(request, controller.signal) + ).resolves.toEqual(payload); + expect(mocks.fetchWithAuth).toHaveBeenCalledWith( + expect.stringContaining("/metric-drilldown"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify(request), + signal: controller.signal, + }) + ); + }); + + it("classifies malformed success and error responses", async () => { + mocks.fetchWithAuth.mockResolvedValueOnce( + response({ body: new SyntaxError("invalid") }) + ); + await expect( + queryMetricDrilldown({ ...selection, limit: 100 }) + ).rejects.toMatchObject({ + status: 200, + body: { error: "invalid_json" }, + }); + + mocks.fetchWithAuth.mockResolvedValueOnce( + response({ ok: false, status: 400, body: { detail: "bad request" } }) + ); + await expect( + queryMetricDrilldown({ ...selection, limit: 100 }) + ).rejects.toMatchObject({ + status: 400, + body: { detail: "bad request" }, + }); + + mocks.fetchWithAuth.mockResolvedValueOnce( + response({ ok: false, status: 502, body: new SyntaxError("invalid") }) + ); + const error = await queryMetricDrilldown({ + ...selection, + limit: 100, + }).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(AnalyticsApiError); + expect(error).toMatchObject({ status: 502, body: null }); + }); + + it("downloads exports using server and fallback filenames", async () => { + mocks.fetchWithAuth.mockResolvedValueOnce( + response({ + disposition: "attachment; filename*=UTF-8''commits%20July.csv", + }) + ); + await downloadMetricDrilldown(selection, "csv"); + expect(mocks.downloadBlob).toHaveBeenLastCalledWith( + expect.any(Blob), + "commits July.csv" + ); + + mocks.fetchWithAuth.mockResolvedValueOnce( + response({ disposition: "attachment; filename*=UTF-8''%ZZ" }) + ); + await downloadMetricDrilldown(selection, "xlsx"); + expect(mocks.downloadBlob).toHaveBeenLastCalledWith( + expect.any(Blob), + "git.commits.xlsx" + ); + + mocks.fetchWithAuth.mockResolvedValueOnce( + response({ ok: false, status: 429, body: { detail: "busy" } }) + ); + await expect( + downloadMetricDrilldown(selection, "csv") + ).rejects.toMatchObject({ + status: 429, + }); + }); + + it("builds normalized selections from canonical results", () => { + expect(evidenceSelection(undefined, "person")).toBeNull(); + expect( + evidenceSelection( + { + metric_key: "git.commits", + entity: { type: "person", ids: ["person"] }, + period: selection.period, + filters: [{ dimension: "repository", values: ["org/repo"] }], + }, + "person", + undefined, + undefined, + ["category", "repository", "category"] + ) + ).toEqual({ + ...selection, + entity: { type: "person", id: "person" }, + filters: [{ dimension: "repository", values: ["org/repo"] }], + display_dimensions: ["category", "repository"], + }); + }); +}); diff --git a/src/api/metric-drilldown-client.ts b/src/api/metric-drilldown-client.ts new file mode 100644 index 0000000..ce65086 --- /dev/null +++ b/src/api/metric-drilldown-client.ts @@ -0,0 +1,123 @@ +import { AnalyticsApiError } from "@/api/analytics-client"; +import { fetchWithAuth } from "@/api/fetch-with-auth"; +import type { + MetricCanonicalSelection, + MetricDimensionFilter, +} from "@/api/metric-results-client"; +import { downloadBlob } from "@/lib/download"; + +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 parseResponseJson( + res: Response, + onInvalid: () => T +): Promise { + try { + return (await res.json()) as T; + } catch { + return onInvalid(); + } +} + +async function errorFor(res: Response): Promise { + const body = await parseResponseJson(res, () => null); + return new AnalyticsApiError(res.status, body); +} + +function exportFilename(disposition: string | null, fallback: string): string { + const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; + if (encoded) { + try { + return decodeURIComponent(encoded); + } catch { + return disposition?.match(/filename="?([^";]+)"?/i)?.[1] ?? fallback; + } + } + return disposition?.match(/filename="?([^";]+)"?/i)?.[1] ?? fallback; +} + +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 parseResponseJson(res, () => { + throw new AnalyticsApiError(res.status, { error: "invalid_json" }); + }); +} + +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(); + downloadBlob( + blob, + exportFilename( + res.headers.get("content-disposition"), + `${selection.metric_key}.${format}` + ) + ); +} + +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 bc9a5e7..364b926 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/auth/session-scope.test.ts b/src/auth/session-scope.test.ts new file mode 100644 index 0000000..abc9fdc --- /dev/null +++ b/src/auth/session-scope.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { sessionAuthorizationScope } from "@/auth/session-scope"; +import type { Session } from "@/auth/types"; + +describe("sessionAuthorizationScope", () => { + it("returns null without a session and canonicalizes role order", () => { + expect(sessionAuthorizationScope(null)).toBeNull(); + expect( + sessionAuthorizationScope({ + tenantId: "tenant", + personId: "person", + email: "person@example.com", + impersonatorEmail: null, + roles: ["viewer", "admin"], + csrfToken: "csrf", + expiresAt: 1, + refreshAt: 1, + } satisfies Session) + ).toBe( + JSON.stringify({ + tenantId: "tenant", + personId: "person", + impersonatorEmail: null, + roles: ["admin", "viewer"], + }) + ); + }); +}); diff --git a/src/auth/session-scope.ts b/src/auth/session-scope.ts new file mode 100644 index 0000000..5571f28 --- /dev/null +++ b/src/auth/session-scope.ts @@ -0,0 +1,13 @@ +import type { Session } from "@/auth/types"; + +export function sessionAuthorizationScope( + session: Session | null +): string | null { + if (!session) return null; + return JSON.stringify({ + tenantId: session.tenantId, + personId: session.personId, + impersonatorEmail: session.impersonatorEmail, + roles: [...session.roles].sort(), + }); +} diff --git a/src/components/metric-evidence-context.ts b/src/components/metric-evidence-context.ts new file mode 100644 index 0000000..2a87ed3 --- /dev/null +++ b/src/components/metric-evidence-context.ts @@ -0,0 +1,42 @@ +import { createContext, useContext } from "react"; + +import type { MetricEvidenceSelection } from "@/api/metric-drilldown-client"; + +export interface EvidenceDialogTarget { + selection: MetricEvidenceSelection; + label: string; +} + +export interface EvidenceDialogState { + targets: readonly [EvidenceDialogTarget, ...EvidenceDialogTarget[]]; + activeMetricKey: string; + title?: string; +} + +export interface EvidenceDialogContextValue { + openEvidence: (selection: MetricEvidenceSelection, label: string) => void; + openEvidenceTargets: ( + targets: readonly EvidenceDialogTarget[], + title?: 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 MetricEvidenceDialogProvider" + ); + } + return context; +} + +export function useMetricEvidenceOptional(): + | EvidenceDialogContextValue + | undefined { + return useContext(EvidenceDialogContext); +} diff --git a/src/components/metric-evidence-dialog-provider.test.tsx b/src/components/metric-evidence-dialog-provider.test.tsx new file mode 100644 index 0000000..cfe6041 --- /dev/null +++ b/src/components/metric-evidence-dialog-provider.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { + useMetricEvidence, + useMetricEvidenceOptional, +} from "@/components/metric-evidence-context"; +import { MetricEvidenceDialogProvider } from "@/components/metric-evidence-dialog-provider"; + +const mocks = vi.hoisted(() => ({ + session: { + tenantId: "tenant-a", + personId: "person-a", + impersonatorEmail: null, + roles: ["viewer"], + } as Record | null, + cancelQueries: vi.fn().mockResolvedValue(undefined), + removeQueries: vi.fn(), +})); + +vi.mock("@/auth/use-auth", () => ({ + useAuth: () => ({ session: mocks.session }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + cancelQueries: mocks.cancelQueries, + removeQueries: mocks.removeQueries, + }), +})); + +vi.mock("@/components/metric-evidence-dialog", () => ({ + MetricEvidenceDialog: ({ + state, + onMetricChange, + onClose, + }: { + state: { + activeMetricKey: string; + targets: Array<{ selection: { metric_key: string } }>; + title?: string; + } | null; + onMetricChange: (key: string) => void; + onClose: () => void; + }) => ( +
+ {state?.activeMetricKey ?? "closed"} + {state?.targets.length ?? 0} + {state?.title} + + +
+ ), +})); + +const period = { from: "2026-07-01", to: "2026-07-31" }; +const git = { + metric_key: "git.commits", + entity: { type: "person" as const, id: "person-a" }, + period, + filters: [], + display_dimensions: [], +}; +const wiki = { ...git, metric_key: "wiki.pages" }; + +function Controls() { + const evidence = useMetricEvidence(); + return ( + <> + + + + + ); +} + +describe("MetricEvidenceDialogProvider", () => { + it("requires the provider for the strict hook", () => { + expect(() => render()).toThrow( + "useMetricEvidence must be used within MetricEvidenceDialogProvider" + ); + expect(useMetricEvidenceOptional).toBeTypeOf("function"); + }); + + it("opens, deduplicates, selects, closes, and clears session-scoped state", async () => { + const user = userEvent.setup(); + mocks.session = { + tenantId: "tenant-a", + personId: "person-a", + impersonatorEmail: null, + roles: ["viewer"], + }; + mocks.cancelQueries.mockClear(); + mocks.removeQueries.mockClear(); + const view = render( + + + + ); + + await user.click(screen.getByRole("button", { name: "open empty" })); + expect(screen.getByText("closed")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "open one" })); + expect(screen.getByText("git.commits")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "open many" })); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("Combined")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "select wiki" })); + expect(screen.getByText("wiki.pages")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "close" })); + expect(screen.getByText("closed")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "open one" })); + mocks.session = { + tenantId: "tenant-b", + personId: "person-a", + impersonatorEmail: null, + roles: ["viewer"], + }; + view.rerender( + + + + ); + await waitFor(() => expect(screen.getByText("closed")).toBeInTheDocument()); + expect(mocks.cancelQueries).toHaveBeenCalledWith({ + queryKey: ["metric-drilldown"], + }); + expect(mocks.removeQueries).toHaveBeenCalledWith({ + queryKey: ["metric-drilldown"], + }); + }); +}); diff --git a/src/components/metric-evidence-dialog-provider.tsx b/src/components/metric-evidence-dialog-provider.tsx new file mode 100644 index 0000000..9b11adc --- /dev/null +++ b/src/components/metric-evidence-dialog-provider.tsx @@ -0,0 +1,94 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { sessionAuthorizationScope } from "@/auth/session-scope"; +import { useAuth } from "@/auth/use-auth"; +import { + EvidenceDialogContext, + type EvidenceDialogState, +} from "@/components/metric-evidence-context"; +import { MetricEvidenceDialog } from "@/components/metric-evidence-dialog"; + +type ScopedEvidenceDialogState = EvidenceDialogState & { + sessionScope: string | null; +}; + +export function MetricEvidenceDialogProvider({ + children, +}: { + children: ReactNode; +}) { + const { session } = useAuth(); + const sessionScope = sessionAuthorizationScope(session); + const queryClient = useQueryClient(); + const previousSessionScope = useRef(sessionScope); + const [state, setState] = useState(null); + useEffect(() => { + if (previousSessionScope.current !== sessionScope) { + void queryClient.cancelQueries({ queryKey: ["metric-drilldown"] }); + queryClient.removeQueries({ queryKey: ["metric-drilldown"] }); + setState(null); + } + previousSessionScope.current = sessionScope; + }, [queryClient, sessionScope]); + const openEvidenceTargets = useCallback( + ( + targets: readonly EvidenceDialogState["targets"][number][], + title?: EvidenceDialogState["title"] + ) => { + const uniqueTargets = [ + ...new Map( + targets.map((target) => [target.selection.metric_key, target]) + ).values(), + ]; + const first = uniqueTargets[0]; + if (!first) return; + setState({ + targets: [first, ...uniqueTargets.slice(1)], + activeMetricKey: first.selection.metric_key, + title, + sessionScope, + }); + }, + [sessionScope] + ); + const openEvidence = useCallback( + ( + selection: EvidenceDialogState["targets"][number]["selection"], + label: string + ) => openEvidenceTargets([{ selection, label }]), + [openEvidenceTargets] + ); + const selectEvidenceMetric = useCallback((metricKey: string) => { + setState((current) => + current?.targets.some( + (target) => target.selection.metric_key === metricKey + ) + ? { ...current, activeMetricKey: metricKey } + : current + ); + }, []); + const value = useMemo( + () => ({ openEvidence, openEvidenceTargets }), + [openEvidence, openEvidenceTargets] + ); + const visibleState = state?.sessionScope === sessionScope ? state : null; + return ( + + {children} + setState(null)} + /> + + ); +} diff --git a/src/components/metric-evidence-dialog.test.tsx b/src/components/metric-evidence-dialog.test.tsx new file mode 100644 index 0000000..9d604a9 --- /dev/null +++ b/src/components/metric-evidence-dialog.test.tsx @@ -0,0 +1,335 @@ +import type { ReactNode } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalyticsApiError } from "@/api/analytics-client"; +import type { EvidenceDialogState } from "@/components/metric-evidence-context"; +import { MetricEvidenceDialog } from "@/components/metric-evidence-dialog"; + +const mocks = vi.hoisted(() => ({ + query: {} as Record, + queryOptions: null as Record | null, + queryMetricDrilldown: vi.fn(), + downloadMetricDrilldown: vi.fn(), + tableProps: null as Record | null, +})); + +vi.mock("@tanstack/react-query", () => ({ + useInfiniteQuery: (options: Record) => { + mocks.queryOptions = options; + return mocks.query; + }, +})); + +vi.mock("@/auth/use-auth", () => ({ + useAuth: () => ({ + session: { + tenantId: "tenant", + personId: "person", + impersonatorEmail: null, + roles: ["viewer"], + }, + }), +})); + +vi.mock("@/api/metric-drilldown-client", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + queryMetricDrilldown: mocks.queryMetricDrilldown, + downloadMetricDrilldown: mocks.downloadMetricDrilldown, + }; +}); + +vi.mock("@/components/metric-evidence-table", () => ({ + MetricEvidenceTable: (props: Record) => { + mocks.tableProps = props; + return
evidence table
; + }, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + children, + onOpenChange, + }: { + children: ReactNode; + onOpenChange: (open: boolean) => void; + }) => ( +
+ {children} + +
+ ), + DialogContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ render }: { render: ReactNode }) => render, + DropdownMenuItem: ({ + children, + onClick, + }: { + children: ReactNode; + onClick: () => void; + }) => ( + + ), +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ + children, + onValueChange, + }: { + children: ReactNode; + onValueChange: (value: string) => void; + }) => ( +
+ {children} + + +
+ ), + SelectContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SelectValue: ({ children }: { children: ReactNode }) => ( + {children} + ), +})); + +const selection = { + metric_key: "git.commits", + entity: { type: "person" as const, id: "person" }, + period: { from: "2026-07-01", to: "2026-07-31" }, + filters: [], + display_dimensions: [], +}; + +const state: EvidenceDialogState = { + targets: [{ selection, label: "Commits" }], + activeMetricKey: "git.commits", +}; + +function readyQuery(overrides: Record = {}) { + return { + data: { + pages: [ + { + columns: [ + { key: "value", label: "Value", type: "number" }, + { key: "ref", label: "Ref", type: "string" }, + ], + rows: [{ values: { ref: "abc", value: 1 } }], + next_cursor: null, + }, + ], + }, + isPending: false, + isError: false, + error: null, + refetch: vi.fn(), + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isFetchNextPageError: false, + ...overrides, + }; +} + +describe("MetricEvidenceDialog", () => { + beforeEach(() => { + mocks.query = readyQuery(); + mocks.queryOptions = null; + mocks.queryMetricDrilldown.mockReset(); + mocks.downloadMetricDrilldown.mockReset().mockResolvedValue(undefined); + mocks.tableProps = null; + }); + + it("loads, orders, paginates, exports, and closes evidence", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render( + + ); + + expect(screen.getByText("evidence table")).toBeInTheDocument(); + expect( + (mocks.tableProps?.columns as Array<{ key: string }>).map( + (column) => column.key + ) + ).toEqual(["ref", "value"]); + + const options = mocks.queryOptions as { + queryFn: (context: { + pageParam?: string; + signal: AbortSignal; + }) => Promise; + getNextPageParam: (page: { + next_cursor: string | null; + }) => string | undefined; + retry: (count: number, error: unknown) => boolean; + }; + const controller = new AbortController(); + mocks.queryMetricDrilldown.mockResolvedValue({ rows: [] }); + await options.queryFn({ pageParam: "cursor", signal: controller.signal }); + expect(mocks.queryMetricDrilldown).toHaveBeenCalledWith( + expect.objectContaining({ cursor: "cursor", limit: 100 }), + controller.signal + ); + expect(options.getNextPageParam({ next_cursor: "next" })).toBe("next"); + expect(options.getNextPageParam({ next_cursor: null })).toBeUndefined(); + expect(options.retry(0, new Error("network"))).toBe(true); + expect(options.retry(0, new AnalyticsApiError(400, {}))).toBe(false); + expect(options.retry(1, new AnalyticsApiError(500, {}))).toBe(false); + + await user.click(screen.getByRole("button", { name: /CSV/ })); + await waitFor(() => + expect(mocks.downloadMetricDrilldown).toHaveBeenCalledWith( + selection, + "csv", + expect.any(AbortSignal) + ) + ); + await user.click(screen.getByRole("button", { name: "dismiss" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders pending, error, empty, and page-limit states", async () => { + const onMetricChange = vi.fn(); + mocks.query = readyQuery({ data: undefined, isPending: true }); + const view = render( + + ); + expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument(); + + const refetch = vi.fn(); + mocks.query = readyQuery({ + data: undefined, + isError: true, + error: new AnalyticsApiError(500, { + detail: "Warehouse unavailable", + trace_id: "trace-1", + }), + refetch, + }); + view.rerender( + + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Warehouse unavailable Trace: trace-1" + ); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(refetch).toHaveBeenCalledTimes(1); + + mocks.query = readyQuery({ + data: { pages: [{ columns: [], rows: [], next_cursor: null }] }, + }); + view.rerender( + + ); + expect( + screen.getByText("No supporting data for this selection") + ).toBeInTheDocument(); + + mocks.query = readyQuery({ + data: { + pages: Array.from({ length: 50 }, () => ({ + columns: [], + rows: [{ values: {} }], + next_cursor: "next", + })), + }, + hasNextPage: true, + }); + view.rerender( + + ); + expect(mocks.tableProps).toMatchObject({ + hasNextPage: false, + pageLimitReached: true, + }); + }); + + it("switches targets and reports export failures", async () => { + const user = userEvent.setup(); + const onMetricChange = vi.fn(); + const multiState: EvidenceDialogState = { + targets: [ + { selection, label: "Commits" }, + { + selection: { ...selection, metric_key: "wiki.pages" }, + label: "Wiki pages", + }, + ], + activeMetricKey: "git.commits", + title: "Combined", + }; + mocks.downloadMetricDrilldown.mockRejectedValue( + new AnalyticsApiError(500, { detail: "Export failed" }) + ); + render( + + ); + + await user.click(screen.getByRole("button", { name: "choose empty" })); + expect(onMetricChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "choose wiki" })); + expect(onMetricChange).toHaveBeenCalledWith("wiki.pages"); + await user.click(screen.getByRole("button", { name: /Excel/ })); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent("Export failed") + ); + }); +}); diff --git a/src/components/metric-evidence-dialog.tsx b/src/components/metric-evidence-dialog.tsx new file mode 100644 index 0000000..1b43808 --- /dev/null +++ b/src/components/metric-evidence-dialog.tsx @@ -0,0 +1,257 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Download, FileSpreadsheet, FileText } from "lucide-react"; + +import { + downloadMetricDrilldown, + queryMetricDrilldown, +} from "@/api/metric-drilldown-client"; +import { AnalyticsApiError } from "@/api/analytics-client"; +import { sessionAuthorizationScope } from "@/auth/session-scope"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; + +export function MetricEvidenceDialog({ + state, + onMetricChange, + onClose, +}: { + state: EvidenceDialogState | null; + onMetricChange: (metricKey: string) => void; + onClose: () => void; +}) { + const { session } = useAuth(); + const sessionScope = sessionAuthorizationScope(session); + const exportController = useRef(null); + const [exporting, setExporting] = useState(false); + const [exportFailure, setExportFailure] = useState(null); + const activeTarget = + state?.targets.find( + (target) => target.selection.metric_key === state.activeMetricKey + ) ?? + state?.targets[0] ?? + null; + const selection = activeTarget?.selection ?? null; + const query = useInfiniteQuery({ + queryKey: ["metric-drilldown", sessionScope, selection], + queryFn: ({ pageParam, signal }) => { + if (!selection) throw new Error("Metric evidence selection is missing"); + return queryMetricDrilldown( + { ...selection, cursor: pageParam, limit: 100 }, + signal + ); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.next_cursor ?? undefined, + enabled: sessionScope != null && selection != 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(); + }, + [] + ); + + function closeDialog(): void { + exportController.current?.abort(); + exportController.current = null; + setExporting(false); + setExportFailure(null); + onClose(); + } + + async function exportRows(format: "csv" | "xlsx") { + if (!selection) return; + exportController.current?.abort(); + const controller = new AbortController(); + exportController.current = controller; + setExporting(true); + setExportFailure(null); + try { + await downloadMetricDrilldown(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 && closeDialog()} + > + {state && activeTarget ? ( + + +
+ {state.targets.length > 1 ? ( + <> + + {state.title ?? "Metric evidence"} + + + + ) : ( + {activeTarget.label} + )} + + + {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 +
+ ) : ( + + )} +
+ ) : null} +
+ ); +} + +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-table.test.tsx b/src/components/metric-evidence-table.test.tsx new file mode 100644 index 0000000..3a483b9 --- /dev/null +++ b/src/components/metric-evidence-table.test.tsx @@ -0,0 +1,123 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { MetricEvidenceTable } from "@/components/metric-evidence-table"; + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn(), +})); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + start: index * 44, + })), + getTotalSize: () => count * 44, + }), +})); + +vi.mock("sonner", () => ({ + toast: { error: mocks.toastError }, +})); + +const columns = [ + { key: "ref", label: "Ref", type: "string" as const }, + { key: "value", label: "Value", type: "number" as const }, + { key: "active", label: "Active", type: "string" as const }, +]; + +const rows = [ + { values: { ref: "abc123", value: 1.234, active: true } }, + { values: { ref: null, value: null, active: false } }, +]; + +function renderTable( + overrides: Partial> = {} +) { + const props = { + rows, + columns, + fetchNextPage: vi.fn().mockResolvedValue(undefined), + hasNextPage: false, + isFetchingNextPage: false, + nextPageError: false, + pageLimitReached: false, + ...overrides, + }; + return { ...render(), props }; +} + +describe("MetricEvidenceTable", () => { + beforeEach(() => { + mocks.toastError.mockReset(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it("preserves table semantics while virtualizing rows", () => { + renderTable(); + + const table = screen.getByRole("table"); + expect(table).toHaveAttribute("aria-rowcount", "2"); + expect(screen.getAllByRole("rowgroup")).toHaveLength(2); + expect(screen.getAllByRole("columnheader")).toHaveLength(3); + expect(screen.getAllByRole("row")[1]).toHaveAttribute("aria-rowindex", "2"); + expect(screen.getAllByRole("cell")).toHaveLength(6); + expect(screen.getByText("1.2")).toBeInTheDocument(); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.getByText("No")).toBeInTheDocument(); + expect(screen.getAllByText("—")).toHaveLength(2); + }); + + it("copies references and reports clipboard failures", async () => { + const user = userEvent.setup(); + const writeText = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined); + const { rerender, props } = renderTable(); + + await user.click(screen.getByRole("button", { name: "Copy abc123" })); + expect(writeText).toHaveBeenCalledWith("abc123"); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + + writeText.mockRejectedValue(new Error("denied")); + rerender(); + await user.click(screen.getByRole("button", { name: "Copied" })); + await waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith("Unable to copy ref") + ); + }); + + it("loads the next page near the end and renders progress states", async () => { + const fetchNextPage = vi.fn().mockResolvedValue(undefined); + const { rerender, props } = renderTable({ + fetchNextPage, + hasNextPage: true, + }); + await waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1)); + + rerender( + + ); + expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Unable to load more rows" + ); + + rerender( + + ); + expect( + screen.getByText(/Showing the first 5,000 rows/) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/metric-evidence-table.tsx b/src/components/metric-evidence-table.tsx new file mode 100644 index 0000000..bba8426 --- /dev/null +++ b/src/components/metric-evidence-table.tsx @@ -0,0 +1,252 @@ +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: 9, 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 && + !nextPageError + ) { + void fetchNextPage(); + } + }, [ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + last, + nextPageError, + 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/chart.tsx b/src/components/ui/chart.tsx index ba2da84..f63dd0f 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -119,6 +119,7 @@ const ComposedChart = RechartsPrimitive.ComposedChart; const CartesianGrid = RechartsPrimitive.CartesianGrid; const XAxis = RechartsPrimitive.XAxis; const YAxis = RechartsPrimitive.YAxis; +const ReferenceArea = RechartsPrimitive.ReferenceArea; const ReferenceLine = RechartsPrimitive.ReferenceLine; const ResponsiveContainer = RechartsPrimitive.ResponsiveContainer; const ChartTooltip = RechartsPrimitive.Tooltip; @@ -425,6 +426,7 @@ export { ChartTreemap, ComposedChart, LineChart, + ReferenceArea, ReferenceLine, ResponsiveContainer, XAxis, diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 5d3d3ac..0977b7d 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/dashboard/group-drilldown-sheet.tsx b/src/components/widgets/dashboard/group-drilldown-sheet.tsx index 2935b36..5a931b4 100644 --- a/src/components/widgets/dashboard/group-drilldown-sheet.tsx +++ b/src/components/widgets/dashboard/group-drilldown-sheet.tsx @@ -38,6 +38,7 @@ export type MetricDrilldownTarget = export interface GroupDrilldownSheetProps { open: boolean; onOpenChange: (open: boolean) => void; + onOpenChangeComplete?: (open: boolean) => void; def: MetricGroup; metricTarget?: MetricDrilldownTarget; range?: DateRange; @@ -48,6 +49,7 @@ export interface GroupDrilldownSheetProps { export function GroupDrilldownSheet({ open, onOpenChange, + onOpenChangeComplete, def, metricTarget, range, @@ -55,7 +57,11 @@ export function GroupDrilldownSheet({ cohortLabel = "department", }: GroupDrilldownSheetProps) { return ( - + {item.gapText} {" "} diff --git a/src/components/widgets/dashboard/kpi-tile.tsx b/src/components/widgets/dashboard/kpi-tile.tsx index d27f4ac..6fb33a3 100644 --- a/src/components/widgets/dashboard/kpi-tile.tsx +++ b/src/components/widgets/dashboard/kpi-tile.tsx @@ -28,21 +28,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 @@ -72,7 +71,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/dashboard/members-grid.tsx b/src/components/widgets/dashboard/members-grid.tsx index 48d7017..14060be 100644 --- a/src/components/widgets/dashboard/members-grid.tsx +++ b/src/components/widgets/dashboard/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 { /** Canonical person id — keys every metric lookup AND the IC link. */ @@ -101,6 +103,7 @@ interface Column { unit: string | null; format: MetricFormat; direction: MetricDirection; + metric: NormalizedMetricResult; } function columnFor(metric: NormalizedMetricResult): Column { @@ -111,6 +114,7 @@ function columnFor(metric: NormalizedMetricResult): Column { unit: metric.unit, format: metric.format, direction: metric.direction, + metric, }; } @@ -176,9 +180,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); } /** @@ -241,7 +243,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, @@ -376,7 +383,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] )} > @@ -593,10 +616,7 @@ function GridCell({ {display} {showTrend && value != null ? ( ) : null} @@ -620,9 +640,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 ? ( @@ -690,7 +712,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`} @@ -699,7 +721,10 @@ function ColumnHeader({ like the value cell, rather than pinning to the column edge. */} {col.heading} - + } diff --git a/src/components/widgets/metric-views/metric-breakdown.test.tsx b/src/components/widgets/metric-views/metric-breakdown.test.tsx index d7fa2d2..907a5f5 100644 --- a/src/components/widgets/metric-views/metric-breakdown.test.tsx +++ b/src/components/widgets/metric-views/metric-breakdown.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { EvidenceDialogContext } from "@/components/metric-evidence-context"; import { MetricBreakdown } from "@/components/widgets/metric-views/metric-breakdown"; import { normalizeMetricResults, @@ -9,7 +11,7 @@ import { import type { MetricResult } from "@/api/metric-results-client"; function breakdownMetric( - values: Array<{ tool: string; value: number | null }>, + values: Array<{ tool: string; value: number | null }> ): NormalizedMetricResult { const result: MetricResult = { metric_key: "ai.accepted_lines", @@ -18,6 +20,13 @@ function breakdownMetric( format: "integer", direction: "higher_is_better", computation: "sum", + drilldown: { granularity: ["event"] }, + selection: { + metric_key: "ai.accepted_lines", + entity: { type: "person", ids: ["me@x.com"] }, + period: { from: "2026-07-01", to: "2026-07-31" }, + filters: [], + }, views: [ { view: "breakdown", @@ -42,7 +51,7 @@ describe("MetricBreakdown", () => { { tool: "claude_code", value: 40 }, ])} entityId="me@x.com" - />, + /> ); expect(screen.getByText("cursor")).toBeInTheDocument(); expect(screen.getByText("claude_code")).toBeInTheDocument(); @@ -51,16 +60,35 @@ describe("MetricBreakdown", () => { expect(screen.getByText("40%")).toBeInTheDocument(); }); - it("shows the empty state when no row has a positive value", () => { + it("keeps supporting-data actions in the empty state", async () => { + const user = userEvent.setup(); + const openEvidence = vi.fn(); render( - , + + + ); expect(screen.getByText("No composition data yet")).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "More actions for Accepted lines" }) + ); + await user.click( + await screen.findByRole("menuitem", { name: "View supporting data" }) + ); + expect(openEvidence).toHaveBeenCalledWith( + expect.objectContaining({ + metric_key: "ai.accepted_lines", + display_dimensions: ["tool"], + }), + "Accepted lines" + ); }); }); diff --git a/src/components/widgets/metric-views/metric-breakdown.tsx b/src/components/widgets/metric-views/metric-breakdown.tsx index 04d6f6f..b25300e 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) => ({ @@ -39,9 +53,12 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { if (rows.length === 0) { return ( - - - {metric.label} + + + + + {metric.label} + @@ -66,8 +83,9 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { })); return ( - - + + + {metric.label} {dimensions.length > 0 @@ -99,10 +117,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 0000000..197d0ac --- /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 6a3eb91..6e4e0ed 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/dashboard/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/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-model.ts b/src/components/widgets/metric-views/metric-timeseries-chart-model.ts index 2a65fcf..5edd4d8 100644 --- a/src/components/widgets/metric-views/metric-timeseries-chart-model.ts +++ b/src/components/widgets/metric-views/metric-timeseries-chart-model.ts @@ -5,6 +5,8 @@ import type { MetricTimeseriesChartConfig } from "@/lib/metrics/timeseries-chart interface MetricTimeseriesChartSeries { key: string; + metricKey: string; + columnKey: string; colorSeed: string; label: string; points: Map; @@ -17,6 +19,34 @@ export interface MetricTimeseriesChartModel { series: MetricTimeseriesChartSeries[]; } +export interface TimeseriesNullRun { + startIndex: number; + endIndex: number; +} + +export function commonNullRuns( + buckets: string[], + series: Array> +): TimeseriesNullRun[] { + if (series.length === 0) return []; + + const runs: TimeseriesNullRun[] = []; + let startIndex: number | null = null; + for (let index = 0; index <= buckets.length; index += 1) { + const bucket = buckets[index]; + const isMissing = + bucket !== undefined && + series.every((points) => points.get(bucket) == null); + if (isMissing && startIndex == null) { + startIndex = index; + } else if (!isMissing && startIndex != null) { + runs.push({ startIndex, endIndex: index - 1 }); + startIndex = null; + } + } + return runs; +} + export function shouldCombineTimeseriesMetrics( model: MetricTimeseriesModel, multiMetric: MetricTimeseriesChartConfig["multiMetric"] @@ -45,6 +75,8 @@ export function buildMetricTimeseriesChartModel( valueMetric: selectedMetric, series: model.metrics.map((metric) => ({ key: safeSeriesKey(metric.metric_key), + metricKey: metric.metric_key, + columnKey: column?.key ?? "", colorSeed: metric.metric_key, label: metric.label, points: column?.points.get(metric.metric_key) ?? new Map(), @@ -58,6 +90,8 @@ export function buildMetricTimeseriesChartModel( valueMetric: selectedMetric, series: model.columns.map((column) => ({ key: column.key, + metricKey: selectedMetric.metric_key, + columnKey: column.key, colorSeed: column.colorSeed, label: column.label, points: column.points.get(selectedMetric.metric_key) ?? new Map(), diff --git a/src/components/widgets/metric-views/metric-timeseries-chart.tsx b/src/components/widgets/metric-views/metric-timeseries-chart.tsx index be95281..8e78bfb 100644 --- a/src/components/widgets/metric-views/metric-timeseries-chart.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-chart.tsx @@ -1,4 +1,5 @@ import { format } from "date-fns"; +import type { DotItemDotProps } from "recharts"; import { BarChart, @@ -9,11 +10,16 @@ import { ChartTooltip, ChartTooltipContent, LineChart, + ReferenceArea, XAxis, YAxis, type ChartConfig, } from "@/components/ui/chart"; -import { buildMetricTimeseriesChartModel } from "@/components/widgets/metric-views/metric-timeseries-chart-model"; +import { + buildMetricTimeseriesChartModel, + commonNullRuns, + type MetricTimeseriesChartModel, +} from "@/components/widgets/metric-views/metric-timeseries-chart-model"; import type { MetricTimeseriesModel } from "@/components/widgets/metric-views/metric-timeseries-model"; import { formatMetricNumber } from "@/lib/format"; import { percentShareLabels } from "@/lib/metrics/shares"; @@ -24,6 +30,11 @@ export interface MetricTimeseriesChartProps { model: MetricTimeseriesModel; selectedMetricKey: string; multiMetric?: MetricTimeseriesChartConfig["multiMetric"]; + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; } function dateLabel(value: string, pattern: string): string { @@ -32,10 +43,53 @@ function dateLabel(value: string, pattern: string): string { return format(new Date(year, month - 1, day), pattern); } +function IsolatedPoint({ + cx, + cy, + index, + points, + stroke, + value, +}: DotItemDotProps) { + if (value == null || cx == null || cy == null) return null; + if (points[index - 1]?.value != null || points[index + 1]?.value != null) { + return null; + } + return ; +} + +function TimeseriesXAxis({ + data, + numeric = false, +}: { + data: Array<{ bucketIndex: number; label: string }>; + numeric?: boolean; +}) { + return ( + item.bucketIndex) : undefined} + tickFormatter={ + numeric + ? (value) => data[Number(value)]?.label ?? "" + : (value) => String(value) + } + tick={{ fontSize: 10, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + height={24} + interval="preserveStartEnd" + /> + ); +} + export function MetricTimeseriesChart({ model, selectedMetricKey, multiMetric = "selectable", + onEvidence, }: MetricTimeseriesChartProps) { const chartModel = buildMetricTimeseriesChartModel( model, @@ -53,8 +107,9 @@ export function MetricTimeseriesChart({ { label: series.label, color: colors[series.colorSeed] }, ]) ); - const data = model.buckets.map((bucketStart) => ({ + const data = model.buckets.map((bucketStart, bucketIndex) => ({ bucketStart, + bucketIndex, label: dateLabel( bucketStart, model.bucket === "month" ? "MMM yyyy" : "MMM d" @@ -63,23 +118,21 @@ export function MetricTimeseriesChart({ ...Object.fromEntries( chartModel.series.map((series) => [ series.key, - series.points.get(bucketStart) ?? 0, + series.points.get(bucketStart) ?? null, ]) ), })); - const totals = chartModel.series.map((series) => series.total ?? 0); - const shares = percentShareLabels(totals); + const totals = chartModel.series.map((series) => series.total); + const shares = percentShareLabels(totals.map((value) => value ?? 0)); + const nullRuns = chartModel.grouped + ? [] + : commonNullRuns( + model.buckets, + chartModel.series.map((series) => series.points) + ); const chartContent = ( <> - @@ -101,6 +154,19 @@ export function MetricTimeseriesChart({ /> ); + const openPoint = ( + series: MetricTimeseriesChartModel["series"][number], + state: unknown + ) => { + const point = state as { payload?: { bucketStart?: string } }; + const bucketStart = point.payload?.bucketStart; + const column = model.columns.find( + (candidate) => candidate.key === series.columnKey + ); + if (bucketStart && column && !column.remainder) { + onEvidence?.(series.metricKey, series.columnKey, bucketStart); + } + }; return (
@@ -114,6 +180,7 @@ export function MetricTimeseriesChart({ margin={{ top: 8, right: 8, left: 0, bottom: 0 }} > {chartContent} + {chartModel.series.map((series) => ( openPoint(series, point)} /> ))} @@ -131,6 +199,29 @@ export function MetricTimeseriesChart({ margin={{ top: 8, right: 8, left: 0, bottom: 0 }} > {chartContent} + + {nullRuns.map((run) => ( + run.startIndex + ? { + value: "No data", + position: "insideTop", + fill: "var(--muted-foreground)", + fontSize: 10, + } + : undefined + } + /> + ))} {chartModel.series.map((series) => ( openPoint(series, point)} /> ))} @@ -157,7 +250,9 @@ export function MetricTimeseriesChart({ {series.label} {chartModel.grouped ? ( - {`${formatMetricNumber(totals[index] ?? 0, chartModel.valueMetric.format)}${chartModel.valueMetric.unit ? ` ${chartModel.valueMetric.unit}` : ""}${shares[index] ? ` · ${shares[index]}%` : ""}`} + {totals[index] == null + ? "—" + : `${formatMetricNumber(totals[index], chartModel.valueMetric.format)}${chartModel.valueMetric.unit ? ` ${chartModel.valueMetric.unit}` : ""}${shares[index] ? ` · ${shares[index]}%` : ""}`} ) : null} diff --git a/src/components/widgets/metric-views/metric-timeseries-chrome.tsx b/src/components/widgets/metric-views/metric-timeseries-chrome.tsx index afd3948..435348f 100644 --- a/src/components/widgets/metric-views/metric-timeseries-chrome.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-chrome.tsx @@ -126,6 +126,7 @@ export function TimeseriesBody({ selectedMetricKey, multiMetric, table, + onEvidence, }: { isPending: boolean; isFetching: boolean; @@ -137,6 +138,11 @@ export function TimeseriesBody({ selectedMetricKey: string; multiMetric: MetricTimeseriesChartConfig["multiMetric"]; table?: MetricTimeseriesTableConfig; + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; }) { return (
@@ -160,12 +166,17 @@ export function TimeseriesBody({ ) : empty ? ( ) : presentation === "table" ? ( - + ) : ( )}
diff --git a/src/components/widgets/metric-views/metric-timeseries-export-browser.test.ts b/src/components/widgets/metric-views/metric-timeseries-export-browser.test.ts index 92e2b2c..b312806 100644 --- a/src/components/widgets/metric-views/metric-timeseries-export-browser.test.ts +++ b/src/components/widgets/metric-views/metric-timeseries-export-browser.test.ts @@ -4,23 +4,22 @@ import { downloadBlob } from "@/components/widgets/metric-views/metric-timeserie describe("downloadBlob", () => { afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("downloads and revokes an object URL", () => { + vi.useFakeTimers(); const createObjectURL = vi.fn(() => "blob:timeseries"); const revokeObjectURL = vi.fn(); const click = vi .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => undefined); vi.stubGlobal("URL", { createObjectURL, revokeObjectURL }); - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - callback(0); - return 1; - }); downloadBlob(new Blob(["data"]), "output.csv"); + vi.runAllTimers(); expect(createObjectURL).toHaveBeenCalledOnce(); expect(click).toHaveBeenCalledOnce(); diff --git a/src/components/widgets/metric-views/metric-timeseries-export.ts b/src/components/widgets/metric-views/metric-timeseries-export.ts index c60001c..ae56cd9 100644 --- a/src/components/widgets/metric-views/metric-timeseries-export.ts +++ b/src/components/widgets/metric-views/metric-timeseries-export.ts @@ -1,5 +1,7 @@ import type { DateRange } from "@/api/period-to-date-range"; +export { downloadBlob } from "@/lib/download"; + export function metricTimeseriesFilename( id: string, range: DateRange, @@ -12,15 +14,3 @@ export function metricTimeseriesFilename( .replace(/^-+|-+$/g, "") || "timeseries"; return `${safeId}_${range.from}_${range.to}.${extension}`; } - -export function downloadBlob(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.hidden = true; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - window.requestAnimationFrame(() => URL.revokeObjectURL(url)); -} diff --git a/src/components/widgets/metric-views/metric-timeseries-model.ts b/src/components/widgets/metric-views/metric-timeseries-model.ts index 24278e2..33a80d5 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 @@ 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; } -function bucketStarts( - range: DateRange, - bucket: MetricBucket -): string[] { +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-presentations.test.tsx b/src/components/widgets/metric-views/metric-timeseries-presentations.test.tsx index 1c0c444..63641d0 100644 --- a/src/components/widgets/metric-views/metric-timeseries-presentations.test.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-presentations.test.tsx @@ -2,7 +2,10 @@ import { render, screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { MetricTimeseriesChart } from "@/components/widgets/metric-views/metric-timeseries-chart"; -import { buildMetricTimeseriesChartModel } from "@/components/widgets/metric-views/metric-timeseries-chart-model"; +import { + buildMetricTimeseriesChartModel, + commonNullRuns, +} from "@/components/widgets/metric-views/metric-timeseries-chart-model"; import { MetricTimeseriesTable } from "@/components/widgets/metric-views/metric-timeseries-table"; import { resolveMetricTimeseriesTableColumns } from "@/components/widgets/metric-views/metric-timeseries-table-model"; import { groupedTimeseriesModel } from "@/components/widgets/metric-views/metric-timeseries.test-fixtures"; @@ -39,7 +42,7 @@ describe("metric timeseries presentations", () => { expect(screen.getByText("Grand total").closest("tr")).toHaveTextContent( "Commits: 6" ); - expect(screen.getAllByText("0").length).toBeGreaterThan(0); + expect(screen.getAllByText("—").length).toBeGreaterThan(0); }); it("renders a grouped single-metric table with a single header row", () => { @@ -57,16 +60,14 @@ describe("metric timeseries presentations", () => { expect(screen.getByText("Grand total")).toBeInTheDocument(); }); - it("zero-fills the grand-total row when every total is missing", () => { + it("hides the grand-total row when every total is missing", () => { const grouped = groupedTimeseriesModel(); const model = { ...grouped, grandTotals: grouped.grandTotals.map(() => null), }; render(); - const grandTotalRow = screen.getByText("Grand total").closest("tr"); - expect(grandTotalRow).toHaveTextContent("Commits: 0"); - expect(grandTotalRow).toHaveTextContent("Lines added: 0"); + expect(screen.queryByText("Grand total")).not.toBeInTheDocument(); }); it("renders an ungrouped single-metric table", () => { @@ -125,7 +126,7 @@ describe("metric timeseries presentations", () => { expect(columns[0]?.label).toBe("Commits"); }); - it("zero-fills missing values in templates", () => { + it("distinguishes missing values from observed zeroes in templates", () => { const model = groupedTimeseriesModel(); const firstColumn = model.columns[0]!; const linePoints = new Map(firstColumn.points.get("git.lines_added")); @@ -141,7 +142,7 @@ describe("metric timeseries presentations", () => { ); const rows = screen.getAllByRole("row"); expect(within(rows[2]!).getAllByRole("cell")[2]).toHaveTextContent( - "+0 / −3" + "— / −3" ); expect(within(rows[3]!).getAllByRole("cell")[2]).toHaveTextContent( "+0 / −0" @@ -218,4 +219,61 @@ describe("metric timeseries presentations", () => { sourceColumn.points.get("git.lines_added") ); }); + + it("finds only contiguous buckets missing from every displayed series", () => { + const buckets = ["a", "b", "c", "d", "e"]; + const series = [ + new Map([ + ["a", 1], + ["b", null], + ["c", null], + ["d", 2], + ["e", null], + ]), + new Map([ + ["a", 1], + ["b", null], + ["c", null], + ["d", null], + ["e", null], + ]), + ]; + + expect(commonNullRuns(buckets, series)).toEqual([ + { startIndex: 1, endIndex: 2 }, + { startIndex: 4, endIndex: 4 }, + ]); + }); + + it("labels multi-bucket gaps without connecting the line", () => { + const grouped = groupedTimeseriesModel(); + const metric = grouped.metrics[0]!; + const sourceColumn = grouped.columns[0]!; + const points = new Map(sourceColumn.points); + points.set( + metric.metric_key, + new Map([ + ["2026-04-20", 3], + ["2026-04-27", null], + ["2026-05-04", null], + ]) + ); + + render( + + ); + + expect( + document.querySelector(".recharts-reference-area-rect") + ).toBeInTheDocument(); + expect(screen.getByText("No data")).toBeInTheDocument(); + }); }); diff --git a/src/components/widgets/metric-views/metric-timeseries-table.tsx b/src/components/widgets/metric-views/metric-timeseries-table.tsx index f40339a..237afc9 100644 --- a/src/components/widgets/metric-views/metric-timeseries-table.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-table.tsx @@ -19,6 +19,11 @@ import type { MetricTimeseriesTableConfig } from "@/lib/metrics/timeseries-table export interface MetricTimeseriesTableProps { model: MetricTimeseriesModel; config?: MetricTimeseriesTableConfig; + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; } const BUCKET_LABEL = { @@ -37,21 +42,24 @@ const TONE_CLASS = { function MetricTableValue({ column, valueFor, + onMetricClick, }: { column: MetricTimeseriesTableColumn; valueFor: (metricKey: string) => number | null | undefined; + onMetricClick?: (metricKey: string) => void; }) { - const hasMetric = column.parts.some( - (part) => part.kind === "metric" && part.metric != null + const hasValue = column.parts.some( + (part) => part.kind === "metric" && valueFor(part.metricKey) != null ); - if (!hasMetric) return <>—; + if (!hasValue) return <>—; return ( {column.parts.map((part, index) => { if (part.kind === "text") return {part.text}; + const value = valueFor(part.metricKey); const metric = part.metric; - if (!metric) { + if (value == null || !metric) { return ( ); } - return ( + const content = ( + <> + {part.prefix} + {formatMetricNumber(value, metric.format)} + + ); + return onMetricClick ? ( + + ) : ( - {part.prefix} - {formatMetricNumber(valueFor(part.metricKey) ?? 0, metric.format)} + {content} ); })} @@ -78,6 +100,7 @@ function MetricTableValue({ export function MetricTimeseriesTable({ model, config, + onEvidence, }: MetricTimeseriesTableProps) { const tableColumns = resolveMetricTimeseriesTableColumns(model, config); const grandTotals = new Map( @@ -86,7 +109,12 @@ export function MetricTimeseriesTable({ model.grandTotals[index], ]) ); - const hasGrandTotal = tableColumns.length > 0; + const hasGrandTotal = tableColumns.some((column) => + column.parts.some( + (part) => + part.kind === "metric" && grandTotals.get(part.metricKey) != null + ) + ); return ( column.points.get(metricKey)?.get(bucketStart) } + onMetricClick={ + column.remainder || !onEvidence + ? undefined + : (metricKey) => + onEvidence(metricKey, column.key, bucketStart) + } /> ); @@ -220,6 +254,11 @@ export function MetricTimeseriesTable({ column.totals.get(metricKey)} + onMetricClick={ + column.remainder || !onEvidence + ? undefined + : (metricKey) => onEvidence(metricKey, column.key, null) + } /> ); diff --git a/src/components/widgets/metric-views/metric-timeseries-view.test.tsx b/src/components/widgets/metric-views/metric-timeseries-view.test.tsx index b05e6f4..4807727 100644 --- a/src/components/widgets/metric-views/metric-timeseries-view.test.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-view.test.tsx @@ -2,10 +2,12 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EvidenceDialogContext } from "@/components/metric-evidence-context"; import { MetricTimeseriesView } from "@/components/widgets/metric-views/metric-timeseries-view"; import { ENTITY_ID, RANGE, + groupedTimeseriesModel, timeseriesByKey, } from "@/components/widgets/metric-views/metric-timeseries.test-fixtures"; @@ -14,6 +16,7 @@ const mocks = vi.hoisted(() => ({ collectionSet: vi.fn(), csv: vi.fn(), xlsx: vi.fn(), + evidenceColumn: "total", })); vi.mock("@/queries/metric-results", () => ({ @@ -22,7 +25,27 @@ vi.mock("@/queries/metric-results", () => ({ })); vi.mock("@/components/widgets/metric-views/metric-timeseries-chart", () => ({ - MetricTimeseriesChart: () =>
chart presentation
, + MetricTimeseriesChart: ({ + onEvidence, + }: { + onEvidence?: ( + metricKey: string, + columnKey: string, + bucketStart: string | null + ) => void; + }) => ( +
+ chart presentation + +
+ ), })); vi.mock("@/components/widgets/metric-views/metric-timeseries-table", () => ({ @@ -339,4 +362,103 @@ describe("MetricTimeseriesView", () => { // The export action is disabled while fetching. expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); }); + + it("opens all targets for a combined chart", async () => { + const user = userEvent.setup(); + const byKey = timeseriesByKey(); + for (const metric of byKey.values()) { + metric.drilldown = { granularity: ["event"] }; + metric.unit = "commits"; + metric.selection = { + metric_key: metric.metric_key, + entity: { type: "person", ids: [ENTITY_ID] }, + period: RANGE, + filters: [], + }; + } + mocks.collection.mockReturnValue({ ...ready, byKey }); + mocks.evidenceColumn = "total"; + const openEvidence = vi.fn(); + const openEvidenceTargets = vi.fn(); + render( + + + + ); + + await user.click( + screen.getByRole("button", { name: "View supporting data" }) + ); + expect(openEvidenceTargets).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + selection: expect.objectContaining({ + metric_key: "git.commits", + display_dimensions: [], + }), + }), + expect.objectContaining({ + selection: expect.objectContaining({ + metric_key: "git.lines_added", + display_dimensions: [], + }), + }), + ]), + "Commits & Lines added" + ); + }); + + it("opens a grouped point with its exact period and dimensions", async () => { + const user = userEvent.setup(); + const byKey = timeseriesByKey(); + const metric = byKey.get("git.commits"); + if (!metric) throw new Error("missing fixture metric"); + metric.drilldown = { granularity: ["event"] }; + metric.selection = { + metric_key: metric.metric_key, + entity: { type: "person", ids: [ENTITY_ID] }, + period: RANGE, + filters: [], + }; + mocks.collection.mockReturnValue({ ...ready, byKey }); + mocks.evidenceColumn = groupedTimeseriesModel().columns[0]?.key ?? ""; + const openEvidence = vi.fn(); + render( + + + + ); + + await user.click(screen.getByRole("button", { name: "drill point" })); + expect(openEvidence).toHaveBeenCalledWith( + expect.objectContaining({ + metric_key: "git.commits", + period: { from: "2026-04-20", to: "2026-04-26" }, + filters: [ + { + dimension: "repository", + values: ["org/repo-a"], + }, + ], + display_dimensions: ["repository"], + }), + "Commits" + ); + }); }); diff --git a/src/components/widgets/metric-views/metric-timeseries-view.tsx b/src/components/widgets/metric-views/metric-timeseries-view.tsx index 0d09626..37adeb7 100644 --- a/src/components/widgets/metric-views/metric-timeseries-view.tsx +++ b/src/components/widgets/metric-views/metric-timeseries-view.tsx @@ -1,7 +1,12 @@ import { useMemo, useState } from "react"; -import { ListFilter, X } from "lucide-react"; +import { Database, ListFilter, X } from "lucide-react"; +import { evidenceSelection } from "@/api/metric-drilldown-client"; import type { DateRange } from "@/api/period-to-date-range"; +import { + useMetricEvidenceOptional, + type EvidenceDialogTarget, +} from "@/components/metric-evidence-context"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -225,6 +230,7 @@ export function MetricTimeseriesView({ groupBy, table, }: MetricTimeseriesViewProps) { + const evidenceContext = useMetricEvidenceOptional(); const [presentation, setPresentation] = useLocalStorageState({ key: `insight.timeseries.${id}.presentation`, defaultValue: defaultPresentation, @@ -329,6 +335,27 @@ export function MetricTimeseriesView({ const shouldCombineMetrics = presentation === "chart" && shouldCombineTimeseriesMetrics(model, chart?.multiMetric ?? "selectable"); + const evidenceMetrics = + presentation === "table" || shouldCombineMetrics + ? model.metrics + : selectedMetric + ? [selectedMetric] + : []; + const evidenceTargets = evidenceMetrics.flatMap( + (metric) => { + if (!metric.drilldown) return []; + const selection = evidenceSelection( + metric.selection, + entityId, + range, + filters, + metric.computation !== "ratio" && selectedGroupBy + ? [selectedGroupBy] + : [] + ); + return selection ? [{ selection, label: metric.label }] : []; + } + ); const filterModels = dimensionOptions .filter((dimension) => dimension !== selectedGroupBy) .map((dimension) => { @@ -386,6 +413,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((left, right) => + left.dimension.localeCompare(right.dimension) + ), + metric.computation !== "ratio" && selectedGroupBy ? [selectedGroupBy] : [] + ); + if (selection) evidenceContext?.openEvidence(selection, metric.label); + } + return ( ) : null} + {evidenceContext && evidenceTargets.length > 0 ? ( + + ) : null} diff --git a/src/components/widgets/metric-views/peer-story.test.tsx b/src/components/widgets/metric-views/peer-story.test.tsx index 72eb04e..9bc4c26 100644 --- a/src/components/widgets/metric-views/peer-story.test.tsx +++ b/src/components/widgets/metric-views/peer-story.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { EvidenceDialogContext } from "@/components/metric-evidence-context"; import { PeerStory } from "@/components/widgets/metric-views/peer-story"; import { buildPeerStoryEntries, @@ -27,6 +29,13 @@ function metric(key: string, value: number): MetricResult { format: "integer", direction: "higher_is_better", computation: "sum", + drilldown: { granularity: ["event"] }, + selection: { + metric_key: key, + entity: { type: "person", ids: ["me@x.com"] }, + period: { from: "2026-07-01", to: "2026-07-31" }, + filters: [], + }, views: [ { view: "period", values: [{ entity_id: "me@x.com", value }] }, { @@ -75,7 +84,7 @@ describe("PeerStory", () => { ["win", 30], ["par", 10], ])} - />, + /> ); expect(screen.getByText("Top issue")).toBeInTheDocument(); // The in-pack metric lands in the supporting fold toggle. @@ -84,25 +93,62 @@ describe("PeerStory", () => { it("critical focus shows only the issue hero", () => { settings.focusMode = "critical"; - render(); + render( + + ); expect(screen.getByText("Top issue")).toBeInTheDocument(); expect(screen.queryByText("Top win")).not.toBeInTheDocument(); }); it("rewards focus shows the win hero", () => { settings.focusMode = "rewards"; - render(); + render( + + ); expect(screen.getByText("Top win")).toBeInTheDocument(); expect(screen.queryByText("Top issue")).not.toBeInTheDocument(); }); - it("neutral focus renders a flat grid with no hero", () => { + it("neutral focus renders flat-grid supporting-data actions", async () => { + const user = userEvent.setup(); + const openEvidence = vi.fn(); settings.focusMode = "neutral"; - render(); + render( + + + + ); expect(screen.queryByText("Top issue")).not.toBeInTheDocument(); expect(screen.queryByText("Top win")).not.toBeInTheDocument(); expect(screen.getByText("issue")).toBeInTheDocument(); expect(screen.getByText("win")).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "More actions for issue" }) + ); + await user.click( + await screen.findByRole("menuitem", { name: "View supporting data" }) + ); + expect(openEvidence).toHaveBeenCalledWith( + expect.objectContaining({ metric_key: "issue" }), + "issue" + ); }); it("shows a multiple for a gap at/above 2× the median", () => { @@ -132,7 +178,7 @@ describe("PeerStory", () => { ["i5", 1], ["i6", 1], ])} - />, + /> ); // hero (1) + side cards (3) leaves 2 as chips; the 6th label renders once. expect(screen.getByText("i6")).toBeInTheDocument(); diff --git a/src/components/widgets/metric-views/peer-story.tsx b/src/components/widgets/metric-views/peer-story.tsx index 1f7b681..82a087d 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} @@ -335,8 +347,9 @@ function OutlierChips({ function FlatGridCard({ entry }: { entry: PeerStoryEntry }) { const unit = metricDisplayUnit(entry.format, entry.unit); return ( - -
+ + +
{entry.label}
@@ -371,8 +384,8 @@ function FlatGrid({ return (
{entries.map((entry) => ( @@ -396,7 +409,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 +517,7 @@ export function PeerStory({ const { focusMode } = useSettings(); const { hero, sideCards, chips, folded } = partitionPeerStory( entries, - focusMode, + focusMode ); if (entries.length === 0) { diff --git a/src/lib/download.ts b/src/lib/download.ts new file mode 100644 index 0000000..fcb45ff --- /dev/null +++ b/src/lib/download.ts @@ -0,0 +1,16 @@ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.hidden = true; + try { + document.body.append(anchor); + anchor.click(); + } finally { + window.setTimeout(() => { + anchor.remove(); + URL.revokeObjectURL(url); + }, 1_000); + } +} diff --git a/src/lib/insight/kpi-row.ts b/src/lib/insight/kpi-row.ts index bcbf666..99ba9f9 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/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 c0b1093..1e54b74 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 65e7525..26f8a29 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 bca5a0d..0428f7a 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { ViewAsBanner } from "@/components/view-as-banner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { normalizePersonId } from "@/lib/metrics/entity"; import { queryClient } from "@/query-client"; +import { MetricEvidenceDialogProvider } from "@/components/metric-evidence-dialog-provider"; // Warms the exact key `useIcPerson` reads, so the shell mounts with the // viewer's tree already cached. Keyed by person_id since the identity cutover: @@ -52,16 +53,18 @@ function RootPending() { function RootLayout() { return ( - - - - - - - - - - + + + + + + + + + + + + ); } diff --git a/src/screens/dashboard.tsx b/src/screens/dashboard.tsx index 9259d5f..ab4e2fb 100644 --- a/src/screens/dashboard.tsx +++ b/src/screens/dashboard.tsx @@ -75,6 +75,11 @@ export function DashboardScreen({ personId }: DashboardScreenProps) { ); const [openGroup, setOpenGroup] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const openDetails = (group: GroupId) => { + setOpenGroup(group); + setDetailsOpen(true); + }; // Full collection for the open metrics group only (drives the drilldown's // chart blocks + peer story). Disabled while nothing is open — empty ids @@ -130,6 +135,7 @@ export function DashboardScreen({ personId }: DashboardScreenProps) { if (personId !== prevPersonId) { setPrevPersonId(personId); setOpenGroup(null); + setDetailsOpen(false); } return ( @@ -168,7 +174,7 @@ export function DashboardScreen({ personId }: DashboardScreenProps) { ); } @@ -189,7 +195,7 @@ export function DashboardScreen({ personId }: DashboardScreenProps) {
@@ -206,7 +212,7 @@ export function DashboardScreen({ personId }: DashboardScreenProps) { def={def} data={result} entityId={entityId} - onOpen={() => setOpenGroup(def.id)} + onOpen={() => openDetails(def.id)} /> ); })} @@ -219,8 +225,11 @@ export function DashboardScreen({ personId }: DashboardScreenProps) { {GROUPS.map((def) => ( setOpenGroup(o ? def.id : null)} + open={detailsOpen && openGroup === def.id} + onOpenChange={setDetailsOpen} + onOpenChangeComplete={(open) => { + if (!open && openGroup === def.id) setOpenGroup(null); + }} def={def} metricTarget={{ kind: "person", diff --git a/src/screens/team-view.tsx b/src/screens/team-view.tsx index bf2177c..dff0964 100644 --- a/src/screens/team-view.tsx +++ b/src/screens/team-view.tsx @@ -52,6 +52,11 @@ export interface TeamViewScreenProps { export function TeamViewScreen({ teamId }: TeamViewScreenProps) { const { period, dateRange, setPeriod } = usePeriod(); const [openGroup, setOpenGroup] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const openDetails = (group: GroupId) => { + setOpenGroup(group); + setDetailsOpen(true); + }; const [directReportsOnly, setDirectReportsOnly] = useState(true); // Close any open drilldown when the viewed team changes. Render-phase @@ -247,7 +252,7 @@ export function TeamViewScreen({ teamId }: TeamViewScreenProps) { def={def} data={result} memberIds={memberEntityIds} - onOpen={() => setOpenGroup(def.id)} + onOpen={() => openDetails(def.id)} subtitle="vs department peers" /> ); @@ -261,8 +266,11 @@ export function TeamViewScreen({ teamId }: TeamViewScreenProps) { {GROUPS.map((def) => ( setOpenGroup(o ? def.id : null)} + open={detailsOpen && openGroup === def.id} + onOpenChange={setDetailsOpen} + onOpenChangeComplete={(open) => { + if (!open && openGroup === def.id) setOpenGroup(null); + }} def={def} metricTarget={{ kind: "team", members: memberRefs }} range={dateRange}