Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
123 changes: 123 additions & 0 deletions apps/ui/src/components/PersonPerformanceCharts.tsx
Original file line number Diff line number Diff line change
@@ -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<DeliveryRollupDTO | null>(null);
const [team, setTeam] = useState<TeamPerformanceDTO | null>(null);
const [trends, setTrends] = useState<RatingTrendsDTO | null>(null);
const [error, setError] = useState<string | null>(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<DeliveryRollupDTO>(`/api/people/${personId}/delivery-rollup${qs}`),
api<TeamPerformanceDTO>(`/api/team-performance${qs}`).catch(() => null),
api<RatingTrendsDTO>("/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 <p className="muted">{error}</p>;
if (!rollup) return <p className="muted">Loading performance graphs…</p>;

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 (
<div className="tp-grid person-perf-grid">
<div className="panel stack">
<h3 style={{ margin: 0 }}>Work in this window</h3>
<p className="muted" style={{ margin: 0 }}>
Synced delivery volume for {rollup.windowStart} → {rollup.windowEnd}
{rollup.storyPointsDone != null ? ` · ${rollup.storyPointsDone} story points done` : ""}.
</p>
<MixBarChart rows={mixRows} />
</div>

<div className="panel stack">
<h3 style={{ margin: 0 }}>Delivery across cycles</h3>
<p className="muted" style={{ margin: 0 }}>
Issues done + PRs merged (oldest → newest). Not a performance score.
</p>
<SeriesChart points={deliverySeries} label="Delivery across cycles" />
{team ? (
<p className="muted" style={{ margin: 0 }}>
<Link to={`/team-performance?cycleId=${team.cycleId}`}>Compare with team</Link>
</p>
) : null}
</div>

{ratingPerson && ratingPerson.overall.some((p) => p.value != null) ? (
<div className="panel stack tp-span-2">
<h3 style={{ margin: 0 }}>Overall rating trend</h3>
<p className="muted" style={{ margin: 0 }}>
Manager overall scores across cycles where a draft was rated.
</p>
<div className="row" style={{ gap: 16, alignItems: "center", flexWrap: "wrap" }}>
<TrendSparkline
label={`${ratingPerson.personName} overall`}
points={ratingPerson.overall.map((p) => ({
value: p.value,
cycleName: p.cycleName,
}))}
/>
<div className="person-rating-series muted">
{ratingPerson.overall.map((p) => (
<span key={p.cycleId}>
{p.cycleName}: <strong>{p.value ?? "—"}</strong>
</span>
))}
</div>
</div>
</div>
) : null}
</div>
);
}
185 changes: 185 additions & 0 deletions apps/ui/src/components/charts.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="consistency-bars">
{rows.map((row) => (
<div key={row.key} className="consistency-bar-row tp-bar-row">
<div className="consistency-bar-label tp-bar-label" title={row.label}>
{row.label}
</div>
<div className="consistency-bar-track">
<div className="consistency-bar-fill" style={{ width: `${(row.count / max) * 100}%` }} />
</div>
<div className="consistency-bar-count">{row.count}</div>
</div>
))}
</div>
);
}

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 (
<svg className="tp-histogram" viewBox={`0 0 ${width} ${height + 28}`} role="img" aria-label={ariaLabel}>
{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 (
<g key={b.key}>
<rect x={x} y={y} width={barW} height={h} rx={6} className="tp-hist-bar" />
<text x={x + barW / 2} y={height + 16} textAnchor="middle" className="tp-hist-label">
{b.label}
</text>
<text x={x + barW / 2} y={y - 6} textAnchor="middle" className="tp-hist-count">
{b.count}
</text>
</g>
);
})}
</svg>
);
}

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 <span className="muted">—</span>;
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 (
<svg className="tp-trend" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={label}>
<polyline points={coords.join(" ")} className="tp-trend-line" fill="none" />
{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 <circle key={p.cycleName} cx={x} cy={y} r={2.5} className="tp-trend-dot" />;
})}
</svg>
);
}

export function DeliverySparkline({ values, label }: { values: number[]; label: string }) {
const w = 100;
const h = 28;
if (values.length < 1) return <span className="muted">—</span>;
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 (
<svg className="tp-trend" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={label}>
<polyline points={coords.join(" ")} className="tp-trend-line" fill="none" />
{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 <circle key={i} cx={x} cy={y} r={2} className="tp-trend-dot" />;
})}
</svg>
);
}

/** 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 <p className="muted">No series data yet.</p>;
}
const max = Math.max(1, ...nums);
const n = points.length;

return (
<svg className="tp-series" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={label}>
<line
x1={pad.left}
y1={pad.top + plotH}
x2={pad.left + plotW}
y2={pad.top + plotH}
className="tp-axis"
/>
<line x1={pad.left} y1={pad.top} x2={pad.left} y2={pad.top + plotH} className="tp-axis" />
{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 (
<g key={`${p.label}-${i}`}>
<rect x={x - barW / 2} y={y} width={barW} height={barH} rx={4} className="tp-hist-bar" />
<text x={x} y={y - 6} textAnchor="middle" className="tp-hist-count">
{p.value}
{valueSuffix}
</text>
<text x={x} y={h - 10} textAnchor="middle" className="tp-hist-label">
{p.label.length > 12 ? `${p.label.slice(0, 11)}…` : p.label}
</text>
</g>
);
})}
</svg>
);
}

export function MixBarChart({
rows,
}: {
rows: Array<{ key: string; label: string; count: number }>;
}) {
const max = maxCount(rows.map((r) => r.count));
return <BarChart rows={rows} max={max} />;
}
25 changes: 24 additions & 1 deletion apps/ui/src/pages/PersonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -413,7 +415,28 @@ export function PersonPage() {
{linkMsg && <p className="muted">{linkMsg}</p>}
</Section>

{id ? <DeliveryRollupPanel personId={id} cycleId={cycles[0]?.id} /> : null}
{id ? (
<Section
id="person-performance"
title="Performance"
when="Use before a 1:1 or writing desk to see work completed vs prior cycles."
how="Read the window mix and cycle bars — volume signals only. Sync integrations if delivery looks empty."
action={
activeCycle ? (
<Link className="btn secondary" to={`/team-performance?cycleId=${activeCycle.id}`}>
Team performance
</Link>
) : (
<Link className="btn secondary" to="/team-performance">
Team performance
</Link>
)
}
>
<PersonPerformanceCharts personId={id} cycleId={cycles[0]?.id} />
<DeliveryRollupPanel personId={id} cycleId={cycles[0]?.id} />
</Section>
) : null}

<div className="grid-2">
<Section
Expand Down
Loading
Loading