feat: add coordination-ROI band to member Work History

Surfaces "rework saved" at the top of the teammate Work History dialog:
a transparent heuristic over collisions Hermes caught early (eligible =
gitOverlap or critical, with an intervention), credit split across
involved engineers. Shows hard counts (clashes caught, conflict-free
files) plus an info-tooltip breakdown of the estimate.

- shared: optional MemberWorkHistoryRoi field (back-compat, self-zeroes)
- backend: query collisions + interventions in getMemberWorkHistory,
  computeRoi helper
- frontend: RoiBand + RoiTooltip components, hidden when no clashes

Implements docs/plans/work-history-roi.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuV8W8oNYRsDWKoqK8Mkqc
This commit is contained in:
Ramis
2026-06-28 07:51:40 -07:00
parent 8d17663734
commit 9fb252ec6a
4 changed files with 178 additions and 2 deletions
+72 -2
View File
@@ -1,4 +1,10 @@
import type { EngineerContext, MemberWorkHistory, MemberWorkHistoryFile } from '@podman/shared';
import type {
Collision,
EngineerContext,
MemberWorkHistory,
MemberWorkHistoryFile,
MemberWorkHistoryRoi,
} from '@podman/shared';
import { getDb } from '../memory/db.js';
import { parseGitStatusPath } from '../graph/live.js';
@@ -80,7 +86,7 @@ export async function getMemberWorkHistory(
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
const [observations, gitState] = await Promise.all([
const [observations, gitState, collisions, interventions] = await Promise.all([
db
.collection<EngineerContext>('observations')
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
@@ -91,6 +97,16 @@ export async function getMemberWorkHistory(
podId,
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
}),
db
.collection<Collision>('collisions')
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0 } })
.sort({ detectedAt: -1 })
.limit(200)
.toArray(),
db
.collection<{ collisionId: string }>('interventions')
.find({ podId }, { projection: { collisionId: 1, _id: 0 } })
.toArray(),
]);
const files = new Map<string, FileAccumulator>();
@@ -158,6 +174,9 @@ export async function getMemberWorkHistory(
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
const interventionIds = new Set(interventions.map((i) => i.collisionId));
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);
return {
podId,
member,
@@ -170,5 +189,56 @@ export async function getMemberWorkHistory(
},
files: fileRows,
timeline: timeline.slice(0, limit),
roi,
};
}
function computeRoi(
member: string,
collisions: Collision[],
interventionCollisionIds: Set<string>,
changedFileCount: number,
): MemberWorkHistoryRoi {
const involved = (c: Collision) =>
c.engineers?.some((e) => sameMember(e, member)) ||
sameMember(c.researcher, member) ||
sameMember(c.editor, member);
const eligible = collisions.filter(
(c) =>
involved(c) &&
interventionCollisionIds.has(c.id) &&
(c.gitOverlap === true || c.severity === 'critical'),
);
const weightOf = (c: Collision): { label: string; minutes: number } => {
if (c.overlapKind === 'research') return { label: 'research overlap', minutes: 10 };
if (c.severity === 'critical') return { label: 'critical same-file', minutes: 45 };
if (c.severity === 'warn') return { label: 'warn same-file', minutes: 20 };
return { label: 'info same-file', minutes: 10 };
};
let savedMinutes = 0;
const groups = new Map<string, { count: number; minutesEach: number }>();
for (const c of eligible) {
const { label, minutes } = weightOf(c);
savedMinutes += minutes / Math.max(1, c.engineers?.length ?? 1);
const g = groups.get(label) ?? { count: 0, minutesEach: minutes };
g.count += 1;
groups.set(label, g);
}
const filesDeconflicted = new Set(eligible.map((c) => c.file)).size;
return {
savedMinutes: Math.round(savedMinutes),
clashesCaught: eligible.length,
filesDeconflicted,
conflictFreeFiles: Math.max(0, changedFileCount - filesDeconflicted),
totalFiles: changedFileCount,
breakdown: [...groups.entries()].map(([label, g]) => ({
label,
count: g.count,
minutesEach: g.minutesEach,
})),
};
}
+85
View File
@@ -9,6 +9,7 @@ import {
EyeIcon,
GitBranchIcon,
ExternalLinkIcon,
InfoIcon,
MessageSquareIcon,
MicIcon,
MicOffIcon,
@@ -33,6 +34,7 @@ import type {
MemberWorkHistory,
MemberWorkHistoryEvent,
MemberWorkHistoryFile,
MemberWorkHistoryRoi,
Pod,
PodActivityEvent,
PodActivityKind,
@@ -1034,6 +1036,7 @@ function WorkHistoryDialog({
{history && !loading && !error && (
<div className="flex flex-col gap-5">
<RoiBand roi={history.roi} />
<div className="grid gap-2 sm:grid-cols-3">
<HistoryStat label="Files" value={history.totals.files} />
<HistoryStat label="Screen logs" value={history.totals.observations} />
@@ -1090,6 +1093,88 @@ function WorkHistoryDialog({
);
}
function formatSaved(minutes: number): string {
if (minutes < 60) return `~${minutes}m`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m ? `~${h}h ${m}m` : `~${h}h`;
}
function RoiTooltip({ roi }: { roi: MemberWorkHistoryRoi }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="How rework saved is estimated"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<InfoIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-64">
<div className="flex flex-col gap-1">
<p className="font-medium">Estimated rework saved</p>
{roi.breakdown.length ? (
roi.breakdown.map((row) => (
<p key={row.label} className="font-mono text-[0.7rem]">
{row.count} × {row.minutesEach}m · {row.label}
</p>
))
) : (
<p className="text-[0.7rem]">No eligible clashes.</p>
)}
<p className="text-[0.68rem] text-muted-foreground">
credit split across engineers · est. only
</p>
</div>
</TooltipContent>
</Tooltip>
);
}
function RoiBand({ roi }: { roi?: MemberWorkHistoryRoi }) {
if (!roi || roi.clashesCaught === 0) return null;
const conflictFree = roi.totalFiles
? Math.round((roi.conflictFreeFiles / roi.totalFiles) * 100)
: 100;
return (
<section className="rounded-lg border bg-primary/5 p-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-center gap-1.5">
<p className="font-mono text-2xl font-semibold">{formatSaved(roi.savedMinutes)}</p>
<span className="text-sm text-muted-foreground">rework saved</span>
<RoiTooltip roi={roi} />
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
estimated · clashes caught pre-commit
</p>
</div>
<div className="text-right">
<p className="font-mono text-lg font-medium">{roi.clashesCaught}</p>
<p className="text-xs text-muted-foreground">clashes caught early</p>
</div>
</div>
{roi.totalFiles > 0 && (
<>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${conflictFree}%` }}
aria-label={`${roi.conflictFreeFiles} of ${roi.totalFiles} files conflict-free`}
/>
</div>
<p className="mt-1.5 text-[0.68rem] text-muted-foreground">
conflict-free: {roi.conflictFreeFiles} of {roi.totalFiles} files ·{' '}
{roi.filesDeconflicted} auto-deconflicted
</p>
</>
)}
</section>
);
}
function HistoryStat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border bg-background px-3 py-2">
+1
View File
@@ -56,5 +56,6 @@ export type {
MemberWorkHistory,
MemberWorkHistoryEvent,
MemberWorkHistoryFile,
MemberWorkHistoryRoi,
MemberWorkHistorySource,
} from './member-history.js';
+20
View File
@@ -33,4 +33,24 @@ export interface MemberWorkHistory {
};
files: MemberWorkHistoryFile[];
timeline: MemberWorkHistoryEvent[];
/**
* Coordination ROI summary — clashes Hermes caught for this member. Optional
* so pods with no collisions / older payloads render without the band.
*/
roi?: MemberWorkHistoryRoi;
}
export interface MemberWorkHistoryRoi {
/** Estimated rework minutes saved (heuristic, labeled "~/est." in UI). */
savedMinutes: number;
/** Eligible collisions caught early (hard count). */
clashesCaught: number;
/** Distinct files that hit an eligible clash. */
filesDeconflicted: number;
/** Member files in flight that never hit a clash. */
conflictFreeFiles: number;
/** Total member files in flight (git changedFiles). */
totalFiles: number;
/** Per-kind breakdown for the tooltip. */
breakdown: { label: string; count: number; minutesEach: number }[];
}