From cc91028ebeebb47fcc2ad37505a1647953ef5d4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:16:22 +0000 Subject: [PATCH] Add team performance page with cycle graphs Aggregate ratings, competencies, writing effort, themes, and prior-cycle deltas into /api/team-performance and a new Team performance nav page. Co-authored-by: Damon --- apps/api/src/index.ts | 139 +++++++ apps/api/src/teamPerformance.test.ts | 110 ++++++ apps/api/src/teamPerformance.ts | 163 ++++++++ apps/ui/src/App.tsx | 3 + apps/ui/src/pages/HomePage.tsx | 6 + apps/ui/src/pages/TeamPerformancePage.tsx | 458 ++++++++++++++++++++++ apps/ui/src/styles.css | 59 +++ docs/CORE_LOOP_EVAL.md | 1 + docs/product/FEATURES.md | 1 + packages/shared/src/index.ts | 44 +++ 10 files changed, 984 insertions(+) create mode 100644 apps/api/src/teamPerformance.test.ts create mode 100644 apps/api/src/teamPerformance.ts create mode 100644 apps/ui/src/pages/TeamPerformancePage.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c472e9c..004bb96 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -47,9 +47,12 @@ import type { IntegrationSettingsDTO, IntegrationSyncResult, PersonExternalLinkDTO, + TeamPerformanceDTO, } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; import { seedWorkspace } from "./seed.js"; +import { buildTeamPerformance } from "./teamPerformance.js"; +import type { TeamPerformancePersonInput } from "./teamPerformance.js"; import { checksum, encryptSecret, @@ -3283,6 +3286,142 @@ app.post("/api/ai/bias-lint", async (c) => { return c.json({ findings, ephemeral: true }); }); +app.get("/api/team-performance", (c) => { + const cycleId = c.req.query("cycleId"); + const db = getDb(); + const allCycles = db.select().from(cycles).all().sort((a, b) => b.windowStart.localeCompare(a.windowStart)); + const cycle = cycleId ? allCycles.find((x) => x.id === cycleId) : allCycles[0]; + if (!cycle) return c.json({ error: "No cycle found" }, 404); + + const priorCycle = + allCycles.find((x) => x.windowStart < cycle.windowStart && x.id !== cycle.id) ?? null; + + const participants = db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycle.id)).all(); + const peopleInputs: TeamPerformancePersonInput[] = []; + + for (const cp of participants) { + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + + const ach = db.select().from(achievements).where(eq(achievements.personId, person.id)).all().length; + const fbRows = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, person.id)).all(); + const docs = db.select().from(documents).where(eq(documents.personId, person.id)).all().length; + const goalsN = db.select().from(goals).where(eq(goals.personId, person.id)).all().length; + const evidenceCount = ach + fbRows.length + docs + goalsN; + const themes: string[] = []; + for (const f of fbRows) { + try { + const parsed = JSON.parse(f.themesJson || "[]") as string[]; + for (const t of parsed) if (typeof t === "string" && t.trim()) themes.push(t.trim()); + } catch { + /* ignore */ + } + } + const openConcerns = db + .select() + .from(concernLogs) + .where(eq(concernLogs.personId, person.id)) + .all() + .filter((row) => row.status === "open").length; + + const manager = db + .select() + .from(reviews) + .where(and(eq(reviews.cycleId, cycle.id), eq(reviews.subjectPersonId, person.id), eq(reviews.type, "manager"))) + .all() + .find((r) => r.status !== "superseded"); + + let overall: number | null = null; + let competencies: Record = {}; + let promoReadiness: string | null = null; + let writingSeconds = 0; + let linkedSources = 0; + let coverageHits = 0; + let coverageNeed = 0; + let managerStatus = "not_started"; + + if (manager) { + managerStatus = manager.status; + try { + const payload = JSON.parse(manager.payloadJson) as Record; + overall = typeof payload.overall === "number" ? payload.overall : null; + competencies = (payload.competencies ?? {}) as Record; + promoReadiness = typeof payload.promo_readiness === "string" ? payload.promo_readiness : null; + if (typeof payload.writing_seconds_total === "number") writingSeconds = payload.writing_seconds_total; + const links = db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all(); + linkedSources = new Set(links.map((l) => l.sourceId)).size; + const scored: Array<{ key: string; score: number }> = [ + ...Object.entries(competencies).map(([key, score]) => ({ key, score })), + ...(overall != null ? [{ key: "overall", score: overall }] : []), + ]; + for (const s of scored) { + if (s.score === MIDDLE_BAND) continue; + coverageNeed += 1; + const has = links.some( + (l) => + (s.key === "overall" && l.targetType === "overall") || + (l.targetType === "competency" && l.targetKey === s.key), + ); + if (has) coverageHits += 1; + } + } catch { + /* ignore bad payload */ + } + } + + let priorOverall: number | null = null; + if (priorCycle) { + const priorManager = db + .select() + .from(reviews) + .where( + and( + eq(reviews.cycleId, priorCycle.id), + eq(reviews.subjectPersonId, person.id), + eq(reviews.type, "manager"), + ), + ) + .all() + .find((r) => r.status !== "superseded"); + if (priorManager) { + try { + const payload = JSON.parse(priorManager.payloadJson) as Record; + priorOverall = typeof payload.overall === "number" ? payload.overall : null; + } catch { + priorOverall = null; + } + } + } + + peopleInputs.push({ + personId: person.id, + personName: person.name, + levelKey: person.levelKey, + overall, + priorOverall, + competencies, + promoReadiness, + writingSeconds, + evidenceCount, + linkedSources, + thin: evidenceCount < 2, + managerStatus, + coverageHits, + coverageNeed, + themes, + openConcerns, + }); + } + + const result: TeamPerformanceDTO = buildTeamPerformance({ + cycleId: cycle.id, + cycleName: cycle.name, + priorCycle: priorCycle ? { cycleId: priorCycle.id, cycleName: priorCycle.name } : null, + people: peopleInputs, + }); + return c.json(result); +}); + app.get("/api/consistency", (c) => { const cycleId = c.req.query("cycleId"); const db = getDb(); diff --git a/apps/api/src/teamPerformance.test.ts b/apps/api/src/teamPerformance.test.ts new file mode 100644 index 0000000..c79b8e4 --- /dev/null +++ b/apps/api/src/teamPerformance.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildTeamPerformance } from "./teamPerformance.js"; + +describe("buildTeamPerformance", () => { + it("aggregates distribution, competency averages, themes, and writing buckets", () => { + const result = buildTeamPerformance({ + cycleId: "c1", + cycleName: "H1", + priorCycle: { cycleId: "c0", cycleName: "H0" }, + people: [ + { + personId: "p1", + personName: "Ada", + levelKey: "L4", + overall: 4, + priorOverall: 3, + competencies: { execution: 4, ownership: 5 }, + promoReadiness: "ready", + writingSeconds: 20 * 60, + evidenceCount: 5, + linkedSources: 2, + thin: false, + managerStatus: "draft", + coverageHits: 2, + coverageNeed: 2, + themes: ["career", "Career", "delivery"], + openConcerns: 0, + }, + { + personId: "p2", + personName: "Ben", + levelKey: "L4", + overall: 4, + priorOverall: null, + competencies: { execution: 3, ownership: 3 }, + promoReadiness: null, + writingSeconds: 0, + evidenceCount: 0, + linkedSources: 0, + thin: true, + managerStatus: "not_started", + coverageHits: 0, + coverageNeed: 0, + themes: ["delivery"], + openConcerns: 1, + }, + { + personId: "p3", + personName: "Cara", + levelKey: null, + overall: null, + priorOverall: 4, + competencies: {}, + promoReadiness: "not_yet", + writingSeconds: 60 * 60, + evidenceCount: 3, + linkedSources: 1, + thin: false, + managerStatus: "draft", + coverageHits: 0, + coverageNeed: 1, + themes: [], + openConcerns: 0, + }, + ], + }); + + assert.equal(result.participantCount, 3); + assert.equal(result.ratedCount, 2); + assert.equal(result.avgOverall, 4); + assert.equal(result.distribution["4"], 2); + assert.equal(result.distribution.unset, 1); + assert.equal(result.promoReadiness.ready, 1); + assert.equal(result.promoReadiness.unset, 1); + assert.equal(result.promoReadiness.not_yet, 1); + assert.equal(result.writingBuckets.find((b) => b.id === "none")?.count, 1); + assert.equal(result.writingBuckets.find((b) => b.id === "under45")?.count, 1); + assert.equal(result.writingBuckets.find((b) => b.id === "over45")?.count, 1); + assert.equal(result.evidence.thinDossierCount, 1); + assert.equal(result.evidence.coveragePct, 67); // 2/3 + assert.equal(result.openConcerns, 1); + assert.equal(result.levels.find((l) => l.levelKey === "L4")?.count, 2); + assert.equal(result.levels.find((l) => l.levelKey === "Unleveled")?.count, 1); + assert.deepEqual( + result.themes.map((t) => t.theme), + ["career", "delivery"], + ); + assert.equal(result.themes.find((t) => t.theme === "career")?.count, 2); + assert.equal(result.themes.find((t) => t.theme === "delivery")?.count, 2); + const exec = result.competencies.find((c) => c.key === "execution"); + assert.equal(exec?.avg, 3.5); + assert.equal(result.people[0].personName, "Ada"); + assert.equal(result.people[0].priorOverall, 3); + assert.equal(result.priorCycle?.cycleId, "c0"); + }); + + it("returns empty-safe defaults when there are no people", () => { + const result = buildTeamPerformance({ + cycleId: "c1", + cycleName: "Empty", + priorCycle: null, + people: [], + }); + assert.equal(result.avgOverall, null); + assert.equal(result.evidence.coveragePct, 100); + assert.equal(result.participantCount, 0); + assert.deepEqual(result.themes, []); + }); +}); diff --git a/apps/api/src/teamPerformance.ts b/apps/api/src/teamPerformance.ts new file mode 100644 index 0000000..74dbd6b --- /dev/null +++ b/apps/api/src/teamPerformance.ts @@ -0,0 +1,163 @@ +import { competencyLabel } from "@prm/shared"; +import type { TeamPerformanceDTO, TeamPerformancePersonDTO } from "@prm/shared"; + +export type TeamPerformancePersonInput = { + personId: string; + personName: string; + levelKey: string | null; + overall: number | null; + priorOverall: number | null; + competencies: Record; + promoReadiness: string | null; + writingSeconds: number; + evidenceCount: number; + linkedSources: number; + thin: boolean; + managerStatus: string; + coverageHits: number; + coverageNeed: number; + themes: string[]; + openConcerns: number; +}; + +const PROMO_KEYS = ["ready", "not_yet", "too_early", "not_applicable", "unset"] as const; + +const WRITING_BUCKETS: Array<{ id: string; label: string; match: (sec: number) => boolean }> = [ + { id: "none", label: "No time logged", match: (s) => s <= 0 }, + { id: "under15", label: "≤15 min", match: (s) => s > 0 && s <= 15 * 60 }, + { id: "under45", label: "16–45 min", match: (s) => s > 15 * 60 && s <= 45 * 60 }, + { id: "over45", label: ">45 min", match: (s) => s > 45 * 60 }, +]; + +export function buildTeamPerformance(args: { + cycleId: string; + cycleName: string; + priorCycle: { cycleId: string; cycleName: string } | null; + people: TeamPerformancePersonInput[]; +}): TeamPerformanceDTO { + const distribution: Record = { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, unset: 0 }; + const promoReadiness: Record = Object.fromEntries(PROMO_KEYS.map((k) => [k, 0])); + const writingBuckets = WRITING_BUCKETS.map((b) => ({ id: b.id, label: b.label, count: 0 })); + const levelMap = new Map(); + const themeMap = new Map(); + const compSums = new Map(); + + let ratedSum = 0; + let ratedCount = 0; + let coverageHits = 0; + let coverageNeed = 0; + let linkedTotal = 0; + let draftCount = 0; + let thin = 0; + let openConcerns = 0; + + const peopleOut: TeamPerformancePersonDTO[] = []; + + for (const p of args.people) { + if (p.overall != null && p.overall >= 1 && p.overall <= 5) { + distribution[String(Math.round(p.overall))] += 1; + ratedSum += p.overall; + ratedCount += 1; + } else { + distribution.unset += 1; + } + + const promo = p.promoReadiness && PROMO_KEYS.includes(p.promoReadiness as (typeof PROMO_KEYS)[number]) + ? p.promoReadiness + : "unset"; + promoReadiness[promo] += 1; + + for (let i = 0; i < WRITING_BUCKETS.length; i++) { + if (WRITING_BUCKETS[i].match(p.writingSeconds)) { + writingBuckets[i].count += 1; + break; + } + } + + const levelKey = p.levelKey?.trim() || "Unleveled"; + levelMap.set(levelKey, (levelMap.get(levelKey) ?? 0) + 1); + + for (const theme of p.themes) { + const t = theme.trim().toLowerCase(); + if (!t) continue; + themeMap.set(t, (themeMap.get(t) ?? 0) + 1); + } + + for (const [key, score] of Object.entries(p.competencies)) { + if (typeof score !== "number") continue; + const cur = compSums.get(key) ?? { sum: 0, n: 0 }; + cur.sum += score; + cur.n += 1; + compSums.set(key, cur); + } + + coverageHits += p.coverageHits; + coverageNeed += p.coverageNeed; + const hasDraft = p.managerStatus !== "missing" && p.managerStatus !== "not_started"; + if (hasDraft) { + draftCount += 1; + linkedTotal += p.linkedSources; + } + if (p.thin) thin += 1; + openConcerns += p.openConcerns; + + peopleOut.push({ + personId: p.personId, + personName: p.personName, + levelKey: p.levelKey, + overall: p.overall, + priorOverall: p.priorOverall, + competencies: p.competencies, + promoReadiness: p.promoReadiness, + writingSeconds: p.writingSeconds, + evidenceCount: p.evidenceCount, + linkedSources: p.linkedSources, + thin: p.thin, + managerStatus: p.managerStatus, + }); + } + + peopleOut.sort( + (a, b) => (b.overall ?? -1) - (a.overall ?? -1) || a.personName.localeCompare(b.personName), + ); + + const competencies = [...compSums.entries()] + .map(([key, { sum, n }]) => ({ + key, + label: competencyLabel(key), + avg: n ? Math.round((sum / n) * 10) / 10 : null, + ratedCount: n, + })) + .sort((a, b) => (b.avg ?? 0) - (a.avg ?? 0) || a.label.localeCompare(b.label)); + + const levels = [...levelMap.entries()] + .map(([levelKey, count]) => ({ levelKey, count })) + .sort((a, b) => b.count - a.count || a.levelKey.localeCompare(b.levelKey)); + + const themes = [...themeMap.entries()] + .map(([theme, count]) => ({ theme, count })) + .sort((a, b) => b.count - a.count || a.theme.localeCompare(b.theme)) + .slice(0, 12); + + return { + cycleId: args.cycleId, + cycleName: args.cycleName, + priorCycle: args.priorCycle, + participantCount: args.people.length, + ratedCount, + avgOverall: ratedCount ? Math.round((ratedSum / ratedCount) * 10) / 10 : null, + distribution, + competencies, + promoReadiness, + writingBuckets, + evidence: { + coveragePct: coverageNeed ? Math.round((coverageHits / coverageNeed) * 100) : 100, + avgLinkedSources: draftCount ? Math.round((linkedTotal / draftCount) * 10) / 10 : 0, + thinDossierCount: thin, + }, + levels, + themes, + openConcerns, + people: peopleOut, + }; +} diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 8fbb09b..88e2d85 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -15,6 +15,7 @@ import { PromotionsPage } from "./pages/PromotionsPage"; import { PromotionDetailPage } from "./pages/PromotionDetailPage"; import { ChatPage } from "./pages/ChatPage"; import { ConsistencyPage } from "./pages/ConsistencyPage"; +import { TeamPerformancePage } from "./pages/TeamPerformancePage"; import { BackfillPage } from "./pages/BackfillPage"; export function App() { @@ -90,6 +91,7 @@ export function App() { Cycles Promotions Consistency + Team performance Backfill AI Chat Settings @@ -119,6 +121,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index e40854b..e697684 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -240,6 +240,12 @@ export function HomePage() { ) : null} +
+
Charts
+ + Team performance + +
)} diff --git a/apps/ui/src/pages/TeamPerformancePage.tsx b/apps/ui/src/pages/TeamPerformancePage.tsx new file mode 100644 index 0000000..1de351a --- /dev/null +++ b/apps/ui/src/pages/TeamPerformancePage.tsx @@ -0,0 +1,458 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import type { CycleDTO, TeamPerformanceDTO } from "@prm/shared"; +import { competencyLabel } from "@prm/shared"; +import { api } from "../lib/api"; +import { NextStep, PageHeader } from "../components/PageChrome"; + +const PROMO_LABELS: Record = { + ready: "Ready", + not_yet: "Not yet", + too_early: "Too early", + not_applicable: "N/A", + 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, +}: { + people: TeamPerformanceDTO["people"]; +}) { + const rated = people.filter((p) => p.overall != null); + if (rated.length === 0) { + return

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

; + } + const maxEvidence = Math.max(1, ...rated.map((p) => p.evidenceCount)); + 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; + + return ( + + + + {[1, 2, 3, 4, 5].map((n) => { + const x = pad.left + ((n - 1) / 4) * plotW; + return ( + + {n} + + ); + })} + + Evidence + + {rated.map((p) => { + const x = pad.left + (((p.overall ?? 1) - 1) / 4) * plotW; + const y = pad.top + plotH - (p.evidenceCount / maxEvidence) * plotH; + return ( + + + + {p.personName}: overall {p.overall}, {p.evidenceCount} evidence + + + ); + })} + + ); +} + +export function TeamPerformancePage() { + const [params, setParams] = useSearchParams(); + const [cycles, setCycles] = useState([]); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const cycleId = params.get("cycleId") ?? ""; + + async function load(cid?: string) { + try { + const qs = cid ? `?cycleId=${encodeURIComponent(cid)}` : ""; + const res = await api(`/api/team-performance${qs}`); + setData(res); + setError(null); + if (!cid && res.cycleId) setParams({ cycleId: res.cycleId }, { replace: true }); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load team performance"); + } + } + + useEffect(() => { + void (async () => { + const list = await api("/api/cycles"); + setCycles(list); + await load(cycleId || undefined); + })(); + }, []); + + useEffect(() => { + if (cycleId) void load(cycleId); + }, [cycleId]); + + const overallBuckets = ["1", "2", "3", "4", "5", "unset"].map((key) => ({ + key, + label: key === "unset" ? "—" : key, + count: data?.distribution[key] ?? 0, + })); + + const competencyRows = + data?.competencies.map((c) => ({ + key: c.key, + label: c.label, + count: c.avg ?? 0, + })) ?? []; + const competencyMax = 5; + + return ( +
+ + Consistency view + + ) : undefined + } + /> + + {error && ( + void load(cycleId || undefined)}> + Retry + + } + /> + )} + +
+
+ + +
+
+ + {!data ? ( +

Loading…

+ ) : ( + <> + 0 ? "warn" : "ok"} + label={`${data.cycleName} · ${data.ratedCount}/${data.participantCount} rated`} + detail={ + data.priorCycle + ? `Compared with prior cycle ${data.priorCycle.cycleName} where available.` + : "Pick a later cycle once you have history for prior-overall deltas." + } + action={Command center} + /> + +
+
+
Avg overall
+ {data.avgOverall ?? "—"} +
+
+
Evidence coverage
+ {data.evidence.coveragePct}% + · avg {data.evidence.avgLinkedSources} sources +
+
+
Thin dossiers
+ {data.evidence.thinDossierCount} + / {data.participantCount} +
+
+
Open concerns
+ {data.openConcerns} +
+
+ +
+
+

Overall distribution

+

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

+ +
+ +
+

Evidence vs overall

+

+ Thin dossiers sit lower; extreme scores should sit higher. +

+ +
+ +
+

Competency averages

+

+ Team mean on the 1–5 scale (people with that competency scored). +

+ {competencyRows.length === 0 ? ( +

No competency scores yet.

+ ) : ( +
+ {data.competencies.map((c) => ( +
+
+ {c.label} +
+
+
+
+
{c.avg ?? "—"}
+
+ ))} +
+ )} +
+ +
+

Promo readiness

+ ({ + key, + label: PROMO_LABELS[key] ?? key, + count, + }))} + max={maxCount(Object.values(data.promoReadiness))} + /> +
+ +
+

Writing time

+ ({ + key: b.id, + label: b.label, + count: b.count, + }))} + max={maxCount(data.writingBuckets.map((b) => b.count))} + /> +
+ +
+

Levels

+ {data.levels.length === 0 ? ( +

No people in this cycle.

+ ) : ( + ({ + key: l.levelKey, + label: l.levelKey, + count: l.count, + }))} + max={maxCount(data.levels.map((l) => l.count))} + /> + )} +
+ +
+

1:1 themes

+

+ Tags from feedback notes across the team (year-round coaching signal). +

+ {data.themes.length === 0 ? ( +

No themes tagged yet — add them on person dossiers.

+ ) : ( + ({ + key: t.theme, + label: t.theme, + count: t.count, + }))} + max={maxCount(data.themes.map((t) => t.count))} + /> + )} +
+
+ +
+

People

+
+ + + + + + + {data.priorCycle ? : null} + + + + + + + {data.people.map((p) => { + const delta = + p.overall != null && p.priorOverall != null ? p.overall - p.priorOverall : null; + const comps = Object.entries(p.competencies); + return ( + + + + + {data.priorCycle ? ( + + ) : null} + + + + + + ); + })} + +
PersonLevelOverallΔ vs priorWritingEvidenceCompetencies +
+ {p.personName} + {p.thin ? thin : null} + {p.levelKey ?? "—"} + {p.overall ?? "—"} + · {p.managerStatus} + 0 ? "tp-up" : delta < 0 ? "tp-down" : "muted"}> + {delta == null ? "—" : delta > 0 ? `+${delta}` : String(delta)} + {formatMinutes(p.writingSeconds)} + {p.evidenceCount} + · {p.linkedSources} linked + + {comps.length === 0 ? ( + — + ) : ( +
`${competencyLabel(k)} ${v}`).join(", ")}> + {comps.map(([k, v]) => ( + + ))} +
+ )} +
+ + Desk + +
+
+
+ + )} +
+ ); +} diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index d01bb3c..6416e59 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -690,6 +690,65 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; } margin: 0; } +.tp-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem 1.25rem; +} +.tp-metric strong { + font-size: 1.35rem; + letter-spacing: -0.02em; +} +.tp-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} +.tp-span-2 { grid-column: 1 / -1; } +.tp-bar-row { + grid-template-columns: minmax(88px, 140px) 1fr 40px; +} +.tp-bar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tp-histogram, +.tp-scatter { + width: 100%; + max-width: 480px; + height: auto; + display: block; +} +.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: 650; } +.tp-axis { stroke: var(--line); stroke-width: 1; } +.tp-dot { fill: var(--brand-strong); opacity: 0.85; } +.tp-dot.thin { fill: var(--warn); } +.tp-up { color: var(--ok); font-weight: 650; } +.tp-down { color: var(--danger); font-weight: 650; } +.tp-spark { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 22px; + min-width: 48px; +} +.tp-spark-bar { + width: 4px; + min-height: 2px; + border-radius: 2px 2px 0 0; + background: var(--brand); + align-self: flex-end; +} +.table-wrap { overflow-x: auto; } + +@media (max-width: 900px) { + .tp-grid { grid-template-columns: 1fr; } + .tp-span-2 { grid-column: auto; } +} + .timeline-list { list-style: none; margin: 0; diff --git a/docs/CORE_LOOP_EVAL.md b/docs/CORE_LOOP_EVAL.md index a481a35..da06ce7 100644 --- a/docs/CORE_LOOP_EVAL.md +++ b/docs/CORE_LOOP_EVAL.md @@ -25,6 +25,7 @@ upload role R&R → tag people → year-round dossier | Export / import self & peer JSON | Done | Pipeline states: pending → exported → returned / waived on command center | | Deadline nudges | Done | In-app list near due date + mailto drafts for missing self (OS mail while app open) | | Command center | Done | Self/peer pipeline columns + cycle dashboard (writing time, evidence %, thin dossiers) | +| Team performance graphs | Done | Rating histogram, competency averages, evidence scatter, writing/promo/theme bars, prior-cycle deltas | | Writing desk | Done | Autosave ~8s; writing-time total with ≤45 min/person signal; OOW evidence tray; finalize freezes citations + timeline | | Out-of-window evidence | Done | Late docs stay on the packet in a separate tray (flag `out_of_window_evidence`) | | Subject export | Done | HTML (+ Print→PDF tip); requires finalized/shared; strips `private_notes` / `em_only`; redacts peer names in cites | diff --git a/docs/product/FEATURES.md b/docs/product/FEATURES.md index a9a4c70..8968003 100644 --- a/docs/product/FEATURES.md +++ b/docs/product/FEATURES.md @@ -138,6 +138,7 @@ Per-participant phase status is source of truth; cycle banner is derived. Overla | 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 | | Adverse-impact reporting | — | Hosted-only | --- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5fb0210..e16a391 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -395,3 +395,47 @@ export interface CycleStatsDTO { managerStatus: string; }>; } + +/** Cycle-scoped team analytics for the Team performance page. */ +export interface TeamPerformancePersonDTO { + personId: string; + personName: string; + levelKey: string | null; + overall: number | null; + /** Prior-cycle overall when a previous cycle exists; null if unrated then. */ + priorOverall: number | null; + competencies: Record; + promoReadiness: string | null; + writingSeconds: number; + evidenceCount: number; + linkedSources: number; + thin: boolean; + managerStatus: string; +} + +export interface TeamPerformanceDTO { + cycleId: string; + cycleName: string; + priorCycle: { cycleId: string; cycleName: string } | null; + participantCount: number; + ratedCount: number; + avgOverall: number | null; + distribution: Record; + competencies: Array<{ + key: string; + label: string; + avg: number | null; + ratedCount: number; + }>; + promoReadiness: Record; + writingBuckets: Array<{ id: string; label: string; count: number }>; + evidence: { + coveragePct: number; + avgLinkedSources: number; + thinDossierCount: number; + }; + levels: Array<{ levelKey: string; count: number }>; + themes: Array<{ theme: string; count: number }>; + openConcerns: number; + people: TeamPerformancePersonDTO[]; +}