Build-ready spec for adding a "rework saved" ROI band to the member Work History dialog: transparent heuristic over caught collisions, additive shared field + backend queries + presentational component. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuV8W8oNYRsDWKoqK8Mkqc
13 KiB
Build spec: Work History "Coordination ROI" band
Self-contained spec for an implementing agent. Everything needed to build is here: current-state anchors, exact edits, code sketches, verification. Read the referenced files before editing. Changes are additive — one optional field on a shared type, two new backend queries, one new presentational component. No route signature changes, no DB writes, no new deps.
Context / why
The Work History dialog (opens when you click a teammate's "History" button) today shows three raw counters (files / screen logs / git changes), a Recent files bar list, and a Timeline. It is pure activity volume — it summarizes nothing and shows none of PodMan's actual value.
We add a Coordination ROI band at the top of the dialog that answers "what did PodMan save this person?": estimated rework-hours saved by clashes Hermes caught early, plus hard defensible counts. This is a summary layer — it must NOT re-stream the pod activity feed ("Team Memory"). Existing Recent files + Timeline sections stay exactly as they are, below the new band.
Locked decisions
- Primary story = saved time / ROI. One number leads:
~Xh Ym rework saved. - Transparent heuristic. The hours are a labeled estimate (
~,(est.)) with an info tooltip showing the per-clash breakdown. Technical judges can see the model; we never claim precision. - Backend extension required (collisions/interventions are not in the
current
MemberWorkHistorypayload). Field is optional → old frontends and pods with no collisions degrade gracefully (band hidden). - Cut: editing/research focus donut. ROI is the single story; a donut dilutes it.
- Credit split across involved engineers so a 2-person clash does not double-count the pod total.
Heuristic (this is the contract — implement exactly)
For each collisions doc in the window where the member is involved AND an
interventions doc exists for it (Hermes actually surfaced it):
- Eligibility (must be "real"): count the collision only if
gitOverlap === trueORseverity === 'critical'. Otherwise skip. - Weight by kind/severity:
condition minutesEach overlapKind === 'research'10 severity === 'critical'(same-file)45 severity === 'warn'(same-file)20 severity === 'info'(same-file)10 (Evaluate research first; a research overlap is always 10 regardless of severity.) - Member share:
minutesEach / max(1, engineers.length). Sum all shares →savedMinutes(round to nearest integer). - breakdown[]: group eligible collisions by label
(
"critical same-file","warn same-file","research overlap", etc.), each entry{ label, count, minutesEach }— for the tooltip.
Member "involved" = engineers array contains the member (case-insensitive),
OR researcher/editor equals the member (case-insensitive). Reuse the
existing sameMember() helper logic.
Hard counts (no estimation):
clashesCaught= number of eligible collisions (the integer behind the hours).filesDeconflicted= distinctcollision.filevalues across eligible collisions.conflictFreeCommits/totalCommits: see "commits" note below.
Commits note (keep simple, defensible)
There is no per-commit log in the window. Use the git ground-truth we have:
totalCommits = count of distinct changedFiles for the member from
engineer_states (already loaded as gitState.changedFiles.length).
conflictFreeCommits = totalCommits - filesDeconflicted (clamped ≥ 0). This
reads as "files in flight that never hit a clash". Label it in the UI as
"conflict-free files", not commits, so the wording matches the data. (The
band copy below already says "files".)
Current-state anchors (read these first)
shared/src/member-history.ts:24-36—MemberWorkHistoryinterface (addroi?).backend/src/activity/member-history.ts:73-174—getMemberWorkHistory().:83-94— thePromise.allthat loadsobservations+engineer_states. Add collisions/interventions here.:45-47—sameMember()helper to reuse for involvement check.:124—gitStatealready in scope;:169usesgitState.changedFiles.length.:161-173— the returned object (addroi).
backend/src/memory/db.ts:47-48— canonical collection namescollisions,interventions(typedCollision,Intervention).backend/src/activity/store.ts:167-178— reference query shape for both collections.shared/src/collision.ts:7-36—Collision(engineers,severity,overlapKind,gitOverlap,researcher,editor,file,detectedAt).shared/src/intervention.ts:8-18—Intervention(collisionId,podId).frontend/src/components/PodView.tsx:33-35— type imports from@podman/shared.frontend/src/components/PodView.tsx:1035-1041— thehistory && !loading && !errorblock; the 3-stat grid is the insert point (band goes ABOVE it).frontend/src/components/PodView.tsx:1093-1100—HistoryStat(style reference for new sub-stats).frontend/src/components/PodView.tsx—timeLabel()exists for relative time; reuse if needed.- Route is unchanged:
backend/src/server.ts:502-506already returns whatevergetMemberWorkHistoryproduces.
Edit 1 — shared type (shared/src/member-history.ts)
Add to the MemberWorkHistory interface (after timeline):
/** 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 }[];
}
shared/src/index.ts:56-59 already re-exports the member-history types via a
export type { ... } block — add MemberWorkHistoryRoi to that list.
Edit 2 — backend (backend/src/activity/member-history.ts)
- Import the types:
import type { Collision } from '@podman/shared';(Intervention only needscollisionId, can stay untyped or import too). - In the
Promise.allat:83, add two queries (windowed, podId-scoped):
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(),
- After building
fileRows, computeroiwith a new local helpercomputeRoi(member, collisions, interventions, gitState):
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;
const totalFiles = changedFileCount;
return {
savedMinutes: Math.round(savedMinutes),
clashesCaught: eligible.length,
filesDeconflicted,
conflictFreeFiles: Math.max(0, totalFiles - filesDeconflicted),
totalFiles,
breakdown: [...groups.entries()].map(([label, g]) => ({
label,
count: g.count,
minutesEach: g.minutesEach,
})),
};
}
- Build the intervention id set and attach to the return:
const interventionIds = new Set(interventions.map((i) => i.collisionId));
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);
Add roi to the returned object at :161-173. Always return it (it self-zeroes
when there are no clashes); the frontend decides whether to show the band based
on clashesCaught/savedMinutes.
Note
Collision.idsurvives the{ _id: 0 }projection —idis a real field (shared/src/collision.ts:8), distinct from Mongo_id. Do not project it out.
Edit 3 — frontend (frontend/src/components/PodView.tsx)
- Add
MemberWorkHistoryRoito the@podman/sharedtype import (:33-35). - Insert
<RoiBand roi={history.roi} />immediately inside thehistory && !loading && !errorblock, BEFORE thegrid ... sm:grid-cols-3stat grid (:1037). - New presentational component (place near
HistoryStat,:1093):
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 RoiBand({ roi }: { roi?: MemberWorkHistoryRoi }) {
if (!roi || roi.clashesCaught === 0) return null; // no clashes → no band
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>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${conflictFree}%` }} />
</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>
);
}
- Tooltip — reuse the existing Tooltip primitives already imported in this file
(
Tooltip,TooltipTrigger,TooltipContent— see the History button at:982-988).RoiTooltiprenders anⓘ/InfoIcontrigger; content listsbreakdownrows as{count} × {minutesEach}m {label}plus a footer line"split across engineers · est. only". Keep it a few lines.
Verification
pnpm -r build # @podman/shared first, then backend + frontend typecheck
Manual / demo check (against a pod that has collisions, e.g. demo-pod):
- Open the app, click a teammate who was in a clash → History.
- Band shows at top:
~Xh Ym rework saved, clashes-caught integer, conflict-free bar. - Hover
ⓘ→ breakdown rows match the heuristic table. - Click a teammate with no clashes → band absent, rest of dialog unchanged.
- Existing Recent files + Timeline sections render unchanged below the band.
Quick data sanity (optional, on the box or via mongosh):
db.collisions.find({ podId: "demo-pod" }).count() // > 0 for a real band
db.interventions.find({ podId: "demo-pod" }).count() // surfaced clashes
Coordination / merge risk (team is concurrent)
- Touches two shared hot files:
shared/src/member-history.tsandfrontend/src/components/PodView.tsx. Both edits are additive (one optional field + one new component + one insert line). Low conflict risk, but announce before pushing. git pull --rebase origin mainimmediately before push. Never force-push main.- No changes to
/api/...route signatures, no new deps, no Mongo writes.
Deploy (after merge, on the box — see CLAUDE.md ops)
cd /root/podman && git pull && pnpm -r build
rm -rf /var/www/podman/* && cp -r frontend/dist/* /var/www/podman/
systemctl restart podman-platform-api podman-platform-agent
(Frontend-visible change + backend payload change → both the static build and the API service must be refreshed.)