chore!: retire the integrated ROI calculator (#44477)

* chore!: retire the integrated ROI calculator

* chore(ui): remove unused ROI demo notice
This commit is contained in:
moe-berri 2026-10-05 10:02:19 -07:00 • committed by GitHub
parent 902736bfe7
commit eeb192d4a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 30 additions and 11029 deletions

View file

@ -139,11 +139,6 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
module_path="litellm.proxy.management_endpoints.model_insights_endpoints",
path_prefixes=("/model-insights",),
),
LazyFeature(
name="roi_calculator",
module_path="litellm.proxy.management_endpoints.roi_calculator_endpoints",
path_prefixes=("/roi-calculator",),
),
LazyFeature(
name="search_tools",
module_path="litellm.proxy.search_endpoints.search_tool_management",

File diff suppressed because it is too large Load diff

View file

@ -1604,11 +1604,6 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[ProxyLifespanState
if not model_info_scheduler.running:
model_info_scheduler.start()
if scheduler is not None and prisma_client is not None:
from litellm.proxy.management_endpoints.roi_calculator_endpoints import register_scheduled_sync
register_scheduled_sync(scheduler)
tracing_settings: Final = cast( # cast-ok: Pydantic validates the legacy untyped settings value
dict[str, object] | None,
TypeAdapter(dict[str, object] | None).validate_python(general_settings.get("tracing")),

View file

@ -1,54 +1,5 @@
# ROI Calculator
# ROI Calculator (retired)
The dashboard compares merged pull or merge requests, elapsed time from opening to merge, new bug and regression issues, and recorded gateway spend over 7, 28, or 90 complete UTC days. Compare against the immediately preceding period of the same length or the same-length period last year
The integrated ROI Calculator is retired. Its dashboard page, API endpoints, OAuth callbacks, and scheduled refresh are no longer available
The calculator combines repository activity with spend recorded by the gateway. Spend per merged change is a person's recorded gateway spend during the period divided by their matched merged changes. To track a branch's AI cost, send repository and branch tags with each request
## Connect repositories
Use **Preview sample report** beside the title to explore the dashboard before connecting repositories. Sample periods, engineer details, quality signals, and branch spend work without changing your connections or live report. **Exit demo** returns to your report or setup
Open `/ui/roi-calculator/`, choose GitHub or GitLab, then connect with an app or access token. Select several repositories and start the sync. Use **Add connection** to keep both providers connected. Each provider and API host retains its credentials, repositories, and identity mappings, and the report combines their activity while counting each person’s gateway spend once. Public repositories also accept an empty token, subject to the provider's anonymous API limits
For GitHub tokens, grant read access to metadata, pull requests and issues. GitLab tokens require `read_api`. Self-hosted instances use their API URL, for example `https://git.example.com/api/v4`
## Configure app authorization
Register a GitHub App with read-only repository permissions for metadata, pull requests and issues. Enable expiring user access tokens and leave authorization during installation disabled, since the gateway starts authorization after installation. Generate a private key in the app settings to allow installation and store it securely. The gateway uses a generated client secret for authorization and does not need the private key
Register a confidential GitLab OAuth application with `read_api` and `read_user` scopes
Set `PROXY_BASE_URL` to the gateway's public URL. The callback URLs are `<PROXY_BASE_URL>/roi-calculator/observed/oauth/github/callback` and `<PROXY_BASE_URL>/roi-calculator/observed/oauth/gitlab/callback`
Set the GitHub App setup URL to `<PROXY_BASE_URL>/roi-calculator/observed/oauth/github/installed`, enable **Redirect on update**, and set `LITELLM_ROI_GITHUB_APP_SLUG` to its URL slug. The first connection then starts with repository installation and continues to user authorization
Set `LITELLM_ROI_GITHUB_CLIENT_ID` and `LITELLM_ROI_GITHUB_CLIENT_SECRET` for GitHub, or `LITELLM_ROI_GITLAB_CLIENT_ID` and `LITELLM_ROI_GITLAB_CLIENT_SECRET` for GitLab. For a self-hosted provider, set `LITELLM_ROI_GITHUB_URL` or `LITELLM_ROI_GITLAB_URL` to its base URL without the API suffix
The gateway encrypts access and refresh tokens using its configured encryption key. Authorization uses PKCE and an expiring, single-use state tied to an HTTP-only browser cookie. Refreshes are coordinated across gateway workers
## Link people
Use **Link accounts** to associate several current or historical usernames with one internal email. Each connection has a separate username field, so a GitHub username never matches a GitLab user implicitly. Saving immediately recalculates the report without fetching repositories again. Public profile emails match automatically when they resolve unambiguously to an internal user
**Matched people only** is on by default for people, merged changes, and branch lists. Turn it off to include outside contributors and their branches. Matching depends on the linked internal account, even when no spend was recorded. This switch filters the lists; summary metrics and quality signals still cover all selected repositories
Agent-authored changes count for a person only when the supported agent metadata explicitly names a requester. Repository issue counts and revert titles are quality signals, not an individual defect score
Bug and regression counts combine repositories with issue tracking enabled. They remain unavailable when none of the selected repositories has issue tracking enabled
## Sync behavior
The default refresh interval is daily and applies to every connection in the workspace. Adding or editing a connection preserves it unless `update_interval_minutes` is supplied. The observed settings API accepts `update_interval_minutes: 0` for manual updates. A cancelled or failed sync preserves the last complete report
Existing settings retain `report_mode: legacy` and their scheduled reports until an administrator saves a connection, authorizes an app, or starts an observed sync. Reading the new dashboard alone does not change the mode. The legacy settings API can explicitly select `report_mode: legacy` again
GitHub collection splits large searches into smaller date ranges to avoid its search-result limit. Both providers validate pagination and reject incomplete responses instead of publishing partial counts
## Branch request tags
Send `repo:github.com/owner/repo` or `repo:gitlab.com/group/project` together with `branch:feature/name` in `metadata.tags`, top-level `tags`, or the comma-separated `x-litellm-tags` header. The tags must identify the source repository and branch, including forks
The report sums recorded requests inside its UTC dates. A branch cost is assigned to a merged change only when that source branch matches one change in the period. Reused branches stay visible in Branch spend without duplicating costs across changes. No retained tagged requests means unknown cost; a recorded zero remains zero
An empty repository produces a successful report with zero merged changes and no merge duration or spend-per-change ratio
The backend implementation and stored configuration are retained. Reverting the retirement change restores the dashboard and registration points. The standalone [litellm-roi-calculator](https://github.com/BerriAI/litellm-roi-calculator) project is separate and remains available

View file

@ -50,6 +50,33 @@ def _has_lazy_middleware(app: FastAPI) -> bool:
return any(middleware.cls is LazyFeatureMiddleware for middleware in app.user_middleware)
@pytest.mark.parametrize("disable_lazy_routes", ("false", "true"))
def test_retired_roi_calculator_cannot_be_loaded_or_called(
monkeypatch: pytest.MonkeyPatch, disable_lazy_routes: str
) -> None:
monkeypatch.setenv(FLAG, disable_lazy_routes)
app: Final = FastAPI()
attach_lazy_features(app)
with TestClient(app) as client:
for method, path in (
("POST", "/lazy/warm/roi_calculator"),
("GET", "/roi-calculator/settings"),
("GET", "/roi-calculator/report?mode=demo"),
("POST", "/roi-calculator/sync"),
("GET", "/roi-calculator/observed/report"),
("GET", "/roi-calculator/observed/apps"),
("POST", "/roi-calculator/observed/sync"),
("POST", "/roi-calculator/observed/oauth/github/start"),
("GET", "/roi-calculator/observed/oauth/github/callback"),
("GET", "/roi-calculator/observed/oauth/github/installed"),
("POST", "/roi-calculator/observed/oauth/gitlab/start"),
("GET", "/roi-calculator/observed/oauth/gitlab/callback"),
):
response: Final = client.request(method, path)
assert response.status_code == 404, f"{method} {path}: {response.text}"
@pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "on"))
def test_flag_registers_every_feature_at_startup(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
monkeypatch.setenv(FLAG, value)

View file

@ -37,7 +37,6 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
usage: "old-usage",
"cost-optimization": "cost-optimization",
"model-insights": "model-insights",
"roi-calculator": "roi-calculator",
agents: "agents",
"router-settings": "router-settings",
users: "users",

View file

@ -1,12 +0,0 @@
import { useId } from "react";
import { Switch } from "@/components/ui/switch";
export function MatchedPeopleToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
const id = useId();
return (
<label htmlFor={id} className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<Switch id={id} size="sm" checked={checked} onCheckedChange={onChange} />
Matched people only
</label>
);
}

View file

@ -1,88 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import ObservedAccounts from "./ObservedAccounts";
afterEach(() => vi.unstubAllGlobals());
it("links several usernames on both providers to one email in a single save", async () => {
const writes: unknown[] = [];
const saved = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (_input: string, init: RequestInit) => {
if (init.method === "PUT") {
writes.push(JSON.parse(String(init.body)));
return Response.json({ report: null });
}
const identities = {
gateway_emails: ["ari@example.test"],
identity_map: { old: "ari@example.test" },
unmatched_logins: ["new"],
connections: [
{
id: "github-id",
source_provider: "github",
api_url: "https://api.github.com",
identity_map: { old: "ari@example.test" },
unmatched_logins: ["new"],
},
{
id: "gitlab-id",
source_provider: "gitlab",
api_url: "https://gitlab.com/api/v4",
identity_map: {},
unmatched_logins: ["new"],
},
],
};
return Response.json(identities);
}),
);
const user = userEvent.setup();
render(<ObservedAccounts accessToken="gateway-test-token" people={[]} onClose={vi.fn()} onSaved={saved} />);
await screen.findByLabelText(/GitHub usernames/);
fireEvent.change(screen.getByLabelText("Internal email"), { target: { value: "ari@example.test" } });
expect(screen.getByLabelText(/GitHub usernames/)).toHaveValue("old");
fireEvent.change(screen.getByLabelText(/GitHub usernames/), { target: { value: "@Old, new, NEW" } });
fireEvent.change(screen.getByLabelText(/GitLab usernames/), { target: { value: "new" } });
await user.click(screen.getByRole("button", { name: "Save accounts" }));
await waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(writes).toEqual([
{
email: "ari@example.test",
accounts: [
{ connection_id: "github-id", login: "old" },
{ connection_id: "github-id", login: "new" },
{ connection_id: "gitlab-id", login: "new" },
],
},
]);
});
it("keeps a conflicting link editable", async () => {
const saved = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (_input: string, init: RequestInit) =>
init.method === "PUT"
? Response.json({ detail: "An account is already linked to another email. Unlink it first" }, { status: 409 })
: Response.json({ gateway_emails: ["ari@example.test"], identity_map: {}, unmatched_logins: [] }),
),
);
const user = userEvent.setup();
render(
<ObservedAccounts
accessToken="gateway-test-token"
people={[]}
initialEmail="ari@example.test"
onClose={vi.fn()}
onSaved={saved}
/>,
);
fireEvent.change(screen.getByLabelText("Source usernames"), { target: { value: "old, new" } });
const button = screen.getByRole("button", { name: "Save accounts" });
await waitFor(() => expect(button).toBeEnabled());
await user.click(button);
expect(await screen.findByRole("alert")).toHaveTextContent("An account is already linked to another email");
expect(screen.getByLabelText("Source usernames")).toHaveValue("old, new");
expect(saved).not.toHaveBeenCalled();
});

View file

@ -1,209 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
import { apiClient } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { accountLogins, type ObservedPerson } from "./observedData";
const connectionIdentityFields = {
id: z.string(),
source_provider: z.enum(["github", "gitlab"]),
api_url: z.string(),
identity_map: z.record(z.string(), z.string()),
unmatched_logins: z.array(z.string()),
};
const identitiesFields = {
gateway_emails: z.array(z.string()),
identity_map: z.record(z.string(), z.string()),
unmatched_logins: z.array(z.string()),
connections: z.array(z.object(connectionIdentityFields)).optional(),
};
const identitiesSchema = z.object(identitiesFields);
function matches(identities: z.infer<typeof identitiesSchema>, email: string, people: ObservedPerson[]) {
const person = people.find((entry) => entry.email === email);
return Object.fromEntries(
(identities.connections ?? []).map((entry) => [
entry.id,
[
...new Set([
...Object.entries(entry.identity_map)
.filter(([, address]) => address === email)
.map(([login]) => login),
...(person?.accounts ?? [])
.filter((account) => account.connection_id === entry.id)
.map((account) => account.login),
]),
].join(", "),
]),
);
}
export default function ObservedAccounts({
accessToken,
people,
initialEmail = "",
onClose,
onSaved,
}: {
accessToken: string;
people: ObservedPerson[];
initialEmail?: string;
onClose: () => void;
onSaved: () => void;
}) {
const [identities, setIdentities] = useState<z.infer<typeof identitiesSchema> | null>(null);
const [email, setEmail] = useState(initialEmail);
const [logins, setLogins] = useState(people.find((person) => person.email === initialEmail)?.logins.join(", ") ?? "");
const [linked, setLinked] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
apiClient
.get<unknown>("/roi-calculator/observed/identities", { accessToken, signal: controller.signal })
.then((data) => {
if (!controller.signal.aborted) {
const parsed = identitiesSchema.parse(data);
setIdentities(parsed);
setLinked(matches(parsed, initialEmail, people));
}
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason));
});
return () => controller.abort();
}, [accessToken, initialEmail, people]);
function selectEmail(value: string) {
setEmail(value);
if (identities) setLinked(matches(identities, value, people));
const automatic = people.find((person) => person.email === value)?.logins ?? [];
const manual = Object.entries(identities?.identity_map ?? {})
.filter(([, address]) => address === value)
.map(([login]) => login);
setLogins([...new Set([...automatic, ...manual])].join(", "));
}
async function save() {
setSaving(true);
setError("");
try {
await apiClient.put<unknown>("/roi-calculator/observed/identities", {
accessToken,
body: {
email: email.trim().toLowerCase(),
...(identities?.connections?.length
? {
accounts: identities.connections.flatMap((entry) =>
accountLogins(linked[entry.id] ?? "").map((login) => ({ connection_id: entry.id, login })),
),
}
: { logins: accountLogins(logins) }),
},
});
onSaved();
onClose();
} catch (reason) {
setError(extractProxyErrorMessage(reason));
} finally {
setSaving(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Link accounts</DialogTitle>
<DialogDescription>Match one internal user to all their source accounts</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label htmlFor="identity-email" className="text-sm font-medium">
Internal email
</label>
<Input
id="identity-email"
disabled={!identities}
list="roi-internal-emails"
value={email}
placeholder="Choose an internal user"
onChange={(event) => selectEmail(event.target.value)}
/>
<datalist id="roi-internal-emails">
{identities?.gateway_emails.map((address) => <option key={address} value={address} />)}
</datalist>
</div>
{identities?.connections?.length ? (
identities.connections.map((entry) => (
<div key={entry.id} className="space-y-2">
<label htmlFor={`identity-${entry.id}`} className="text-sm font-medium">
{entry.source_provider === "github" ? "GitHub" : "GitLab"} usernames
<span className="ml-2 text-xs font-normal text-muted-foreground">{new URL(entry.api_url).host}</span>
</label>
<Input
id={`identity-${entry.id}`}
value={linked[entry.id] ?? ""}
onChange={(event) => setLinked({ ...linked, [entry.id]: event.target.value })}
placeholder="current-account, old-account"
/>
{entry.unmatched_logins.length > 0 && (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">{entry.unmatched_logins.length} unmatched accounts</summary>
<div className="mt-2 max-h-32 overflow-auto flex flex-wrap gap-1">
{entry.unmatched_logins.map((login) => (
<Button
key={login}
size="sm"
variant="outline"
onClick={() =>
setLinked({
...linked,
[entry.id]: [...new Set([...accountLogins(linked[entry.id] ?? ""), login])].join(", "),
})
}
>
{login}
</Button>
))}
</div>
</details>
)}
</div>
))
) : (
<div className="space-y-2">
<label htmlFor="identity-logins" className="text-sm font-medium">
Source usernames
</label>
<Input
id="identity-logins"
value={logins}
onChange={(event) => setLogins(event.target.value)}
placeholder="current-account, old-account"
/>
</div>
)}
<p className="text-xs text-muted-foreground">
Separate accounts with commas. Their merged changes are combined, and gateway spend is counted once
</p>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<Button className="w-full" disabled={!identities || !email.trim() || saving} onClick={save}>
{saving ? "Saving…" : "Save accounts"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,198 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import ObservedConnections from "./ObservedConnections";
import type { ObservedSettings } from "./observedData";
const settings: ObservedSettings = {
id: "initial-github",
source_provider: "github",
api_url: "https://api.github.com",
repos: [],
has_token: false,
connection_type: "token",
update_interval_minutes: 1440,
ready: false,
};
const app = { configured: true, api_url: null, callback_url: null };
afterEach(() => vi.unstubAllGlobals());
describe("observed ROI connections", () => {
it("identifies the saved connection when editing its host", async () => {
const writes: unknown[] = [];
const existing = { ...settings, id: "saved-github", has_token: true, repos: ["org/service"], ready: true };
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app });
if (path.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false });
if (path.endsWith("/settings")) {
writes.push(JSON.parse(String(init.body)));
return Response.json({ ...existing, id: "enterprise-github", api_url: "https://git.example.test/api/v3" });
}
throw new Error(path);
}),
);
const user = userEvent.setup();
render(
<ObservedConnections
accessToken="gateway-test-token"
settings={{ ...existing, connections: [existing] }}
onClose={vi.fn()}
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Edit GitHub api.github.com" }));
await user.click(screen.getByRole("button", { name: "Change connection" }));
await user.click(screen.getByText("Self-hosted instance"));
fireEvent.change(screen.getByLabelText("API URL"), { target: { value: "https://git.example.test/api/v3" } });
fireEvent.change(screen.getByLabelText("GitHub access token"), { target: { value: "enterprise-test-token" } });
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByRole("heading", { name: "Choose repositories" })).toBeInTheDocument();
expect(writes).toEqual([
{
connection_id: "saved-github",
source_provider: "github",
api_url: "https://git.example.test/api/v3",
token: "enterprise-test-token",
repos: [],
update_interval_minutes: 1440,
},
]);
});
it("starts a GitHub installation when switching from a GitLab app connection", async () => {
const starts = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
const url = new URL(input, "http://localhost");
if (url.pathname.endsWith("/apps"))
return Response.json({ github: { ...app, can_install: true }, gitlab: app });
if (url.pathname.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false });
starts(url.searchParams.get("install"), init.credentials);
return Response.json({ detail: "Authorization test stopped before redirect" }, { status: 502 });
}),
);
const user = userEvent.setup();
const connected: ObservedSettings = {
...settings,
source_provider: "gitlab",
connection_type: "app",
has_token: true,
};
render(<ObservedConnections accessToken="test-token" settings={connected} onClose={vi.fn()} onSaved={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Change connection" }));
await user.click(screen.getByRole("button", { name: "GitHub", exact: true }));
const connect = screen.getByRole("button", { name: "Connect GitHub", exact: true });
await waitFor(() => expect(connect).toBeEnabled());
await user.click(connect);
expect(await screen.findByRole("alert")).toHaveTextContent("Authorization test stopped before redirect");
expect(starts).toHaveBeenCalledWith("true", "include");
});
it.each(["GitHub", "GitLab"] as const)(
"connects %s with a token, saves repositories, and starts a sync",
async (label) => {
const provider = label === "GitHub" ? "github" : "gitlab";
const apiUrl = provider === "github" ? "https://api.github.com" : "https://gitlab.com/api/v4";
const saved = vi.fn();
const closed = vi.fn();
const writes: { path: string; body: unknown }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
if (init.method === "PUT" || init.method === "POST")
writes.push({ path, body: init.body ? JSON.parse(String(init.body)) : undefined });
if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app });
if (path.endsWith("/repositories"))
return Response.json({
repositories: [{ name: "org/service", visibility: "private", archived: false }],
has_more: false,
});
if (path.endsWith("/settings")) {
const request = JSON.parse(String(init.body)) as { repos: string[] };
const connected = {
...settings,
id: `saved-${provider}`,
source_provider: provider,
api_url: apiUrl,
has_token: true,
repos: request.repos,
ready: request.repos.length > 0,
};
return Response.json(connected);
}
if (path.endsWith("/sync")) return Response.json({ running: true }, { status: 202 });
throw new Error(path);
}),
);
const user = userEvent.setup();
render(
<ObservedConnections accessToken="gateway-test-token" settings={settings} onClose={closed} onSaved={saved} />,
);
await user.click(screen.getByRole("button", { name: label, exact: true }));
await user.click(screen.getByRole("button", { name: "Access token", exact: true }));
fireEvent.change(screen.getByLabelText(`${label} access token`), { target: { value: "source-test-token" } });
await user.click(screen.getByRole("button", { name: "Continue" }));
await user.click(await screen.findByRole("checkbox", { name: /org\/service/ }));
await user.click(screen.getByRole("button", { name: "Save and sync" }));
await waitFor(() => expect(saved).toHaveBeenCalledOnce());
expect(closed).toHaveBeenCalledOnce();
expect(writes).toEqual([
{
path: "/roi-calculator/observed/settings",
body: {
source_provider: provider,
api_url: apiUrl,
token: "source-test-token",
repos: [],
update_interval_minutes: 1440,
},
},
{
path: "/roi-calculator/observed/settings",
body: {
connection_id: `saved-${provider}`,
source_provider: provider,
api_url: apiUrl,
repos: ["org/service"],
update_interval_minutes: 1440,
},
},
{ path: "/roi-calculator/observed/sync", body: undefined },
]);
},
);
it.each(["GitHub", "GitLab"] as const)(
"starts %s app authorization with a browser cookie and displays provider failures",
async (label) => {
const provider = label.toLowerCase();
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
if (String(input).endsWith("/apps")) return Response.json({ github: app, gitlab: app });
expect(String(input)).toContain(`/oauth/${provider}/start`);
expect(init.credentials).toBe("include");
expect(init.method).toBe("POST");
return Response.json({ detail: "Provider unavailable. Try again" }, { status: 502 });
}),
);
const user = userEvent.setup();
render(
<ObservedConnections
accessToken="gateway-test-token"
settings={settings}
onClose={vi.fn()}
onSaved={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: label, exact: true }));
const button = await screen.findByRole("button", { name: `Connect ${label}`, exact: true });
await waitFor(() => expect(button).toBeEnabled());
await user.click(button);
expect(await screen.findByRole("alert")).toHaveTextContent("Provider unavailable. Try again");
expect(button).toBeEnabled();
},
);
});

View file

@ -1,606 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Github, Gitlab, ArrowLeft, KeyRound } from "lucide-react";
import { z } from "zod";
import { apiClient } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
observedSettingsSchema,
repositoryNames,
type ObservedSettings,
type ObservedConnection,
} from "./observedData";
const appFields = {
configured: z.boolean(),
can_install: z.boolean().optional().default(false),
api_url: z.string().nullable(),
callback_url: z.string().nullable(),
};
const appSchema = z.object(appFields);
const appsSchema = z.object({ github: appSchema, gitlab: appSchema });
const repositoriesSchema = z.object({
repositories: z.array(z.object({ name: z.string(), visibility: z.string(), archived: z.boolean() })),
has_more: z.boolean(),
});
const defaultUrl = { github: "https://api.github.com", gitlab: "https://gitlab.com/api/v4" };
function preferredConnectionMethod(selected: "app" | "token" | null, configured: boolean | undefined) {
return selected ?? (configured ? "app" : "token");
}
function TokenFields({
label,
provider,
token,
onToken,
apiUrl,
onUrl,
hasToken,
}: {
label: string;
provider: ObservedSettings["source_provider"];
token: string;
onToken: (value: string) => void;
apiUrl: string;
onUrl: (value: string) => void;
hasToken: boolean;
}) {
return (
<>
<label htmlFor="roi-source-token" className="block text-sm font-medium">
{label} access token
</label>
<Input
id="roi-source-token"
type="password"
autoComplete="off"
value={token}
onChange={(event) => onToken(event.target.value)}
placeholder={hasToken ? "Leave blank to keep the saved token" : "Optional for public repositories"}
/>
<p className="text-xs text-muted-foreground">
{provider === "github"
? "Fine-grained token: read access to pull requests, issues, and metadata"
: "Token with read_api scope"}
</p>
<details className="text-sm">
<summary className="cursor-pointer">Self-hosted instance</summary>
<label htmlFor="roi-source-url" className="mt-3 block text-xs">
API URL
</label>
<Input id="roi-source-url" value={apiUrl} onChange={(event) => onUrl(event.target.value)} />
</details>
</>
);
}
function AppMessage({ configured, label }: { configured: boolean; label: string }) {
return (
<p className="text-sm text-muted-foreground">
{configured
? `You’ll authorize ${label}, then choose repositories`
: `Register the ${label} app in gateway settings, or connect with a token`}
</p>
);
}
function RepositoryChoices({
available,
repos,
setRepos,
query,
setQuery,
page,
setPage,
}: {
available: z.infer<typeof repositoriesSchema> | null;
repos: string;
setRepos: (value: string) => void;
query: string;
setQuery: (value: string) => void;
page: number;
setPage: (value: number) => void;
}) {
return (
<>
<Input
aria-label="Find repositories"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
placeholder="Find repositories…"
/>
<div className="max-h-48 overflow-y-auto rounded-lg border divide-y">
{!available && (
<p role="status" className="p-3 text-sm text-muted-foreground">
Loading repositories…
</p>
)}
{available?.repositories.length === 0 && (
<p className="p-3 text-sm text-muted-foreground">No repositories found</p>
)}
{available?.repositories
.filter((repo) => !repo.archived)
.map((repo) => (
<label key={repo.name} className="flex cursor-pointer items-center gap-3 p-3 text-sm">
<input
type="checkbox"
checked={repositoryNames(repos).includes(repo.name)}
onChange={(event) =>
setRepos(
(event.target.checked
? [...new Set([...repositoryNames(repos), repo.name])]
: repositoryNames(repos).filter((name) => name !== repo.name)
).join(", "),
)
}
/>
<span className="flex-1">{repo.name}</span>
<span className="text-xs text-muted-foreground">{repo.visibility}</span>
</label>
))}
</div>
<div className="flex justify-between">
<Button size="sm" variant="ghost" disabled={page === 1} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" variant="ghost" disabled={!available?.has_more} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</>
);
}
function ConnectionMethod({
method,
setMethod,
label,
provider,
token,
setToken,
apiUrl,
setApiUrl,
connected,
apps,
busy,
connect,
}: {
method: "app" | "token";
setMethod: (value: "app" | "token") => void;
label: string;
provider: ObservedSettings["source_provider"];
token: string;
setToken: (value: string) => void;
apiUrl: string;
setApiUrl: (value: string) => void;
connected: ObservedConnection;
apps: z.infer<typeof appsSchema> | null;
busy: boolean;
connect: () => void;
}) {
const connectLabel = method === "app" ? `Connect ${label}` : "Continue";
const sameSource = connected.source_provider === provider && connected.api_url === apiUrl;
const hasSavedToken = sameSource && connected.has_token && connected.connection_type === "token";
return (
<>
<div className="grid grid-cols-2 gap-2">
<Button
variant={method === "app" ? "secondary" : "outline"}
aria-pressed={method === "app"}
onClick={() => setMethod("app")}
>
Connect with app
</Button>
<Button
variant={method === "token" ? "secondary" : "outline"}
aria-pressed={method === "token"}
onClick={() => setMethod("token")}
>
<KeyRound />
Access token
</Button>
</div>
{method === "token" && (
<TokenFields
label={label}
provider={provider}
token={token}
onToken={setToken}
apiUrl={apiUrl}
onUrl={setApiUrl}
hasToken={hasSavedToken}
/>
)}
{method === "app" && <AppMessage configured={Boolean(apps?.[provider].configured)} label={label} />}
<Button
className="w-full"
disabled={busy || (method === "app" && !apps?.[provider].configured)}
onClick={connect}
>
{busy ? "Connecting…" : connectLabel}
</Button>
</>
);
}
type ConnectionStep = "list" | "connect" | "repos";
const stepTitles = { list: "Connections", connect: "Connect your code", repos: "Choose repositories" };
function initialStep(settings: ObservedSettings, afterAuthorization: boolean): ConnectionStep {
if (afterAuthorization) return "repos";
if (settings.connections?.length) return "list";
return settings.has_token || settings.ready ? "repos" : "connect";
}
function stepDescription(step: ConnectionStep, label: string) {
if (step === "list") return "All selected repositories appear in one report";
if (step === "connect") return "Connect GitHub and GitLab with an app or access token";
return `Select ${label} repositories to compare`;
}
function connectionMethodLabel(entry: ObservedConnection) {
if (entry.connection_type === "app") return "App";
return entry.has_token ? "Token" : "Public access";
}
function ConnectionList({
connections,
onEdit,
onAdd,
}: {
connections: ObservedConnection[];
onEdit: (entry: ObservedConnection) => void;
onAdd: () => void;
}) {
return (
<>
{connections.map((entry) => (
<div key={entry.id} className="flex items-center justify-between gap-3 rounded-lg border p-4">
<div className="min-w-0 text-sm">
<p className="flex items-center gap-2 font-medium">
{entry.source_provider === "github" ? <Github className="size-4" /> : <Gitlab className="size-4" />}
{entry.source_provider === "github" ? "GitHub" : "GitLab"}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{new URL(entry.api_url).host}</p>
<p className="mt-2 text-xs text-muted-foreground">
{entry.repos.length} repositories · {connectionMethodLabel(entry)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onEdit(entry)}
aria-label={`Edit ${entry.source_provider === "github" ? "GitHub" : "GitLab"} ${new URL(entry.api_url).host}`}
>
Edit
</Button>
</div>
))}
<Button className="w-full" variant="outline" onClick={onAdd}>
Add connection
</Button>
</>
);
}
function initialMethod(settings: ObservedSettings) {
return settings.has_token ? settings.connection_type : null;
}
function hasConnections(settings: ObservedSettings) {
return Boolean(settings.connections?.length);
}
function canManageApp(connected: ObservedConnection, apps: z.infer<typeof appsSchema> | null) {
return (
connected.connection_type === "app" && connected.source_provider === "github" && Boolean(apps?.github.can_install)
);
}
export default function ObservedConnections({
accessToken,
settings,
onClose,
onSaved,
initialError = "",
afterAuthorization = false,
}: {
accessToken: string;
settings: ObservedSettings;
onClose: () => void;
onSaved: () => void;
initialError?: string;
afterAuthorization?: boolean;
}) {
const [savedSettings, setSavedSettings] = useState(settings);
const [connected, setConnected] = useState<ObservedConnection>(settings);
const [provider, setProvider] = useState(settings.source_provider);
const [apiUrl, setApiUrl] = useState(settings.api_url);
const [selectedMethod, setMethod] = useState<"app" | "token" | null>(initialMethod(settings));
const [step, setStep] = useState<ConnectionStep>(() => initialStep(settings, afterAuthorization));
const [token, setToken] = useState("");
const [repos, setRepos] = useState(settings.repos.join(", "));
const [apps, setApps] = useState<z.infer<typeof appsSchema> | null>(null);
const method = preferredConnectionMethod(selectedMethod, apps?.[provider].configured);
const [available, setAvailable] = useState<z.infer<typeof repositoriesSchema> | null>(null);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(initialError);
const label = provider === "github" ? "GitHub" : "GitLab";
const saveLabel = repositoryNames(repos).length ? "Save and sync" : "Save repositories";
const manageApp = canManageApp(connected, apps);
const showConnections = hasConnections(savedSettings);
useEffect(() => {
const controller = new AbortController();
apiClient
.get<unknown>("/roi-calculator/observed/apps", { accessToken, signal: controller.signal })
.then((data) => {
if (!controller.signal.aborted) setApps(appsSchema.parse(data));
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason));
});
return () => controller.abort();
}, [accessToken]);
useEffect(() => {
if (step !== "repos" || (!connected.has_token && connected.source_provider === "github")) return;
const controller = new AbortController();
const timer = setTimeout(() => {
apiClient
.get<unknown>("/roi-calculator/observed/repositories", {
accessToken,
signal: controller.signal,
query: { query, page, connection: connected.id },
})
.then((data) => {
if (!controller.signal.aborted) setAvailable(repositoriesSchema.parse(data));
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) setError(extractProxyErrorMessage(reason));
});
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [accessToken, step, connected, query, page]);
function selectProvider(value: ObservedSettings["source_provider"]) {
setProvider(value);
const existing = savedSettings.connections?.find(
(entry) => entry.source_provider === value && entry.api_url === defaultUrl[value],
);
setApiUrl(existing?.api_url ?? defaultUrl[value]);
setConnected(
existing ?? {
...settings,
source_provider: value,
api_url: defaultUrl[value],
repos: [],
has_token: false,
ready: false,
connection_type: "token",
id: undefined,
},
);
setMethod(existing?.connection_type ?? null);
setToken("");
setError("");
}
function searchRepositories(value: string) {
setAvailable(null);
setError("");
setQuery(value);
}
function changePage(value: number) {
setAvailable(null);
setError("");
setPage(value);
}
async function connect(install = false) {
setBusy(true);
setError("");
try {
if (method === "app" || install) {
const sameApp = connected.source_provider === provider && connected.connection_type === "app";
const firstInstallation = provider === "github" && !sameApp && apps?.github.can_install;
const result = z.object({ url: z.string().url() }).parse(
await apiClient.post<unknown>(`/roi-calculator/observed/oauth/${provider}/start`, {
accessToken,
credentials: "include",
query: { install: install || Boolean(firstInstallation) },
}),
);
window.location.assign(result.url);
return;
}
const same = provider === connected.source_provider && apiUrl === connected.api_url;
const keepToken = same && connected.has_token && connected.connection_type === "token";
const result = observedSettingsSchema.parse(
await apiClient.put<unknown>("/roi-calculator/observed/settings", {
accessToken,
body: {
connection_id: savedSettings.connections?.find((entry) => entry.id === connected.id)?.id,
source_provider: provider,
api_url: apiUrl,
token: token || (keepToken ? undefined : ""),
repos: same ? connected.repos : [],
update_interval_minutes: connected.update_interval_minutes,
},
}),
);
setSavedSettings(result);
setConnected(result);
setToken("");
setRepos(result.repos.join(", "));
setAvailable(null);
setStep("repos");
} catch (reason) {
setError(extractProxyErrorMessage(reason));
} finally {
setBusy(false);
}
}
async function save() {
setBusy(true);
setError("");
try {
const result = observedSettingsSchema.parse(
await apiClient.put<unknown>("/roi-calculator/observed/settings", {
accessToken,
body: {
connection_id: connected.id,
source_provider: connected.source_provider,
api_url: connected.api_url,
repos: repositoryNames(repos),
update_interval_minutes: connected.update_interval_minutes,
},
}),
);
if (result.ready) await apiClient.post<unknown>("/roi-calculator/observed/sync", { accessToken });
onSaved();
onClose();
} catch (reason) {
setError(extractProxyErrorMessage(reason));
} finally {
setBusy(false);
}
}
function edit(entry: ObservedConnection) {
setConnected(entry);
setProvider(entry.source_provider);
setApiUrl(entry.api_url);
setMethod(entry.connection_type);
setRepos(entry.repos.join(", "));
setToken("");
setQuery("");
setPage(1);
setAvailable(null);
setError("");
setStep("repos");
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{stepTitles[step]}</DialogTitle>
<DialogDescription>{stepDescription(step, label)}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{step === "list" && (
<ConnectionList
connections={savedSettings.connections ?? []}
onEdit={edit}
onAdd={() => {
selectProvider(
savedSettings.connections?.some((entry) => entry.source_provider === "github") ? "gitlab" : "github",
);
setStep("connect");
}}
/>
)}
{step === "connect" && (
<>
{showConnections && (
<Button variant="ghost" size="sm" onClick={() => setStep("list")}>
<ArrowLeft />
All connections
</Button>
)}
<div className="grid grid-cols-2 gap-3" role="group" aria-label="Code provider">
{(["github", "gitlab"] as const).map((value) => (
<Button
key={value}
variant={provider === value ? "default" : "outline"}
aria-pressed={provider === value}
onClick={() => selectProvider(value)}
>
{value === "github" ? <Github /> : <Gitlab />}
{value === "github" ? "GitHub" : "GitLab"}
</Button>
))}
</div>
<ConnectionMethod
method={method}
setMethod={setMethod}
label={label}
provider={provider}
token={token}
setToken={setToken}
apiUrl={apiUrl}
setApiUrl={setApiUrl}
connected={connected}
apps={apps}
busy={busy}
connect={() => connect()}
/>
</>
)}
{step === "repos" && (
<>
{showConnections && (
<Button variant="ghost" size="sm" onClick={() => setStep("list")}>
<ArrowLeft />
All connections
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => setStep("connect")}>
<ArrowLeft />
Change connection
</Button>
{manageApp && (
<Button variant="outline" size="sm" disabled={busy} onClick={() => connect(true)}>
Manage GitHub repositories
</Button>
)}
<label htmlFor="roi-repositories" className="block text-sm font-medium">
Repositories
</label>
<Input
id="roi-repositories"
value={repos}
onChange={(event) => setRepos(event.target.value)}
placeholder={
provider === "github" ? "owner/repo, owner/another-repo" : "group/project, group/subgroup/project"
}
/>
{(connected.has_token || connected.source_provider === "gitlab") && (
<>
<RepositoryChoices
available={available}
repos={repos}
setRepos={setRepos}
query={query}
setQuery={searchRepositories}
page={page}
setPage={changePage}
/>
</>
)}
<Button className="w-full" disabled={busy} onClick={save}>
{busy ? "Saving…" : saveLabel}
</Button>
</>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -1,239 +0,0 @@
"use client";
import { useState } from "react";
import { ExternalLink, GitPullRequest } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
dateRange,
changeTerms,
duration,
money,
number,
recordedBranches,
type Comparison,
type ObservedPerson,
type ObservedPull,
type ObservedSnapshot,
type Period,
} from "./observedData";
export function PullList({
pulls,
provider,
matchedOnly = false,
}: {
matchedOnly?: boolean;
pulls: ObservedPull[];
provider: ObservedSnapshot["source_provider"];
}) {
const terms = changeTerms(provider);
const emptyMessage = matchedOnly
? "No merged changes from matched people in this period"
: `No ${terms.lower} in this period`;
const [query, setQuery] = useState("");
const [limit, setLimit] = useState(20);
const filtered = pulls.filter((pull) =>
`${pull.number} ${pull.title} ${pull.author} ${pull.repo} ${pull.source_repo} ${pull.source_branch}`
.toLowerCase()
.includes(query.toLowerCase()),
);
return (
<div className="space-y-3">
<Input
aria-label={`Search ${terms.lower}`}
placeholder={`Search ${terms.lower}…`}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setLimit(20);
}}
className="max-w-sm"
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>{terms.requests}</TableHead>
<TableHead>Author</TableHead>
<TableHead className="text-right">Opened to merged</TableHead>
<TableHead className="text-right">Tagged spend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.slice(0, limit).map((pull) => (
<TableRow key={pull.url}>
<TableCell className="max-w-lg whitespace-normal py-3">
<a
className="group flex items-start gap-2 hover:underline"
href={pull.url}
target="_blank"
rel="noreferrer"
>
<GitPullRequest className="mt-0.5 size-4 shrink-0 text-violet-500" />
<span>
<span className="mr-2 text-muted-foreground">#{pull.number}</span>
{pull.title}
<span className="mt-1 block text-xs text-muted-foreground">{pull.repo}</span>
</span>
<ExternalLink className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
</a>
</TableCell>
<TableCell>
<span className="text-xs">{pull.author || "Deleted author"}</span>
{pull.agent && (
<Badge variant="secondary" className="ml-2">
Agent
</Badge>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{duration(pull.merge_hours)}</TableCell>
<TableCell
className="text-right tabular-nums"
title={
pull.branch_cost.status === "ambiguous"
? "Branch is shared by multiple changes"
: "Spend tagged to this branch during the period"
}
>
{money(pull.branch_cost.spend)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-xs text-muted-foreground">
Elapsed time from opening to merge, not engineering effort or time saved
</p>
{filtered.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
{query ? `No ${terms.lower} match this search` : emptyMessage}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{number(Math.min(limit, filtered.length))} of {number(filtered.length)} {terms.lower}
</span>
{limit < filtered.length && (
<Button variant="outline" size="sm" onClick={() => setLimit(limit + 40)}>
Show more
</Button>
)}
</div>
</div>
);
}
export function PersonDetails({
person,
snapshot,
comparison,
onClose,
onEdit,
}: {
person: ObservedPerson;
snapshot: ObservedSnapshot;
comparison: Comparison;
onClose: () => void;
onEdit?: () => void;
}) {
const terms = changeTerms(snapshot.source_provider);
const [period, setPeriod] = useState<Period>("current");
const current = person.periods.current;
const baseline = person.periods[comparison];
const urls = new Set(person.periods[period].pr_urls);
const pulls = snapshot.pulls[period].filter((pull) => urls.has(pull.url));
return (
<Sheet
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<SheetContent className="overflow-y-auto p-6 data-[side=right]:w-full data-[side=right]:sm:max-w-3xl">
<SheetHeader className="p-0 pr-8">
<SheetTitle className="text-xl">{person.name}</SheetTitle>
<SheetDescription>{[person.email, person.logins.join(", ")].filter(Boolean).join(" · ")}</SheetDescription>
</SheetHeader>
{onEdit && (
<Button variant="outline" className="w-fit" onClick={onEdit}>
Edit linked accounts
</Button>
)}
<div className="mt-2 grid grid-cols-1 gap-y-4 divide-y rounded-lg border py-5 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
<div className="px-4">
<p className="text-xs text-muted-foreground">Merged {terms.plural}</p>
<p className="mt-2 text-2xl font-semibold">{number(current.merged_prs)}</p>
<p className="mt-1 text-xs text-muted-foreground">{number(baseline.merged_prs)} in comparison</p>
</div>
<div className="px-4">
<p className="text-xs text-muted-foreground">Recorded spend</p>
<p className="mt-2 text-2xl font-semibold">
{money(current.spend_observation === "no_records" ? null : current.gateway_recorded_spend)}
</p>
<p className="mt-1 text-xs text-muted-foreground">Gateway only</p>
</div>
<div className="px-4">
<p className="text-xs text-muted-foreground">Spend / matched {terms.singular}</p>
<p className="mt-2 text-2xl font-semibold">{money(current.recorded_spend_per_attributed_pr)}</p>
<p className="mt-1 text-xs text-muted-foreground">Period average</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<h3 className="font-medium">Merged {terms.plural}</h3>
<div className="flex gap-1 rounded-lg bg-muted p-1">
<Button size="sm" variant={period === "current" ? "outline" : "ghost"} onClick={() => setPeriod("current")}>
Current
</Button>
<Button
size="sm"
variant={period === comparison ? "outline" : "ghost"}
onClick={() => setPeriod(comparison)}
>
{comparison === "previous" ? "Previous period" : "Last year"}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{dateRange(snapshot.periods[period].window)} · UTC</p>
<PullList key={period} pulls={pulls} provider={snapshot.source_provider} />
</SheetContent>
</Sheet>
);
}
export function BranchSpend({ snapshot, matchedOnly = true }: { snapshot: ObservedSnapshot; matchedOnly?: boolean }) {
const rows = recordedBranches(snapshot, matchedOnly);
return (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Branch</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Tagged spend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={`${row.repo}/${row.branch}`}>
<TableCell>{row.repo}</TableCell>
<TableCell>{row.branch}</TableCell>
<TableCell className="text-right">{number(row.requests)}</TableCell>
<TableCell className="text-right">{money(row.spend)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{rows.length === 0 && (
<p className="p-8 text-center text-sm text-muted-foreground">
{matchedOnly
? "No tagged branch spend for matched people in this period"
: "No tagged branch spend in this period"}
</p>
)}
</div>
);
}

View file

@ -1,384 +0,0 @@
import { fireEvent, render as renderWithoutNuqs, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NuqsAdapter } from "nuqs/adapters/react";
import ObservedROIView from "./ObservedROIView";
import { createObservedDemo } from "./observedDemo";
import type { ObservedSettings, ObservedSnapshot, ObservedStatus } from "./observedData";
const render = (ui: Parameters<typeof renderWithoutNuqs>[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter });
const settings: ObservedSettings = {
source_provider: "gitlab",
api_url: "https://gitlab.com/api/v4",
repos: ["org/service"],
has_token: true,
connection_type: "app",
update_interval_minutes: 1440,
ready: true,
};
const idle: ObservedStatus = {
running: false,
phase: "complete",
stage: "",
done: 0,
total: 0,
error: null,
finished_at: null,
};
const period = {
window: { start: "2026-09-01", end: "2026-09-28" },
merged_prs: 1,
median_merge_hours: 16 / 3600,
human_authored: 1,
agent_authored: 0,
missing_author: 0,
agents_without_requester: 0,
matched_internal_prs: 0,
new_bug_labeled_issues: 0,
new_regression_labeled_issues: 0,
explicitly_titled_revert_prs: 0,
matched_users_recorded_spend: 0,
spend_observation: "no_records" as const,
human_summary: { median_merge_hours: 16 / 3600 },
};
const report: ObservedSnapshot = {
source_provider: "gitlab",
repos: ["org/service"],
unmatched_logins: [],
unlinked_branches: [],
captured_at: "2026-09-29T00:00:00Z",
periods: { current: period, previous: period, last_year: period },
people: [],
pulls: { current: [], previous: [], last_year: [] },
};
const app = { configured: true, api_url: null, callback_url: null };
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState(null, "", "/");
});
describe("observed ROI dashboard", () => {
it("defaults every contributor list to matched people and keeps the switch across tabs", async () => {
const sample = createObservedDemo(7);
const outside = {
...sample.pulls.current[0],
url: "https://gitlab.com/outside/api/-/merge_requests/999",
author: "outside",
agent: false,
title: "Outside change",
source_branch: "outside-only",
branch_cost: {
repo: "gitlab.com/outside/api",
branch: "outside-only",
spend: 123,
requests: 5,
status: "matched" as const,
},
};
const report = {
...sample,
pulls: { ...sample.pulls, current: [...sample.pulls.current, outside] },
unlinked_branches: [{ repo: "gitlab.com/outside/api", branch: "orphan-only", spend: 5, requests: 1 }],
};
const requests = vi.fn(async (input: string, _init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/settings")) return Response.json(settings);
if (path.endsWith("/report")) return Response.json({ report });
if (path.endsWith("/sync")) return Response.json(idle);
throw new Error(path);
});
vi.stubGlobal("fetch", requests);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" isViewOnly />);
expect(await screen.findByRole("switch", { name: "Matched people only" })).toBeChecked();
expect(screen.getByRole("tab", { name: "Engineers 3" })).toBeInTheDocument();
expect(screen.queryByText("outside")).not.toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
expect(screen.getByRole("tab", { name: "Engineers 4" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View outside's merged changes" }));
expect(await screen.findByRole("dialog", { name: "outside" })).toHaveTextContent("Outside change");
expect(screen.queryByRole("button", { name: "Edit linked accounts" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
await user.click(screen.getByRole("tab", { name: "Merged changes" }));
expect(screen.queryByText("Outside change")).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Branch spend" }));
expect(screen.getAllByText(/feature\/sample-/).length).toBeGreaterThan(0);
expect(screen.queryByText("outside-only")).not.toBeInTheDocument();
expect(screen.queryByText("orphan-only")).not.toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
expect(screen.getByText("outside-only")).toBeInTheDocument();
expect(screen.getByText("orphan-only")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Merged changes" }));
expect(screen.getByText("Outside change")).toBeInTheDocument();
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it("shows a useful empty matched view and exposes all changes when the filter is off", async () => {
const sample = { ...createObservedDemo(7), people: [] };
vi.stubGlobal(
"fetch",
vi.fn(async (input: string) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/settings")) return Response.json(settings);
if (path.endsWith("/report")) return Response.json({ report: sample });
return Response.json(idle);
}),
);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" />);
expect(await screen.findByText("No merged changes from matched people in this period")).toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
expect(screen.queryByText("No merged changes from matched people in this period")).not.toBeInTheDocument();
expect(screen.getAllByRole("link", { name: /Add repository search/ }).length).toBeGreaterThan(0);
});
it("previews every sample view before setup, changes sample periods without writes, and exits back to setup", async () => {
const requests = vi.fn(async (input: string, _init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
const disconnected = { ...settings, ready: false, repos: [], has_token: false };
if (path.endsWith("/settings")) return Response.json(disconnected);
if (path.endsWith("/report")) return Response.json({ report: null });
if (path.endsWith("/sync")) return Response.json(idle);
throw new Error(path);
});
vi.stubGlobal("fetch", requests);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" />);
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
expect(screen.queryByText("Ready to sync")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Preview sample report" }));
expect(screen.getByRole("status")).toHaveTextContent("You’re viewing demo data");
expect(screen.getByRole("tab", { name: "Engineers 3", selected: true })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connections" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Link accounts" })).not.toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe("?demo=1"));
await user.click(screen.getByRole("button", { name: "View Alex Rivera's merged changes" }));
expect(await screen.findByRole("dialog", { name: "Alex Rivera" })).toHaveTextContent("alex-demo@example.com");
expect(screen.getByRole("heading", { name: "Merged changes" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit linked accounts" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(screen.getByRole("tab", { name: "Merged changes" }));
expect(screen.getByRole("img", { name: /Merged changes by week/ })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Quality" }));
expect(screen.getByText("New regression-labeled issues")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Branch spend" }));
expect(screen.getAllByText(/feature\/sample-/).length).toBeGreaterThan(0);
await user.click(screen.getByRole("combobox", { name: "Reporting period" }));
await user.click(await screen.findByRole("option", { name: "Last 7 days" }));
expect(screen.getByRole("combobox", { name: "Reporting period" })).toHaveTextContent("Last 7 days");
await user.click(screen.getByRole("combobox", { name: "Comparison period" }));
await user.click(await screen.findByRole("option", { name: "vs. same period last year" }));
expect(screen.getByRole("combobox", { name: "Comparison period" })).toHaveTextContent("vs. same period last year");
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect GitHub or GitLab" })).toBeEnabled();
await waitFor(() => expect(window.location.search).toBe(""));
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it("keeps live sync and its report intact when entering and exiting the demo", async () => {
const requests = vi.fn(async (input: string, _init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/settings")) return Response.json(settings);
if (path.endsWith("/report")) return Response.json({ report });
if (path.endsWith("/sync")) return Response.json({ ...idle, running: true, stage: "Reading changes" });
throw new Error(path);
});
vi.stubGlobal("fetch", requests);
window.history.replaceState(null, "", "/roi-calculator/?from=review#report");
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" />);
expect(await screen.findByRole("button", { name: "Cancel sync" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Preview sample report" }));
expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "2 repositories" })).toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe("?from=review&demo=1"));
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled();
expect(screen.getByRole("button", { name: "1 repository" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe("?from=review"));
expect(window.location.hash).toBe("#report");
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
it.each(["failed", "pending"])("opens a demo URL even when live requests are %s", async (state) => {
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
const pending = Promise.withResolvers<Response>();
vi.stubGlobal(
"fetch",
vi.fn(() => (state === "failed" ? Promise.reject(new Error("Live data unavailable")) : pending.promise)),
);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" isViewOnly />);
expect(screen.getByRole("status")).toHaveTextContent("You’re viewing demo data");
expect(screen.getByText("Alex Rivera")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.queryByText("Alex Rivera")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe(""));
if (state === "failed") expect(await screen.findByRole("alert")).toHaveTextContent("Live data unavailable");
});
it("retries failures, keeps the report during cancellation, and refreshes after completion", async () => {
let status: ObservedStatus = { ...idle, phase: "error", error: "Provider temporarily unavailable" };
let completeOnPoll = false;
let currentReport = { ...report, repos: ["org/service", "org/docs"] };
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/settings")) return Response.json(settings);
if (path.endsWith("/report")) return Response.json({ report: currentReport });
if (path.endsWith("/sync")) {
if (init.method === "POST") status = { ...idle, running: true, phase: "pulls" };
if (init.method === "DELETE") status = { ...idle, phase: "cancelled" };
if (init.method === "GET" && completeOnPoll) {
status = { ...idle, finished_at: "2026-09-29T00:01:00Z" };
currentReport = { ...report, repos: ["org/updated"] };
}
return Response.json(status);
}
throw new Error(path);
}),
);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" />);
expect(await screen.findByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Quality" }));
expect(screen.getByRole("alert")).toHaveTextContent("Provider temporarily unavailable");
await user.click(screen.getByRole("button", { name: "Retry" }));
await user.click(await screen.findByRole("button", { name: "Cancel sync" }));
expect(await screen.findByRole("button", { name: "Sync now" })).toBeEnabled();
expect(screen.queryByText("org/service")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "2 repositories" }));
const repositories = await screen.findByRole("dialog", { name: "Repositories" });
expect(within(repositories).getByText("org/service")).toBeInTheDocument();
expect(within(repositories).getByText("org/docs")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Repositories" })).not.toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Sync now" }));
expect(await screen.findByRole("button", { name: "Cancel sync" })).toBeEnabled();
completeOnPoll = true;
await user.click(await screen.findByRole("button", { name: "1 repository" }, { timeout: 4000 }));
expect(
await within(screen.getByRole("dialog", { name: "Repositories" })).findByText("org/updated"),
).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByRole("tab", { name: "Quality", selected: true })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sync now" })).toBeEnabled();
});
it("syncs the selected range and labels its equal-length comparison", async () => {
let currentReport = report;
const requested: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
const url = new URL(input, "http://localhost");
if (url.pathname.endsWith("/settings")) return Response.json(settings);
if (url.pathname.endsWith("/report")) return Response.json({ report: currentReport });
if (url.pathname.endsWith("/sync")) {
if (init.method === "POST") {
requested.push(url.searchParams.get("days") ?? "");
currentReport = {
...report,
periods: {
...report.periods,
current: { ...period, window: { start: "2026-09-22", end: "2026-09-28" } },
previous: { ...period, window: { start: "2026-09-15", end: "2026-09-21" } },
},
};
}
return Response.json(idle);
}
throw new Error(url.pathname);
}),
);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" />);
await user.click(await screen.findByRole("combobox", { name: "Reporting period" }));
await user.click(await screen.findByRole("option", { name: "Last 7 days" }));
await waitFor(() => expect(requested).toEqual(["7"]));
expect(await screen.findByText(/Comparing with Sep 15.*Sep 21/)).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Reporting period" })).toHaveTextContent("Last 7 days");
expect(screen.getByRole("combobox", { name: "Comparison period" })).toHaveTextContent("vs. previous period");
});
it("shows a successful empty repository without a setup prompt or invented durations", async () => {
const emptyPeriod = {
...period,
merged_prs: 0,
human_authored: 0,
median_merge_hours: null,
human_summary: { median_merge_hours: null },
};
const empty = { ...report, periods: { current: emptyPeriod, previous: emptyPeriod, last_year: emptyPeriod } };
vi.stubGlobal(
"fetch",
vi.fn(async (input: string) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/settings")) return Response.json(settings);
if (path.endsWith("/report")) return Response.json({ report: empty });
if (path.endsWith("/sync")) return Response.json(idle);
throw new Error(path);
}),
);
render(<ObservedROIView accessToken="test-only-gateway-token" />);
expect(await screen.findByRole("heading", { name: "No merged changes yet" })).toBeInTheDocument();
expect(screen.getByText("No merges")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sync now" })).toBeEnabled();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
expect(screen.queryByText("0h")).not.toBeInTheDocument();
});
it.each([
{ query: "connected=gitlab", alerts: [] },
{ query: "connection_failed=1", alerts: ["Connection failed or expired. Try again or use a token"] },
{ query: "connection_cancelled=1", alerts: ["Connection cancelled. Choose an app or token to try again"] },
])("resumes setup after $query and refreshes saved changes after closing", async ({ query, alerts }) => {
window.history.replaceState(null, "", `/roi-calculator/?${query}`);
let currentSettings = settings;
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
const path = new URL(input, "http://localhost").pathname;
if (path.endsWith("/apps")) return Response.json({ github: app, gitlab: app });
if (path.endsWith("/repositories")) return Response.json({ repositories: [], has_more: false });
if (path.endsWith("/settings")) {
if (init.method === "PUT") currentSettings = { ...settings, repos: ["org/changed"] };
return Response.json(currentSettings);
}
if (path.endsWith("/report")) return Response.json({ report: { ...report, repos: currentSettings.repos } });
if (path.endsWith("/sync"))
return init.method === "POST"
? Response.json({ detail: "Provider unavailable" }, { status: 502 })
: Response.json(idle);
throw new Error(path);
}),
);
const user = userEvent.setup();
render(<ObservedROIView accessToken="test-only-gateway-token" />);
const dialog = await screen.findByRole("dialog", { name: "Choose repositories" });
expect(
within(dialog)
.queryAllByRole("alert")
.map((alert) => alert.textContent),
).toEqual(alerts);
await waitFor(() => expect(window.location.search).toBe(""));
fireEvent.change(within(dialog).getByLabelText("Repositories"), { target: { value: "org/changed" } });
await user.click(within(dialog).getByRole("button", { name: "Save and sync" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Provider unavailable");
await user.click(within(dialog).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await user.click(await screen.findByRole("button", { name: "1 repository" }));
expect(
await within(screen.getByRole("dialog", { name: "Repositories" })).findByText("org/changed"),
).toBeInTheDocument();
});
});

View file

@ -1,252 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { parseAsString, useQueryStates } from "nuqs";
import { Link2, RefreshCw } from "lucide-react";
import { apiClient } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { Page } from "@/components/shared/Page";
import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
import { DemoNotice } from "@/components/shared/DemoNotice";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import ObservedConnections from "./ObservedConnections";
import ObservedReport from "./ObservedReport";
import { useObservedReport, type ObservedViewData } from "./useObservedReport";
import { syncMessage, type ObservedSnapshot } from "./observedData";
import { createObservedDemo } from "./observedDemo";
import { parseAsDemoFlag } from "./demoUrlState";
const OBSERVED_ROI_QUERY_PARSERS = {
demo: parseAsDemoFlag,
connected: parseAsString,
connection_cancelled: parseAsString,
connection_failed: parseAsString,
};
function SyncActions({
data,
error,
busy,
readOnly,
compact = false,
onSync,
onRetry,
}: {
data: ObservedViewData | null;
error: string;
busy: boolean;
readOnly: boolean;
compact?: boolean;
onSync: (cancel: boolean) => void;
onRetry: () => void;
}) {
const message = error || data?.status.error;
const statusMessage = data ? syncMessage(data.status, data.report) : "";
const canSync = data?.settings.ready && !readOnly;
if (!message && !statusMessage && !canSync) return null;
return (
<div className={compact ? "contents" : "space-y-2"}>
{message && (
<div
role="alert"
className="flex basis-full items-center justify-between gap-3 rounded-lg border border-destructive/30 p-3 text-sm text-destructive"
>
<span>{message}</span>
<Button size="sm" variant="outline" onClick={onRetry}>
Retry
</Button>
</div>
)}
{data && (
<div className={compact ? "contents" : "flex flex-wrap items-center justify-between gap-2"}>
{statusMessage && (
<span role="status" className="text-xs text-muted-foreground">
{statusMessage}
</span>
)}
{!readOnly && data.settings.ready && (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onSync(data.status.running)}>
<RefreshCw className={data.status.running ? "animate-spin" : ""} />
{data.status.running ? "Cancel sync" : "Sync now"}
</Button>
)}
</div>
)}
</div>
);
}
function EmptyReport({
data,
readOnly,
onConnect,
}: {
data: ObservedViewData;
readOnly: boolean;
onConnect: () => void;
}) {
function title() {
if (data.status.running) return "Reading repository activity";
return data.settings.ready ? "Ready for your first report" : "Connect your repositories";
}
return (
<div className="flex flex-col items-center gap-4 px-6 py-12 text-center">
<h2 className="text-lg font-medium">{title()}</h2>
<p className="max-w-md text-sm text-muted-foreground">
{data.status.running
? "Your report will appear here when the first sync finishes"
: "Compare merged changes, issue trends, and recorded AI spend across your team"}
</p>
{!readOnly && !data.status.running && (
<Button onClick={onConnect}>
<Link2 />
{data.settings.ready ? "Connections" : "Connect GitHub or GitLab"}
</Button>
)}
</div>
);
}
export default function ObservedROIView({
accessToken,
isViewOnly = false,
}: {
accessToken: string;
isViewOnly?: boolean;
}) {
const { data, error, refresh } = useObservedReport(accessToken);
const [{ demo, connected, connection_cancelled, connection_failed }, setQueryParams] =
useQueryStates(OBSERVED_ROI_QUERY_PARSERS);
const [sample, setSample] = useState<ObservedSnapshot | null>(() => (demo === true ? createObservedDemo(28) : null));
const [connections, setConnections] = useState(
["github", "gitlab"].includes(connected ?? "") || connection_cancelled !== null || connection_failed !== null,
);
const [connectionError, setConnectionError] = useState(() => {
if (connection_failed !== null) return "Connection failed or expired. Try again or use a token";
if (connection_cancelled !== null) return "Connection cancelled. Choose an app or token to try again";
return "";
});
const [afterAuthorization, setAfterAuthorization] = useState(Boolean(connected));
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
useEffect(() => {
setQueryParams({ connected: null, connection_cancelled: null, connection_failed: null });
}, [setQueryParams]);
function previewSample(enabled: boolean) {
setQueryParams({ demo: enabled ? true : null });
setSample(enabled ? createObservedDemo(28) : null);
}
function closeConnections() {
setConnections(false);
setConnectionError("");
setAfterAuthorization(false);
refresh();
}
async function sync(cancel: boolean, days?: number) {
setBusy(true);
setActionError("");
try {
if (cancel) await apiClient.delete("/roi-calculator/observed/sync", { accessToken });
else await apiClient.post("/roi-calculator/observed/sync", { accessToken, query: { days } });
refresh();
} catch (reason) {
setActionError(extractProxyErrorMessage(reason));
} finally {
setBusy(false);
}
}
function retry() {
if (error || isViewOnly || !data?.settings.ready) refresh();
else void sync(data.status.running);
}
if (sample) {
return (
<ObservedReport
key="demo"
snapshot={sample}
accessToken={accessToken}
readOnly
onRefresh={refresh}
onConnect={() => setConnections(true)}
actions={null}
notice={<DemoNotice onExit={() => previewSample(false)} />}
syncing={false}
onPeriod={(days) => setSample(createObservedDemo(days))}
/>
);
}
const previewButton = (
<Button size="sm" variant="ghost" onClick={() => previewSample(true)}>
Preview sample report
</Button>
);
const actions = (
<>
{previewButton}
<SyncActions
data={data}
error={error || actionError}
busy={busy}
readOnly={isViewOnly}
compact={Boolean(data?.report)}
onSync={sync}
onRetry={retry}
/>
</>
);
const content = data?.report ? (
<ObservedReport
key="live"
snapshot={data.report}
accessToken={accessToken}
readOnly={isViewOnly}
onRefresh={refresh}
onConnect={() => setConnections(true)}
actions={actions}
syncing={busy || data.status.running}
onPeriod={isViewOnly ? undefined : (days) => void sync(false, days)}
/>
) : (
<Page>
<PageHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageHeaderTitle>ROI Calculator</PageHeaderTitle>
{previewButton}
</div>
<PageHeaderDescription>Are we shipping more, with fewer bugs, at a better cost?</PageHeaderDescription>
</PageHeader>
<SyncActions
data={data}
error={error || actionError}
busy={busy}
readOnly={isViewOnly}
onSync={sync}
onRetry={retry}
/>
{!data && !error && (
<>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-96 w-full" />
</>
)}
{data && <EmptyReport data={data} readOnly={isViewOnly} onConnect={() => setConnections(true)} />}
</Page>
);
const showConnections = connections && data && !isViewOnly;
return (
<>
{content}
{showConnections && (
<ObservedConnections
accessToken={accessToken}
settings={data.settings}
initialError={connectionError}
afterAuthorization={afterAuthorization}
onClose={closeConnections}
onSaved={refresh}
/>
)}
</>
);
}

View file

@ -1,631 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, CalendarDays, ChevronDown, ChevronRight, Link2, Search, Users } from "lucide-react";
import { Page, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
import { PageHeader, PageHeaderTitle } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import ObservedAccounts from "./ObservedAccounts";
import { MatchedPeopleToggle } from "./MatchedPeopleToggle";
import { BranchSpend, PersonDetails, PullList } from "./ObservedDetails";
import {
change,
dateRange,
changeTerms,
duration,
money,
number,
visiblePeople,
reportPeople,
filterObservedPulls,
weeklyMerges,
type Comparison,
type ReportPerson,
type ObservedSnapshot,
type PeopleSort,
} from "./observedData";
function Delta({
current,
baseline,
neutral = false,
}: {
current: number | null;
baseline: number | null;
neutral?: boolean;
}) {
const delta = current === null || baseline === null ? null : change(current, baseline);
if (delta === null) return <span className="text-xs text-muted-foreground">No baseline</span>;
const Icon = delta >= 0 ? ArrowUp : ArrowDown;
return (
<span
aria-label={`${number(Math.abs(delta))}% ${delta >= 0 ? "increase" : "decrease"}`}
className={`inline-flex items-center gap-1 text-xs tabular-nums ${neutral ? "text-muted-foreground" : "text-foreground"}`}
>
<Icon className="size-3" />
{number(Math.abs(delta))}%
</span>
);
}
function Metric({
label,
value,
detail,
current,
baseline,
}: {
label: string;
value: string;
detail: string;
current?: number | null;
baseline?: number | null;
}) {
return (
<div className="min-w-0 bg-background px-4 py-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">{label}</div>
<div className="mt-1 flex flex-wrap items-baseline gap-2">
<span className="text-2xl font-semibold tracking-tight tabular-nums">{value}</span>
{current !== undefined && baseline !== undefined && <Delta current={current} baseline={baseline} />}
</div>
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
</div>
);
}
function ShippingTrend({ snapshot, comparison }: { snapshot: ObservedSnapshot; comparison: Comparison }) {
const terms = changeTerms(snapshot.source_provider);
const current = weeklyMerges(snapshot, "current");
const baseline = weeklyMerges(snapshot, comparison);
const max = Math.max(1, ...current, ...baseline);
return (
<div className="rounded-xl border p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-medium">Repository shipping activity</h2>
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-blue-500" />
Current period
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-slate-300 dark:bg-slate-600" />
{comparison === "previous" ? "Previous period" : "Last year"}
</span>
</div>
</div>
<div
className="mt-5 grid gap-2"
style={{ gridTemplateColumns: `repeat(${current.length}, minmax(0, 1fr))` }}
role="img"
aria-label={`Merged ${terms.plural} by week. Current: ${current.join(", ")}. Comparison: ${baseline.join(", ")}`}
>
{current.map((value, week) => (
<div key={week} className="min-w-0">
<div className="flex h-28 items-end justify-center gap-2 border-b border-border/60">
<div
className="group relative w-10 rounded-t bg-slate-200 dark:bg-slate-700"
style={{ height: `${Math.max(2, (baseline[week] / max) * 85)}%` }}
>
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-muted-foreground">
{baseline[week]}
</span>
</div>
<div
className="relative w-10 rounded-t bg-blue-500/90"
style={{ height: `${Math.max(2, (value / max) * 85)}%` }}
>
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] font-medium">{value}</span>
</div>
</div>
<p className="mt-2 text-center text-[11px] text-muted-foreground">W{week + 1}</p>
</div>
))}
</div>
</div>
);
}
function PeopleTable({
rows,
provider,
matchedOnly,
comparison,
onSelect,
}: {
rows: ReportPerson[];
provider: ObservedSnapshot["source_provider"];
matchedOnly: boolean;
comparison: Comparison;
onSelect: (person: ReportPerson) => void;
}) {
const terms = changeTerms(provider);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<PeopleSort>("merged");
const people = visiblePeople(rows, query, sort);
const emptyMessage = matchedOnly
? "No matched people. Link accounts or turn off the filter to see all contributors"
: "No contributors in these periods";
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative w-72">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
<Input
aria-label="Search engineers"
placeholder="Search engineers…"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{people.length} {people.length === 1 ? "engineer" : "engineers"}
</span>
<Select
value={sort}
onValueChange={(value) => {
if (value) setSort(value);
}}
items={[
{ value: "merged", label: `Most merged ${terms.plural}` },
{ value: "spend", label: "Highest spend" },
{ value: "cost", label: `Highest cost / ${terms.singular}` },
{ value: "name", label: "Name" },
]}
>
<SelectTrigger aria-label="Sort engineers">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="merged">Most merged {terms.plural}</SelectItem>
<SelectItem value="spend">Highest spend</SelectItem>
<SelectItem value="cost">Highest cost / {terms.singular}</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="pl-5">Engineer</TableHead>
<TableHead className="text-right">Merged {terms.plural}</TableHead>
<TableHead>Authored / agent</TableHead>
<TableHead className="text-right">
{comparison === "previous" ? "vs. previous" : "vs. last year"}
</TableHead>
<TableHead className="text-right">Median merge</TableHead>
<TableHead className="text-right">Recorded spend</TableHead>
<TableHead className="text-right">Spend / {terms.singular}</TableHead>
<TableHead>
<span className="sr-only">Details</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{people.map((person) => {
const current = person.periods.current;
const baseline = person.periods[comparison];
return (
<TableRow key={person.id}>
<TableCell className="py-3 pl-5">
<button
className="flex items-center gap-3 rounded text-left hover:underline"
onClick={() => onSelect(person)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
{person.name.slice(0, 2).toUpperCase()}
</span>
<span>
<span className="block font-medium">{person.name}</span>
<span className="text-xs text-muted-foreground">
{person.email || `Not linked · ${person.host}`}
</span>
</span>
</button>
</TableCell>
<TableCell className="text-right font-medium tabular-nums">{number(current.merged_prs)}</TableCell>
<TableCell>
<div className="flex w-28 items-center gap-2">
<div className="flex h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<span
className="bg-blue-500"
style={{
width: `${current.merged_prs ? (current.direct_authored / current.merged_prs) * 100 : 0}%`,
}}
/>
<span
className="bg-violet-400"
style={{
width: `${current.merged_prs ? (current.declared_agent_owned / current.merged_prs) * 100 : 0}%`,
}}
/>
</div>
<span className="text-[11px] text-muted-foreground">
{current.direct_authored}/{current.declared_agent_owned}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<Delta current={current.merged_prs} baseline={baseline.merged_prs} neutral />
<span className="ml-2 text-xs text-muted-foreground">({baseline.merged_prs})</span>
</TableCell>
<TableCell className="text-right tabular-nums">{duration(current.median_merge_hours)}</TableCell>
<TableCell className="text-right tabular-nums">
{money(current.spend_observation === "no_records" ? null : current.gateway_recorded_spend)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{money(current.recorded_spend_per_attributed_pr)}
</TableCell>
<TableCell>
<Button
size="icon-xs"
variant="ghost"
aria-label={`View ${person.name}'s ${terms.lower}`}
onClick={() => onSelect(person)}
>
<ChevronRight />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{people.length === 0 && (
<div className="p-10 text-center text-sm text-muted-foreground">
{query ? `No engineers match “${query}”` : emptyMessage}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
<span className="mr-3 inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-blue-500" />
Authored
</span>
<span className="mr-3 inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-violet-400" />
Agent, explicit requester
</span>
Spend / {terms.singular} is recorded period spend divided by matched {terms.plural}
</p>
</div>
);
}
function Quality({ snapshot, comparison }: { snapshot: ObservedSnapshot; comparison: Comparison }) {
const terms = changeTerms(snapshot.source_provider);
const current = snapshot.periods.current;
const baseline = snapshot.periods[comparison];
const rows = [
{
label: "New bug-labeled issues",
current: current.new_bug_labeled_issues,
baseline: baseline.new_bug_labeled_issues,
detail: "Opened during the period, with bug or kind:bug labels at collection",
},
{
label: "New regression-labeled issues",
current: current.new_regression_labeled_issues,
baseline: baseline.new_regression_labeled_issues,
detail: "Opened during the period and labeled as regressions",
},
{
label: `Revert-titled ${terms.plural}`,
current: current.explicitly_titled_revert_prs,
baseline: baseline.explicitly_titled_revert_prs,
detail: `Merged ${terms.plural} whose titles explicitly indicate a revert`,
},
];
return (
<div className="space-y-4">
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-5">Repository signal</TableHead>
<TableHead className="text-right">Current</TableHead>
<TableHead className="text-right">Comparison</TableHead>
<TableHead className="pr-5 text-right">Change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.label}>
<TableCell className="py-5 pl-5">
<p className="font-medium">{row.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{row.detail}</p>
</TableCell>
<TableCell className="text-right font-medium">{number(row.current)}</TableCell>
<TableCell className="text-right text-muted-foreground">{number(row.baseline)}</TableCell>
<TableCell className="pr-5 text-right">
<Delta current={row.current} baseline={row.baseline} neutral />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="max-w-3xl text-xs leading-relaxed text-muted-foreground">
These signals help check whether more shipping comes with more bugs. Labels and revert titles are incomplete
proxies; they do not establish a change-failure rate or attribute bugs to an engineer.
</p>
</div>
);
}
function costPerChange(period: ObservedSnapshot["periods"]["current"]) {
if (period.spend_observation !== "records_present" || period.matched_internal_prs === 0) return null;
return period.matched_users_recorded_spend / period.matched_internal_prs;
}
export default function ObservedReport({
snapshot,
accessToken,
readOnly,
onRefresh,
onConnect,
actions,
notice,
syncing,
onPeriod,
}: {
snapshot: ObservedSnapshot;
accessToken: string;
readOnly: boolean;
onRefresh: () => void;
onConnect: () => void;
actions: React.ReactNode;
notice?: React.ReactNode;
syncing: boolean;
onPeriod?: (days: number) => void;
}) {
const [comparison, setComparison] = useState<Comparison>("previous");
const [activeTab, setActiveTab] = useState(
snapshot.people.length && snapshot.periods.current.merged_prs > 0 ? "people" : "pulls",
);
const [accountEmail, setAccountEmail] = useState<string | null>(null);
const [matchedOnly, setMatchedOnly] = useState(true);
const people = useMemo(() => reportPeople(snapshot, matchedOnly), [snapshot, matchedOnly]);
const pulls = useMemo(() => filterObservedPulls(snapshot, "current", matchedOnly), [snapshot, matchedOnly]);
const [personId, setPersonId] = useState<string | null>(null);
const person = people.find((entry) => entry.id === personId) ?? null;
const terms = changeTerms(snapshot.source_provider);
const current = snapshot.periods.current;
const baseline = snapshot.periods[comparison];
const days = Math.round((Date.parse(current.window.end) - Date.parse(current.window.start)) / 86400000) + 1;
const rangeOptions = [...new Set([7, 28, 90, days])].sort((a, b) => a - b);
const cost = costPerChange(current);
const baselineCost = costPerChange(baseline);
return (
<Page className="mx-auto max-w-[1500px] gap-3 pb-10 sm:pt-4">
<PageHeader className="flex flex-wrap items-center justify-between gap-3">
<PageHeaderTitle className="text-xl">ROI Calculator</PageHeaderTitle>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{actions}
{!readOnly && (
<Button size="sm" variant="outline" onClick={onConnect}>
<Link2 />
Connections
</Button>
)}
</div>
</PageHeader>
{notice}
<div className="flex flex-wrap items-center justify-between gap-2">
<Popover>
<PopoverTrigger render={<Button size="sm" variant="outline" className="self-start" />}>
{number(snapshot.repos.length)} {snapshot.repos.length === 1 ? "repository" : "repositories"}
<ChevronDown className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="start" className="w-80 max-w-[calc(100vw-2rem)] gap-2 p-3">
<PopoverTitle>Repositories</PopoverTitle>
<ul className="max-h-64 space-y-2 overflow-y-auto text-xs break-words">
{snapshot.repos.map((repo) => (
<li key={repo}>{repo}</li>
))}
</ul>
</PopoverContent>
</Popover>
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(days)}
disabled={!onPeriod || syncing}
onValueChange={(value) => {
if (value && Number(value) !== days) onPeriod?.(Number(value));
}}
items={rangeOptions.map((value) => ({ value: String(value), label: `Last ${value} days` }))}
>
<SelectTrigger size="sm" aria-label="Reporting period">
<SelectValue />
</SelectTrigger>
<SelectContent>
{rangeOptions.map((value) => (
<SelectItem key={value} value={String(value)}>
Last {value} days
</SelectItem>
))}
</SelectContent>
</Select>
<span className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<CalendarDays className="size-3.5 text-muted-foreground" />
{dateRange(current.window)}
</span>
<Select
value={comparison}
onValueChange={(value) => {
if (value) setComparison(value);
}}
items={[
{ value: "previous", label: "vs. previous period" },
{ value: "last_year", label: "vs. same period last year" },
]}
>
<SelectTrigger size="sm" aria-label="Comparison period">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="previous">vs. previous period</SelectItem>
<SelectItem value="last_year">vs. same period last year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border bg-border lg:grid-cols-4">
<Metric
label={`Merged ${terms.plural}`}
value={number(current.merged_prs)}
current={current.merged_prs}
baseline={baseline.merged_prs}
detail={`${number(baseline.merged_prs)} in comparison`}
/>
<Metric
label="Median time to merge"
value={current.merged_prs === 0 ? "No merges" : duration(current.median_merge_hours)}
current={current.median_merge_hours ?? undefined}
baseline={baseline.median_merge_hours ?? undefined}
detail={`${duration(baseline.median_merge_hours)} in comparison`}
/>
<Metric
label="New bugs"
value={number(current.new_bug_labeled_issues)}
current={current.new_bug_labeled_issues}
baseline={baseline.new_bug_labeled_issues}
detail={`${number(baseline.new_bug_labeled_issues)} in comparison · bug-labeled issues`}
/>
<Metric
label={`Recorded spend / matched ${terms.singular}`}
value={money(cost)}
detail={
baselineCost === null
? "No gateway records for comparison"
: `${money(baselineCost)} in comparison · gateway only`
}
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 border-b">
<PageTabsList className="min-w-0 flex-1 basis-full gap-4 border-0 xl:basis-auto">
<PageTabsTrigger value="people">
Engineers <span className="ml-1.5 text-muted-foreground">{people.length}</span>
</PageTabsTrigger>
<PageTabsTrigger value="pulls">{terms.requests}</PageTabsTrigger>
<PageTabsTrigger value="quality">Quality</PageTabsTrigger>
<PageTabsTrigger value="branches">Branch spend</PageTabsTrigger>
</PageTabsList>
{activeTab !== "quality" && <MatchedPeopleToggle checked={matchedOnly} onChange={setMatchedOnly} />}
{!readOnly && (
<Button
size="sm"
variant="outline"
aria-label="Link accounts"
title="Link accounts"
onClick={() => setAccountEmail("")}
>
<Users />
<span className="hidden sm:inline">Link accounts</span>
</Button>
)}
</div>
<TabsContent value="people">
<PeopleTable
rows={people}
provider={snapshot.source_provider}
matchedOnly={matchedOnly}
comparison={comparison}
onSelect={(selected) => setPersonId(selected.id)}
/>
</TabsContent>
<TabsContent value="pulls" className="space-y-5">
{current.merged_prs > 0 || baseline.merged_prs > 0 ? (
<div className="grid gap-4 lg:grid-cols-[1.6fr_1fr]">
<ShippingTrend snapshot={snapshot} comparison={comparison} />
<div className="flex flex-col justify-between rounded-xl border p-5">
<div>
<h2 className="text-sm font-medium">Behind the numbers</h2>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{number(current.agent_authored)} of {number(current.merged_prs)} {terms.plural} were authored by
agents or bots.
</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
Human-authored median merge time:{" "}
<span className="font-medium text-foreground">
{duration(current.human_summary.median_merge_hours)}
</span>
, compared with {duration(baseline.human_summary.median_merge_hours)}.
</p>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<span className="text-xs text-muted-foreground">
{number(current.agents_without_requester)} agent {terms.plural} have no requester
</span>
</div>
</div>
</div>
) : (
<div className="rounded-xl border p-8 text-center">
<h2 className="text-base font-medium">No merged changes yet</h2>
<p className="mt-2 text-sm text-muted-foreground">
Your repositories are connected. New activity will appear after the next sync
</p>
</div>
)}
<p className="mb-4 text-xs text-muted-foreground">
{matchedOnly
? "Changes from people linked to internal accounts"
: `All repository ${terms.plural}, including agent work without a known requester`}
</p>
<PullList pulls={pulls} provider={snapshot.source_provider} matchedOnly={matchedOnly} />
</TabsContent>
<TabsContent value="quality">
<Quality snapshot={snapshot} comparison={comparison} />
</TabsContent>
<TabsContent value="branches">
<BranchSpend snapshot={snapshot} matchedOnly={matchedOnly} />
</TabsContent>
</Tabs>
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
<span className="w-full">
{number(current.matched_internal_prs)} {terms.plural} matched to {snapshot.people.length} engineers ·{" "}
{number(current.agents_without_requester)} agent {terms.plural} without a requester
</span>
<span>Comparing with {dateRange(baseline.window)} · All dates UTC</span>
<span>Spend recorded by this gateway · Merge time is elapsed time, not effort</span>
</div>
{accountEmail !== null && (
<ObservedAccounts
accessToken={accessToken}
people={snapshot.people}
initialEmail={accountEmail}
onClose={() => setAccountEmail(null)}
onSaved={onRefresh}
/>
)}
{person && (
<PersonDetails
key={person.id}
person={person}
snapshot={snapshot}
comparison={comparison}
onClose={() => setPersonId(null)}
onEdit={
readOnly || !person.matched
? undefined
: () => {
setAccountEmail(person.email);
setPersonId(null);
}
}
/>
)}
</Page>
);
}

View file

@ -1,217 +0,0 @@
"use client";
import React from "react";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { effortNote, estimateLabel, branchCostLabel } from "./roiCalculatorData";
import type { ROIIdentityMapUpdate, ROIPull, ROISummary } from "./roiCalculatorData";
import type { ROIPerson } from "./roiCalculatorData";
export type PersonMatchSelection = { person: ROIPerson; login: string };
export function PullReasoningDialog({
pull,
summary,
onClose,
}: {
pull: ROIPull | null;
summary: ROISummary | null;
onClose: () => void;
}) {
const titleRef = React.useRef<HTMLHeadingElement>(null);
return (
<Dialog open={Boolean(pull)} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl" initialFocus={titleRef}>
{pull && (
<>
<DialogHeader>
<DialogTitle ref={titleRef} tabIndex={-1} className="pr-8 text-lg leading-6">
{pull.title}
</DialogTitle>
<DialogDescription>
{pull.repo} #{pull.number} · {pull.login}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<dl className="grid grid-cols-2 gap-4 rounded-lg border bg-muted/20 p-4">
<div className="space-y-2">
<dt className="text-xs text-muted-foreground">Estimated effort</dt>
<dd className="text-2xl font-semibold tabular-nums">{estimateLabel(pull.estimate)}</dd>
</div>
<div className="space-y-2">
<dt className="text-xs text-muted-foreground">Recorded AI cost</dt>
<dd className="text-xl font-semibold tabular-nums">{branchCostLabel(pull)}</dd>
{pull.branch_cost?.status === "matched" && (
<dd className="text-xs text-muted-foreground">{pull.branch_cost.requests} requests</dd>
)}
</div>
</dl>
<p className="text-xs leading-5 text-muted-foreground">
{effortNote(pull.estimate.effort_basis ?? summary?.effort_basis)}
</p>
</div>
<section>
<h3 className="mb-2 font-medium">Reasoning</h3>
<p className="whitespace-pre-wrap leading-relaxed">
{pull.estimate.reasoning || "No estimate available."}
</p>
</section>
<dl className="grid grid-cols-[auto_1fr] gap-x-5 gap-y-2 text-xs">
<dt className="text-muted-foreground">Model</dt>
<dd className="break-all">{pull.estimate.model || summary?.estimator_model}</dd>
<dt className="text-muted-foreground">Merged</dt>
<dd>{new Date(pull.merged_at).toLocaleDateString(undefined, { timeZone: "UTC" })}</dd>
<dt className="text-muted-foreground">Email match</dt>
<dd>{pull.email || "Not matched"}</dd>
</dl>
<section className="space-y-2 border-t pt-4 text-sm">
<h3 className="font-medium">Track costs for this branch</h3>
{pull.branch_cost?.status === "matched" && (
<p className="text-muted-foreground">
{pull.branch_cost.spend?.toFixed(8)} USD across {pull.branch_cost.requests} requests
</p>
)}
{pull.source_repo && pull.source_branch ? (
<>
<p className="text-muted-foreground">Send both tags with each gateway request from this branch:</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
{JSON.stringify(
{ metadata: { tags: [`repo:${pull.source_repo}`, `branch:${pull.source_branch}`] } },
null,
2,
)}
</pre>
<p className="text-xs text-muted-foreground">
Retained requests in the report’s UTC period. Branch names are case-sensitive. Reused branches
cannot be split between changes.
</p>
</>
) : (
<p className="text-muted-foreground">
The source repository or branch is unavailable. Sync again to refresh its metadata.
</p>
)}
</section>
{summary?.estimator_prompt && (
<details className="rounded-md border p-3">
<summary className="cursor-pointer font-medium">Estimator prompt</summary>
<p className="mt-2 whitespace-pre-wrap text-sm">{summary.estimator_prompt}</p>
</details>
)}
<DialogFooter>
{pull.url && (
<a
className={buttonVariants({ variant: "outline" })}
href={pull.url}
target="_blank"
rel="noopener noreferrer"
>
View on {summary?.source_provider === "gitlab" ? "GitLab" : "GitHub"}
</a>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
export function IdentityMatchDialog({
selection,
identityMap,
gatewayEmails,
onClose,
onSave,
}: {
selection: PersonMatchSelection | null;
identityMap: Record<string, string>;
gatewayEmails: string[];
onClose: () => void;
onSave: (payload: ROIIdentityMapUpdate) => Promise<void>;
}) {
const [email, setEmail] = React.useState(() =>
selection ? identityMap[selection.login.toLowerCase()] ?? selection.person.email ?? "" : "",
);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const person = selection?.person ?? null;
const login = selection?.login ?? "";
const existingEmail = identityMap[login.toLowerCase()];
const save = async (value: string | null) => {
if (!login) return;
try {
setBusy(true);
await onSave({ github_login: login, email: value });
setError(null);
onClose();
} catch (reason) {
setError(extractErrorMessage(reason));
} finally {
setBusy(false);
}
};
return (
<Dialog open={Boolean(person)} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Match email</DialogTitle>
<DialogDescription>Link {login} to their gateway email. Manual matches take priority.</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void save(email.trim());
}}
>
<div className="grid gap-2">
<Label htmlFor="roi-match-email">Gateway email</Label>
<Input
id="roi-match-email"
autoComplete="off"
list="roi-gateway-emails"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<datalist id="roi-gateway-emails">
{Array.from(new Set(gatewayEmails)).map((address) => (
<option key={address} value={address} />
))}
</datalist>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
{existingEmail && (
<Button disabled={busy} type="button" variant="outline" onClick={() => void save(null)}>
Use automatic match
</Button>
)}
<Button disabled={busy || !email.trim()} type="submit">
{busy ? "Saving…" : "Save match"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -1,814 +0,0 @@
import userEvent from "@testing-library/user-event";
import { act, fireEvent, render as renderWithoutNuqs, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NuqsAdapter } from "nuqs/adapters/react";
import { apiClient } from "@/components/networking";
import ROICalculatorView from "./ROICalculatorView";
const render = (ui: Parameters<typeof renderWithoutNuqs>[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter });
vi.mock("@/components/networking", () => ({
apiClient: {
delete: vi.fn(),
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}));
const summary = {
id: null,
mode: "live",
start: "2026-09-01",
end: "2026-09-30",
synced_at: "2026-09-30T12:00:00Z",
repos: ["org/repo"],
estimator_model: "estimator",
estimator_prompt: "Estimate hours.",
warnings: [],
effort_basis: "without_ai",
metrics: {
matched_spend: 12,
output_hours: 4,
total_spend: 20,
total_output_hours: 4,
excluded_spend: 8,
cost_per_hour: 3,
hours_per_dollar: 1 / 3,
merged_prs: 1,
estimated_prs: 1,
matched_prs: 1,
cohort_people: 1,
people_with_prs: 1,
pending_prs: 0,
},
people: [
{
id: "alice@example.com",
email: "alice@example.com",
logins: ["alice", "alice-work"],
spend: 12,
hours: 4,
prs: 1,
estimated_prs: 1,
pending_prs: 0,
match_methods: ["profile email"],
eligible: true,
cost_per_hour: 3,
},
],
pulls: [
{
repo: "org/repo",
source_repo: "github.com/org/repo",
source_branch: "feature/routing",
branch_cost: {
repo: "github.com/org/repo",
branch: "feature/routing",
status: "matched",
spend: 8,
requests: 12,
},
number: 42,
title: "Improve request routing",
url: "https://github.com/org/repo/pull/42",
login: "alice",
emails: ["alice@example.com"],
profile_email: "alice@example.com",
merged_at: "2026-09-12T00:00:00Z",
head_sha: "abc",
additions: 10,
deletions: 2,
changed_files: 1,
commit_count: 1,
incomplete_metadata: false,
estimate: {
status: "estimated",
hours: 4,
reasoning: "Updated routing and added a regression test.",
model: "estimator",
evidence_source: "pr_metadata",
effort_basis: "without_ai",
cached: false,
},
cache_key: "cache",
email: "alice@example.com",
match_method: "profile email",
matched: true,
},
],
trend: [{ date: "2026-09-12", spend: 12, hours: 4, prs: 1 }],
} as const;
const settings = {
github_api_url: "https://api.github.com",
repos: ["org/repo"],
estimator_model: "estimator",
estimator_prompt: "Estimate hours.",
backfill_days: 30,
identity_map: {},
has_github_token: true,
default_prompt: "Estimate hours.",
available_models: ["estimator"],
ready: true,
};
const idleStatus = {
running: false,
phase: "idle",
stage: "Idle",
done: 0,
total: 0,
estimated: 0,
reused: 0,
needs_attention: 0,
error: null,
};
describe("ROICalculatorView", () => {
beforeEach(() => {
window.history.replaceState(null, "", "/roi-calculator/");
vi.mocked(apiClient.get).mockReset();
vi.mocked(apiClient.put).mockReset();
vi.mocked(apiClient.post).mockReset();
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(idleStatus);
});
vi.mocked(apiClient.put).mockResolvedValue({ report: summary, identity_map: { alice: "alice@example.com" } });
});
it.each(["github", "gitlab"])("filters people and branches by linked accounts for %s", async (provider) => {
const outsidePerson = {
...summary.people[0],
id: "outside",
email: "",
logins: ["outside"],
match_methods: ["no gateway match"],
spend: null,
};
const outsidePull = {
...summary.pulls[0],
number: 43,
title: "External contribution",
login: "outside",
email: "",
match_method: "no gateway match",
matched: false,
};
const linkedPerson = { ...summary.people[0], spend: null, match_methods: ["manual"], eligible: false };
const spendOnlyPerson = {
...summary.people[0],
id: "internal@example.test",
email: "internal@example.test",
logins: [],
spend: 8.5,
prs: 0,
match_methods: [],
estimated_prs: 0,
hours: 0,
eligible: false,
cost_per_hour: null,
};
const linkedPull = { ...summary.pulls[0], matched: false, match_method: "manual" };
const report = {
...summary,
source_provider: provider,
people: [linkedPerson, spendOnlyPerson, outsidePerson],
pulls: [linkedPull, outsidePull],
};
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path.endsWith("/settings")) return Promise.resolve(settings);
if (path.endsWith("/report")) return Promise.resolve({ report });
return Promise.resolve(idleStatus);
});
const user = userEvent.setup();
render(<ROICalculatorView accessToken="token" />);
await user.click(await screen.findByRole("tab", { name: "People" }));
expect(screen.getByRole("switch", { name: "Matched people only" })).toBeChecked();
expect(screen.getByRole("button", { name: "alice" })).toBeInTheDocument();
expect(screen.getByRole("row", { name: /internal@example.test/ })).toHaveTextContent("$8.50");
expect(screen.queryByRole("button", { name: "outside" })).not.toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
expect(screen.getByRole("button", { name: "outside" })).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Branches" }));
expect(screen.getByRole("switch", { name: "Matched people only" })).not.toBeChecked();
expect(screen.getByText("External contribution")).toBeInTheDocument();
await user.click(screen.getByRole("switch", { name: "Matched people only" }));
expect(screen.queryByText("External contribution")).not.toBeInTheDocument();
expect(screen.getByText("Improve request routing")).toBeInTheDocument();
expect(apiClient.put).not.toHaveBeenCalled();
});
it("shows the spend summary and opens an accessible pull reasoning dialog", async () => {
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByText("Gateway AI cost")).toBeInTheDocument();
expect(screen.getByText("$20.00")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" }));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Updated routing and added a regression test.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "View on GitHub" })).toHaveAttribute(
"href",
"https://github.com/org/repo/pull/42",
);
});
it("separates the overview, people, and branch reports into three tabs", async () => {
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("heading", { name: "Where AI costs are matched" })).toBeVisible();
fireEvent.click(screen.getByRole("tab", { name: "Branches" }));
expect(screen.getByRole("heading", { name: "Costs by branch" })).toBeVisible();
expect(screen.getByRole("cell", { name: "$8.00" })).toBeVisible();
fireEvent.click(screen.getByRole("tab", { name: "People" }));
expect(screen.getByRole("heading", { name: "People and account matches" })).toBeVisible();
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
expect(screen.getByRole("heading", { name: "Highest-cost changes" })).toBeVisible();
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
});
it("shows incomplete repository results without a spend-per-hour figure", async () => {
const warning = "Incomplete report: could not read org/unavailable. Spend-per-hour figures are unavailable.";
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (path === "/roi-calculator/report") {
return Promise.resolve({
report: {
...summary,
warnings: [warning],
metrics: { ...summary.metrics, cost_per_hour: null, hours_per_dollar: null },
people: summary.people.map((person) => ({ ...person, cost_per_hour: null })),
},
});
}
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("alert")).toHaveTextContent(warning);
expect(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" })).toBeInTheDocument();
expect(screen.queryByText("$3.00")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "People" }));
fireEvent.click(screen.getByText("How this is calculated"));
expect(
screen.getByText("Spend per estimated hour is unavailable until all selected repositories can be read."),
).toBeVisible();
});
it("lets a view-only admin read the report without write controls", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(runningStatus);
});
render(<ROICalculatorView accessToken="token" userRole="Admin" isViewOnly />);
expect(await screen.findByText("Gateway AI cost")).toBeInTheDocument();
expect(screen.getByRole("note")).toHaveTextContent("Read-only access");
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "People" }));
expect(screen.getByText("alice-work")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "alice-work" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
expect(screen.getByLabelText("GitHub token (optional for public repositories)")).toBeDisabled();
expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
});
it("lets an admin open the people view and save a manual email match", async () => {
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("tab", { name: "People" }));
fireEvent.click(await screen.findByRole("button", { name: "alice-work" }));
fireEvent.change(screen.getByLabelText("Gateway email"), {
target: { value: "alice+work@example.com" },
});
fireEvent.click(screen.getByRole("button", { name: "Save match" }));
await waitFor(() =>
expect(apiClient.put).toHaveBeenCalledWith("/roi-calculator/identity-map", {
accessToken: "token",
body: { github_login: "alice-work", email: "alice+work@example.com" },
}),
);
});
it("presents onboarding settings once when no report exists", async () => {
const emptySettings = { ...settings, has_github_token: false, ready: false, repos: [], estimator_model: "" };
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") return Promise.resolve(emptySettings);
if (path === "/roi-calculator/report") return Promise.resolve({ report: null });
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
expect(screen.getByLabelText("GitHub token (optional for public repositories)")).toHaveAttribute(
"type",
"password",
);
expect(screen.getAllByText("Connect your repositories")).toHaveLength(1);
});
it.each(["github", "gitlab"])("only permits unauthenticated repository browsing for GitLab: %s", async (provider) => {
const publicSettings = {
...settings,
source_provider: provider,
gitlab_api_url: "https://gitlab.com/api/v4",
has_github_token: false,
has_gitlab_token: false,
};
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") {
return Promise.resolve(publicSettings);
}
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
const load = screen.getByRole("button", { name: "Load repositories" });
if (provider === "github") expect(load).toBeDisabled();
else expect(load).toBeEnabled();
expect(screen.getByRole("textbox", { name: "Repository name" })).toBeEnabled();
});
it("searches by the real model name and saves the selected gateway alias", async () => {
const user = userEvent.setup();
const modelSettings = {
...settings,
available_models: ["estimator", "fast-estimator"],
estimator_models: [
{ model_name: "estimator", provider_models: ["custom-model"] },
{ model_name: "fast-estimator", provider_models: ["openai/gpt-6-luna"] },
],
};
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") return Promise.resolve(modelSettings);
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(idleStatus);
});
vi.mocked(apiClient.put).mockResolvedValue({ ...modelSettings, estimator_model: "fast-estimator" });
render(<ROICalculatorView accessToken="token" />);
await user.click(await screen.findByRole("button", { name: "Settings" }));
const search = screen.getByRole("combobox", { name: "Estimator model" });
await user.clear(search);
await user.type(search, "Luna");
expect(screen.queryByRole("option", { name: /custom-model/ })).not.toBeInTheDocument();
await user.click(await screen.findByRole("option", { name: /GPT-6 Luna.*Recommended/ }));
expect(search).toHaveValue("GPT-6 Luna");
await user.click(screen.getByRole("button", { name: "Save settings" }));
expect(apiClient.put).toHaveBeenCalledWith(
"/roi-calculator/settings",
expect.objectContaining({
body: expect.objectContaining({ estimator_model: "fast-estimator" }),
}),
);
});
it("closes the old settings dialog when saving a different source", async () => {
const gitlabSettings = {
...settings,
source_provider: "gitlab",
gitlab_api_url: "https://gitlab.com/api/v4",
has_gitlab_token: false,
repos: [],
ready: false,
};
vi.mocked(apiClient.put).mockResolvedValue(gitlabSettings);
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } });
fireEvent.click(screen.getByRole("button", { name: "Save settings" }));
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getAllByLabelText("Repository source")).toHaveLength(1);
expect(screen.getByLabelText("Repository source")).toHaveValue("gitlab");
});
it("keeps a running analysis visible when a source change finishes saving", async () => {
const gitlabSettings = { ...settings, source_provider: "gitlab", repos: ["group/project"] };
const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 };
const saveRequest = Promise.withResolvers<typeof gitlabSettings>();
vi.mocked(apiClient.put).mockReturnValue(saveRequest.promise);
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } });
fireEvent.click(screen.getByRole("button", { name: "Save settings" }));
vi.mocked(apiClient.get).mockResolvedValue(runningStatus);
expect(await screen.findByRole("progressbar", { hidden: true }, { timeout: 3000 })).toBeInTheDocument();
saveRequest.resolve(gitlabSettings);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("progressbar", { name: "Sync progress" })).toBeVisible();
expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
});
it.each(["success", "failure"])("ignores an old report refresh %s after switching sources", async (outcome) => {
const gitlabSettings = { ...settings, source_provider: "gitlab", repos: [], ready: false };
const oldRequest = Promise.withResolvers<{ report: typeof summary }>();
const complete = { ...idleStatus, phase: "complete", finished_at: "2026-09-30T12:00:00Z" };
vi.mocked(apiClient.put).mockResolvedValue(gitlabSettings);
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
vi.mocked(apiClient.get)
.mockClear()
.mockImplementation((path: string) =>
path === "/roi-calculator/report" ? oldRequest.promise : Promise.resolve(complete),
);
await waitFor(
() => expect(apiClient.get).toHaveBeenCalledWith("/roi-calculator/report", { accessToken: "token" }),
{
timeout: 3000,
},
);
fireEvent.change(screen.getByLabelText("Repository source"), { target: { value: "gitlab" } });
fireEvent.click(screen.getByRole("button", { name: "Save settings" }));
expect(await screen.findByRole("heading", { name: "Connect your repositories" })).toBeVisible();
await act(async () => {
if (outcome === "success") oldRequest.resolve({ report: summary });
else oldRequest.reject(new Error("The old source is unavailable"));
});
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeVisible();
expect(screen.getByLabelText("Repository source")).toHaveValue("gitlab");
expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument();
expect(screen.queryByText("The old source is unavailable")).not.toBeInTheDocument();
const nextReport = { ...summary, pulls: [{ ...summary.pulls[0], title: "New source merge request" }] };
vi.mocked(apiClient.get).mockImplementation((path: string) =>
Promise.resolve(
path === "/roi-calculator/report"
? { report: nextReport }
: { ...complete, finished_at: "2026-09-30T13:00:00Z" },
),
);
expect(await screen.findByText("New source merge request", {}, { timeout: 3000 })).toBeVisible();
expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument();
});
it("clearly identifies the sample report and returns to setup when exiting", async () => {
const emptySettings = { ...settings, has_github_token: false, ready: false, repos: [], estimator_model: "" };
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(emptySettings);
if (path === "/roi-calculator/report") {
return Promise.resolve({ report: options?.query?.mode === "demo" ? { ...summary, mode: "demo" } : null });
}
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Preview sample report" }));
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Cost / estimated hour")).toBeVisible();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeVisible();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
expect(apiClient.put).not.toHaveBeenCalled();
});
it("opens sample PR costs from a live report and restores the live data on exit", async () => {
const samplePull = {
...summary.pulls[0],
title: "Sample usage breakdown",
source_repo: "github.com/org/repo",
source_branch: "feature/usage",
branch_cost: {
status: "matched",
spend: 9.1,
requests: 75,
repo: "github.com/org/repo",
branch: "feature/usage",
},
};
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (path === "/roi-calculator/report") {
return Promise.resolve({
report: options?.query?.mode === "demo" ? { ...summary, mode: "demo", pulls: [samplePull] } : summary,
});
}
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("tab", { name: "Branches" }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "no matching PR" } });
fireEvent.click(screen.getByRole("button", { name: "Preview sample report" }));
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
await waitFor(() => expect(window.location.search).toBe("?demo=1"));
expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("searchbox")).toHaveValue("");
expect(screen.getByRole("cell", { name: "$9.10" })).toBeVisible();
expect(screen.queryByText("Improve request routing")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 };
vi.mocked(apiClient.get).mockClear().mockResolvedValue(runningStatus);
await waitFor(() => expect(apiClient.get).toHaveBeenCalledWith("/roi-calculator/sync", { accessToken: "token" }), {
timeout: 3000,
});
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open estimate for org/repo pull request 42" }));
expect(await screen.findByRole("dialog")).toBeVisible();
expect(screen.getByText("75 requests")).toBeVisible();
expect(screen.getByText(/branch:feature\/usage/)).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByText("Improve request routing")).toBeVisible();
expect(screen.queryByText("Sample usage breakdown")).not.toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe(""));
expect(screen.getByRole("button", { name: "Syncing…" })).toBeDisabled();
expect(apiClient.post).not.toHaveBeenCalled();
expect(apiClient.put).not.toHaveBeenCalled();
});
it("opens a demo link with sample data even while live analysis is running", async () => {
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
const demoSummary = { ...summary, mode: "demo", metrics: { ...summary.metrics, total_spend: 38.4 } };
const runningStatus = { ...idleStatus, running: true, phase: "estimating", total: 1 };
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (path === "/roi-calculator/report") {
return Promise.resolve({ report: options?.query?.mode === "demo" ? demoSummary : summary });
}
return Promise.resolve(runningStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
expect(screen.getByText("$38.40")).toBeVisible();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("progressbar")).toBeVisible();
expect(screen.getByText("$20.00")).toBeVisible();
await waitFor(() => expect(window.location.search).toBe(""));
});
it.each(["report", "sync"])("loads a demo link when the live %s request fails", async (failedRequest) => {
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } });
if (path === `/roi-calculator/${failedRequest}`) return Promise.reject(new Error("Live data unavailable"));
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
expect(screen.getByText("Gateway AI cost")).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
});
it("shows the demo without waiting for a stalled live request", async () => {
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } });
return new Promise(() => {});
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
expect(screen.getByText("Gateway AI cost")).toBeVisible();
});
it("keeps the live calculator usable when a demo link cannot load sample data", async () => {
window.history.replaceState(null, "", "/roi-calculator/?demo=1&from=review#overview");
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (options?.query?.mode === "demo") return Promise.reject(new Error("Sample data unavailable"));
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByText("Gateway AI cost")).toBeVisible();
expect(screen.getByText("$20.00")).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("Sample data unavailable");
expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
await waitFor(() => expect(window.location.search).toBe("?from=review"));
expect(window.location.hash).toBe("#overview");
});
it.each(["report", "sync"])("waits for the live %s when exiting a demo", async (pendingRequest) => {
window.history.replaceState(null, "", "/roi-calculator/?demo=1");
const pending = Promise.withResolvers<{ report: typeof summary } | typeof idleStatus>();
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (options?.query?.mode === "demo") return Promise.resolve({ report: { ...summary, mode: "demo" } });
if (path === `/roi-calculator/${pendingRequest}`) return pending.promise;
if (path === "/roi-calculator/report") return Promise.resolve({ report: summary });
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Exit demo" }));
expect(screen.getByText("Loading ROI Calculator…")).toBeVisible();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe(""));
pending.resolve(pendingRequest === "report" ? { report: summary } : idleStatus);
expect(await screen.findByText("Gateway AI cost")).toBeVisible();
expect(screen.getByText("$20.00")).toBeVisible();
expect(screen.queryByText("Loading ROI Calculator…")).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
});
it.each(["report", "demo"])("retains a failed %s load after a successful sync poll", async (failedRequest) => {
if (failedRequest === "demo") window.history.replaceState(null, "", "/roi-calculator/?demo=1");
const message = `The ${failedRequest} is unavailable`;
vi.mocked(apiClient.get).mockImplementation((path: string, options) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (options?.query?.mode === "demo") return Promise.reject(new Error(message));
if (path === "/roi-calculator/report") {
return failedRequest === "report" ? Promise.reject(new Error(message)) : Promise.resolve({ report: summary });
}
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("alert")).toHaveTextContent(message);
const running = { ...idleStatus, running: true, phase: "estimating", total: 1 };
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/report") return Promise.reject(new Error(message));
return Promise.resolve(running);
});
expect(await screen.findByRole("progressbar", { name: "Sync progress" }, { timeout: 3000 })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent(message);
});
it("retries a failed initial report and clears its error only when the report recovers", async () => {
vi.mocked(apiClient.get).mockImplementation((path: string) => {
if (path === "/roi-calculator/settings") return Promise.resolve(settings);
if (path === "/roi-calculator/report") return Promise.reject(new Error("Report unavailable"));
return Promise.resolve(idleStatus);
});
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("alert")).toHaveTextContent("Report unavailable");
expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Run analysis" })).toBeEnabled();
vi.mocked(apiClient.get).mockImplementation((path: string) =>
Promise.resolve(path === "/roi-calculator/report" ? { report: summary } : idleStatus),
);
expect(await screen.findByText("Gateway AI cost", {}, { timeout: 3000 })).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
});
it("returns to Overview and shows the last sync time when completion is polled from Settings", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
const completedStatus = { ...idleStatus, phase: "complete", done: 57, total: 57, reused: 57 };
vi.mocked(apiClient.get)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce({ report: null })
.mockResolvedValueOnce(runningStatus)
.mockResolvedValueOnce(completedStatus)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
window.setTimeout(() => resolve({ report: summary }), 25);
}),
);
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
expect(await screen.findByText("Gateway AI cost", {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Last synced Sep 30, 2026, 12:00 PM UTC");
});
it("shows the sync error returned by the status endpoint", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
const errorStatus = {
...idleStatus,
phase: "error",
error: "The estimator could not score a pull request.",
};
vi.mocked(apiClient.get)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce({ report: null })
.mockResolvedValueOnce(runningStatus)
.mockResolvedValueOnce(errorStatus);
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
"The estimator could not score a pull request.",
);
expect(screen.getByText("Sync failed")).toBeInTheDocument();
});
it("shows a report error and ends progress when the completed report cannot load", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 };
vi.mocked(apiClient.get)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce({ report: null })
.mockResolvedValueOnce(runningStatus)
.mockResolvedValueOnce(completedStatus)
.mockRejectedValueOnce(new Error("The report could not be loaded."));
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
"The report could not be loaded.",
);
expect(screen.queryByRole("progressbar", { name: "Sync progress" })).not.toBeInTheDocument();
});
it("clears a transient poll error when the next poll completes and loads the report", async () => {
const runningStatus = {
...idleStatus,
running: true,
phase: "estimating",
stage: "Estimating pull requests",
total: 1,
};
const completedStatus = { ...idleStatus, phase: "complete", done: 1, total: 1 };
vi.mocked(apiClient.get)
.mockResolvedValueOnce(settings)
.mockResolvedValueOnce({ report: null })
.mockResolvedValueOnce(runningStatus)
.mockRejectedValueOnce(new Error("The sync status could not be loaded."))
.mockResolvedValueOnce(completedStatus)
.mockResolvedValueOnce({ report: summary });
render(<ROICalculatorView accessToken="token" />);
expect(await screen.findByRole("progressbar", { name: "Sync progress" })).toBeInTheDocument();
expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent(
"The sync status could not be loaded.",
);
expect(await screen.findByText("Gateway AI cost", {}, { timeout: 7000 })).toBeInTheDocument();
expect(screen.queryByText("The sync status could not be loaded.")).not.toBeInTheDocument();
});
it("saves the edited schedule before running from Settings", async () => {
vi.mocked(apiClient.put).mockResolvedValue(settings);
vi.mocked(apiClient.post).mockResolvedValue({ ...idleStatus, running: true });
render(<ROICalculatorView accessToken="token" />);
fireEvent.click(await screen.findByRole("button", { name: "Settings" }));
fireEvent.change(screen.getByLabelText("Update interval (hours)"), { target: { value: "6" } });
fireEvent.click(screen.getByRole("button", { name: "Save and run analysis" }));
await waitFor(() => expect(apiClient.post).toHaveBeenCalledWith("/roi-calculator/sync", { accessToken: "token" }));
expect(apiClient.put).toHaveBeenCalledWith(
"/roi-calculator/settings",
expect.objectContaining({
body: expect.objectContaining({ update_interval_minutes: 360, estimator_model: "estimator" }),
}),
);
expect(vi.mocked(apiClient.put).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(apiClient.post).mock.invocationCallOrder[0],
);
});
});

View file

@ -1,508 +0,0 @@
"use client";
import { Page, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
import React from "react";
import { Calculator, RefreshCw, Settings2 } from "lucide-react";
import { apiClient } from "@/components/networking";
import { DemoNotice } from "@/components/shared/DemoNotice";
import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isProxyAdminTierRole } from "@/utils/roles";
import { useQueryState } from "nuqs";
import ROISettingsPanel from "./ROISettingsPanel";
import { MatchedPeopleToggle } from "./MatchedPeopleToggle";
import { IdentityMatchDialog, type PersonMatchSelection, PullReasoningDialog } from "./ROICalculatorDialogs";
import { ROIBranches, ROIOverview, ROIPeopleView } from "./ROICalculatorViews";
import { filterPulls, formatSyncedAt } from "./roiCalculatorData";
import type {
ROIIdentityMapResponse,
ROIIdentityMapUpdate,
ROIPull,
ROIReportResponse,
ROISettings,
ROISummary,
ROISyncStatus,
} from "./roiCalculatorData";
import { parseAsDemoFlag } from "./demoUrlState";
type View = "overview" | "people" | "branches";
const IDLE_STATUS: ROISyncStatus = {
running: false,
elapsed_seconds: 0,
phase: "idle",
stage: "Idle",
done: 0,
total: 0,
estimated: 0,
reused: 0,
needs_attention: 0,
error: null,
};
export default function ROICalculatorView({
accessToken,
userRole = null,
isViewOnly = false,
}: {
accessToken: string | null;
userRole?: string | null;
isViewOnly?: boolean;
}) {
const [demo, setDemo] = useQueryState("demo", parseAsDemoFlag);
const [demoRequestedOnLoad] = React.useState(demo === true);
const [sampleSummary, setSampleSummary] = React.useState<ROISummary | null>(null);
const adminReadOnly = isViewOnly && isProxyAdminTierRole(userRole ?? "");
const readOnly = adminReadOnly || sampleSummary !== null;
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [view, setView] = React.useState<View>("overview");
const [settings, setSettings] = React.useState<ROISettings | null>(null);
const [liveSummary, setSummary] = React.useState<ROISummary | null>(null);
const summary = sampleSummary ?? liveSummary;
const [status, setStatus] = React.useState<ROISyncStatus>(IDLE_STATUS);
const [selectedPull, setSelectedPull] = React.useState<ROIPull | null>(null);
const [matchingPerson, setMatchingPerson] = React.useState<PersonMatchSelection | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [demoError, setDemoError] = React.useState<string | null>(null);
const [reportError, setReportError] = React.useState<string | null>(null);
const [syncError, setSyncError] = React.useState<string | null>(null);
const [loadingInitialData, setLoadingInitialData] = React.useState(true);
const [loadingLiveData, setLoadingLiveData] = React.useState(true);
const statusRef = React.useRef<ROISyncStatus>(IDLE_STATUS);
const reportNeedsRefresh = React.useRef(false);
const sourceRevision = React.useRef(0);
const settingsLoaded = settings !== null && !loadingInitialData && !loadingLiveData;
const requestError = [error, demoError, reportError, syncError].filter(Boolean).join(" ");
const [query, setQuery] = React.useState("");
const [matchedOnly, setMatchedOnly] = React.useState(true);
const loadReport = React.useCallback(async () => {
if (!accessToken) return null;
const response: ROIReportResponse = await apiClient.get("/roi-calculator/report", { accessToken });
return response.report;
}, [accessToken]);
React.useEffect(() => {
if (!accessToken) return;
let cancelled = false;
const settingsRequest = apiClient.get<ROISettings>("/roi-calculator/settings", { accessToken });
const reportRequest = apiClient
.get<ROIReportResponse>("/roi-calculator/report", { accessToken })
.then((response) => {
if (cancelled) return;
setSummary(response.report);
setReportError(null);
reportNeedsRefresh.current = false;
})
.catch((reason: unknown) => {
if (cancelled) return;
setReportError(extractErrorMessage(reason));
reportNeedsRefresh.current = true;
});
const statusRequest = apiClient
.get<ROISyncStatus>("/roi-calculator/sync", { accessToken })
.then((syncStatus) => {
if (cancelled) return;
setStatus(syncStatus);
statusRef.current = syncStatus;
setSyncError(null);
})
.catch((reason: unknown) => {
if (!cancelled) setSyncError(extractErrorMessage(reason));
});
const liveData = Promise.all([reportRequest, statusRequest])
.then(() => null)
.finally(() => {
if (!cancelled) setLoadingLiveData(false);
});
Promise.all([
settingsRequest,
demoRequestedOnLoad
? apiClient
.get<ROIReportResponse>("/roi-calculator/report", { accessToken, query: { mode: "demo" } })
.catch((reason: unknown) => {
if (!cancelled) {
setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`);
setDemo(null);
}
return liveData;
})
: liveData,
])
.then(([nextSettings, sampleResponse]) => {
if (cancelled) return;
setSettings(nextSettings);
setSampleSummary(sampleResponse?.report ?? null);
setError(null);
if (sampleResponse) setDemoError(null);
})
.catch((reason: unknown) => {
if (!cancelled) setError(extractErrorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoadingInitialData(false);
});
return () => {
cancelled = true;
};
}, [accessToken, demoRequestedOnLoad, setDemo]);
React.useEffect(() => {
if (!accessToken || !settingsLoaded) return;
let cancelled = false;
let requestInFlight = false;
const interval = window.setInterval(() => {
if (requestInFlight) return;
requestInFlight = true;
const revision = sourceRevision.current;
const isCurrent = () => !cancelled && revision === sourceRevision.current;
apiClient
.get<ROISyncStatus>("/roi-calculator/sync", { accessToken })
.then(async (nextStatus) => {
if (!isCurrent()) return;
const previousStatus = statusRef.current;
statusRef.current = nextStatus;
setStatus(nextStatus);
setSyncError(null);
const finished = !nextStatus.running && nextStatus.phase === "complete";
const reportChanged = previousStatus.running || nextStatus.finished_at !== previousStatus.finished_at;
if (reportNeedsRefresh.current || (finished && reportChanged)) {
reportNeedsRefresh.current = true;
try {
const report = await loadReport();
if (!isCurrent()) return;
setSummary(report);
setReportError(null);
reportNeedsRefresh.current = false;
} catch (reason: unknown) {
if (isCurrent()) setReportError(extractErrorMessage(reason));
}
}
})
.catch((reason: unknown) => {
if (isCurrent()) setSyncError(extractErrorMessage(reason));
})
.finally(() => {
requestInFlight = false;
});
}, 1500);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [accessToken, loadReport, settingsLoaded]);
const startSync = React.useCallback(async () => {
if (!accessToken || readOnly) return;
try {
setError(null);
const nextStatus = await apiClient.post<ROISyncStatus>("/roi-calculator/sync", { accessToken });
statusRef.current = nextStatus;
setStatus(nextStatus);
setSyncError(null);
setSettingsOpen(false);
} catch (reason) {
setError(extractErrorMessage(reason));
}
}, [accessToken, readOnly]);
const cancelSync = React.useCallback(async () => {
if (!accessToken || readOnly) return;
try {
const nextStatus = await apiClient.delete<ROISyncStatus>("/roi-calculator/sync", { accessToken });
setStatus(nextStatus);
statusRef.current = nextStatus;
setSyncError(null);
setError(null);
} catch (reason) {
setError(extractErrorMessage(reason));
}
}, [accessToken, readOnly]);
const updateIdentity = React.useCallback(
async (payload: ROIIdentityMapUpdate) => {
if (!accessToken || readOnly) return;
const response: ROIIdentityMapResponse = await apiClient.put("/roi-calculator/identity-map", {
accessToken,
body: payload,
});
setSummary(response.report);
setReportError(null);
reportNeedsRefresh.current = false;
setSettings((current) => (current ? { ...current, identity_map: response.identity_map } : current));
},
[accessToken, readOnly],
);
const filteredPulls = React.useMemo(
() => (summary ? filterPulls(summary.pulls, query, matchedOnly) : []),
[query, summary, matchedOnly],
);
if (error && !settings && !loadingInitialData) {
return (
<div className="p-8">
<Alert variant="destructive">
<AlertTitle>Could not load ROI Calculator</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
);
}
const awaitingLiveData = !sampleSummary && loadingLiveData;
if (!settings || loadingInitialData || awaitingLiveData) {
return (
<div className="space-y-6 p-8">
<p role="status">Loading ROI Calculator…</p>
<Skeleton className="h-16 w-96" />
<Skeleton className="h-96 w-full" />
</div>
);
}
const previewSample = async () => {
try {
const response = await apiClient.get<ROIReportResponse>("/roi-calculator/report", {
accessToken,
query: { mode: "demo" },
});
setSampleSummary(response.report);
setDemoError(null);
setDemo(true);
setView("branches");
setQuery("");
} catch (reason) {
setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`);
}
};
const resetView = (updated: ROISettings, resetSyncStatus = true) => {
sourceRevision.current += 1;
setSettings(updated);
setSummary(null);
setReportError(null);
reportNeedsRefresh.current = false;
setSettingsOpen(false);
setView("overview");
if (resetSyncStatus) {
setStatus(IDLE_STATUS);
statusRef.current = IDLE_STATUS;
setSyncError(null);
setError(null);
}
};
const showLiveStatus = !sampleSummary && !status.running;
const showReportActions = summary !== null || reportError !== null;
const progress = status.total > 0 ? Math.min(100, (status.done / status.total) * 100) : 0;
const statusIsIdleOrComplete = status.phase === "idle" || status.phase === "complete";
const syncIsUpToDate = !status.running && statusIsIdleOrComplete;
const syncedAt = sampleSummary?.synced_at ?? (syncIsUpToDate ? summary?.synced_at : null);
return (
<Page className="gap-6">
<PageHeader className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageHeaderTitle>
<Calculator />
ROI Calculator
</PageHeaderTitle>
{!sampleSummary && (
<div className="flex items-center gap-2">
{showLiveStatus && (
<Button variant="ghost" onClick={() => void previewSample()}>
Preview sample report
</Button>
)}
{showReportActions && (
<Button variant="outline" onClick={() => setSettingsOpen(true)}>
<Settings2 />
Settings
</Button>
)}
{showReportActions && !readOnly && (
<Button onClick={() => void startSync()} disabled={status.running || !settings.ready}>
<RefreshCw className={status.running ? "animate-spin" : ""} />
{status.running ? "Syncing…" : "Run analysis"}
</Button>
)}
</div>
)}
</div>
<PageHeaderDescription className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>
{summary
? `${summary.start} through ${summary.end} · UTC`
: "Compare AI costs with estimated engineering effort"}
</span>
{syncedAt && (
<span className="text-xs" role="status">
Last synced {formatSyncedAt(syncedAt)}
</span>
)}
</PageHeaderDescription>
</PageHeader>
{sampleSummary && (
<DemoNotice
onExit={() => {
setDemo(null);
setSampleSummary(null);
}}
/>
)}
{adminReadOnly && (
<p className="text-sm text-muted-foreground" role="note">
Read-only access. Settings, analysis runs, and email matches are unavailable.
</p>
)}
{summary && (
<Tabs value={view} onValueChange={(value) => setView(value as View)}>
<PageTabsList aria-label="ROI Calculator views">
<PageTabsTrigger value="overview">Overview</PageTabsTrigger>
<PageTabsTrigger value="people">People</PageTabsTrigger>
<PageTabsTrigger value="branches">Branches</PageTabsTrigger>
</PageTabsList>
{view !== "overview" && (
<div className="flex justify-end">
<MatchedPeopleToggle checked={matchedOnly} onChange={setMatchedOnly} />
</div>
)}
</Tabs>
)}
{!sampleSummary && requestError && (
<Alert variant="destructive">
<AlertTitle>ROI Calculator request failed</AlertTitle>
<AlertDescription>{requestError}</AlertDescription>
</Alert>
)}
{!sampleSummary && status.error && (
<Alert variant="destructive">
<AlertTitle>Sync failed</AlertTitle>
<AlertDescription>{status.error}</AlertDescription>
</Alert>
)}
{summary?.warnings.map((warning) => (
<Alert key={warning}>
<AlertTitle>Sync note</AlertTitle>
<AlertDescription>{warning}</AlertDescription>
</Alert>
))}
{!sampleSummary && status.running && (
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-4">
<div
aria-label="Sync progress"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progress}
className="min-w-0 flex-1 space-y-2"
role="progressbar"
>
<p className="font-medium">{status.stage}</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-muted-foreground">
{status.done} of {status.total} changes processed · {status.reused} reused
{` · ${status.elapsed_seconds ?? 0}s elapsed`}
{status.remaining_seconds != null ? ` · about ${status.remaining_seconds}s remaining` : ""}
</p>
</div>
{!readOnly && (
<Button variant="outline" onClick={() => void cancelSync()}>
Cancel sync
</Button>
)}
</CardContent>
</Card>
)}
{!summary && !status.running && !reportError ? (
<ROISettingsPanel
accessToken={accessToken}
initialSettings={settings}
onboarding={!summary}
onSaved={setSettings}
onReset={resetView}
onStartSync={startSync}
readOnly={readOnly}
syncDisabled={status.running}
/>
) : null}
{view === "overview" && summary && (
<ROIOverview
summary={summary}
onSelectPull={setSelectedPull}
onViewPeople={() => setView("people")}
onViewBranches={() => setView("branches")}
/>
)}
{view === "branches" && summary && (
<ROIBranches
summary={summary}
pulls={filteredPulls}
matchedOnly={matchedOnly}
query={query}
onQueryChange={setQuery}
onSelectPull={setSelectedPull}
/>
)}
{view === "people" && summary && (
<ROIPeopleView
summary={summary}
identityMap={settings.identity_map}
matchedOnly={matchedOnly}
onMatch={(person, login) => setMatchingPerson({ person, login })}
readOnly={readOnly}
/>
)}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Calculator settings</DialogTitle>
<DialogDescription>Connect repositories and choose how to estimate effort.</DialogDescription>
</DialogHeader>
<ROISettingsPanel
accessToken={accessToken}
initialSettings={settings}
onboarding={false}
onSaved={(updated) => {
if (
updated.source_provider !== settings.source_provider ||
updated.github_api_url !== settings.github_api_url ||
updated.gitlab_api_url !== settings.gitlab_api_url
) {
resetView(updated, false);
return;
}
setSettings(updated);
}}
onReset={resetView}
onStartSync={startSync}
readOnly={readOnly}
syncDisabled={status.running}
/>
</DialogContent>
</Dialog>
<PullReasoningDialog pull={selectedPull} summary={summary} onClose={() => setSelectedPull(null)} />
{!readOnly && (
<IdentityMatchDialog
key={matchingPerson?.login.toLowerCase() ?? "closed"}
selection={matchingPerson}
identityMap={settings.identity_map}
gatewayEmails={liveSummary?.people.flatMap((person) => (person.email ? [person.email] : [])) ?? []}
onClose={() => setMatchingPerson(null)}
onSave={updateIdentity}
/>
)}
</Page>
);
}

View file

@ -1,569 +0,0 @@
"use client";
import React from "react";
import { ChevronDown, Download, GitBranch, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
peopleCsv,
isMatchedPerson,
effortNote,
estimateLabel,
formatMoney,
formatNumber,
branchCostLabel,
highestCostPulls,
} from "./roiCalculatorData";
import type { ROIPerson, ROIPull, ROISummary } from "./roiCalculatorData";
type PullSelection = { summary: ROISummary; onSelectPull: (pull: ROIPull) => void };
export function ROIOverview({
summary,
onSelectPull,
onViewPeople,
onViewBranches,
}: PullSelection & {
onViewPeople: () => void;
onViewBranches: () => void;
}) {
const metrics = summary.metrics;
const branches = summary.branch_metrics;
const topPulls = highestCostPulls(summary.pulls);
return (
<div className="space-y-8">
<div className="@container overflow-hidden rounded-xl border">
<dl aria-label="Report overview" className="grid grid-cols-2 @min-[760px]:grid-cols-4">
<MetricCard
title="Gateway AI cost"
value={formatMoney(metrics.total_spend)}
description="All gateway usage in this period"
primary
/>
<MetricCard
title="Estimated effort"
value={`${formatNumber(metrics.total_output_hours)} hrs`}
description={summary.effort_basis === "without_ai" ? "Estimated without AI" : "Check estimate assumptions"}
/>
<MetricCard
title="Merged changes"
value={formatNumber(metrics.merged_prs)}
description={`${metrics.estimated_prs} estimated`}
/>
<MetricCard
title="People"
value={formatNumber(metrics.people_with_prs)}
description="Contributors to merged work"
/>
</dl>
</div>
<section aria-label="Cost coverage" className="space-y-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">Where AI costs are matched</h2>
<p className="text-sm text-muted-foreground">
People use gateway account costs. Branches use tagged requests for these repositories.
</p>
</div>
<div className="overflow-hidden rounded-xl border">
<Table className="min-w-[600px]">
<TableHeader className="bg-muted/40">
<TableRow className="hover:bg-transparent">
<TableHead className="px-4 text-xs">View</TableHead>
<TableHead className="px-4 text-right text-xs">Matched cost</TableHead>
<TableHead className="px-4 text-right text-xs">Unmatched cost</TableHead>
<TableHead className="px-4 text-right text-xs">Changes matched</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="px-4 py-3">
<Button variant="link" className="h-auto p-0" onClick={onViewPeople}>
People
</Button>
</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">
{formatMoney(metrics.matched_spend)}
</TableCell>
<TableCell className="px-4 py-3 text-right text-muted-foreground tabular-nums">
{formatMoney(metrics.excluded_spend)}
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">
{metrics.matched_prs} / {metrics.merged_prs}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="px-4 py-3">
<Button variant="link" className="h-auto p-0" onClick={onViewBranches}>
Branches
</Button>
</TableCell>
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">
{formatMoney(branches?.spend)}
</TableCell>
<TableCell className="px-4 py-3 text-right text-muted-foreground tabular-nums">
{formatMoney(branches?.unlinked_spend)}
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">
{branches?.matched_pulls ?? 0} / {metrics.merged_prs}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</section>
<ROIPulls
summary={summary}
pulls={topPulls}
onSelectPull={onSelectPull}
compact
onViewBranches={onViewBranches}
/>
</div>
);
}
export function ROIBranches({
summary,
onSelectPull,
pulls,
query,
onQueryChange,
matchedOnly,
}: PullSelection & {
pulls: ROIPull[];
query: string;
matchedOnly: boolean;
onQueryChange: (query: string) => void;
}) {
return (
<div className="space-y-8">
<section aria-label="Branch cost analysis" className="space-y-4">
<div className="@container overflow-hidden rounded-xl border">
<ROIMetrics summary={summary} branchMode />
<ROIComparison summary={summary} branchMode />
</div>
</section>
<ROIPulls
summary={summary}
pulls={pulls}
query={query}
matchedOnly={matchedOnly}
onQueryChange={onQueryChange}
onSelectPull={onSelectPull}
/>
</div>
);
}
function ROIPulls({
summary,
onSelectPull,
pulls,
query = "",
onQueryChange,
compact = false,
matchedOnly = false,
onViewBranches,
}: PullSelection & {
pulls: ROIPull[];
query?: string;
onQueryChange?: (query: string) => void;
compact?: boolean;
matchedOnly?: boolean;
onViewBranches?: () => void;
}) {
const changeName = summary.source_provider === "gitlab" ? "merge request" : "pull request";
const [pagination, setPagination] = React.useState({ query, visibleCount: 10 });
const visibleCount = pagination.query === query ? pagination.visibleCount : 10;
const metrics = summary.metrics;
const emptyMessage = compact
? "No merged changes with tagged costs yet. Open Branches to see how to add tags."
: `No merged ${changeName}s ${matchedOnly ? "from matched people " : ""}in this period.`;
return (
<section aria-label={`Merged ${changeName}s`} className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">{compact ? "Highest-cost changes" : "Costs by branch"}</h2>
<p className="text-sm text-muted-foreground">
{compact
? "Merged work ranked by tagged AI cost"
: `${pulls.length} of ${metrics.merged_prs} ${changeName}s`}
</p>
</div>
{compact ? (
<Button variant="outline" onClick={onViewBranches}>
View all branches
</Button>
) : (
<div className="relative w-full sm:w-64">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label={`Search ${changeName}s`}
className="pl-9"
placeholder={`Search ${changeName}s`}
type="search"
value={query}
onChange={(event) => onQueryChange?.(event.target.value)}
/>
</div>
)}
</div>
<div className="overflow-hidden rounded-xl border">
<Table className="min-w-[600px] table-fixed">
<TableHeader className="bg-muted/40">
<TableRow className="hover:bg-transparent">
<TableHead className="w-3/5 px-4 text-xs text-muted-foreground">
{changeName === "merge request" ? "Merge request" : "Pull request"}
</TableHead>
<TableHead className="px-4 text-right text-xs text-muted-foreground">AI cost</TableHead>
<TableHead className="px-4 text-right text-xs text-muted-foreground">Estimated effort</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pulls.slice(0, visibleCount).map((pull) => (
<TableRow key={`${pull.repo}#${pull.number}`}>
<TableCell className="whitespace-normal px-4 py-3">
<Button
aria-label={`Open estimate for ${pull.repo} ${changeName} ${pull.number}`}
className="h-auto w-full justify-start whitespace-normal p-0 text-left"
variant="link"
onClick={() => onSelectPull(pull)}
>
<span className="min-w-0 space-y-1">
<span className="block break-words font-medium leading-5">{pull.title}</span>
<span className="block break-all text-xs font-normal text-muted-foreground">
{pull.repo} #{pull.number} · {pull.login}
</span>
{!compact && pull.source_branch && (
<span className="flex items-start gap-1.5 text-xs font-normal text-muted-foreground">
<GitBranch aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
<span className="break-all">{pull.source_branch}</span>
</span>
)}
</span>
</Button>
</TableCell>
<TableCell
className={`px-4 py-3 text-right tabular-nums ${pull.branch_cost?.status === "matched" ? "font-medium" : "whitespace-normal text-xs text-muted-foreground"}`}
>
{branchCostLabel(pull)}
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{estimateLabel(pull.estimate)}</TableCell>
</TableRow>
))}
{pulls.length === 0 && (
<TableRow>
<TableCell className="h-32 text-center text-muted-foreground" colSpan={3}>
{query ? `No matching ${changeName}s. Try another search.` : emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{pulls.length > visibleCount && (
<div className="flex items-center justify-between gap-4 border-t px-4 py-3">
<p className="text-xs text-muted-foreground">
Showing {Math.min(visibleCount, pulls.length)} of {pulls.length}
</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setPagination((current) => ({
query,
visibleCount: (current.query === query ? current.visibleCount : 10) + 25,
}))
}
>
Load more {changeName}s
</Button>
</div>
)}
</div>
</section>
);
}
function ROIMetrics({ summary, branchMode }: { summary: ROISummary; branchMode: boolean }) {
const branches = summary.branch_metrics;
const metrics = summary.metrics;
return (
<dl aria-label="Spend and estimated engineering effort" className="grid grid-cols-2 @min-[760px]:grid-cols-4">
<MetricCard
title="Cost / estimated hour"
value={formatMoney(branchMode ? branches?.cost_per_hour : metrics.cost_per_hour)}
description="AI cost ÷ estimated effort"
primary
/>
<MetricCard
title="Matched AI costs"
value={formatMoney(branchMode ? branches?.spend : metrics.matched_spend)}
description={branchMode ? "Recorded branch requests" : "Matched gateway accounts"}
/>
<MetricCard
title="Estimated effort"
value={`${formatNumber(branchMode ? branches?.hours : metrics.output_hours)} hrs`}
description={summary.effort_basis === "without_ai" ? "Estimated without AI" : "Check estimate assumptions"}
/>
<MetricCard
title={branchMode ? "Branch coverage" : "Email coverage"}
value={`${branchMode ? branches?.matched_pulls ?? 0 : metrics.matched_prs} / ${metrics.merged_prs}`}
description={branchMode ? "Changes with recorded costs" : "Changes with email matches"}
/>
</dl>
);
}
function MetricCard({
title,
value,
description,
primary = false,
}: {
title: string;
value: string;
description: string;
primary?: boolean;
}) {
return (
<div className={`min-w-0 space-y-2 p-4 sm:p-5 ${primary ? "bg-muted/30" : ""}`}>
<dt className="min-h-8 text-xs font-medium text-muted-foreground @min-[360px]:min-h-0">{title}</dt>
<dd className="whitespace-nowrap text-xl font-semibold tracking-tight tabular-nums @min-[600px]:text-2xl">
{value}
</dd>
<dd className="text-xs leading-5 text-muted-foreground">{description}</dd>
</div>
);
}
function ROIComparison({
summary,
branchMode,
onViewPeople,
}: {
summary: ROISummary;
branchMode: boolean;
onViewPeople?: () => void;
}) {
const branches = summary.branch_metrics;
const metrics = summary.metrics;
const unavailableRate =
metrics.output_hours > 0
? "Spend per estimated hour is unavailable until all selected repositories can be read."
: "Match gateway accounts to calculate costs per estimated hour.";
return (
<div className="border-t">
{!branchMode && metrics.cohort_people === 0 && onViewPeople && (
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3">
<p className="text-sm text-muted-foreground">Match people to gateway accounts to see their AI costs.</p>
<Button variant="outline" size="sm" onClick={onViewPeople}>
Match people
</Button>
</div>
)}
<details className="group">
<summary className="flex cursor-pointer list-none flex-wrap items-center gap-x-3 gap-y-1 px-5 py-3 text-xs [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 font-medium">
<ChevronDown aria-hidden="true" className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
How this is calculated
</span>
<span className="text-muted-foreground sm:ml-auto">
{formatMoney(branchMode ? branches?.unlinked_spend : metrics.excluded_spend)}{" "}
{branchMode ? "in unmatched costs" : "excluded from calculation"}
</span>
</summary>
<div className="space-y-3 border-t bg-muted/20 px-5 py-4 text-sm leading-6 text-muted-foreground">
<p>{effortNote(summary.effort_basis)}</p>
{branchMode ? (
<>
<p>
Only branches with matched request costs and complete effort estimates enter the calculation. Costs
cover retained requests in this report’s UTC dates, not the branch’s lifetime.
</p>
<p>
Open a change below to find its repository and branch tags. Send both with each gateway request. Email
matching is not required. Shared branches stay ambiguous so their costs are not counted twice.
</p>
<p>
{formatMoney(branches?.total_tagged_spend)} in tagged costs was found for these repositories. Costs
without a unique, fully estimated change stay unmatched.
</p>
{(summary.unlinked_branches?.length ?? 0) > 0 && (
<div className="space-y-2 border-t pt-3">
<h3 className="font-medium text-foreground">Unmatched branches</h3>
<ul className="divide-y">
{summary.unlinked_branches?.map((row) => (
<li
key={`${row.repo}:${row.branch}`}
className="flex items-start justify-between gap-4 py-2 text-xs"
>
<span className="min-w-0 break-all">
{row.repo}
<br />
{row.branch}
</span>
<span className="shrink-0 tabular-nums">{formatMoney(row.spend)}</span>
</li>
))}
</ul>
</div>
)}
</>
) : (
<>
<p>
{metrics.cost_per_hour != null
? `${formatMoney(metrics.matched_spend)} AI costs ÷ ${formatNumber(metrics.output_hours)} estimated hours = ${formatMoney(metrics.cost_per_hour)} per estimated hour.`
: unavailableRate}
</p>
<p>
Includes {metrics.cohort_people} matched {metrics.cohort_people === 1 ? "person" : "people"} with
complete estimates. Costs include each person’s full gateway usage across repositories during this UTC
period.
</p>
{onViewPeople && (
<Button variant="link" className="h-auto p-0" onClick={onViewPeople}>
Review email matches
</Button>
)}
</>
)}
</div>
</details>
</div>
);
}
export function ROIPeopleView({
summary,
identityMap,
onMatch,
readOnly = false,
matchedOnly = true,
}: {
summary: ROISummary;
identityMap: Record<string, string>;
onMatch: (person: ROIPerson, login: string) => void;
readOnly?: boolean;
matchedOnly?: boolean;
}) {
const people = matchedOnly ? summary.people.filter(isMatchedPerson) : summary.people;
const exportCsv = () => {
const url = URL.createObjectURL(new Blob([peopleCsv({ ...summary, people })], { type: "text/csv;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = "litellm-roi.csv";
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return (
<div className="space-y-6">
<div className="@container overflow-hidden rounded-xl border">
<ROIMetrics summary={summary} branchMode={false} />
<ROIComparison summary={summary} branchMode={false} />
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">People and account matches</h2>
<p className="text-sm text-muted-foreground">Select a person to match their gateway email.</p>
</div>
<Button variant="outline" onClick={exportCsv}>
<Download />
Export CSV
</Button>
</div>
<div className="overflow-hidden rounded-xl border">
<Table className="min-w-[640px]">
<TableHeader className="bg-muted/40">
<TableRow className="hover:bg-transparent">
<TableHead className="px-4 text-xs text-muted-foreground">Person</TableHead>
<TableHead className="px-4 text-right text-xs text-muted-foreground">AI cost</TableHead>
<TableHead className="px-4 text-right text-xs text-muted-foreground">Estimated effort</TableHead>
<TableHead className="px-4 text-right text-xs text-muted-foreground">Cost / est. hour</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{people.map((person) => (
<TableRow key={person.id}>
<TableCell className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
{person.logins.length ? (
person.logins.map((login) =>
readOnly ? (
<span key={login}>{login}</span>
) : (
<Button
key={login}
variant="link"
className="h-auto p-0"
onClick={() => onMatch(person, login)}
>
{login}
</Button>
),
)
) : (
<span>Unassigned gateway spend</span>
)}
<span className="text-xs text-muted-foreground">
{isMatchedPerson(person) ? "Matched" : "Unmatched"}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{person.email || "No public email"}
{person.logins.some((login) => identityMap[login.toLowerCase()]) ? " · Manual match" : ""}
</p>
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">{formatMoney(person.spend)}</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">
{person.estimated_prs > 0 ? `${formatNumber(person.hours)} hrs` : "—"}
<p className="mt-1 text-xs text-muted-foreground">
{person.prs} {person.prs === 1 ? "change" : "changes"}
{person.pending_prs > 0 ? ` · ${person.pending_prs} pending` : ""}
</p>
</TableCell>
<TableCell className="px-4 py-3 text-right tabular-nums">
{formatMoney(person.cost_per_hour)}
{!person.eligible && <p className="mt-1 text-xs text-muted-foreground">Not included</p>}
</TableCell>
</TableRow>
))}
{people.length === 0 && (
<TableRow>
<TableCell className="h-32 text-center text-muted-foreground" colSpan={4}>
{matchedOnly
? "No matched people in this period. Turn off the filter to see all contributors"
: "No people in this period"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<details className="group rounded-xl border">
<summary className="flex cursor-pointer list-none items-center gap-2 px-5 py-3 text-xs font-medium [&::-webkit-details-marker]:hidden">
<ChevronDown aria-hidden="true" className="size-3.5 transition-transform group-open:rotate-180" />
How email matching works
</summary>
<div className="space-y-3 border-t px-5 py-4 text-sm leading-6 text-muted-foreground">
<p>
Matches use the author’s public profile email
{summary.source_provider === "gitlab"
? "."
: " or commit emails associated with their GitHub account."}{" "}
Private, noreply, and ambiguous emails stay unmatched. Manual matches take priority.
</p>
<p>
Costs include each person’s full gateway usage for this period. People without a spend record or with
incomplete estimates are not included in the calculation.
</p>
<p>{effortNote(summary.effort_basis)}</p>
</div>
</details>
</div>
);
}

View file

@ -1,566 +0,0 @@
"use client";
import React from "react";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { estimatorModelOptions } from "./roiCalculatorData";
import { apiClient } from "@/components/networking";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import type { ROIRepository, ROIRepositoriesResponse, ROISettings, ROISettingsUpdate } from "./roiCalculatorData";
export default function ROISettingsPanel({
accessToken,
initialSettings,
onboarding,
onSaved,
onReset,
onStartSync,
readOnly,
syncDisabled,
}: {
accessToken: string | null;
initialSettings: ROISettings;
onboarding: boolean;
onSaved: (settings: ROISettings) => void;
onReset: (settings: ROISettings) => void;
onStartSync: () => Promise<void>;
readOnly: boolean;
syncDisabled: boolean;
}) {
const [provider, setProvider] = React.useState<"github" | "gitlab">(initialSettings.source_provider ?? "github");
const sourceName = provider === "gitlab" ? "GitLab" : "GitHub";
const savedToken = provider === "gitlab" ? initialSettings.has_gitlab_token : initialSettings.has_github_token;
const savedUrl = provider === "gitlab" ? initialSettings.gitlab_api_url : initialSettings.github_api_url;
const initialStep = savedToken ? 1 : 0;
const [step, setStep] = React.useState(initialSettings.ready ? 2 : initialStep);
const [apiUrl, setApiUrl] = React.useState(savedUrl ?? "https://gitlab.com/api/v4");
const [token, setToken] = React.useState("");
const [clearToken, setClearToken] = React.useState(false);
const [repos, setRepos] = React.useState(initialSettings.repos);
const [model, setModel] = React.useState(initialSettings.estimator_model);
const [prompt, setPrompt] = React.useState(initialSettings.estimator_prompt);
const [backfillDays, setBackfillDays] = React.useState(String(initialSettings.backfill_days));
const [intervalHours, setIntervalHours] = React.useState(
String((initialSettings.update_interval_minutes ?? 1440) / 60),
);
const [estimatorKey, setEstimatorKey] = React.useState("");
const [clearEstimatorKey, setClearEstimatorKey] = React.useState(false);
const [repositoryName, setRepositoryName] = React.useState("");
const [resetOpen, setResetOpen] = React.useState(false);
const [repositoryQuery, setRepositoryQuery] = React.useState("");
const [repositoryPage, setRepositoryPage] = React.useState(1);
const [availableRepos, setAvailableRepos] = React.useState<ROIRepository[]>([]);
const [hasMoreRepos, setHasMoreRepos] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [message, setMessage] = React.useState<string | null>(null);
const sourceUnchanged = provider === (initialSettings.source_provider ?? "github") && apiUrl === savedUrl;
const credentialsSaved = sourceUnchanged && !token.trim() && !clearToken;
const canLoadRepositories = credentialsSaved && (provider === "gitlab" || savedToken);
const tokenHelp =
provider === "gitlab"
? "For private projects, use a token with read_api scope and project access."
: "For private repositories, use a token with read access to contents and pull requests.";
const changeProvider = (next: "github" | "gitlab") => {
setProvider(next);
setApiUrl(
next === "gitlab"
? initialSettings.gitlab_api_url ?? "https://gitlab.com/api/v4"
: initialSettings.github_api_url,
);
setToken("");
setClearToken(false);
setRepos([]);
setAvailableRepos([]);
setHasMoreRepos(false);
};
const loadRepositories = async (page: number) => {
if (!accessToken || !canLoadRepositories) return;
try {
setBusy(true);
const response: ROIRepositoriesResponse = await apiClient.get("/roi-calculator/repositories", {
accessToken,
query: { query: repositoryQuery, page },
});
setAvailableRepos((current) => (page === 1 ? response.repositories : [...current, ...response.repositories]));
setHasMoreRepos(response.has_more);
setRepositoryPage(page);
setError(null);
} catch (reason) {
setError(extractErrorMessage(reason));
} finally {
setBusy(false);
}
};
const saveSettings = async () => {
if (!accessToken || readOnly) return false;
const tokenValue = clearToken ? null : token.trim() || undefined;
const body: ROISettingsUpdate = {
source_provider: provider,
...(provider === "gitlab" ? { gitlab_api_url: apiUrl } : { github_api_url: apiUrl }),
repos,
estimator_model: model,
estimator_prompt: prompt,
backfill_days: Number(backfillDays),
update_interval_minutes: Number(intervalHours) * 60,
...(clearEstimatorKey ? { estimator_key: null } : {}),
...(estimatorKey.trim() ? { estimator_key: estimatorKey.trim() } : {}),
...(provider === "gitlab" ? { gitlab_token: tokenValue } : { github_token: tokenValue }),
};
try {
setBusy(true);
const updated: ROISettings = await apiClient.put("/roi-calculator/settings", { accessToken, body });
onSaved(updated);
setToken("");
setEstimatorKey("");
setClearEstimatorKey(false);
setClearToken(false);
setMessage("Settings saved.");
setError(null);
return true;
} catch (reason) {
setError(extractErrorMessage(reason));
setMessage(null);
return false;
} finally {
setBusy(false);
}
};
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!(await saveSettings())) return;
if (onboarding && step === 0) {
setStep(1);
if (!(token.trim() || savedToken) || clearToken) return;
try {
const result = await apiClient.get<ROIRepositoriesResponse>("/roi-calculator/repositories", { accessToken });
setAvailableRepos(result.repositories);
setHasMoreRepos(result.has_more);
setRepositoryPage(1);
setStep(1);
} catch (reason) {
setError(extractErrorMessage(reason));
}
} else if (onboarding && step === 1) setStep(2);
else if (onboarding) await onStartSync();
};
const saveAndRun = async () => {
if (await saveSettings()) await onStartSync();
};
const testConnections = async () => {
if (!(await saveSettings())) return;
setBusy(true);
try {
await apiClient.post("/roi-calculator/connections/test", { accessToken });
setMessage("Gateway model and selected repositories are available.");
} catch (reason) {
setError(extractErrorMessage(reason));
} finally {
setBusy(false);
}
};
const resetSetup = async () => {
setBusy(true);
try {
const updated = await apiClient.post<ROISettings>("/roi-calculator/setup/reset", { accessToken });
setRepos([]);
setStep(0);
setResetOpen(false);
onReset(updated);
} catch (reason) {
setError(extractErrorMessage(reason));
} finally {
setBusy(false);
}
};
const toggleRepository = (name: string) => {
setRepos((current) => (current.includes(name) ? current.filter((repo) => repo !== name) : [...current, name]));
};
const formDisabled = busy || syncDisabled;
const runDisabled = formDisabled || !repos.length || !model;
const sourceUrlChanged = apiUrl !== savedUrl;
const missingReplacementToken = savedToken && sourceUrlChanged && !token.trim();
const stepReady = [true, repos.length > 0, Boolean(model)][step];
const onboardingLabel = step < 2 ? "Continue" : "Start backfill";
const submitLabel = onboarding ? onboardingLabel : "Save settings";
return (
<Card className={onboarding ? "max-w-2xl" : "border-0 py-0 shadow-none ring-0"}>
{onboarding && (
<CardHeader>
<h2 className="text-base leading-normal font-medium">
{["Connect your repositories", "Choose repositories", "Choose an estimator"][step]}
</h2>
<CardDescription>
Your gateway is already connected. Choose a source and an estimator for your first report.
</CardDescription>
</CardHeader>
)}
<CardContent className={onboarding ? "space-y-5" : "space-y-5 px-0"}>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{message && (
<p className="text-sm text-emerald-700" role="status">
{message}
</p>
)}
{onboarding && (
<p className="text-sm text-muted-foreground">Step {step + 1} of 3 · Source / Repositories / Estimator</p>
)}
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
<fieldset disabled={busy || syncDisabled || readOnly} className="space-y-5">
{(!onboarding || step === 0) && (
<section className="space-y-4">
{!onboarding && <h3 className="font-semibold">Connection</h3>}
<div className="grid gap-2">
<Label htmlFor="roi-source">Repository source</Label>
<select
id="roi-source"
className="h-9 min-w-0 rounded-md border bg-background pl-3 pr-9 text-sm"
value={provider}
onChange={(event) => changeProvider(event.target.value as "github" | "gitlab")}
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
</select>
{provider !== (initialSettings.source_provider ?? "github") && !onboarding && (
<p className="text-xs text-muted-foreground">
Switching source starts a new report and resets email matches.
</p>
)}
</div>
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">Self-hosted {sourceName}</summary>
<div className="mt-3 grid gap-2">
<Label htmlFor="roi-github-url">{sourceName} API URL</Label>
<Input
disabled={readOnly}
id="roi-github-url"
value={apiUrl}
onChange={(event) => setApiUrl(event.target.value)}
/>
</div>
</details>
<div className="grid gap-2">
<Label htmlFor="roi-github-token">{sourceName} token (optional for public repositories)</Label>
<Input
autoComplete="new-password"
disabled={readOnly}
id="roi-github-token"
type="password"
value={token}
onChange={(event) => {
setToken(event.target.value);
setClearToken(false);
}}
placeholder={savedToken ? "Token saved" : `Enter a ${sourceName} token`}
/>
<p className="text-xs text-muted-foreground">
{savedToken ? "A token is saved securely and is never shown here." : tokenHelp}
</p>
{missingReplacementToken && (
<p className="text-xs text-amber-700">
Changing the API URL clears the saved token. Enter a replacement token to keep access.
</p>
)}
{savedToken && (
<label className="flex items-center gap-2 text-sm">
<input
aria-label={`Clear saved ${sourceName} token`}
checked={clearToken}
disabled={readOnly}
type="checkbox"
onChange={(event) => setClearToken(event.target.checked)}
/>
Clear saved token
</label>
)}
</div>
</section>
)}
{(!onboarding || step === 1) && (
<section className={onboarding ? "space-y-4" : "space-y-4 border-t pt-5"}>
<h3 className="font-semibold">Repositories</h3>
<Label className="sr-only" htmlFor="roi-repository-search">
Search repositories
</Label>
<div className="flex gap-2">
<Input
id="roi-repository-search"
value={repositoryQuery}
onChange={(event) => setRepositoryQuery(event.target.value)}
placeholder="Search repositories"
/>
<Button
type="button"
variant="outline"
disabled={busy || !canLoadRepositories}
onClick={() => void loadRepositories(1)}
>
Load repositories
</Button>
</div>
{!canLoadRepositories && (
<p className="text-xs text-muted-foreground">
{provider === "github" && !savedToken
? "Save a GitHub token to browse repositories, or add a public repository by name."
: "Save the source and connection settings before loading repositories."}
</p>
)}
{repos.length > 0 && (
<div className="flex flex-wrap gap-2">
{repos.map((repo) => (
<Button
key={repo}
type="button"
variant="outline"
disabled={readOnly}
onClick={() => toggleRepository(repo)}
aria-label={`Remove ${repo}`}
>
{repo} ×
</Button>
))}
</div>
)}
<div>
<Label htmlFor="roi-repository-name">Add a repository by name</Label>
<div className="mt-2 flex gap-2">
<Input
id="roi-repository-name"
aria-label="Repository name"
placeholder={provider === "gitlab" ? "group/subgroup/project" : "owner/repository"}
value={repositoryName}
onChange={(e) => setRepositoryName(e.target.value)}
/>
<Button
type="button"
variant="outline"
disabled={!repositoryName.trim()}
onClick={() => {
if (!repos.includes(repositoryName.trim())) setRepos([...repos, repositoryName.trim()]);
setRepositoryName("");
}}
>
Add
</Button>
</div>
</div>
{availableRepos.length > 0 && (
<div className="max-h-56 space-y-2 overflow-y-auto rounded-md border p-3">
{availableRepos.map((repository) => (
<label key={repository.name} className="flex items-center gap-2 text-sm">
<input
aria-label={`Select ${repository.name}`}
checked={repos.includes(repository.name)}
disabled={readOnly}
type="checkbox"
onChange={() => toggleRepository(repository.name)}
/>
<span>{repository.name}</span>
<span className="text-xs text-muted-foreground">
{repository.visibility}
{repository.archived ? " · archived" : ""}
</span>
</label>
))}
</div>
)}
{hasMoreRepos && (
<Button
type="button"
variant="link"
className="w-fit px-0"
disabled={busy}
onClick={() => void loadRepositories(repositoryPage + 1)}
>
Load more repositories
</Button>
)}
</section>
)}
{(!onboarding || step === 2) && (
<section className={onboarding ? "space-y-4" : "space-y-4 border-t pt-5"}>
{!onboarding && <h3 className="font-semibold">Estimation and updates</h3>}
<div className="grid gap-2">
<Label htmlFor="roi-estimator-model">Estimator model</Label>
<SearchSelect
inputId="roi-estimator-model"
options={estimatorModelOptions(initialSettings)}
value={model}
onValueChange={(value) => setModel(value ?? "")}
placeholder="Search estimator models"
emptyText="No matching models configured on this gateway"
disabled={readOnly}
className="h-9"
/>
<p className="text-xs text-muted-foreground">
We recommend GPT-6 Luna for estimating PR effort. Choose a model configured on your gateway.
</p>
</div>
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced estimator options</summary>
<div className="mt-3 grid gap-2">
<Label htmlFor="roi-estimator-prompt">Estimator prompt</Label>
<Textarea
id="roi-estimator-prompt"
rows={5}
disabled={readOnly}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
</details>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid content-start gap-2">
<Label htmlFor="roi-backfill-days">Backfill days</Label>
<Input
id="roi-backfill-days"
min={1}
max={3650}
type="number"
disabled={readOnly}
value={backfillDays}
onChange={(event) => setBackfillDays(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="roi-interval">Update interval (hours)</Label>
<Input
id="roi-interval"
type="number"
min={0}
max={720}
step="any"
required
value={intervalHours}
onChange={(e) => setIntervalHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Use 0 for manual updates. Automatic updates require at least 5 minutes and a running gateway.
</p>
</div>
</div>
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced settings</summary>
<div className="mt-3 space-y-3">
<Label htmlFor="roi-estimator-key">Estimator API key</Label>
<Input
id="roi-estimator-key"
type="password"
autoComplete="new-password"
value={estimatorKey}
onChange={(e) => {
setEstimatorKey(e.target.value);
setClearEstimatorKey(false);
}}
placeholder={initialSettings.has_estimator_key ? "Key saved" : "Optional gateway key"}
/>
<p className="text-xs text-muted-foreground">
Defaults to the gateway admin key. Use a dedicated inference key to separate estimation costs from
people&apos;s spend.
</p>
{initialSettings.has_estimator_key && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clearEstimatorKey}
onChange={(e) => setClearEstimatorKey(e.target.checked)}
/>
Use gateway admin key instead
</label>
)}
<Button type="button" variant="link" onClick={() => setPrompt(initialSettings.default_prompt)}>
Reset prompt
</Button>
{!onboarding && !readOnly && (
<Button type="button" variant="outline" onClick={() => setResetOpen(true)}>
Restart setup
</Button>
)}
</div>
</details>
<p className="text-xs text-muted-foreground">
Estimates use descriptions, file counts, and commit messages, without source code. Hours represent
estimated effort without AI, not measured hours saved.
</p>
</section>
)}
{!readOnly && (
<div className="flex flex-wrap gap-2 border-t pt-5">
{onboarding && step > 0 && (
<Button
type="button"
variant="outline"
disabled={busy || syncDisabled}
onClick={() => setStep(step - 1)}
>
Back
</Button>
)}
<Button disabled={formDisabled || (onboarding && !stepReady)} type="submit">
{busy ? "Saving…" : submitLabel}
</Button>
{!onboarding && (
<Button type="button" variant="outline" onClick={() => void testConnections()}>
Test connections
</Button>
)}
{!onboarding && (
<Button disabled={runDisabled} type="button" variant="outline" onClick={() => void saveAndRun()}>
Save and run analysis
</Button>
)}
</div>
)}
</fieldset>
</form>
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Restart setup?</DialogTitle>
<DialogDescription>
Clear reports and repository selections. Saved connections and cached estimates will be kept.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setResetOpen(false)}>
Cancel
</Button>
<Button disabled={busy} onClick={() => void resetSetup()}>
Restart setup
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View file

@ -1,6 +0,0 @@
import { createParser } from "nuqs";
export const parseAsDemoFlag = createParser({
parse: (value) => (value === "1" ? true : null),
serialize: () => "1",
});

View file

@ -1,234 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
change,
duration,
money,
visiblePeople,
weeklyMerges,
filterObservedPulls,
reportPeople,
recordedBranches,
type ObservedPerson,
type ObservedSnapshot,
} from "./observedData";
import { createObservedDemo } from "./observedDemo";
const period = (merged: number, spend: number | null) => ({
merged_prs: merged,
prs_per_week: merged / 4,
median_merge_hours: null,
direct_authored: merged,
declared_agent_owned: 0,
gateway_recorded_spend: spend ?? 0,
recorded_spend_per_attributed_pr: spend !== null && merged > 0 ? spend / merged : null,
spend_observation: spend === null ? ("no_records" as const) : ("records_present" as const),
pr_urls: [],
});
const person = (name: string, merged: number, spend: number | null): ObservedPerson => ({
name,
email: `${name}@example.test`,
logins: [`old-${name}`],
periods: { current: period(merged, spend), previous: period(0, null), last_year: period(0, null) },
});
describe("observed ROI metrics", () => {
it("shows the same contributors when the browser has no Map.groupBy", () => {
const sample = { ...createObservedDemo(7), people: [] };
const expected = reportPeople(sample, false);
const legacyMap = new Proxy(Map, {
get: (target, key, receiver) => (key === "groupBy" ? undefined : Reflect.get(target, key, receiver)),
});
vi.stubGlobal("Map", legacyMap);
try {
expect(reportPeople(sample, false)).toEqual(expected);
} finally {
vi.unstubAllGlobals();
}
});
it("filters by attributed changes across providers, independently of spend or author names", () => {
const sample = createObservedDemo(7);
const external = {
...sample.pulls.current[0],
url: "https://gitlab.com/demo/api/-/merge_requests/999",
author: "alex-demo",
agent: false,
connection_id: "demo-gitlab",
};
const report = { ...sample, pulls: { ...sample.pulls, current: [...sample.pulls.current, external] } };
const noSpend = {
...report,
people: report.people.map((row) => ({
...row,
periods: {
...row.periods,
current: { ...row.periods.current, gateway_recorded_spend: 0, spend_observation: "no_records" as const },
},
})),
};
expect(filterObservedPulls(noSpend, "current", true)).toEqual(sample.pulls.current);
expect(filterObservedPulls(noSpend, "current", false)).toEqual(report.pulls.current);
expect(filterObservedPulls(noSpend, "current", true).some((pull) => pull.agent)).toBe(true);
expect(reportPeople(noSpend, true).map((row) => row.email)).toEqual(sample.people.map((row) => row.email));
});
it("keeps unmatched identities separate by host, with unknown spend and accurate periods", () => {
const sample = createObservedDemo(7);
const external = {
...sample.pulls.current[0],
author: "contributor",
agent: false,
connection_id: "gitlab-public",
url: "https://gitlab.com/demo/api/-/merge_requests/999",
merge_hours: 4,
};
const otherHost = {
...external,
connection_id: "gitlab-private",
url: "https://git.example.test/demo/api/-/merge_requests/999",
merge_hours: null,
};
const requested = {
...external,
url: "https://gitlab.com/demo/api/-/merge_requests/1000",
author: "devin-ai",
agent: true,
requester: "contributor",
merge_hours: 8,
};
const unassigned = { ...requested, url: "https://gitlab.com/demo/api/-/merge_requests/1001", requester: "" };
const report = {
...sample,
pulls: {
...sample.pulls,
current: [...sample.pulls.current, external, otherHost, requested, unassigned],
previous: [external],
},
};
const outsiders = reportPeople(report, false).filter((row) => !row.matched);
expect(outsiders).toHaveLength(2);
expect(new Set(outsiders.map((row) => row.id)).size).toBe(2);
const publicPerson = outsiders.find((row) => row.host === "gitlab.com")!;
const expectedCurrent = {
merged_prs: 2,
prs_per_week: 2,
median_merge_hours: 6,
direct_authored: 1,
declared_agent_owned: 1,
spend_observation: "no_records",
recorded_spend_per_attributed_pr: null,
};
expect(publicPerson.periods.current).toMatchObject(expectedCurrent);
expect(publicPerson.periods.previous.merged_prs).toBe(1);
expect(outsiders.find((row) => row.host === "git.example.test")!.periods.current.median_merge_hours).toBeNull();
expect(filterObservedPulls(report, "current", false)).toContain(unassigned);
});
it("filters branch spend using matched change ownership and the full repository and branch key", () => {
const sample = createObservedDemo(7);
const own = sample.pulls.current[0];
const externalCost = { ...own.branch_cost, repo: "gitlab.com/outside/service", spend: 99 };
const external = {
...own,
url: "https://gitlab.com/outside/service/-/merge_requests/999",
branch_cost: externalCost,
};
const shared = { repo: own.branch_cost.repo, branch: "shared", spend: 12, requests: 4 };
const ambiguous = {
...own,
source_branch: shared.branch,
branch_cost: { ...shared, status: "ambiguous" as const },
};
const report = {
...sample,
pulls: { ...sample.pulls, current: [ambiguous, external] },
unlinked_branches: [shared, { ...shared, branch: "unowned" }],
};
expect(recordedBranches(report, true)).toEqual([shared]);
expect(recordedBranches(report, false).map((row) => row.branch)).toEqual([
externalCost.branch,
"shared",
"unowned",
]);
});
it("does not claim infinite growth when the baseline is missing", () => {
expect(change(12, 0)).toBeNull();
expect(change(0, 0)).toBeNull();
expect(change(15, 10)).toBe(50);
expect(change(0, 10)).toBe(-100);
});
it("distinguishes missing cost, measured zero, and small nonzero spend", () => {
expect(money(null)).toBe("Unavailable");
expect(money(0)).toBe("$0.00");
expect(money(0.001)).toBe("<$0.01");
expect(money(1.235)).toBe("$1.24");
expect(duration(null)).toBe("Unavailable");
});
it("searches historical identities and sorts without changing the source", () => {
const people = [person("Ari", 2, 8), person("Bea", 10, 0), person("Cam", 0, null)];
expect(visiblePeople(people, " OLD-ARI ", "merged").map((row) => row.name)).toEqual(["Ari"]);
expect(visiblePeople(people, "", "merged").map((row) => row.name)).toEqual(["Bea", "Ari", "Cam"]);
expect(visiblePeople(people, "", "cost").map((row) => row.name)).toEqual(["Ari", "Bea", "Cam"]);
expect(people.map((row) => row.name)).toEqual(["Ari", "Bea", "Cam"]);
expect(visiblePeople(people, "missing", "name")).toEqual([]);
});
it("keeps short elapsed merge times from rounding to zero hours", () => {
expect(duration(null)).toBe("Unavailable");
expect(duration(0)).toBe("0m");
expect(duration(16 / 3600)).toBe("<1m");
expect(duration(59 / 3600)).toBe("<1m");
expect(duration(1 / 60)).toBe("1m");
expect(duration(79 / 3600)).toBe("1.3m");
expect(duration(140 / 3600)).toBe("2.3m");
expect(duration(0.5)).toBe("30m");
expect(duration(1)).toBe("1h");
expect(duration(3.82)).toBe("3.8h");
});
it.each([7, 28, 90])("includes every day of a %s-day range in its weekly chart", (days) => {
const start = Date.parse("2026-01-01T00:00:00Z");
const atDay = (day: number) => new Date(start + day * 86400000).toISOString();
const pulls = Array.from({ length: days + 1 }, (_, day) => ({ merged_at: atDay(day) }));
const snapshot = {
periods: { current: { window: { start: "2026-01-01", end: atDay(days - 1).slice(0, 10) } } },
pulls: { current: pulls },
} as ObservedSnapshot;
const weeks = weeklyMerges(snapshot, "current");
expect(weeks).toHaveLength(Math.ceil(days / 7));
expect(weeks.reduce((sum, count) => sum + count, 0)).toBe(days);
expect(weeks.at(-1)).toBe(days % 7 || 7);
});
it("aligns comparisons to their own UTC windows and counts each boundary once", () => {
const currentStart = "2026-01-01";
const previousStart = "2025-12-04";
const pulls = [
"2026-01-01T00:00:00Z",
"2026-01-07T23:59:59Z",
"2026-01-08T00:00:00Z",
"2026-01-28T23:59:59Z",
"2026-01-29T00:00:00Z",
].map((merged_at, number) => ({
number,
merged_at,
title: "Example",
url: "https://github.com/example/repo/pull/1",
author: "ari",
agent: false,
merge_hours: 1,
}));
const snapshot = {
periods: {
current: { window: { start: currentStart, end: "2026-01-28" } },
previous: { window: { start: previousStart, end: "2025-12-31" } },
},
pulls: { current: pulls, previous: [{ ...pulls[0], merged_at: `${previousStart}T00:00:00Z` }] },
} as ObservedSnapshot;
expect(weeklyMerges(snapshot, "current")).toEqual([2, 1, 0, 1]);
expect(weeklyMerges(snapshot, "previous")).toEqual([1, 0, 0, 0]);
});
});

View file

@ -1,298 +0,0 @@
import { z } from "zod";
const windowSchema = z.object({ start: z.string(), end: z.string() });
const personPeriodFields = {
merged_prs: z.number(),
prs_per_week: z.number(),
median_merge_hours: z.number().nullable(),
direct_authored: z.number(),
declared_agent_owned: z.number(),
gateway_recorded_spend: z.number(),
recorded_spend_per_attributed_pr: z.number().nullable(),
spend_observation: z.enum(["records_present", "no_records"]),
pr_urls: z.array(z.string()),
};
const personPeriodSchema = z.object(personPeriodFields);
const periodFields = {
window: windowSchema,
merged_prs: z.number(),
median_merge_hours: z.number().nullable(),
human_authored: z.number(),
agent_authored: z.number(),
missing_author: z.number(),
agents_without_requester: z.number(),
matched_internal_prs: z.number(),
new_bug_labeled_issues: z.number().nullable(),
new_regression_labeled_issues: z.number().nullable(),
explicitly_titled_revert_prs: z.number(),
matched_users_recorded_spend: z.number(),
spend_observation: z.enum(["records_present", "no_records"]),
human_summary: z.object({ median_merge_hours: z.number().nullable() }),
};
const periodSchema = z.object(periodFields);
const periods = <T extends z.ZodType>(schema: T) => z.object({ current: schema, previous: schema, last_year: schema });
const personFields = {
name: z.string(),
email: z.string(),
logins: z.array(z.string()),
periods: periods(personPeriodSchema),
accounts: z.array(z.object({ connection_id: z.string(), login: z.string() })).optional(),
};
const branchCostFields = {
repo: z.string(),
branch: z.string(),
spend: z.number().nullable(),
requests: z.number(),
status: z.enum(["matched", "unattributed", "ambiguous", "unavailable"]),
};
const branchSpendFields = { repo: z.string(), branch: z.string(), spend: z.number(), requests: z.number() };
const pullFields = {
connection_id: z.string().optional(),
number: z.number(),
title: z.string(),
url: z
.string()
.url()
.refine((url) => new URL(url).protocol === "https:"),
author: z.string(),
agent: z.boolean(),
requester: z.string().optional(),
merged_at: z.string(),
merge_hours: z.number().nullable(),
repo: z.string(),
source_repo: z.string(),
source_branch: z.string(),
branch_cost: z.object(branchCostFields),
};
const snapshotFields = {
source_provider: z.enum(["github", "gitlab", "mixed"]),
repos: z.array(z.string()),
unmatched_logins: z.array(z.string()),
unlinked_branches: z.array(z.object(branchSpendFields)),
captured_at: z.string(),
periods: periods(periodSchema),
people: z.array(z.object(personFields)),
pulls: periods(z.array(z.object(pullFields))),
};
export const observedSnapshotSchema = z.object(snapshotFields);
const settingsFields = {
id: z.string().optional(),
source_provider: z.enum(["github", "gitlab"]),
api_url: z.string(),
repos: z.array(z.string()),
has_token: z.boolean(),
connection_type: z.enum(["token", "app"]),
update_interval_minutes: z.number(),
ready: z.boolean(),
};
export const observedConnectionSchema = z.object(settingsFields);
export const observedSettingsSchema = z.object({
...settingsFields,
connections: z.array(observedConnectionSchema).optional(),
});
export type ObservedConnection = z.infer<typeof observedConnectionSchema>;
const statusFields = {
running: z.boolean(),
phase: z.string(),
stage: z.string(),
done: z.number(),
total: z.number(),
error: z.string().nullable(),
finished_at: z.string().nullable().optional(),
};
export const observedStatusSchema = z.object(statusFields);
export const observedReportResponseSchema = z.object({ report: observedSnapshotSchema.nullable() });
export type ObservedSettings = z.infer<typeof observedSettingsSchema>;
export type ObservedStatus = z.infer<typeof observedStatusSchema>;
export type ObservedSnapshot = z.infer<typeof observedSnapshotSchema>;
export type ObservedPerson = ObservedSnapshot["people"][number];
export type ObservedPull = ObservedSnapshot["pulls"]["current"][number];
export type Period = keyof ObservedSnapshot["periods"];
export type Comparison = Exclude<Period, "current">;
export type PeopleSort = "merged" | "spend" | "cost" | "name";
export type ReportPerson = ObservedPerson & { id: string; matched: boolean; host: string };
export const number = (value: number | null) =>
value === null ? "Unavailable" : value.toLocaleString("en-US", { maximumFractionDigits: 1 });
export function money(value: number | null) {
if (value === null) return "Unavailable";
if (value > 0 && value < 0.01) return "<$0.01";
return value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 });
}
export function duration(value: number | null) {
if (value === null) return "Unavailable";
if (value === 0) return "0m";
if (value < 1 / 60) return "<1m";
if (value < 1) return `${number(value * 60)}m`;
return `${number(value)}h`;
}
export const change = (current: number, baseline: number) =>
baseline === 0 ? null : ((current - baseline) / baseline) * 100;
export const accountLogins = (value: string) => [
...new Set(
value
.split(/[\s,]+/)
.map((login) => login.replace(/^@/, "").toLowerCase())
.filter(Boolean),
),
];
export const repositoryNames = (value: string) => [
...new Set(
value
.split(/[\s,]+/)
.filter(Boolean)
.map((repo) => {
const path = repo.replace(/^https:\/\/[^/]+\//, "");
return path.replace(/\/$/, "").replace(/\.git$/, "");
}),
),
];
export function dateRange(window: { start: string; end: string }) {
const date = (value: string) =>
new Date(`${value}T00:00:00Z`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
return `${date(window.start)} – ${date(window.end)}, ${window.end.slice(0, 4)}`;
}
export function visiblePeople<T extends ObservedPerson>(people: T[], query: string, sort: PeopleSort) {
const value = (person: ObservedPerson) => {
const current = person.periods.current;
if (sort === "spend") return current.spend_observation === "no_records" ? -1 : current.gateway_recorded_spend;
if (sort === "cost") return current.recorded_spend_per_attributed_pr ?? -1;
return current.merged_prs;
};
return people
.filter((person) =>
[person.name, person.email, ...person.logins].join(" ").toLowerCase().includes(query.trim().toLowerCase()),
)
.toSorted((a, b) => (sort === "name" ? a.name.localeCompare(b.name) : value(b) - value(a)));
}
export function weeklyMerges(snapshot: ObservedSnapshot, period: Period) {
const start = Date.parse(`${snapshot.periods[period].window.start}T00:00:00Z`);
const end = Date.parse(`${snapshot.periods[period].window.end}T00:00:00Z`) + 86_400_000;
const days = (end - start) / 86_400_000;
return Array.from(
{ length: Math.ceil(days / 7) },
(_, week) =>
snapshot.pulls[period].filter((pull) => {
const day = (Date.parse(pull.merged_at) - start) / 86_400_000;
return day >= week * 7 && day < Math.min((week + 1) * 7, days);
}).length,
);
}
export function syncMessage(status: ObservedStatus, report: ObservedSnapshot | null) {
if (status.running) return status.total ? `${status.stage} · ${status.done} / ${status.total}` : status.stage;
return report ? `Updated ${new Date(report.captured_at).toLocaleString()}` : "";
}
export function recordedBranches(snapshot: ObservedSnapshot, matchedOnly = false) {
const matched = snapshot.pulls.current.flatMap((pull) => {
const cost = pull.branch_cost;
if (cost.status !== "matched" || cost.spend === null) return [];
return [{ repo: cost.repo, branch: cost.branch, spend: cost.spend, requests: cost.requests }];
});
const rows = [
...new Map([...matched, ...snapshot.unlinked_branches].map((row) => [`${row.repo}\n${row.branch}`, row])).values(),
];
const keys = new Set(
filterObservedPulls(snapshot, "current", true).map(
(pull) => `${pull.branch_cost.repo}\n${pull.branch_cost.branch}`,
),
);
return rows
.filter((row) => !matchedOnly || keys.has(`${row.repo}\n${row.branch}`))
.toSorted((a, b) => b.spend - a.spend);
}
export function filterObservedPulls(snapshot: ObservedSnapshot, period: Period, matchedOnly: boolean) {
if (!matchedOnly) return snapshot.pulls[period];
const urls = new Set(snapshot.people.flatMap((person) => person.periods[period].pr_urls));
return snapshot.pulls[period].filter((pull) => urls.has(pull.url));
}
function unmatchedPeriod(
pulls: ObservedPull[],
window: { start: string; end: string },
): ObservedPerson["periods"]["current"] {
const hours = pulls
.flatMap((pull) => (pull.merge_hours === null ? [] : [pull.merge_hours]))
.toSorted((a, b) => a - b);
const middle = Math.floor(hours.length / 2);
const median = hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2;
const days = (Date.parse(window.end) - Date.parse(window.start)) / 86_400_000 + 1;
return {
merged_prs: pulls.length,
prs_per_week: (pulls.length * 7) / days,
median_merge_hours: hours.length ? median : null,
direct_authored: pulls.filter((pull) => !pull.agent).length,
declared_agent_owned: pulls.filter((pull) => pull.agent).length,
gateway_recorded_spend: 0,
recorded_spend_per_attributed_pr: null,
spend_observation: "no_records",
pr_urls: pulls.map((pull) => pull.url),
};
}
const ownerLogin = (pull: ObservedPull) => (pull.agent ? pull.requester ?? "" : pull.author);
const accountKey = (pull: ObservedPull) =>
`${pull.connection_id ?? new URL(pull.url).origin}\n${ownerLogin(pull).toLowerCase()}`;
export function reportPeople(snapshot: ObservedSnapshot, matchedOnly: boolean): ReportPerson[] {
const matched = snapshot.people.map((person) => ({ ...person, id: person.email, matched: true, host: "" }));
if (matchedOnly) return matched;
const unmatchedGroups = (period: Period) => {
const urls = new Set(snapshot.people.flatMap((person) => person.periods[period].pr_urls));
const pulls = snapshot.pulls[period].filter((pull) => !urls.has(pull.url) && ownerLogin(pull));
const sorted = pulls
.map((pull) => ({ key: accountKey(pull), pull }))
.toSorted((a, b) => a.key.localeCompare(b.key));
const starts = sorted.flatMap((entry, index) =>
index === 0 || entry.key !== sorted[index - 1].key ? [index] : [],
);
return new Map(
starts.map((start, index) => [
sorted[start].key,
sorted.slice(start, starts[index + 1]).map((entry) => entry.pull),
]),
);
};
const groups = {
current: unmatchedGroups("current"),
previous: unmatchedGroups("previous"),
last_year: unmatchedGroups("last_year"),
};
const accounts = new Map([...groups.current, ...groups.previous, ...groups.last_year]);
const unmatched = [...accounts].map(([id, pulls]) => {
const pull = pulls[0];
const login = ownerLogin(pull);
const period = (key: Period) => unmatchedPeriod(groups[key].get(id) ?? [], snapshot.periods[key].window);
return {
id,
matched: false,
host: new URL(pull.url).host,
name: login,
email: "",
logins: [login],
accounts: [{ connection_id: pull.connection_id ?? "", login }],
periods: { current: period("current"), previous: period("previous"), last_year: period("last_year") },
};
});
return [...matched, ...unmatched];
}
export function changeTerms(provider: ObservedSnapshot["source_provider"]) {
if (provider === "mixed")
return { singular: "change", plural: "changes", requests: "Merged changes", lower: "merged changes" };
return provider === "gitlab"
? { singular: "MR", plural: "MRs", requests: "Merge requests", lower: "merge requests" }
: { singular: "PR", plural: "PRs", requests: "Pull requests", lower: "pull requests" };
}

View file

@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { observedSnapshotSchema, weeklyMerges, type Period } from "./observedData";
import { createObservedDemo } from "./observedDemo";
describe("observed sample report", () => {
it("clamps the year-ago comparison on leap day while keeping the selected length", () => {
const report = createObservedDemo(28, new Date("2024-03-01T12:00:00Z"));
expect(report.periods.current.window.end).toBe("2024-02-29");
expect(report.periods.last_year.window).toEqual({ start: "2023-02-01", end: "2023-02-28" });
});
it.each([7, 28, 90])("keeps totals, attribution, costs, and comparison windows consistent for %i days", (days) => {
const report = createObservedDemo(days, new Date("2026-10-03T14:00:00Z"));
expect(observedSnapshotSchema.safeParse(report).success).toBe(true);
for (const period of ["current", "previous", "last_year"] satisfies Period[]) {
const metrics = report.periods[period];
const pulls = report.pulls[period];
const people = report.people.map((person) => person.periods[period]);
const start = Date.parse(metrics.window.start);
const end = Date.parse(metrics.window.end) + 86_400_000;
expect((end - start) / 86_400_000).toBe(days);
expect(metrics.merged_prs).toBe(pulls.length);
expect(new Set(pulls.map((pull) => pull.url)).size).toBe(pulls.length);
expect(weeklyMerges(report, period).reduce((sum, value) => sum + value, 0)).toBe(pulls.length);
expect(pulls.every((pull) => Date.parse(pull.merged_at) >= start && Date.parse(pull.merged_at) < end)).toBe(true);
expect(metrics.matched_users_recorded_spend).toBe(
people.reduce((sum, person) => sum + person.gateway_recorded_spend, 0),
);
expect(people.reduce((sum, person) => sum + person.merged_prs, 0)).toBe(metrics.matched_internal_prs);
for (const person of people) {
const attributed = pulls.filter((pull) => person.pr_urls.includes(pull.url));
expect(attributed).toHaveLength(person.merged_prs);
expect(person.direct_authored).toBe(attributed.filter((pull) => !pull.agent).length);
expect(person.declared_agent_owned).toBe(attributed.filter((pull) => pull.agent).length);
expect(person.recorded_spend_per_attributed_pr).toBe(person.gateway_recorded_spend / person.merged_prs);
}
}
expect(Date.parse(report.periods.previous.window.end) + 86_400_000).toBe(
Date.parse(report.periods.current.window.start),
);
});
});

View file

@ -1,120 +0,0 @@
import type { ObservedPerson, ObservedPull, ObservedSnapshot, Period } from "./observedData";
const DAY = 86_400_000;
const engineers = [
{ name: "Alex Rivera", login: "alex-demo", weekly: [8, 6, 4] },
{ name: "Sam Chen", login: "sam-demo", weekly: [6, 5, 4] },
{ name: "Jordan Lee", login: "jordan-demo", weekly: [4, 4, 3] },
];
const titles = [
"Add repository search",
"Fix retry handling",
"Speed up activity queries",
"Add usage export",
"Improve connection setup",
"Fix pagination",
];
const isoDate = (timestamp: number) => new Date(timestamp).toISOString().slice(0, 10);
function median(pulls: ObservedPull[]) {
const hours = pulls.map((pull) => pull.merge_hours ?? 0).toSorted((a, b) => a - b);
const middle = Math.floor(hours.length / 2);
if (!hours.length) return null;
return hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2;
}
function samplePeriod(days: number, end: number, comparison: number) {
const start = end - (days - 1) * DAY;
const people = engineers.map((engineer, index) => {
const count = Math.round((engineer.weekly[comparison] * days) / 7);
const pulls: ObservedPull[] = Array.from({ length: count }, (_, position) => {
const number = 10000 * (comparison + 1) + index * 1000 + position;
const repo = index === 1 ? "demo/api" : "demo/web";
const branch = `feature/sample-${number}`;
const agent = position % 5 === 0;
return {
connection_id: index === 1 ? "demo-gitlab" : "demo-github",
number,
title: titles[position % titles.length],
url: `https://example.com/${repo}/changes/${number}`,
author: agent ? "demo-agent" : engineer.login,
agent,
merged_at: new Date(start + Math.floor((position * days) / count) * DAY + 12 * 3_600_000).toISOString(),
merge_hours: position === 0 ? 16 / 3600 : 4 + ((position * 7) % 24) + comparison * 6,
repo,
source_repo: repo,
source_branch: branch,
branch_cost: { repo, branch, spend: 2 + (position % 4), requests: 20 + position, status: "matched" },
};
});
const spend = pulls.reduce((total, pull) => total + (pull.branch_cost.spend ?? 0), 0);
const metrics: ObservedPerson["periods"]["current"] = {
merged_prs: pulls.length,
prs_per_week: (pulls.length * 7) / days,
median_merge_hours: median(pulls),
direct_authored: pulls.filter((pull) => !pull.agent).length,
declared_agent_owned: pulls.filter((pull) => pull.agent).length,
gateway_recorded_spend: spend,
recorded_spend_per_attributed_pr: pulls.length ? spend / pulls.length : null,
spend_observation: "records_present",
pr_urls: pulls.map((pull) => pull.url),
};
return { metrics, pulls };
});
const pulls = people.flatMap((person) => person.pulls).toSorted((a, b) => b.merged_at.localeCompare(a.merged_at));
const metrics: ObservedSnapshot["periods"]["current"] = {
window: { start: isoDate(start), end: isoDate(end) },
merged_prs: pulls.length,
median_merge_hours: median(pulls),
human_authored: pulls.filter((pull) => !pull.agent).length,
agent_authored: pulls.filter((pull) => pull.agent).length,
missing_author: 0,
agents_without_requester: 0,
matched_internal_prs: pulls.length,
new_bug_labeled_issues: Math.round(((comparison + 1) * days) / 7),
new_regression_labeled_issues: comparison,
explicitly_titled_revert_prs: 0,
matched_users_recorded_spend: people.reduce((total, person) => total + person.metrics.gateway_recorded_spend, 0),
spend_observation: "records_present",
human_summary: { median_merge_hours: median(pulls.filter((pull) => !pull.agent)) },
};
return { metrics, pulls, people };
}
export function createObservedDemo(days: number, now = new Date()): ObservedSnapshot {
const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - DAY;
const yearAgo = new Date(end);
const lastYear = yearAgo.getUTCFullYear() - 1;
const month = yearAgo.getUTCMonth();
const lastDay = new Date(Date.UTC(lastYear, month + 1, 0)).getUTCDate();
const lastYearEnd = Date.UTC(lastYear, month, Math.min(yearAgo.getUTCDate(), lastDay));
const periods = {
current: samplePeriod(days, end, 0),
previous: samplePeriod(days, end - days * DAY, 1),
last_year: samplePeriod(days, lastYearEnd, 2),
};
const personPeriod = (period: Period, index: number) => periods[period].people[index].metrics;
return {
source_provider: "mixed",
repos: ["demo/web", "demo/api"],
unmatched_logins: [],
unlinked_branches: [],
captured_at: now.toISOString(),
periods: {
current: periods.current.metrics,
previous: periods.previous.metrics,
last_year: periods.last_year.metrics,
},
people: engineers.map((engineer, index) => ({
name: engineer.name,
email: `${engineer.login}@example.com`,
logins: [engineer.login],
periods: {
current: personPeriod("current", index),
previous: personPeriod("previous", index),
last_year: personPeriod("last_year", index),
},
})),
pulls: { current: periods.current.pulls, previous: periods.previous.pulls, last_year: periods.last_year.pulls },
};
}

View file

@ -1,203 +0,0 @@
import { describe, expect, it } from "vitest";
import {
coverageLabel,
effortNote,
estimateLabel,
estimatorModelOptions,
filterPulls,
formatMoney,
formatNumber,
formatSyncedAt,
highestCostPulls,
isMatchedPerson,
isMatchedPull,
peopleCsv,
} from "./roiCalculatorData";
import type { ROIPerson, ROIPull } from "./roiCalculatorData";
const pull = (overrides: Partial<ROIPull>): ROIPull => ({
source_repo: "github.com/org/repo",
source_branch: "feature",
repo: "org/repo",
number: 42,
title: "Improve request routing",
url: "https://github.com/org/repo/pull/42",
login: "alice",
emails: ["alice@example.com"],
profile_email: "alice@example.com",
merged_at: "2026-09-12T00:00:00Z",
head_sha: "abc",
additions: 10,
deletions: 2,
changed_files: 1,
commit_count: 1,
incomplete_metadata: false,
estimate: { status: "estimated", hours: 4.5, reasoning: "Metadata-based estimate.", cached: false },
email: "alice@example.com",
match_method: "profile email",
matched: true,
...overrides,
});
const summary = {
metrics: { matched_prs: 1, merged_prs: 2 },
};
describe("ROI calculator display helpers", () => {
it.each([0, 8.5])("retains internal accounts with %s recorded spend and no changes in the filtered CSV", (spend) => {
const internal: ROIPerson = {
id: "internal@example.test",
email: "internal@example.test",
logins: [],
spend,
hours: 0,
prs: 0,
estimated_prs: 0,
pending_prs: 0,
match_methods: [],
eligible: false,
cost_per_hour: null,
};
const external = { ...internal, id: "outside", email: "", spend: null, logins: ["outside"] };
const people = [internal, external].filter(isMatchedPerson);
expect(people).toEqual([internal]);
const report = { start: "2026-09-01", end: "2026-09-30", effort_basis: "without_ai", people };
expect(peopleCsv(report)).toContain(`"internal@example.test","","${spend}","0","0"`);
expect(peopleCsv(report)).not.toContain("outside");
});
it("keeps linked identities without spend or estimates and excludes unrelated tagged spend", () => {
const manual = pull({ match_method: "manual", matched: false });
const external = pull({
email: "",
match_method: "no gateway match",
branch_cost: { repo: "org/repo", branch: "external", spend: 10, requests: 2, status: "matched" },
});
expect(isMatchedPull(manual)).toBe(true);
expect(filterPulls([manual, external], "", true)).toEqual([manual]);
expect(filterPulls([manual, external], "", false)).toEqual([manual, external]);
expect(filterPulls([manual, external], "missing", true)).toEqual([]);
const person: ROIPerson = {
id: "linked",
email: "linked@example.test",
logins: ["linked"],
spend: null,
hours: 0,
prs: 1,
estimated_prs: 0,
pending_prs: 1,
match_methods: ["manual"],
eligible: false,
cost_per_hour: null,
};
expect(isMatchedPerson(person)).toBe(true);
expect(isMatchedPerson({ ...person, match_methods: ["no gateway match"] })).toBe(false);
expect(isMatchedPerson({ ...person, email: "" })).toBe(false);
});
it("ranks only attributed PR costs, limits the overview to five and preserves report order", () => {
const pulls = [2, 6, 1, 4, 3, 5].map((spend) =>
pull({
number: spend,
branch_cost: { status: "matched", spend, requests: 1, repo: "github.com/org/repo", branch: "feature" },
}),
);
pulls.push(
pull({
number: 99,
branch_cost: { status: "ambiguous", spend: 99, requests: 1, repo: "github.com/org/repo", branch: "feature" },
}),
);
pulls.push(pull({ number: 100 }));
expect(highestCostPulls(pulls).map((item) => item.number)).toEqual([6, 5, 4, 3, 2]);
expect(pulls.map((item) => item.number)).toEqual([2, 6, 1, 4, 3, 5, 99, 100]);
});
it("formats spend and estimated hours without losing null values", () => {
expect(formatMoney(1234.5)).toBe("$1,234.50");
expect(formatMoney(0.0001)).toBe("$0.0001");
expect(formatMoney(0.000186)).toBe("$0.000186");
expect(formatMoney(0.0000001)).toBe("<$0.000001");
expect(formatMoney(0)).toBe("$0.00");
expect(formatMoney(null)).toBe("—");
expect(formatNumber(4.25)).toBe("4.3");
expect(formatNumber(null)).toBe("—");
});
it("formats report sync timestamps in UTC", () => {
expect(formatSyncedAt("2026-09-30T12:00:00Z")).toBe("Sep 30, 2026, 12:00 PM UTC");
expect(formatSyncedAt("invalid")).toBe("invalid");
});
it("keeps caveat copy tied to the estimate basis and reports match coverage", () => {
expect(effortNote("without_ai")).toContain("not actual hours worked or hours saved");
expect(effortNote(null)).toContain("Earlier estimates");
expect(coverageLabel(summary)).toBe("1 of 2 matched");
});
it("labels estimates and filters PRs by title, repository, number, or login", () => {
const matchingPull = pull({});
expect(estimateLabel(matchingPull.estimate)).toBe("4.5 hrs");
const incompleteEstimate = { status: "needs_review" as const, hours: null, reasoning: "", cached: false };
expect(estimateLabel(incompleteEstimate)).toBe("Needs review");
expect(filterPulls([matchingPull], "ROUTING")).toEqual([matchingPull]);
expect(filterPulls([matchingPull], "nobody")).toEqual([]);
});
});
it("exports precise spend, cohort eligibility and safely quoted CSV values", () => {
const exportSummary = {
start: "2026-09-01",
end: "2026-09-30",
effort_basis: "without_ai",
people: [
{
id: "export-person",
estimated_prs: 1,
match_methods: ["manual"],
email: '=HYPERLINK("bad")',
logins: ["alice", "bob"],
spend: 0.0001,
hours: 4,
prs: 1,
pending_prs: 0,
eligible: true,
cost_per_hour: 0.000025,
},
],
};
const csv = peopleCsv(exportSummary);
expect(csv.split("\r\n")).toHaveLength(2);
expect(csv).toContain('"\'=HYPERLINK(""bad"")","alice;bob","0.0001","4","1","0","true","0.000025"');
expect(csv).toContain('"2026-09-01","2026-09-30","without_ai"');
});
it("recommends the real Luna model and preserves its gateway name for requests", () => {
expect(
estimatorModelOptions({
available_models: ["general", "fast-estimator"],
estimator_models: [
{ model_name: "general", provider_models: ["anthropic/claude-haiku"] },
{ model_name: "fast-estimator", provider_models: ["openai/gpt-6-luna"] },
],
}),
).toEqual([
{
value: "fast-estimator",
label: "GPT-6 Luna",
sublabel: "Recommended · Gateway name: fast-estimator",
recommended: true,
},
{ value: "general", label: "anthropic/claude-haiku", sublabel: "Gateway name: general", recommended: false },
]);
});
it("does not invent available models or recommend an alias pointing to a different model", () => {
expect(
estimatorModelOptions({
available_models: ["gpt-6-luna"],
estimator_models: [{ model_name: "gpt-6-luna", provider_models: ["custom-model"] }],
}),
).toEqual([{ value: "gpt-6-luna", label: "custom-model", sublabel: "Gateway name: gpt-6-luna", recommended: false }]);
expect(estimatorModelOptions({ available_models: [], estimator_models: [] })).toEqual([]);
});

View file

@ -1,151 +0,0 @@
import type { components } from "@/lib/http/schema";
export type ROISummary = components["schemas"]["ROISummaryResponse"];
export type ROIPull = components["schemas"]["ROIPullResponse"];
export type ROIPerson = components["schemas"]["ROIPersonResponse"];
export type ROIEstimate = components["schemas"]["ROIEstimateResponse"];
export type ROISyncStatus = components["schemas"]["ROISyncStatus"];
export type ROISettings = components["schemas"]["ROISettingsResponse"];
export type ROISettingsUpdate = components["schemas"]["ROISettingsUpdate"];
export type ROIRepository = components["schemas"]["ROIRepository"];
export type ROIRepositoriesResponse = components["schemas"]["ROIRepositoriesResponse"];
export type ROIReportResponse = components["schemas"]["ROIReportResponse"];
export type ROIIdentityMapUpdate = components["schemas"]["ROIIdentityMapUpdate"];
export type ROIIdentityMapResponse = components["schemas"]["ROIIdentityMapResponse"];
const SYNCED_AT_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
timeZoneName: "short",
};
export const formatMoney = (value: number | null | undefined): string => {
if (value == null) return "—";
if (value > 0 && value < 0.000001) return "<$0.000001";
const options: Intl.NumberFormatOptions = {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2,
};
return new Intl.NumberFormat("en-US", options).format(value);
};
export const formatNumber = (value: number | null | undefined): string =>
value == null ? "—" : new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }).format(value);
export const formatSyncedAt = (value: string): string => {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return value;
return new Intl.DateTimeFormat("en-US", SYNCED_AT_FORMAT_OPTIONS).format(timestamp);
};
export const effortNote = (basis: string | null | undefined): string =>
basis === "without_ai"
? "Estimated engineering hours without AI assistance, not actual hours worked or hours saved."
: "Earlier estimates did not specify AI assistance. Sync to estimate engineering hours without AI.";
export const coverageLabel = (summary: {
metrics: Pick<ROISummary["metrics"], "matched_prs" | "merged_prs">;
source_provider?: string;
}): string => `${summary.metrics.matched_prs} of ${summary.metrics.merged_prs} matched`;
export const estimateLabel = (estimate: ROIEstimate): string => {
if (estimate.status === "estimated") return `${formatNumber(estimate.hours)} hrs`;
if (estimate.status === "error") return "Estimate failed";
return "Needs review";
};
const matchedMethods = new Set(["manual", "commit email", "profile email"]);
export const isMatchedPerson = (person: ROIPerson) =>
Boolean(person.email) && (person.spend !== null || person.match_methods.some((method) => matchedMethods.has(method)));
export const isMatchedPull = (pull: ROIPull) => Boolean(pull.email) && matchedMethods.has(pull.match_method);
export const filterPulls = (pulls: ROIPull[], query: string, matchedOnly = false): ROIPull[] => {
const normalized = query.trim().toLocaleLowerCase();
const visible = matchedOnly ? pulls.filter(isMatchedPull) : pulls;
if (!normalized) return visible;
return visible.filter((pull) =>
`${pull.title} ${pull.repo} ${pull.number} ${pull.login} ${pull.source_branch ?? ""}`
.toLocaleLowerCase()
.includes(normalized),
);
};
export const highestCostPulls = (pulls: ROIPull[]): ROIPull[] =>
pulls
.filter((pull) => pull.branch_cost?.status === "matched")
.sort((left, right) => (right.branch_cost?.spend ?? 0) - (left.branch_cost?.spend ?? 0))
.slice(0, 5);
export const peopleCsv = (summary: Pick<ROISummary, "people" | "start" | "end" | "effort_basis">): string => {
const escape = (value: unknown): string => {
const text = value == null ? "" : String(value);
const safe = /^[=+@\-\t\r]/.test(text) ? `'${text}` : text;
return `"${safe.replaceAll('"', '""')}"`;
};
const rows = summary.people.map((person) => [
person.email,
person.logins.join(";"),
person.spend,
person.hours,
person.prs,
person.pending_prs,
person.eligible,
person.cost_per_hour,
summary.start,
summary.end,
summary.effort_basis ?? "unspecified",
]);
return [
[
"email",
"source_logins",
"gateway_spend_usd",
"estimated_hours",
"merged_prs",
"pending_estimates",
"in_matched_cohort",
"cost_per_estimated_hour",
"start_utc",
"end_utc",
"effort_basis",
],
...rows,
]
.map((row) => row.map(escape).join(","))
.join("\r\n");
};
export const branchCostLabel = (pull: ROIPull): string => {
if (!pull.branch_cost || pull.branch_cost.status === "unavailable") return "Sync to calculate";
if (pull.branch_cost.status === "ambiguous") return "Ambiguous branch";
if (pull.branch_cost.status === "unattributed") return "No tagged requests";
return formatMoney(pull.branch_cost.spend);
};
export const estimatorModelOptions = (settings: Pick<ROISettings, "available_models" | "estimator_models">) => {
const details = new Map(settings.estimator_models?.map((model) => [model.model_name, model]));
const isLuna = (name: string) => /(?:^|\/)gpt-6-luna(?:-\d{4}-\d{2}-\d{2})?$/i.test(name);
return settings.available_models
.map((name) => {
const models = details.get(name)?.provider_models ?? [];
const recommended = models.length > 0 && models.every(isLuna);
const label = [...new Set(models.map((model) => (isLuna(model) ? "GPT-6 Luna" : model)))].join(", ") || name;
return {
value: name,
label,
sublabel: [recommended ? "Recommended" : "", label !== name ? `Gateway name: ${name}` : ""]
.filter(Boolean)
.join(" · "),
recommended,
};
})
.sort(
(left, right) => Number(right.recommended) - Number(left.recommended) || left.label.localeCompare(right.label),
);
};

View file

@ -1,60 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import {
observedSettingsSchema,
observedReportResponseSchema,
observedStatusSchema,
type ObservedSettings,
type ObservedSnapshot,
type ObservedStatus,
} from "./observedData";
export type ObservedViewData = { settings: ObservedSettings; report: ObservedSnapshot | null; status: ObservedStatus };
export function useObservedReport(accessToken: string) {
const [data, setData] = useState<ObservedViewData | null>(null);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout>;
const options = { accessToken, signal: controller.signal };
async function poll(previous?: ObservedViewData) {
try {
const status = observedStatusSchema.parse(
await apiClient.get<unknown>("/roi-calculator/observed/sync", options),
);
const finished = previous?.status.running && !status.running;
const changed = !previous || previous.status.finished_at !== status.finished_at || finished;
const updated = changed
? await Promise.all([
apiClient
.get<unknown>("/roi-calculator/observed/settings", options)
.then((value) => observedSettingsSchema.parse(value)),
apiClient
.get<unknown>("/roi-calculator/observed/report", options)
.then((value) => observedReportResponseSchema.parse(value)),
])
: null;
if (controller.signal.aborted) return;
const existing = previous ? { ...previous, status } : null;
const next = updated ? { settings: updated[0], report: updated[1].report, status } : existing;
setData(next);
setError("");
timer = setTimeout(() => void poll(next ?? undefined), status.running ? 2000 : 30000);
} catch (reason) {
if (controller.signal.aborted) return;
setError(extractProxyErrorMessage(reason));
timer = setTimeout(() => void poll(previous), 10000);
}
}
void poll();
return () => {
controller.abort();
clearTimeout(timer);
};
}, [accessToken, revision]);
return { data, error, refresh };
}

View file

@ -1,10 +0,0 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import ObservedROIView from "./_components/ObservedROIView";
export default function ROICalculatorPage() {
const { accessToken, isViewOnly } = useAuthorized();
if (!accessToken) return null;
return <ObservedROIView key={accessToken} accessToken={accessToken} isViewOnly={isViewOnly} />;
}

View file

@ -25,7 +25,6 @@ import {
Activity,
Aperture,
BarChart3,
Calculator,
Bell,
Blocks,
Bot,
@ -224,17 +223,6 @@ const menuGroups: MenuGroup[] = [
</span>
),
},
{
key: "roi-calculator",
page: "roi-calculator",
icon: <Calculator {...ICON} />,
roles: all_admin_roles,
label: (
<span className="flex items-center gap-2">
ROI Calculator <BetaBadge />
</span>
),
},
{
key: "cost-optimization",
page: "cost-optimization",

View file

@ -21,7 +21,6 @@ export const pageDescriptions: Record<string, string> = {
new_usage: "View usage analytics and metrics",
"cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
"model-insights": "Model Leaderboard: compare usage, spend, tokens, and task mix across this gateway",
"roi-calculator": "Compare gateway spend with estimated engineering effort for merged pull requests",
logs: "Access request and response logs",
lens: "Review agent activity and investigate patterns with supporting evidence",
"guardrails-monitor": "Monitor guardrail performance and view logs",

View file

@ -1,16 +0,0 @@
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
export function DemoNotice({ onExit }: { onExit?: () => void }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-info/20 bg-info/10 px-4 py-3">
<p role="status" className="flex items-center gap-2 text-sm font-medium text-info">
<Info aria-hidden="true" className="size-4 shrink-0" />
You’re viewing demo data
</p>
<Button variant="outline" size="sm" onClick={onExit}>
Exit demo
</Button>
</div>
);
}

File diff suppressed because it is too large Load diff