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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 33 additions & 27 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(...)({…})`
Expand All @@ -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"],
},
],
},
Expand All @@ -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",
},
},
{
Expand All @@ -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",
},
},
]);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/api/metric-definitions-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 =
Expand Down
167 changes: 167 additions & 0 deletions src/api/metric-drilldown-client.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
});
});
});
Loading