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:
@@ -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 { getDb } from '../memory/db.js';
|
||||||
import { parseGitStatusPath } from '../graph/live.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 limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
||||||
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
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
|
db
|
||||||
.collection<EngineerContext>('observations')
|
.collection<EngineerContext>('observations')
|
||||||
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
@@ -91,6 +97,16 @@ export async function getMemberWorkHistory(
|
|||||||
podId,
|
podId,
|
||||||
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
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>();
|
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));
|
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 {
|
return {
|
||||||
podId,
|
podId,
|
||||||
member,
|
member,
|
||||||
@@ -170,5 +189,56 @@ export async function getMemberWorkHistory(
|
|||||||
},
|
},
|
||||||
files: fileRows,
|
files: fileRows,
|
||||||
timeline: timeline.slice(0, limit),
|
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,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
EyeIcon,
|
EyeIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
|
InfoIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
MicOffIcon,
|
MicOffIcon,
|
||||||
@@ -33,6 +34,7 @@ import type {
|
|||||||
MemberWorkHistory,
|
MemberWorkHistory,
|
||||||
MemberWorkHistoryEvent,
|
MemberWorkHistoryEvent,
|
||||||
MemberWorkHistoryFile,
|
MemberWorkHistoryFile,
|
||||||
|
MemberWorkHistoryRoi,
|
||||||
Pod,
|
Pod,
|
||||||
PodActivityEvent,
|
PodActivityEvent,
|
||||||
PodActivityKind,
|
PodActivityKind,
|
||||||
@@ -1034,6 +1036,7 @@ function WorkHistoryDialog({
|
|||||||
|
|
||||||
{history && !loading && !error && (
|
{history && !loading && !error && (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
<RoiBand roi={history.roi} />
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<HistoryStat label="Files" value={history.totals.files} />
|
<HistoryStat label="Files" value={history.totals.files} />
|
||||||
<HistoryStat label="Screen logs" value={history.totals.observations} />
|
<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 }) {
|
function HistoryStat({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background px-3 py-2">
|
<div className="rounded-lg border bg-background px-3 py-2">
|
||||||
|
|||||||
@@ -56,5 +56,6 @@ export type {
|
|||||||
MemberWorkHistory,
|
MemberWorkHistory,
|
||||||
MemberWorkHistoryEvent,
|
MemberWorkHistoryEvent,
|
||||||
MemberWorkHistoryFile,
|
MemberWorkHistoryFile,
|
||||||
|
MemberWorkHistoryRoi,
|
||||||
MemberWorkHistorySource,
|
MemberWorkHistorySource,
|
||||||
} from './member-history.js';
|
} from './member-history.js';
|
||||||
|
|||||||
@@ -33,4 +33,24 @@ export interface MemberWorkHistory {
|
|||||||
};
|
};
|
||||||
files: MemberWorkHistoryFile[];
|
files: MemberWorkHistoryFile[];
|
||||||
timeline: MemberWorkHistoryEvent[];
|
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 }[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user