From 361313eeb934e5f2365d6548b09cb73b94d9f4fb Mon Sep 17 00:00:00 2001 From: Michael Nefedov Date: Mon, 4 May 2026 11:20:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20React=20yield=20views=20=E2=80=94?= =?UTF-8?q?=20sessions=20+=20projects=20(AUR-270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visualizes the git-correlation $/commit + $/line endpoints (AUR-265). - /sessions/:id/yield — commits-in-window table (hash, author, subject, files, +/-) with totals row, plus $/commit + $/line + cost + lines-changed callouts. Empty state when no commits landed during the session window. - /projects/:name/yield — weekly composed chart (cost bars + commit bars + $/commit line overlay), plus a sortable 'spend without commit' session list (cost / lines / files). Sortable column buttons. Both views handle the API's ok:false reasons (no store, no project tag, not a git repo under WORKSPACE_ROOT) with a helpful explainer. Lazy-loaded behind Suspense in main.tsx; nav links from Session.tsx and ProjectDetail.tsx. Full suite 369/369; typecheck clean (one pre-existing Logs.tsx unused var unrelated to this PR). --- web/src/lib/api.ts | 58 +++++++ web/src/main.tsx | 4 + web/src/routes/ProjectDetail.tsx | 8 +- web/src/routes/ProjectYield.tsx | 289 +++++++++++++++++++++++++++++++ web/src/routes/Session.tsx | 5 +- web/src/routes/SessionYield.tsx | 133 ++++++++++++++ 6 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/ProjectYield.tsx create mode 100644 web/src/routes/SessionYield.tsx diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 12b3a5a..ca0fc77 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -92,6 +92,64 @@ export const api = { }>; }>(`/api/projects/${encodeURIComponent(name)}/activity`), + sessionYield: (id: string) => + getJson< + | { + sessionId: string; + ok: true; + project: string; + repoPath: string; + yield: { + sessionId: string; + costUsd: number; + commits: Array<{ + hash: string; + authorDate: string; + authorName: string; + filesChanged: number; + insertions: number; + deletions: number; + subject: string; + }>; + totalInsertions: number; + totalDeletions: number; + totalFilesChanged: number; + costPerCommit: number | null; + costPerLineChanged: number | null; + }; + } + | { sessionId: string; ok: false; reason: string } + >(`/api/sessions/${encodeURIComponent(id)}/yield`), + + projectYield: (name: string) => + getJson< + | { + project: string; + ok: true; + repoPath: string; + yield: { + project: string; + weekly: Array<{ + weekStart: string; + costUsd: number; + commits: number; + costPerCommit: number | null; + }>; + spendWithoutCommit: Array<{ + sessionId: string; + costUsd: number; + commits: never[]; + totalInsertions: number; + totalDeletions: number; + totalFilesChanged: number; + costPerCommit: number | null; + costPerLineChanged: number | null; + }>; + }; + } + | { project: string; ok: false; reason: string } + >(`/api/projects/${encodeURIComponent(name)}/yield`), + search: ( query: string, mode: "live" | "cross" | "semantic" = "live", diff --git a/web/src/main.tsx b/web/src/main.tsx index 26e9fed..bbfdefd 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -25,6 +25,8 @@ const SessionDiffsPage = lazy(() => import("./routes/SessionDiffs").then((m) => const SessionReplayPage = lazy(() => import("./routes/SessionReplay").then((m) => ({ default: m.SessionReplayPage }))); const SessionActivityPage = lazy(() => import("./routes/SessionActivity").then((m) => ({ default: m.SessionActivityPage }))); const ProjectActivityPage = lazy(() => import("./routes/ProjectActivity").then((m) => ({ default: m.ProjectActivityPage }))); +const SessionYieldPage = lazy(() => import("./routes/SessionYield").then((m) => ({ default: m.SessionYieldPage }))); +const ProjectYieldPage = lazy(() => import("./routes/ProjectYield").then((m) => ({ default: m.ProjectYieldPage }))); const TrendsPage = lazy(() => import("./routes/Trends").then((m) => ({ default: m.TrendsPage }))); const SettingsShell = lazy(() => import("./routes/Settings").then((m) => ({ default: m.SettingsShell }))); const BudgetsSettings = lazy(() => import("./routes/Settings").then((m) => ({ default: m.BudgetsSettings }))); @@ -57,7 +59,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render( )} /> )} /> )} /> + )} /> )} /> + )} /> } /> } /> } /> diff --git a/web/src/routes/ProjectDetail.tsx b/web/src/routes/ProjectDetail.tsx index e84dd24..36d4111 100644 --- a/web/src/routes/ProjectDetail.tsx +++ b/web/src/routes/ProjectDetail.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useParams } from "react-router-dom"; import { api } from "../lib/api"; import { agentColor, formatDateTime, formatUSD } from "../lib/format"; -import { ArrowLeft, PieChart } from "lucide-react"; +import { ArrowLeft, PieChart, DollarSign } from "lucide-react"; import clsx from "clsx"; export function ProjectDetailPage() { @@ -29,6 +29,12 @@ export function ProjectDetailPage() { > activity + + yield + diff --git a/web/src/routes/ProjectYield.tsx b/web/src/routes/ProjectYield.tsx new file mode 100644 index 0000000..734a520 --- /dev/null +++ b/web/src/routes/ProjectYield.tsx @@ -0,0 +1,289 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../lib/api"; +import { formatUSD } from "../lib/format"; +import { + ComposedChart, + Line, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { ArrowLeft } from "lucide-react"; + +type SortKey = "cost" | "lines" | "files"; + +export function ProjectYieldPage() { + const { name = "" } = useParams(); + const decoded = decodeURIComponent(name); + + const q = useQuery({ + queryKey: ["project-yield", name], + queryFn: () => api.projectYield(name), + refetchInterval: 10_000, + }); + + return ( +
+
+ + + +

yield

+ {decoded} +
+ + {q.isLoading &&
loading…
} + + {!q.isLoading && q.data && q.data.ok === false && ( +
+
+ no yield data: {q.data.reason} +
+
+ yield correlates project sessions with the commits landed during their + windows. Requires the project to be a git repo under{" "} + WORKSPACE_ROOT. +
+
+ )} + + {!q.isLoading && q.data && q.data.ok === true && ( + + )} +
+ ); +} + +function ProjectYieldBody({ + data, +}: { + data: Extract>, { ok: true }>; +}) { + const [sortKey, setSortKey] = useState("cost"); + const y = data.yield; + + const totals = useMemo(() => { + let cost = 0; + let commits = 0; + for (const w of y.weekly) { + cost += w.costUsd; + commits += w.commits; + } + const overallPerCommit = commits > 0 ? cost / commits : null; + return { cost, commits, overallPerCommit }; + }, [y.weekly]); + + const sortedSpend = useMemo(() => { + const copy = [...y.spendWithoutCommit]; + if (sortKey === "cost") { + copy.sort((a, b) => b.costUsd - a.costUsd); + } else if (sortKey === "lines") { + copy.sort( + (a, b) => + b.totalInsertions + b.totalDeletions - (a.totalInsertions + a.totalDeletions), + ); + } else { + copy.sort((a, b) => b.totalFilesChanged - a.totalFilesChanged); + } + return copy; + }, [y.spendWithoutCommit, sortKey]); + + const empty = y.weekly.length === 0 && y.spendWithoutCommit.length === 0; + + return ( + <> +
+ + + + +
+ +
+ repo {data.repoPath} +
+ + {empty ? ( +
+ no sessions or commits in window for this project yet. +
+ ) : ( + <> +
+
+ weekly spend vs commits ($/commit overlay) +
+
+ + + + t.slice(0, 10)} + /> + `$${v.toFixed(0)}`} + /> + + { + const val = typeof v === "number" ? v : Number(v ?? 0); + if (name === "costUsd") return [formatUSD(val), "cost"]; + if (name === "costPerCommit") + return [val ? formatUSD(val) : "—", "$/commit"]; + return [String(val), String(name ?? "")]; + }} + /> + + + + + + +
+
+ +
+
+
+ spend without commit +
+ + {sortedSpend.length} session{sortedSpend.length === 1 ? "" : "s"} + +
+ sort: + + cost + + + lines + + + files + +
+
+ {sortedSpend.length === 0 ? ( +
+ every session in this project landed at least one commit. ✨ +
+ ) : ( +
+ + + + + + + + + + + {sortedSpend.map((s) => ( + + + + + + + + ))} + +
sessioncostfiles+
+ + {s.sessionId.slice(0, 24)} + + {formatUSD(s.costUsd)}{s.totalFilesChanged} + +{s.totalInsertions} + + −{s.totalDeletions} +
+ )} + + + )} + + ); +} + +function SortBtn({ + k, + cur, + onClick, + children, +}: { + k: SortKey; + cur: SortKey; + onClick: (k: SortKey) => void; + children: React.ReactNode; +}) { + const active = k === cur; + return ( + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/src/routes/Session.tsx b/web/src/routes/Session.tsx index e4b5ce5..56000ad 100644 --- a/web/src/routes/Session.tsx +++ b/web/src/routes/Session.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useParams } from "react-router-dom"; import { api } from "../lib/api"; import { agentColor, formatTime, riskClass, typeIcon } from "../lib/format"; -import { ArrowLeft, Download, BarChart3, Activity, GitBranch, FileEdit, Play, PieChart } from "lucide-react"; +import { ArrowLeft, Download, BarChart3, Activity, GitBranch, FileEdit, Play, PieChart, DollarSign } from "lucide-react"; import clsx from "clsx"; import type { AgentEvent } from "../lib/types"; @@ -50,6 +50,9 @@ export function SessionPage() { activity + + yield + api.sessionYield(id), + refetchInterval: 10_000, + }); + + return ( +
+
+ + + +

yield

+ {id.slice(0, 16)} +
+ + {q.isLoading &&
loading…
} + + {!q.isLoading && q.data && q.data.ok === false && ( +
+
no yield data: {q.data.reason}
+
+ yield correlates session cost with commits landed during the session window. + Requires the session to have a project tag and the project to be a git repo + under WORKSPACE_ROOT. +
+
+ )} + + {!q.isLoading && q.data && q.data.ok === true && ( + + )} +
+ ); +} + +function SessionYieldBody({ + data, +}: { + data: Extract>, { ok: true }>; +}) { + const y = data.yield; + const totalLines = y.totalInsertions + y.totalDeletions; + + return ( + <> +
+ + + + + +
+ +
+ project {data.project} + {" · "} + repo {data.repoPath} +
+ +
+
+ commits in window +
+ {y.commits.length === 0 ? ( +
+ no commits landed during this session — spend without commit. +
+ ) : ( + + + + + + + + + + + + + {y.commits.map((c) => ( + + + + + + + + + ))} + + + + + + + +
hashauthor / datesubjectfiles+
{c.hash.slice(0, 8)} +
{c.authorName}
+
{formatDateTime(c.authorDate)}
+
{c.subject}{c.filesChanged}+{c.insertions}−{c.deletions}
+ totals + {y.totalFilesChanged}+{y.totalInsertions}−{y.totalDeletions}
+ )} +
+ + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +}