diff --git a/docs/plans/work-history-roi.md b/docs/plans/work-history-roi.md new file mode 100644 index 0000000..f88589b --- /dev/null +++ b/docs/plans/work-history-roi.md @@ -0,0 +1,299 @@ +# 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 `MemberWorkHistory` payload). 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): + +1. **Eligibility (must be "real"):** count the collision only if + `gitOverlap === true` OR `severity === 'critical'`. Otherwise skip. +2. **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.) +3. **Member share:** `minutesEach / max(1, engineers.length)`. Sum all shares → + `savedMinutes` (round to nearest integer). +4. **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` = distinct `collision.file` values 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` — `MemberWorkHistory` interface (add `roi?`). +- `backend/src/activity/member-history.ts:73-174` — `getMemberWorkHistory()`. + - `:83-94` — the `Promise.all` that loads `observations` + `engineer_states`. Add collisions/interventions here. + - `:45-47` — `sameMember()` helper to reuse for involvement check. + - `:124` — `gitState` already in scope; `:169` uses `gitState.changedFiles.length`. + - `:161-173` — the returned object (add `roi`). +- `backend/src/memory/db.ts:47-48` — canonical collection names `collisions`, `interventions` (typed `Collision`, `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` — the `history && !loading && !error` block; 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-506` already returns whatever `getMemberWorkHistory` produces. + +## Edit 1 — shared type (`shared/src/member-history.ts`) + +Add to the `MemberWorkHistory` interface (after `timeline`): + +```ts + /** 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`) + +1. Import the types: `import type { Collision } from '@podman/shared';` (Intervention only needs `collisionId`, can stay untyped or import too). +2. In the `Promise.all` at `:83`, add two queries (windowed, podId-scoped): + +```ts +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(), +``` + +3. After building `fileRows`, compute `roi` with a new local helper + `computeRoi(member, collisions, interventions, gitState)`: + +```ts +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; + 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, + })), + }; +} +``` + +4. Build the intervention id set and attach to the return: + +```ts +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.id` survives the `{ _id: 0 }` projection — `id` is 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`) + +1. Add `MemberWorkHistoryRoi` to the `@podman/shared` type import (`:33-35`). +2. Insert `` immediately inside the + `history && !loading && !error` block, BEFORE the `grid ... sm:grid-cols-3` + stat grid (`:1037`). +3. New presentational component (place near `HistoryStat`, `:1093`): + +```tsx +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 ( +
+
+
+
+

{formatSaved(roi.savedMinutes)}

+ rework saved + +
+

+ estimated · clashes caught pre-commit +

+
+
+

{roi.clashesCaught}

+

clashes caught early

+
+
+
+
+
+

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

+
+ ); +} +``` + +4. Tooltip — reuse the existing Tooltip primitives already imported in this file + (`Tooltip`, `TooltipTrigger`, `TooltipContent` — see the History button at + `:982-988`). `RoiTooltip` renders an `ⓘ`/`InfoIcon` trigger; content lists + `breakdown` rows as `{count} × {minutesEach}m {label}` plus a footer line + `"split across engineers · est. only"`. Keep it a few lines. + +## Verification + +```bash +pnpm -r build # @podman/shared first, then backend + frontend typecheck +``` +Manual / demo check (against a pod that has collisions, e.g. demo-pod): +1. Open the app, click a teammate who was in a clash → History. +2. Band shows at top: `~Xh Ym rework saved`, clashes-caught integer, conflict-free bar. +3. Hover `ⓘ` → breakdown rows match the heuristic table. +4. Click a teammate with **no** clashes → band absent, rest of dialog unchanged. +5. Existing Recent files + Timeline sections render unchanged below the band. + +Quick data sanity (optional, on the box or via mongosh): +```js +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.ts` and + `frontend/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 main` immediately 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) + +```bash +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.)