diff --git a/backend/src/activity/member-history.ts b/backend/src/activity/member-history.ts index 2707bf5..85f5db5 100644 --- a/backend/src/activity/member-history.ts +++ b/backend/src/activity/member-history.ts @@ -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('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('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(); @@ -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, + 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(); + 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, + })), }; } diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index e1644dc..ed87544 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -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 && (
+
@@ -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 ( + + + + + +
+

Estimated rework saved

+ {roi.breakdown.length ? ( + roi.breakdown.map((row) => ( +

+ {row.count} × {row.minutesEach}m · {row.label} +

+ )) + ) : ( +

No eligible clashes.

+ )} +

+ credit split across engineers · est. only +

+
+
+
+ ); +} + +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 ( +
+
+
+
+

{formatSaved(roi.savedMinutes)}

+ rework saved + +
+

+ estimated · clashes caught pre-commit +

+
+
+

{roi.clashesCaught}

+

clashes caught early

+
+
+ {roi.totalFiles > 0 && ( + <> +
+
+
+

+ conflict-free: {roi.conflictFreeFiles} of {roi.totalFiles} files ·{' '} + {roi.filesDeconflicted} auto-deconflicted +

+ + )} +
+ ); +} + function HistoryStat({ label, value }: { label: string; value: number }) { return (
diff --git a/shared/src/index.ts b/shared/src/index.ts index 37a0b97..919832a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -56,5 +56,6 @@ export type { MemberWorkHistory, MemberWorkHistoryEvent, MemberWorkHistoryFile, + MemberWorkHistoryRoi, MemberWorkHistorySource, } from './member-history.js'; diff --git a/shared/src/member-history.ts b/shared/src/member-history.ts index 0e911f7..15243e3 100644 --- a/shared/src/member-history.ts +++ b/shared/src/member-history.ts @@ -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 }[]; }