diff --git a/CHANGELOG.md b/CHANGELOG.md index a459344..4baf4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - Guided **/setup** onboarding: team → assign-first roles → optional AI → integrations with credential guides → evidence backfill with progress - **1:1 sessions** on the person dossier — manual Q&A agenda plus optional AI-suggested questions grounded in evidence - Home **suggested next actions** queue: prioritized primary NextStep plus “Up next” list that refreshes when you return to the tab +- Team / person **work-completed graphs**: delivery-by-person bars + delivery-vs-rating on Team performance; window mix + cycle series + rating trend on dossiers; Sprint pulse throughput chart ### Changed diff --git a/apps/ui/src/components/PersonPerformanceCharts.tsx b/apps/ui/src/components/PersonPerformanceCharts.tsx new file mode 100644 index 0000000..e7da58b --- /dev/null +++ b/apps/ui/src/components/PersonPerformanceCharts.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import type { DeliveryRollupDTO, RatingTrendsDTO, TeamPerformanceDTO } from "@prm/shared"; +import { api } from "../lib/api"; +import { MixBarChart, SeriesChart, TrendSparkline } from "./charts"; + +/** + * Person-level performance graphs from delivery rollup + cycle history. + * Volume signals only — not a score. + */ +export function PersonPerformanceCharts({ + personId, + cycleId, +}: { + personId: string; + cycleId?: string | null; +}) { + const [rollup, setRollup] = useState(null); + const [team, setTeam] = useState(null); + const [trends, setTrends] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!personId) return; + let cancelled = false; + void (async () => { + try { + const qs = cycleId ? `?cycleId=${encodeURIComponent(cycleId)}` : ""; + const [r, tp, rt] = await Promise.all([ + api(`/api/people/${personId}/delivery-rollup${qs}`), + api(`/api/team-performance${qs}`).catch(() => null), + api("/api/rating-trends").catch(() => null), + ]); + if (cancelled) return; + setRollup(r); + setTeam(tp); + setTrends(rt); + setError(null); + } catch (e) { + if (cancelled) return; + setError(e instanceof Error ? e.message : "Failed to load performance graphs"); + } + })(); + return () => { + cancelled = true; + }; + }, [personId, cycleId]); + + if (error) return

{error}

; + if (!rollup) return

Loading performance graphs…

; + + const personRow = team?.people.find((p) => p.personId === personId); + const priorTrend = personRow?.deliveryTrend ?? []; + const deliverySeries = personRow + ? [ + ...priorTrend.map((v, i) => ({ + label: priorTrend.length === 1 ? "Prior" : `−${priorTrend.length - i}`, + value: v, + })), + { label: team?.cycleName ?? "Current", value: personRow.deliveryDone }, + ] + : [{ label: "This window", value: rollup.issuesDone + rollup.prsMerged }]; + + const ratingPerson = trends?.people.find((p) => p.personId === personId); + const mixRows = [ + { key: "done", label: "Issues done", count: rollup.issuesDone }, + { key: "wip", label: "In progress", count: rollup.issuesInProgress }, + { key: "prs", label: "PRs merged", count: rollup.prsMerged }, + { key: "reviews", label: "Reviews given", count: rollup.reviewsGiven }, + { key: "bugs", label: "Open bugs", count: rollup.openBugs }, + ]; + + return ( +
+
+

Work in this window

+

+ Synced delivery volume for {rollup.windowStart} → {rollup.windowEnd} + {rollup.storyPointsDone != null ? ` · ${rollup.storyPointsDone} story points done` : ""}. +

+ +
+ +
+

Delivery across cycles

+

+ Issues done + PRs merged (oldest → newest). Not a performance score. +

+ + {team ? ( +

+ Compare with team +

+ ) : null} +
+ + {ratingPerson && ratingPerson.overall.some((p) => p.value != null) ? ( +
+

Overall rating trend

+

+ Manager overall scores across cycles where a draft was rated. +

+
+ ({ + value: p.value, + cycleName: p.cycleName, + }))} + /> +
+ {ratingPerson.overall.map((p) => ( + + {p.cycleName}: {p.value ?? "—"} + + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/ui/src/components/charts.tsx b/apps/ui/src/components/charts.tsx new file mode 100644 index 0000000..14e17a0 --- /dev/null +++ b/apps/ui/src/components/charts.tsx @@ -0,0 +1,185 @@ +/** Lightweight SVG/CSS charts shared by Team performance, Sprint pulse, and dossiers. */ + +export function maxCount(values: number[]) { + return Math.max(1, ...values); +} + +export function BarChart({ + rows, + max, +}: { + rows: Array<{ key: string; label: string; count: number }>; + max: number; +}) { + return ( +
+ {rows.map((row) => ( +
+
+ {row.label} +
+
+
+
+
{row.count}
+
+ ))} +
+ ); +} + +export function Histogram({ + buckets, + ariaLabel = "Distribution", +}: { + buckets: Array<{ key: string; label: string; count: number }>; + ariaLabel?: string; +}) { + const max = maxCount(buckets.map((b) => b.count)); + const height = 140; + const gap = 10; + const barW = 36; + const width = buckets.length * (barW + gap) + gap; + return ( + + {buckets.map((b, i) => { + const h = b.count === 0 ? 0 : Math.max(4, (b.count / max) * height); + const x = gap + i * (barW + gap); + const y = height - h; + return ( + + + + {b.label} + + + {b.count} + + + ); + })} + + ); +} + +export function TrendSparkline({ + points, + label, + min = 1, + max = 5, +}: { + points: Array<{ value: number | null; cycleName: string }>; + label: string; + min?: number; + max?: number; +}) { + const w = 160; + const h = 36; + const vals = points.map((p) => p.value).filter((v): v is number => v != null); + if (vals.length < 1) return ; + const coords = points + .map((p, i) => { + if (p.value == null) return null; + const x = points.length === 1 ? w / 2 : (i / (points.length - 1)) * (w - 8) + 4; + const y = h - 4 - ((p.value - min) / (max - min)) * (h - 8); + return `${x},${y}`; + }) + .filter(Boolean); + return ( + + + {points.map((p, i) => { + if (p.value == null) return null; + const x = points.length === 1 ? w / 2 : (i / (points.length - 1)) * (w - 8) + 4; + const y = h - 4 - ((p.value - min) / (max - min)) * (h - 8); + return ; + })} + + ); +} + +export function DeliverySparkline({ values, label }: { values: number[]; label: string }) { + const w = 100; + const h = 28; + if (values.length < 1) return ; + const max = Math.max(1, ...values); + const coords = values.map((v, i) => { + const x = values.length === 1 ? w / 2 : (i / (values.length - 1)) * (w - 8) + 4; + const y = h - 4 - (v / max) * (h - 8); + return `${x},${y}`; + }); + return ( + + + {values.map((v, i) => { + const x = values.length === 1 ? w / 2 : (i / (values.length - 1)) * (w - 8) + 4; + const y = h - 4 - (v / max) * (h - 8); + return ; + })} + + ); +} + +/** Larger cycle-series chart for dossier / team delivery history. */ +export function SeriesChart({ + points, + label, + valueSuffix = "", +}: { + points: Array<{ label: string; value: number | null }>; + label: string; + valueSuffix?: string; +}) { + const w = 420; + const h = 160; + const pad = { left: 36, right: 16, top: 20, bottom: 36 }; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; + const nums = points.map((p) => p.value).filter((v): v is number => v != null); + if (nums.length === 0) { + return

No series data yet.

; + } + const max = Math.max(1, ...nums); + const n = points.length; + + return ( + + + + {points.map((p, i) => { + if (p.value == null) return null; + const x = pad.left + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW); + const barW = Math.min(48, plotW / Math.max(n, 1) - 8); + const barH = Math.max(2, (p.value / max) * plotH); + const y = pad.top + plotH - barH; + return ( + + + + {p.value} + {valueSuffix} + + + {p.label.length > 12 ? `${p.label.slice(0, 11)}…` : p.label} + + + ); + })} + + ); +} + +export function MixBarChart({ + rows, +}: { + rows: Array<{ key: string; label: string; count: number }>; +}) { + const max = maxCount(rows.map((r) => r.count)); + return ; +} diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index 63f5d49..5142dbb 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -17,6 +17,7 @@ import { PageSectionsLayout, type SectionNavItem } from "../components/PageSecti import { DeliveryRollupPanel } from "../components/DeliveryRollupPanel"; import { OneOnOnesPanel } from "../components/OneOnOnesPanel"; import { AppSelect } from "../components/AppSelect"; +import { PersonPerformanceCharts } from "../components/PersonPerformanceCharts"; export function PersonPage() { const { id } = useParams(); @@ -116,6 +117,7 @@ export function PersonPage() { const sections: SectionNavItem[] = [ ...(activeCycle ? [{ id: "person-reassign", label: "Reassign" }] : []), { id: "timeline", label: "Timeline" }, + { id: "person-performance", label: "Performance" }, { id: "integrations", label: "Integrations" }, { id: "capture-achievements", label: "Achievements" }, { id: "person-goals", label: "Goals" }, @@ -413,7 +415,28 @@ export function PersonPage() { {linkMsg &&

{linkMsg}

} - {id ? : null} + {id ? ( +
+ Team performance + + ) : ( + + Team performance + + ) + } + > + + +
+ ) : null}
+
+ {data.people.every((p) => p.achievements === 0) ? ( +

No achievements in this window yet.

+ ) : ( + ({ + key: p.personId, + label: p.personName, + count: p.achievements, + })) + .sort((a, b) => b.count - a.count)} + max={maxCount(data.people.map((p) => p.achievements))} + /> + )} +
+
= { ready: "Ready", @@ -16,87 +23,12 @@ const PROMO_LABELS: Record = { unset: "Unset", }; -function maxCount(values: number[]) { - return Math.max(1, ...values); -} - function formatMinutes(seconds: number) { if (seconds <= 0) return "—"; const m = Math.round(seconds / 60); return `${m}m`; } -function BarChart({ - rows, - max, -}: { - rows: Array<{ key: string; label: string; count: number }>; - max: number; -}) { - return ( -
- {rows.map((row) => ( -
-
- {row.label} -
-
-
-
-
{row.count}
-
- ))} -
- ); -} - -function Histogram({ - buckets, -}: { - buckets: Array<{ key: string; label: string; count: number }>; -}) { - const max = maxCount(buckets.map((b) => b.count)); - const height = 140; - const gap = 10; - const barW = 36; - const width = buckets.length * (barW + gap) + gap; - return ( - - {buckets.map((b, i) => { - const h = b.count === 0 ? 0 : Math.max(4, (b.count / max) * height); - const x = gap + i * (barW + gap); - const y = height - h; - return ( - - - - {b.label} - - - {b.count} - - - ); - })} - - ); -} - function EvidenceScatter({ people, }: { @@ -156,57 +88,66 @@ function EvidenceScatter({ ); } -function TrendSparkline({ - points, - label, +function DeliveryScatter({ + people, }: { - points: Array<{ value: number | null; cycleName: string }>; - label: string; + people: TeamPerformanceDTO["people"]; }) { - const w = 160; - const h = 36; - const vals = points.map((p) => p.value).filter((v): v is number => v != null); - if (vals.length < 1) return ; - const min = 1; - const max = 5; - const coords = points - .map((p, i) => { - if (p.value == null) return null; - const x = points.length === 1 ? w / 2 : (i / (points.length - 1)) * (w - 8) + 4; - const y = h - 4 - ((p.value - min) / (max - min)) * (h - 8); - return `${x},${y}`; - }) - .filter(Boolean); - return ( - - - {points.map((p, i) => { - if (p.value == null) return null; - const x = points.length === 1 ? w / 2 : (i / (points.length - 1)) * (w - 8) + 4; - const y = h - 4 - ((p.value - min) / (max - min)) * (h - 8); - return ; - })} - - ); -} + const rated = people.filter((p) => p.overall != null); + if (rated.length === 0) { + return

No overall ratings yet — points appear once you score drafts.

; + } + const maxDelivery = Math.max(1, ...rated.map((p) => p.deliveryDone ?? 0)); + const w = 420; + const h = 200; + const pad = { left: 36, right: 16, top: 16, bottom: 32 }; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; -function DeliverySparkline({ values, label }: { values: number[]; label: string }) { - const w = 100; - const h = 28; - if (values.length < 1) return ; - const max = Math.max(1, ...values); - const coords = values.map((v, i) => { - const x = values.length === 1 ? w / 2 : (i / (values.length - 1)) * (w - 8) + 4; - const y = h - 4 - (v / max) * (h - 8); - return `${x},${y}`; - }); return ( - - - {values.map((v, i) => { - const x = values.length === 1 ? w / 2 : (i / (values.length - 1)) * (w - 8) + 4; - const y = h - 4 - (v / max) * (h - 8); - return ; + + + + {[1, 2, 3, 4, 5].map((n) => { + const x = pad.left + ((n - 1) / 4) * plotW; + return ( + + {n} + + ); + })} + + Delivery + + {rated.map((p) => { + const x = pad.left + (((p.overall ?? 1) - 1) / 4) * plotW; + const y = pad.top + plotH - ((p.deliveryDone ?? 0) / maxDelivery) * plotH; + return ( + + + + {p.personName}: overall {p.overall}, {p.deliveryDone ?? 0} delivery + {p.storyPointsDone != null ? `, ${p.storyPointsDone} pts` : ""} + + + ); })} ); @@ -265,11 +206,25 @@ export function TeamPerformancePage() { })) ?? []; const competencyMax = 5; + const deliveryRows = [...(data?.people ?? [])] + .map((p) => ({ + key: p.personId, + label: p.personName, + count: p.deliveryDone ?? 0, + })) + .sort((a, b) => b.count - a.count); + const deliveryTotal = deliveryRows.reduce((s, r) => s + r.count, 0); + const storyPointsTotal = (data?.people ?? []).reduce( + (s, p) => s + (p.storyPointsDone ?? 0), + 0, + ); + const storyPointsKnown = (data?.people ?? []).some((p) => p.storyPointsDone != null); + return (
@@ -347,13 +302,22 @@ export function TeamPerformancePage() { id="tp-overview" title="Overview" when="Use first to see whether this cycle’s ratings, evidence, and concerns look healthy." - how="Read the four metrics below, then drill into charts or people when something looks off." + how="Read the five metrics below, then drill into charts or people when something looks off." >
Avg overall
{data.avgOverall ?? "—"}
+
+
Work completed
+ {deliveryTotal} + + {" "} + · issues + PRs + {storyPointsKnown ? ` · ${storyPointsTotal} pts` : ""} + +
Evidence coverage
{data.evidence.coveragePct}% @@ -374,8 +338,8 @@ export function TeamPerformancePage() {
@@ -383,7 +347,7 @@ export function TeamPerformancePage() {

Watch for clustering (everyone a 3 or 4) before you finalize.

- +
@@ -394,6 +358,30 @@ export function TeamPerformancePage() {
+
+

Work completed by person

+

+ Issues done + PRs merged in this cycle window — delivery volume, not a score. Open a + dossier for the breakdown. +

+ {deliveryRows.every((r) => r.count === 0) ? ( +

+ No synced delivery yet — connect Jira / Bitbucket / GitHub on person dossiers, or + check Sprint pulse. +

+ ) : ( + r.count))} /> + )} +
+ +
+

Delivery vs overall

+

+ High ratings with near-zero delivery may need better cites — or a thin integration sync. +

+ +
+

Competency averages

diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index 9fe7f7b..572e9e2 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -1400,12 +1400,22 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; } white-space: nowrap; } .tp-histogram, -.tp-scatter { +.tp-scatter, +.tp-series { width: 100%; max-width: 480px; height: auto; display: block; } +.person-rating-series { + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; + font-size: 0.9rem; +} +.person-perf-grid { + margin-bottom: 0.75rem; +} .tp-hist-bar { fill: var(--brand); opacity: 0.9; } .tp-hist-label { fill: var(--muted); font-size: 11px; } .tp-hist-count { fill: var(--ink-soft); font-size: 11px; font-weight: 600; } diff --git a/docs/product/FEATURES.md b/docs/product/FEATURES.md index 1d7ddc3..26c0e67 100644 --- a/docs/product/FEATURES.md +++ b/docs/product/FEATURES.md @@ -135,12 +135,13 @@ Per-participant phase status is source of truth; cycle banner is derived. Overla | Feature | Priority | Notes | |---------|----------|-------| | Person timeline | P0 | Person dossier chronological stream (evidence, concerns, reviews, decisions) | -| Competency sparklines (manager ratings only) | P0 | Team performance → rating trends across cycles | +| Competency sparklines (manager ratings only) | P0 | Team performance → rating trends; person dossier overall trend | | Stagnation hints | P1 | Local heuristics | | Search finalized reviews | P1 | FTS on workspace | | Workspace stats (time, evidence %) | P0 | Cycle dashboard on Home — writing time, coverage %, thin dossiers | -| Team performance graphs | P0 | `/team-performance` — rating histogram, competency averages, writing buckets, themes, prior-cycle deltas, cycle compare | -| Sprint / window pulse | P0 | `/sprint-pulse` — date-range throughput, themes, concerns, evidence freshness | +| Team performance graphs | P0 | `/team-performance` — ratings, work completed by person, delivery vs overall, competencies, writing, themes, cycle compare | +| Person delivery / rating graphs | P0 | Dossier Performance section — window mix, multi-cycle delivery bars, rating trend | +| Sprint / window pulse | P0 | `/sprint-pulse` — throughput chart, themes, concerns, evidence freshness | | Adverse-impact reporting | — | Hosted-only | ---