From afaeb545f029b7728d636dc08785bfb968cc9e0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:38:12 +0000 Subject: [PATCH 1/2] Add sprint pulse, timeline, trends, and cycle compare Ship mid-cycle date-window coaching, person timelines, cross-cycle rating sparklines, cycle compare, and evidence freshness on drafts. Co-authored-by: Damon --- README.md | 14 +- apps/api/src/analytics.test.ts | 151 ++++++++ apps/api/src/analytics.ts | 401 ++++++++++++++++++++++ apps/api/src/analyticsGather.ts | 313 +++++++++++++++++ apps/api/src/index.ts | 34 ++ apps/api/src/seed.ts | 102 +++++- apps/ui/src/App.tsx | 3 + apps/ui/src/pages/HomePage.tsx | 2 + apps/ui/src/pages/PersonPage.tsx | 28 ++ apps/ui/src/pages/SprintPulsePage.tsx | 253 ++++++++++++++ apps/ui/src/pages/TeamPerformancePage.tsx | 178 +++++++++- apps/ui/src/styles.css | 16 + docs/CORE_LOOP_EVAL.md | 4 +- docs/product/FEATURES.md | 7 +- packages/shared/src/index.ts | 111 ++++++ 15 files changed, 1596 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/analytics.test.ts create mode 100644 apps/api/src/analytics.ts create mode 100644 apps/api/src/analyticsGather.ts create mode 100644 apps/ui/src/pages/SprintPulsePage.tsx diff --git a/README.md b/README.md index b8ab26c..9651426 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **Intent:** Build a citation pool all year so the writing desk is not empty in week one of reviews. -**How it works:** Capture achievements, goals, feedback (with optional 1:1 theme tags), documents, concern / underperformance notes, and external links (for example Jira or Bitbucket sync). Visibility controls keep EM-only material out of subject exports. AI digests and drafts pull from this pool (and from concerns only when you opt them in). +**How it works:** Capture achievements, goals, feedback (with optional 1:1 theme tags), documents, concern / underperformance notes, and external links (for example Jira or Bitbucket sync). The **Timeline** panel stitches those events with manager reviews and rating decisions so you can prep a 1:1 without opening five pages. Visibility controls keep EM-only material out of subject exports. AI digests and drafts pull from this pool (and from concerns only when you opt them in). ![Person dossier](./docs/screenshots/04-person.png) @@ -157,10 +157,16 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **Intent:** See the team's rating and coaching shape as graphs, not only as a queue of drafts. -**How it works:** Cycle-scoped charts for overall distribution, evidence vs overall, competency averages, promo readiness, writing-time buckets, level mix, and top 1:1 themes. The people table adds competency sparklines and prior-cycle overall deltas when history exists. Use it with Consistency: graphs for pattern, cards for per-person fixes. +**How it works:** Cycle-scoped charts for overall distribution, evidence vs overall, competency averages, promo readiness, writing-time buckets, level mix, and top 1:1 themes. Cross-cycle **rating trends** and **cycle compare** call out movers, repeat thin dossiers, and theme drift. The people table adds competency sparklines and prior-cycle overall deltas when history exists. ![Team performance](./docs/screenshots/11-team-performance.png) +### Sprint pulse + +**Intent:** Review the last sprint (or any date window) for coaching, not final ratings. + +**How it works:** Pick last 7 / 14 / 30 days or a custom range. See per-person achievement volume, Jira / Bitbucket throughput, feedback themes, open concerns, and evidence freshness (in-window vs older dossier items, plus stale-only cites on manager drafts). + ### Backfill **Intent:** Unblock writing when dossiers are still empty after you first adopt the tool. @@ -192,7 +198,9 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc - **Encrypted vault** (AES-256-GCM) for DB and dossier files on lock - **People / roles / cycles** with self and peer JSON bundles - **Writing desk**: competencies, evidence links, citation freeze, HTML subject packets -- **Consistency** and **team performance** views before you share +- **Consistency** and **team performance** views before you share (including cross-cycle trends and cycle compare) +- **Sprint pulse** for date-window coaching and evidence freshness +- **Person timeline** on each dossier - **Bias / faithfulness lint** (advisory) on drafts - **Promotions** against next-role expectations - **Backfill** wizard plus MCP / Jira / Bitbucket paths for thin dossiers diff --git a/apps/api/src/analytics.test.ts b/apps/api/src/analytics.test.ts new file mode 100644 index 0000000..8eda139 --- /dev/null +++ b/apps/api/src/analytics.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + addDays, + buildCycleCompare, + buildPersonTimeline, + buildRatingTrends, + buildSprintPulse, + inRange, + resolvePulseWindow, +} from "./analytics.js"; + +describe("pulse window helpers", () => { + it("defaults to last 14 days", () => { + const w = resolvePulseWindow({ today: "2026-07-25" }); + assert.equal(w.from, "2026-07-12"); + assert.equal(w.to, "2026-07-25"); + assert.equal(w.preset, "last_14"); + }); + + it("honors custom from/to", () => { + const w = resolvePulseWindow({ from: "2026-03-01", to: "2026-03-14", today: "2026-07-25" }); + assert.equal(w.preset, "custom"); + assert.equal(w.from, "2026-03-01"); + }); + + it("addDays and inRange work on ISO dates", () => { + assert.equal(addDays("2026-07-01", 10), "2026-07-11"); + assert.equal(inRange("2026-07-10", "2026-07-01", "2026-07-15"), true); + assert.equal(inRange("2026-06-30", "2026-07-01", "2026-07-15"), false); + }); +}); + +describe("buildSprintPulse", () => { + it("counts window activity, themes, and stale cites", () => { + const result = buildSprintPulse({ + from: "2026-07-01", + to: "2026-07-14", + preset: "custom", + people: [ + { + personId: "p1", + personName: "Ada", + levelKey: "IC4", + achievements: [ + { occurredAt: "2026-07-05", sourceProvider: "jira" }, + { occurredAt: "2026-07-08", sourceProvider: "bitbucket" }, + { occurredAt: "2026-01-01", sourceProvider: null }, + ], + feedback: [{ occurredAt: "2026-07-06", themes: ["Delivery", "career"] }], + documents: [{ occurredAt: "2025-12-01" }], + openConcernsInWindow: 1, + linkedSources: [{ occurredAt: "2026-01-01" }, { occurredAt: "2025-06-01" }], + }, + { + personId: "p2", + personName: "Ben", + levelKey: "IC3", + achievements: [], + feedback: [], + documents: [], + openConcernsInWindow: 0, + linkedSources: [{ occurredAt: "2026-07-03" }], + }, + ], + }); + assert.equal(result.summary.achievements, 2); + assert.equal(result.summary.jira, 1); + assert.equal(result.summary.bitbucket, 1); + assert.equal(result.summary.feedback, 1); + assert.equal(result.summary.openConcerns, 1); + assert.equal(result.summary.staleCitePeople, 1); + assert.equal(result.people[0].personName, "Ada"); + assert.equal(result.people[0].staleOnlyCites, true); + assert.equal(result.people[1].staleOnlyCites, false); + assert.ok(result.themes.some((t) => t.theme === "delivery")); + }); +}); + +describe("buildPersonTimeline", () => { + it("sorts newest first", () => { + const result = buildPersonTimeline({ + personId: "p1", + personName: "Ada", + entries: [ + { at: "2026-01-01", kind: "goal", title: "Old" }, + { at: "2026-07-01", kind: "achievement", title: "New" }, + ], + }); + assert.equal(result.entries[0].title, "New"); + }); +}); + +describe("buildRatingTrends", () => { + it("computes team averages across cycles", () => { + const trends = buildRatingTrends([ + { + cycleId: "c1", + cycleName: "H2", + windowEnd: "2025-12-31", + people: [ + { personId: "p1", personName: "Ada", overall: 3, competencies: { execution: 3 }, thin: false, themes: [] }, + { personId: "p2", personName: "Ben", overall: 4, competencies: { execution: 4 }, thin: false, themes: [] }, + ], + }, + { + cycleId: "c2", + cycleName: "H1", + windowEnd: "2026-06-30", + people: [ + { personId: "p1", personName: "Ada", overall: 4, competencies: { execution: 5 }, thin: false, themes: [] }, + { personId: "p2", personName: "Ben", overall: 4, competencies: { execution: 4 }, thin: true, themes: [] }, + ], + }, + ]); + const overall = trends.team.find((t) => t.key === "overall"); + assert.equal(overall?.points[0].value, 3.5); + assert.equal(overall?.points[1].value, 4); + assert.equal(trends.people[0].overall[1].value, 4); + }); +}); + +describe("buildCycleCompare", () => { + it("flags movers, repeat thin, and theme drift", () => { + const compare = buildCycleCompare({ + prior: { + cycleId: "c1", + cycleName: "H2", + windowEnd: "2025-12-31", + people: [ + { personId: "p1", personName: "Ada", overall: 3, competencies: {}, thin: true, themes: ["focus"] }, + { personId: "p2", personName: "Ben", overall: 4, competencies: {}, thin: false, themes: ["focus"] }, + ], + }, + current: { + cycleId: "c2", + cycleName: "H1", + windowEnd: "2026-06-30", + people: [ + { personId: "p1", personName: "Ada", overall: 4, competencies: {}, thin: true, themes: ["career"] }, + { personId: "p2", personName: "Ben", overall: 4, competencies: {}, thin: false, themes: ["career"] }, + ], + }, + }); + assert.equal(compare.movers[0].personName, "Ada"); + assert.equal(compare.movers[0].delta, 1); + assert.equal(compare.repeatThin.length, 1); + assert.deepEqual(compare.themeDrift.appeared, ["career"]); + assert.deepEqual(compare.themeDrift.disappeared, ["focus"]); + }); +}); diff --git a/apps/api/src/analytics.ts b/apps/api/src/analytics.ts new file mode 100644 index 0000000..707d3fa --- /dev/null +++ b/apps/api/src/analytics.ts @@ -0,0 +1,401 @@ +import { competencyLabel } from "@prm/shared"; +import type { + CycleCompareDTO, + PersonTimelineDTO, + RatingTrendPointDTO, + RatingTrendsDTO, + SprintPulseDTO, + SprintPulsePersonDTO, + TimelineEntryDTO, +} from "@prm/shared"; + +export function isoToday(d = new Date()) { + return d.toISOString().slice(0, 10); +} + +export function addDays(iso: string, days: number) { + const d = new Date(`${iso}T12:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +export function inRange(iso: string, from: string, to: string) { + const day = iso.slice(0, 10); + return day >= from && day <= to; +} + +export function resolvePulseWindow(opts: { + from?: string | null; + to?: string | null; + preset?: string | null; + today?: string; +}): { from: string; to: string; preset: string } { + const today = opts.today ?? isoToday(); + const preset = (opts.preset ?? "last_14").trim() || "last_14"; + if (opts.from && opts.to) { + return { from: opts.from.slice(0, 10), to: opts.to.slice(0, 10), preset: "custom" }; + } + if (preset === "last_30") { + return { from: addDays(today, -29), to: today, preset }; + } + if (preset === "last_7") { + return { from: addDays(today, -6), to: today, preset }; + } + // default last_14 + return { from: addDays(today, -13), to: today, preset: "last_14" }; +} + +export type PulsePersonInput = { + personId: string; + personName: string; + levelKey: string | null; + achievements: Array<{ occurredAt: string; sourceProvider?: string | null }>; + feedback: Array<{ occurredAt: string; themes: string[] }>; + documents: Array<{ occurredAt: string }>; + openConcernsInWindow: number; + /** Linked evidence sources on current manager draft, with source occurredAt when known. */ + linkedSources: Array<{ occurredAt: string | null }>; +}; + +export function buildSprintPulse(args: { + from: string; + to: string; + preset: string; + people: PulsePersonInput[]; +}): SprintPulseDTO { + const themeMap = new Map(); + const peopleOut: SprintPulsePersonDTO[] = []; + let achievements = 0; + let jira = 0; + let bitbucket = 0; + let feedback = 0; + let documents = 0; + let openConcerns = 0; + let staleCitePeople = 0; + + for (const p of args.people) { + const achIn = p.achievements.filter((a) => inRange(a.occurredAt, args.from, args.to)); + const fbIn = p.feedback.filter((f) => inRange(f.occurredAt, args.from, args.to)); + const docIn = p.documents.filter((d) => inRange(d.occurredAt, args.from, args.to)); + const jiraN = achIn.filter((a) => a.sourceProvider === "jira").length; + const bbN = achIn.filter((a) => a.sourceProvider === "bitbucket").length; + const themes: string[] = []; + for (const f of fbIn) { + for (const t of f.themes) { + const key = t.trim().toLowerCase(); + if (!key) continue; + themes.push(key); + themeMap.set(key, (themeMap.get(key) ?? 0) + 1); + } + } + + const allEvidenceDates = [ + ...p.achievements.map((a) => a.occurredAt), + ...p.feedback.map((f) => f.occurredAt), + ...p.documents.map((d) => d.occurredAt), + ]; + const inWindowEvidence = allEvidenceDates.filter((d) => inRange(d, args.from, args.to)).length; + const olderEvidence = allEvidenceDates.length - inWindowEvidence; + + let linkedInWindow = 0; + let linkedStale = 0; + for (const link of p.linkedSources) { + if (!link.occurredAt) continue; + if (inRange(link.occurredAt, args.from, args.to)) linkedInWindow += 1; + else linkedStale += 1; + } + const staleOnlyCites = linkedStale > 0 && linkedInWindow === 0 && p.linkedSources.length > 0; + + achievements += achIn.length; + jira += jiraN; + bitbucket += bbN; + feedback += fbIn.length; + documents += docIn.length; + openConcerns += p.openConcernsInWindow; + if (staleOnlyCites) staleCitePeople += 1; + + peopleOut.push({ + personId: p.personId, + personName: p.personName, + levelKey: p.levelKey, + achievements: achIn.length, + jira: jiraN, + bitbucket: bbN, + feedback: fbIn.length, + documents: docIn.length, + openConcerns: p.openConcernsInWindow, + themes: [...new Set(themes)], + inWindowEvidence, + olderEvidence, + linkedInWindow, + linkedStale, + staleOnlyCites, + }); + } + + peopleOut.sort( + (a, b) => + b.achievements + b.feedback + b.documents - (a.achievements + a.feedback + a.documents) || + a.personName.localeCompare(b.personName), + ); + + return { + from: args.from, + to: args.to, + preset: args.preset, + summary: { + people: args.people.length, + achievements, + jira, + bitbucket, + feedback, + documents, + openConcerns, + staleCitePeople, + }, + themes: [...themeMap.entries()] + .map(([theme, count]) => ({ theme, count })) + .sort((a, b) => b.count - a.count || a.theme.localeCompare(b.theme)) + .slice(0, 12), + people: peopleOut, + }; +} + +export function buildPersonTimeline(args: { + personId: string; + personName: string; + entries: TimelineEntryDTO[]; +}): PersonTimelineDTO { + const entries = [...args.entries].sort((a, b) => b.at.localeCompare(a.at) || a.title.localeCompare(b.title)); + return { personId: args.personId, personName: args.personName, entries }; +} + +export type CycleRatingSnapshot = { + cycleId: string; + cycleName: string; + windowEnd: string; + people: Array<{ + personId: string; + personName: string; + overall: number | null; + competencies: Record; + thin: boolean; + themes: string[]; + }>; +}; + +export function buildRatingTrends(cycles: CycleRatingSnapshot[]): RatingTrendsDTO { + const ordered = [...cycles].sort((a, b) => a.windowEnd.localeCompare(b.windowEnd)); + const cycleMeta = ordered.map((c) => ({ + cycleId: c.cycleId, + cycleName: c.cycleName, + windowEnd: c.windowEnd, + })); + + const personMap = new Map< + string, + { + personId: string; + personName: string; + overall: RatingTrendPointDTO[]; + competencies: Record; + } + >(); + const teamCompKeys = new Set(["overall"]); + + for (const cycle of ordered) { + for (const p of cycle.people) { + let row = personMap.get(p.personId); + if (!row) { + row = { + personId: p.personId, + personName: p.personName, + overall: [], + competencies: {}, + }; + personMap.set(p.personId, row); + } + row.overall.push({ + cycleId: cycle.cycleId, + cycleName: cycle.cycleName, + windowEnd: cycle.windowEnd, + value: p.overall, + }); + for (const [key, value] of Object.entries(p.competencies)) { + teamCompKeys.add(key); + if (!row.competencies[key]) row.competencies[key] = []; + row.competencies[key].push({ + cycleId: cycle.cycleId, + cycleName: cycle.cycleName, + windowEnd: cycle.windowEnd, + value, + }); + } + } + // Fill missing people points as null for alignment + for (const row of personMap.values()) { + if (!row.overall.some((pt) => pt.cycleId === cycle.cycleId)) { + row.overall.push({ + cycleId: cycle.cycleId, + cycleName: cycle.cycleName, + windowEnd: cycle.windowEnd, + value: null, + }); + } + } + } + + const people = [...personMap.values()].sort((a, b) => a.personName.localeCompare(b.personName)); + for (const row of people) { + row.overall.sort((a, b) => a.windowEnd.localeCompare(b.windowEnd)); + for (const pts of Object.values(row.competencies)) { + pts.sort((a, b) => a.windowEnd.localeCompare(b.windowEnd)); + } + } + + const team = [...teamCompKeys].map((key) => { + const points: RatingTrendPointDTO[] = ordered.map((cycle) => { + const vals = cycle.people + .map((p) => (key === "overall" ? p.overall : p.competencies[key] ?? null)) + .filter((v): v is number => typeof v === "number"); + const avg = vals.length ? Math.round((vals.reduce((s, v) => s + v, 0) / vals.length) * 10) / 10 : null; + return { + cycleId: cycle.cycleId, + cycleName: cycle.cycleName, + windowEnd: cycle.windowEnd, + value: avg, + }; + }); + return { + key, + label: key === "overall" ? "Overall" : competencyLabel(key), + points, + }; + }); + + team.sort((a, b) => (a.key === "overall" ? -1 : b.key === "overall" ? 1 : a.label.localeCompare(b.label))); + + return { cycles: cycleMeta, team, people }; +} + +function emptyDist(): Record { + return { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, unset: 0 }; +} + +function distAndAvg(people: CycleRatingSnapshot["people"]) { + const distribution = emptyDist(); + let sum = 0; + let rated = 0; + for (const p of people) { + if (p.overall != null && p.overall >= 1 && p.overall <= 5) { + distribution[String(Math.round(p.overall))] += 1; + sum += p.overall; + rated += 1; + } else distribution.unset += 1; + } + return { + distribution, + avgOverall: rated ? Math.round((sum / rated) * 10) / 10 : null, + ratedCount: rated, + thinCount: people.filter((p) => p.thin).length, + }; +} + +function themeCounts(people: CycleRatingSnapshot["people"]) { + const map = new Map(); + for (const p of people) { + for (const t of p.themes) { + const key = t.trim().toLowerCase(); + if (!key) continue; + map.set(key, (map.get(key) ?? 0) + 1); + } + } + return [...map.entries()] + .map(([theme, count]) => ({ theme, count })) + .sort((a, b) => b.count - a.count || a.theme.localeCompare(b.theme)); +} + +export function buildCycleCompare(args: { + current: CycleRatingSnapshot; + prior: CycleRatingSnapshot | null; +}): CycleCompareDTO { + const curStats = distAndAvg(args.current.people); + const curThemes = themeCounts(args.current.people); + const current = { + cycleId: args.current.cycleId, + cycleName: args.current.cycleName, + ...curStats, + themes: curThemes.slice(0, 12), + }; + + if (!args.prior) { + return { + current, + prior: null, + movers: [], + repeatThin: [], + themeDrift: { appeared: [], disappeared: [], grew: [], shrank: [] }, + }; + } + + const priorStats = distAndAvg(args.prior.people); + const priorThemes = themeCounts(args.prior.people); + const prior = { + cycleId: args.prior.cycleId, + cycleName: args.prior.cycleName, + ...priorStats, + themes: priorThemes.slice(0, 12), + }; + + const priorById = new Map(args.prior.people.map((p) => [p.personId, p])); + const movers = args.current.people + .map((p) => { + const prev = priorById.get(p.personId); + const from = prev?.overall ?? null; + const to = p.overall; + const delta = from != null && to != null ? to - from : null; + return { + personId: p.personId, + personName: p.personName, + from, + to, + delta, + }; + }) + .filter((m) => m.delta != null && m.delta !== 0) + .sort((a, b) => Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0) || a.personName.localeCompare(b.personName)); + + const repeatThin = args.current.people + .filter((p) => p.thin && priorById.get(p.personId)?.thin) + .map((p) => ({ personId: p.personId, personName: p.personName })) + .sort((a, b) => a.personName.localeCompare(b.personName)); + + const curThemeMap = new Map(curThemes.map((t) => [t.theme, t.count])); + const priorThemeMap = new Map(priorThemes.map((t) => [t.theme, t.count])); + const appeared: string[] = []; + const disappeared: string[] = []; + const grew: Array<{ theme: string; from: number; to: number }> = []; + const shrank: Array<{ theme: string; from: number; to: number }> = []; + for (const [theme, count] of curThemeMap) { + const prev = priorThemeMap.get(theme); + if (prev == null) appeared.push(theme); + else if (count > prev) grew.push({ theme, from: prev, to: count }); + else if (count < prev) shrank.push({ theme, from: prev, to: count }); + } + for (const theme of priorThemeMap.keys()) { + if (!curThemeMap.has(theme)) disappeared.push(theme); + } + + return { + current, + prior, + movers, + repeatThin, + themeDrift: { + appeared: appeared.sort(), + disappeared: disappeared.sort(), + grew: grew.sort((a, b) => b.to - b.from - (a.to - a.from)), + shrank: shrank.sort((a, b) => a.to - a.from - (b.to - b.from)), + }, + }; +} diff --git a/apps/api/src/analyticsGather.ts b/apps/api/src/analyticsGather.ts new file mode 100644 index 0000000..db5b807 --- /dev/null +++ b/apps/api/src/analyticsGather.ts @@ -0,0 +1,313 @@ +import { and, eq } from "drizzle-orm"; +import { + achievements, + activityEvents, + concernLogs, + cycleParticipants, + cycles, + documents, + evidenceLinks, + feedbackItems, + goals, + people, + reviews, +} from "@prm/db"; +import type { + CycleCompareDTO, + PersonTimelineDTO, + RatingTrendsDTO, + SprintPulseDTO, + TimelineEntryDTO, +} from "@prm/shared"; +import { + buildCycleCompare, + buildPersonTimeline, + buildRatingTrends, + buildSprintPulse, + resolvePulseWindow, + type CycleRatingSnapshot, + type PulsePersonInput, +} from "./analytics.js"; +import { getDb } from "./store.js"; + +type Db = ReturnType; + +function parseThemes(raw: string | null | undefined): string[] { + try { + const parsed = JSON.parse(raw || "[]") as string[]; + return parsed.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim()); + } catch { + return []; + } +} + +function managerPayload(review: { payloadJson: string } | undefined) { + if (!review) return { overall: null as number | null, competencies: {} as Record }; + try { + const payload = JSON.parse(review.payloadJson) as Record; + return { + overall: typeof payload.overall === "number" ? payload.overall : null, + competencies: (payload.competencies ?? {}) as Record, + }; + } catch { + return { overall: null, competencies: {} }; + } +} + +function sourceOccurredAt( + db: Db, + sourceType: string, + sourceId: string, +): string | null { + if (sourceType === "achievement") { + return db.select().from(achievements).where(eq(achievements.id, sourceId)).all()[0]?.occurredAt ?? null; + } + if (sourceType === "feedback") { + return db.select().from(feedbackItems).where(eq(feedbackItems.id, sourceId)).all()[0]?.occurredAt ?? null; + } + if (sourceType === "document") { + return db.select().from(documents).where(eq(documents.id, sourceId)).all()[0]?.occurredAt ?? null; + } + if (sourceType === "goal") { + const g = db.select().from(goals).where(eq(goals.id, sourceId)).all()[0]; + return g?.createdAt?.slice(0, 10) ?? null; + } + return null; +} + +function evidenceCountFor(db: Db, personId: string) { + const ach = db.select().from(achievements).where(eq(achievements.personId, personId)).all().length; + const fb = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, personId)).all().length; + const docs = db.select().from(documents).where(eq(documents.personId, personId)).all().length; + const goalsN = db.select().from(goals).where(eq(goals.personId, personId)).all().length; + return ach + fb + docs + goalsN; +} + +function cycleSnapshot(db: Db, cycle: { id: string; name: string; windowEnd: string }): CycleRatingSnapshot { + const participants = db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycle.id)).all(); + const peopleRows = []; + for (const cp of participants) { + const person = db.select().from(people).where(eq(people.id, cp.personId)).all()[0]; + if (!person) continue; + 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"); + const { overall, competencies } = managerPayload(manager); + const fbRows = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, person.id)).all(); + const themes = fbRows.flatMap((f) => parseThemes(f.themesJson)); + peopleRows.push({ + personId: person.id, + personName: person.name, + overall, + competencies, + thin: evidenceCountFor(db, person.id) < 2, + themes, + }); + } + return { + cycleId: cycle.id, + cycleName: cycle.name, + windowEnd: cycle.windowEnd, + people: peopleRows, + }; +} + +export function loadSprintPulse( + db: Db, + opts: { from?: string | null; to?: string | null; preset?: string | null }, +): SprintPulseDTO { + const window = resolvePulseWindow(opts); + const directs = db + .select() + .from(people) + .all() + .filter((p) => p.managerId) + .sort((a, b) => a.name.localeCompare(b.name)); + + const inputs: PulsePersonInput[] = directs.map((person) => { + const ach = db.select().from(achievements).where(eq(achievements.personId, person.id)).all(); + const fb = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, person.id)).all(); + const docs = db.select().from(documents).where(eq(documents.personId, person.id)).all(); + const openConcernsInWindow = db + .select() + .from(concernLogs) + .where(eq(concernLogs.personId, person.id)) + .all() + .filter((c) => c.status === "open" && c.occurredAt.slice(0, 10) >= window.from && c.occurredAt.slice(0, 10) <= window.to) + .length; + + // Prefer active/latest cycle manager draft for freshness of cites + const allCycles = db.select().from(cycles).all().sort((a, b) => b.windowStart.localeCompare(a.windowStart)); + const latest = allCycles[0]; + let linkedSources: Array<{ occurredAt: string | null }> = []; + if (latest) { + const manager = db + .select() + .from(reviews) + .where( + and(eq(reviews.cycleId, latest.id), eq(reviews.subjectPersonId, person.id), eq(reviews.type, "manager")), + ) + .all() + .find((r) => r.status !== "superseded"); + if (manager) { + const links = db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all(); + linkedSources = links.map((l) => ({ + occurredAt: sourceOccurredAt(db, l.sourceType, l.sourceId), + })); + } + } + + return { + personId: person.id, + personName: person.name, + levelKey: person.levelKey, + achievements: ach.map((a) => ({ occurredAt: a.occurredAt, sourceProvider: a.sourceProvider })), + feedback: fb.map((f) => ({ occurredAt: f.occurredAt, themes: parseThemes(f.themesJson) })), + documents: docs.map((d) => ({ occurredAt: d.occurredAt })), + openConcernsInWindow, + linkedSources, + }; + }); + + return buildSprintPulse({ ...window, people: inputs }); +} + +export function loadPersonTimeline(db: Db, personId: string): PersonTimelineDTO | null { + const person = db.select().from(people).where(eq(people.id, personId)).all()[0]; + if (!person) return null; + + const entries: TimelineEntryDTO[] = []; + + for (const a of db.select().from(achievements).where(eq(achievements.personId, personId)).all()) { + const provider = a.sourceProvider ? ` · ${a.sourceProvider}` : ""; + entries.push({ + at: a.occurredAt, + kind: a.sourceProvider === "jira" || a.sourceProvider === "bitbucket" ? a.sourceProvider : "achievement", + title: a.title + provider, + detail: a.description, + sourceId: a.id, + }); + } + for (const f of db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, personId)).all()) { + const themes = parseThemes(f.themesJson); + entries.push({ + at: f.occurredAt, + kind: "feedback", + title: `Feedback (${f.kind}) from ${f.fromName ?? "someone"}`, + detail: themes.length ? `${f.body.slice(0, 160)} · themes: ${themes.join(", ")}` : f.body.slice(0, 200), + sourceId: f.id, + }); + } + for (const d of db.select().from(documents).where(eq(documents.personId, personId)).all()) { + entries.push({ + at: d.occurredAt, + kind: "document", + title: d.title, + detail: d.filename, + sourceId: d.id, + }); + } + for (const g of db.select().from(goals).where(eq(goals.personId, personId)).all()) { + entries.push({ + at: g.createdAt?.slice(0, 10) ?? g.createdAt, + kind: "goal", + title: g.title, + detail: g.status, + sourceId: g.id, + }); + } + for (const c of db.select().from(concernLogs).where(eq(concernLogs.personId, personId)).all()) { + entries.push({ + at: c.occurredAt, + kind: "concern", + title: `Concern (${c.status}): ${c.title}`, + detail: c.body.slice(0, 200), + sourceId: c.id, + }); + } + + const managerReviews = db + .select() + .from(reviews) + .where(and(eq(reviews.subjectPersonId, personId), eq(reviews.type, "manager"))) + .all() + .filter((r) => r.status !== "superseded"); + for (const r of managerReviews) { + const cycle = db.select().from(cycles).where(eq(cycles.id, r.cycleId)).all()[0]; + const { overall, competencies } = managerPayload(r); + const compBit = Object.entries(competencies) + .map(([k, v]) => `${k}:${v}`) + .join(", "); + entries.push({ + at: (r.updatedAt || r.createdAt).slice(0, 10), + kind: "review", + title: `${cycle?.name ?? "Cycle"} manager review (${r.status})`, + detail: overall != null ? `Overall ${overall}${compBit ? ` · ${compBit}` : ""}` : r.status, + sourceId: r.id, + }); + } + + const decisions = db + .select() + .from(activityEvents) + .all() + .filter((e) => { + if (e.action !== "review.decision" && e.action !== "promo.decision") return false; + try { + const detail = JSON.parse(e.detailJson || "{}") as Record; + return detail.subjectPersonId === personId || detail.personId === personId; + } catch { + return false; + } + }); + for (const e of decisions) { + let detailText = e.action; + try { + const detail = JSON.parse(e.detailJson || "{}") as Record; + const reason = typeof detail.reason === "string" ? detail.reason : null; + const changes = Array.isArray(detail.changes) ? detail.changes : []; + detailText = reason + ? reason + : changes.length + ? JSON.stringify(changes).slice(0, 200) + : e.action; + } catch { + /* ignore */ + } + entries.push({ + at: e.createdAt.slice(0, 10), + kind: "decision", + title: e.action === "promo.decision" ? "Promotion decision" : "Rating decision", + detail: detailText, + sourceId: e.id, + }); + } + + return buildPersonTimeline({ personId, personName: person.name, entries }); +} + +export function loadRatingTrends(db: Db): RatingTrendsDTO { + const allCycles = db.select().from(cycles).all().sort((a, b) => a.windowEnd.localeCompare(b.windowEnd)); + return buildRatingTrends(allCycles.map((c) => cycleSnapshot(db, c))); +} + +export function loadCycleCompare( + db: Db, + opts: { cycleId?: string | null; compareTo?: string | null }, +): CycleCompareDTO | null { + const allCycles = db.select().from(cycles).all().sort((a, b) => b.windowStart.localeCompare(a.windowStart)); + if (!allCycles.length) return null; + const current = opts.cycleId ? allCycles.find((c) => c.id === opts.cycleId) : allCycles[0]; + if (!current) return null; + const prior = + (opts.compareTo ? allCycles.find((c) => c.id === opts.compareTo) : null) ?? + allCycles.find((c) => c.windowStart < current.windowStart && c.id !== current.id) ?? + null; + return buildCycleCompare({ + current: cycleSnapshot(db, current), + prior: prior ? cycleSnapshot(db, prior) : null, + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 004bb96..063e39a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -53,6 +53,7 @@ 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 { loadCycleCompare, loadPersonTimeline, loadRatingTrends, loadSprintPulse } from "./analyticsGather.js"; import { checksum, encryptSecret, @@ -3286,6 +3287,39 @@ app.post("/api/ai/bias-lint", async (c) => { return c.json({ findings, ephemeral: true }); }); +app.get("/api/sprint-pulse", (c) => { + const db = getDb(); + return c.json( + loadSprintPulse(db, { + from: c.req.query("from"), + to: c.req.query("to"), + preset: c.req.query("preset"), + }), + ); +}); + +app.get("/api/people/:id/timeline", (c) => { + const db = getDb(); + const timeline = loadPersonTimeline(db, c.req.param("id")); + if (!timeline) return c.json({ error: "Not found" }, 404); + return c.json(timeline); +}); + +app.get("/api/rating-trends", (c) => { + const db = getDb(); + return c.json(loadRatingTrends(db)); +}); + +app.get("/api/cycle-compare", (c) => { + const db = getDb(); + const result = loadCycleCompare(db, { + cycleId: c.req.query("cycleId"), + compareTo: c.req.query("compareTo"), + }); + if (!result) return c.json({ error: "No cycle found" }, 404); + return c.json(result); +}); + app.get("/api/team-performance", (c) => { const cycleId = c.req.query("cycleId"); const db = getDb(); diff --git a/apps/api/src/seed.ts b/apps/api/src/seed.ts index 71566d3..cda0c9f 100644 --- a/apps/api/src/seed.ts +++ b/apps/api/src/seed.ts @@ -3,11 +3,13 @@ import { accounts, achievements, aiSettings, + concernLogs, cycleParticipants, cycles, feedbackItems, goals, people, + reviews, roleAssignments, roleDefinitions, roleExpectations, @@ -251,10 +253,63 @@ export async function seedWorkspace(opts: { body: "Clear communication in cross-functional design reviews.", visibility: "em_and_subject", occurredAt: "2026-04-02", + themesJson: JSON.stringify(["collaboration", "delivery"]), createdAt, }).run(); } + // Recent window evidence (sprint pulse demos) + a couple of external sync-style rows + const recentDirects = TEAM.filter((t) => !t.isEm).slice(0, 3); + for (const [i, member] of recentDirects.entries()) { + const pid = personIds[member.name]; + db.insert(achievements).values({ + id: id("ach"), + personId: pid, + title: i === 0 ? "Closed checkout latency epic" : "Merged payment retry hardening", + description: "Synced-style achievement for the current coaching window.", + occurredAt: i === 0 ? "2026-07-18" : "2026-07-20", + createdAt, + sourceProvider: i === 0 ? "jira" : "bitbucket", + externalId: i === 0 ? "CHK-241" : "pr-1882", + externalUrl: i === 0 ? "https://example.atlassian.net/browse/CHK-241" : "https://bitbucket.org/example/pr/1882", + }).run(); + db.insert(feedbackItems).values({ + id: id("fb"), + toPersonId: pid, + fromName: "EM note", + kind: "coaching", + body: "Discussed pacing and stakeholder updates for the current sprint.", + visibility: "em_only", + occurredAt: "2026-07-21", + themesJson: JSON.stringify(["pacing", "stakeholders"]), + createdAt, + }).run(); + } + const firstDirect = personIds[TEAM.find((t) => !t.isEm)!.name]; + db.insert(concernLogs).values({ + id: id("concern"), + personId: firstDirect, + occurredAt: "2026-07-19", + title: "Missed partner handoff", + body: "Late handoff to design delayed review by a day.", + expectationText: "Confirm handoffs in Slack the morning of the due date.", + planText: "Add handoff checklist to the squad ritual.", + status: "open", + aiAllowed: 0, + createdAt, + updatedAt: createdAt, + }).run(); + + const priorCycleId = id("cycle"); + db.insert(cycles).values({ + id: priorCycleId, + name: "H2 2025", + windowStart: "2025-07-01", + windowEnd: "2025-12-31", + status: "closed", + createdAt, + }).run(); + const cycleId = id("cycle"); db.insert(cycles).values({ id: cycleId, @@ -272,15 +327,44 @@ export async function seedWorkspace(opts: { .from(roleAssignments) .where(and(eq(roleAssignments.personId, pid), isNull(roleAssignments.effectiveTo))) .all()[0]; - db.insert(cycleParticipants).values({ - id: id("cp"), - cycleId, - personId: pid, - managerPersonId: emPersonId, - roleDefinitionId: assignment?.roleDefinitionId ?? null, - targetNextRoleDefinitionId: assignment?.targetNextRoleDefinitionId ?? null, - eligibility: "full", - phaseStatusJson: JSON.stringify({ self: "pending", peer: "pending", manager: "draft" }), + for (const cid of [priorCycleId, cycleId]) { + db.insert(cycleParticipants).values({ + id: id("cp"), + cycleId: cid, + personId: pid, + managerPersonId: emPersonId, + roleDefinitionId: assignment?.roleDefinitionId ?? null, + targetNextRoleDefinitionId: assignment?.targetNextRoleDefinitionId ?? null, + eligibility: "full", + phaseStatusJson: JSON.stringify({ + self: cid === cycleId ? "pending" : "returned", + peer: cid === cycleId ? "pending" : "returned", + manager: cid === cycleId ? "draft" : "finalized", + }), + }).run(); + } + } + + // Prior-cycle manager ratings so trends / compare have history + const rated = TEAM.filter((t) => !t.isEm).slice(0, 4); + for (const [i, member] of rated.entries()) { + const pid = personIds[member.name]; + const overall = 3 + (i % 2); + db.insert(reviews).values({ + id: id("rev"), + cycleId: priorCycleId, + subjectPersonId: pid, + authorPersonId: emPersonId, + type: "manager", + status: "finalized", + payloadJson: JSON.stringify({ + overall, + competencies: { execution: overall, ownership: overall, collaboration: 3 }, + promo_readiness: "not_applicable", + summary: `H2 2025 summary for ${member.name}`, + }), + createdAt, + updatedAt: createdAt, }).run(); } diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 88e2d85..32d8b45 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -16,6 +16,7 @@ import { PromotionDetailPage } from "./pages/PromotionDetailPage"; import { ChatPage } from "./pages/ChatPage"; import { ConsistencyPage } from "./pages/ConsistencyPage"; import { TeamPerformancePage } from "./pages/TeamPerformancePage"; +import { SprintPulsePage } from "./pages/SprintPulsePage"; import { BackfillPage } from "./pages/BackfillPage"; export function App() { @@ -92,6 +93,7 @@ export function App() { Promotions Consistency Team performance + Sprint pulse Backfill AI Chat Settings @@ -122,6 +124,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index e697684..32af410 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -245,6 +245,8 @@ export function HomePage() { Team performance + {" · "} + Sprint pulse diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index cd13559..a3e80a1 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -8,6 +8,7 @@ import type { IntegrationSyncResult, PersonDTO, PersonExternalLinkDTO, + PersonTimelineDTO, } from "@prm/shared"; import { api, getToken } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -50,6 +51,7 @@ export function PersonPage() { const [linkResults, setLinkResults] = useState([]); const [linkMsg, setLinkMsg] = useState(null); const [syncBusy, setSyncBusy] = useState(false); + const [timeline, setTimeline] = useState(null); async function load() { if (!id) return; @@ -68,6 +70,11 @@ export function PersonPage() { } catch { setExtLinks([]); } + try { + setTimeline(await api(`/api/people/${id}/timeline`)); + } catch { + setTimeline(null); + } } useEffect(() => { @@ -127,6 +134,27 @@ export function PersonPage() { /> )} +
+

Timeline

+

+ Chronological dossier, reviews, concerns, and rating decisions. Use this to prep a 1:1 or reconstruct + why the story changed. +

+ {!timeline || timeline.entries.length === 0 ? ( +

No timeline events yet.

+ ) : ( +
    + {timeline.entries.slice(0, 40).map((e, idx) => ( +
  • + {e.at}{" "} + {e.kind} {e.title} + {e.detail ?
    {e.detail}
    : null} +
  • + ))} +
+ )} +
+

Jira & Bitbucket

diff --git a/apps/ui/src/pages/SprintPulsePage.tsx b/apps/ui/src/pages/SprintPulsePage.tsx new file mode 100644 index 0000000..8f50d5a --- /dev/null +++ b/apps/ui/src/pages/SprintPulsePage.tsx @@ -0,0 +1,253 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import type { SprintPulseDTO } from "@prm/shared"; +import { api } from "../lib/api"; +import { NextStep, PageHeader } from "../components/PageChrome"; + +const PRESETS = [ + { id: "last_7", label: "Last 7 days" }, + { id: "last_14", label: "Last 14 days" }, + { id: "last_30", label: "Last 30 days" }, +]; + +export function SprintPulsePage() { + const [params, setParams] = useSearchParams(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const preset = params.get("preset") ?? "last_14"; + const from = params.get("from") ?? ""; + const to = params.get("to") ?? ""; + + async function load() { + try { + const qs = new URLSearchParams(); + if (from && to) { + qs.set("from", from); + qs.set("to", to); + } else { + qs.set("preset", preset); + } + const res = await api(`/api/sprint-pulse?${qs}`); + setData(res); + setError(null); + if (!from && !to) { + setParams({ preset: res.preset, from: res.from, to: res.to }, { replace: true }); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load sprint pulse"); + } + } + + useEffect(() => { + void load(); + }, [preset, from, to]); + + return ( +

+ Team performance} + /> + + {error && ( + void load()}> + Retry + + } + /> + )} + +
+
+
+ + +
+
+ + + setParams({ + from: e.target.value, + to: to || data?.to || e.target.value, + preset: "custom", + }) + } + /> +
+
+ + + setParams({ + from: from || data?.from || e.target.value, + to: e.target.value, + preset: "custom", + }) + } + /> +
+
+

+ Counts achievements (including Jira / Bitbucket sync), feedback themes, documents, and open + concerns dated in the window. Flags people whose manager-draft cites are all older than the window. +

+
+ + {!data ? ( +

Loading…

+ ) : ( + <> + 0 ? "warn" : "ok"} + label={`${data.from} → ${data.to}`} + detail={ + data.summary.staleCitePeople > 0 + ? `${data.summary.staleCitePeople} draft(s) only cite older evidence. Prefer fresher cites before finalize.` + : "Evidence freshness looks healthy for drafts that have cites." + } + action={Open team} + /> + +
+
+
Achievements
+ {data.summary.achievements} + + {" "} + · {data.summary.jira} Jira · {data.summary.bitbucket} Bitbucket + +
+
+
Feedback
+ {data.summary.feedback} +
+
+
Documents
+ {data.summary.documents} +
+
+
Open concerns
+ {data.summary.openConcerns} +
+
+ +
+
+

1:1 themes in window

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

No themes tagged on feedback in this range.

+ ) : ( +
+ {data.themes.map((t) => ( +
+
{t.theme}
+
+
+
+
{t.count}
+
+ ))} +
+ )} +
+
+

Evidence freshness

+

+ In-window dossier items vs older items, plus whether current draft cites are stale. +

+
    +
  • + {data.summary.staleCitePeople} people with stale-only cites +
  • +
  • + Throughput: {data.summary.jira + data.summary.bitbucket} synced + tickets / PRs in window +
  • +
+
+
+ +
+

People

+
+ + + + + + + + + + + + + + {data.people.map((p) => ( + + + + + + + + + + + ))} + +
PersonAchievementsJira / BBFeedbackConcernsIn window / olderDraft cites +
+ {p.personName} + {p.staleOnlyCites ? stale cites : null} + {p.achievements} + {p.jira} / {p.bitbucket} + + {p.feedback} + {p.themes.length ? ( + · {p.themes.slice(0, 3).join(", ")} + ) : null} + {p.openConcerns} + {p.inWindowEvidence} / {p.olderEvidence} + + {p.linkedInWindow} fresh · {p.linkedStale} stale + + + Dossier + +
+
+
+ + )} +
+ ); +} diff --git a/apps/ui/src/pages/TeamPerformancePage.tsx b/apps/ui/src/pages/TeamPerformancePage.tsx index 1de351a..469371d 100644 --- a/apps/ui/src/pages/TeamPerformancePage.tsx +++ b/apps/ui/src/pages/TeamPerformancePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; -import type { CycleDTO, TeamPerformanceDTO } from "@prm/shared"; +import type { CycleCompareDTO, CycleDTO, RatingTrendsDTO, TeamPerformanceDTO } from "@prm/shared"; import { competencyLabel } from "@prm/shared"; import { api } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -153,18 +153,60 @@ function EvidenceScatter({ ); } +function TrendSparkline({ + points, + label, +}: { + points: Array<{ value: number | null; cycleName: string }>; + label: string; +}) { + 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 ; + })} + + ); +} + export function TeamPerformancePage() { const [params, setParams] = useSearchParams(); const [cycles, setCycles] = useState([]); const [data, setData] = useState(null); + const [trends, setTrends] = useState(null); + const [compare, setCompare] = 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}`); + const [res, trendRes, compareRes] = await Promise.all([ + api(`/api/team-performance${qs}`), + api("/api/rating-trends"), + api(`/api/cycle-compare${qs}`), + ]); setData(res); + setTrends(trendRes); + setCompare(compareRes); setError(null); if (!cid && res.cycleId) setParams({ cycleId: res.cycleId }, { replace: true }); } catch (e) { @@ -205,9 +247,14 @@ export function TeamPerformancePage() { job="See rating shape, competency averages, writing effort, and coaching themes across the cycle." primary={ data ? ( - - Consistency view - + <> + + Sprint pulse + + + Consistency view + + ) : undefined } /> @@ -451,6 +498,127 @@ export function TeamPerformancePage() {
+ + {trends && trends.cycles.length > 0 && ( +
+
+

Rating trends across cycles

+

+ Team averages (manager ratings). Spot stagnation or sudden jumps before the next packet. +

+
+ + + + + + {trends.cycles.map((c) => ( + + ))} + + + + {trends.team.map((series) => ( + + + + {series.points.map((p) => ( + + ))} + + ))} + +
SeriesSparkline{c.cycleName}
{series.label} + ({ + value: p.value, + cycleName: p.cycleName, + }))} + /> + {p.value ?? "—"}
+
+
+
+ )} + + {compare && ( +
+

Cycle compare

+

+ {compare.prior + ? `${compare.prior.cycleName} → ${compare.current.cycleName}` + : `${compare.current.cycleName} (no prior cycle yet)`} +

+ {compare.prior ? ( +
+
+

Distribution shift

+
+ {["1", "2", "3", "4", "5", "unset"].map((bucket) => { + const cur = compare.current.distribution[bucket] ?? 0; + const prev = compare.prior?.distribution[bucket] ?? 0; + const max = Math.max(1, cur, prev); + return ( +
+
{bucket === "unset" ? "—" : bucket}
+
+
+
+
+
+ {prev}→{cur} +
+
+ ); + })} +
+

+ Avg overall {compare.prior.avgOverall ?? "—"} → {compare.current.avgOverall ?? "—"} +

+
+
+

Movers (±1+)

+ {compare.movers.length === 0 ? ( +

No overall moves between cycles.

+ ) : ( +
    + {compare.movers.map((m) => ( +
  • + {m.personName}{" "} + 0 ? "tp-up" : "tp-down"}> + {m.from ?? "—"} → {m.to ?? "—"} ({m.delta && m.delta > 0 ? "+" : ""} + {m.delta}) + +
  • + ))} +
+ )} +

Repeat thin dossiers

+ {compare.repeatThin.length === 0 ? ( +

None repeating from the prior cycle.

+ ) : ( +
    + {compare.repeatThin.map((p) => ( +
  • + {p.personName} +
  • + ))} +
+ )} +

Theme drift

+

+ Appeared: {compare.themeDrift.appeared.join(", ") || "—"} +
+ Disappeared: {compare.themeDrift.disappeared.join(", ") || "—"} +

+
+
+ ) : ( +

Add another cycle with ratings to unlock compare.

+ )} +
+ )} )}
diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index 6416e59..97456d8 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -743,6 +743,22 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; } align-self: flex-end; } .table-wrap { overflow-x: auto; } +.tp-trend { width: 160px; height: 36px; display: block; } +.tp-trend-line { stroke: var(--brand); stroke-width: 2; } +.tp-trend-dot { fill: var(--brand-strong); } +.compare-track { position: relative; height: 14px; } +.compare-fill { + position: absolute; + left: 0; + top: 0; + height: 6px; + border-radius: 999px; +} +.compare-fill.prior { background: var(--muted); opacity: 0.45; top: 0; } +.compare-fill.current { background: var(--brand); top: 7px; } +.person-timeline li { padding: 0.35rem 0; border-bottom: 1px solid var(--line); } +.person-timeline li:last-child { border-bottom: 0; } +.page-header-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } @media (max-width: 900px) { .tp-grid { grid-template-columns: 1fr; } diff --git a/docs/CORE_LOOP_EVAL.md b/docs/CORE_LOOP_EVAL.md index da06ce7..6e883f1 100644 --- a/docs/CORE_LOOP_EVAL.md +++ b/docs/CORE_LOOP_EVAL.md @@ -25,7 +25,9 @@ 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 | +| Team performance graphs | Done | Rating histogram, competency averages, evidence scatter, writing/promo/theme bars, prior-cycle deltas, cross-cycle trends, cycle compare | +| Sprint / window pulse | Done | Date-range coaching pulse: throughput, themes, concerns, stale-cite freshness | +| Person timeline | Done | Dossier + reviews + concerns + decision events on the person page | | 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 8968003..9857c14 100644 --- a/docs/product/FEATURES.md +++ b/docs/product/FEATURES.md @@ -133,12 +133,13 @@ Per-participant phase status is source of truth; cycle banner is derived. Overla | Feature | Priority | Notes | |---------|----------|-------| -| Person timeline | P0 | | -| Competency sparklines (manager ratings only) | P0 | Filter by review type | +| Person timeline | P0 | Person dossier chronological stream (evidence, concerns, reviews, decisions) | +| Competency sparklines (manager ratings only) | P0 | Team performance → rating trends across cycles | | 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 | +| 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 | | Adverse-impact reporting | — | Hosted-only | --- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e16a391..60e8db1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -439,3 +439,114 @@ export interface TeamPerformanceDTO { openConcerns: number; people: TeamPerformancePersonDTO[]; } + +export interface TimelineEntryDTO { + at: string; + kind: string; + title: string; + detail?: string | null; + sourceId?: string; +} + +/** Mid-cycle coaching pulse for an arbitrary date window (not a formal review cycle). */ +export interface SprintPulsePersonDTO { + personId: string; + personName: string; + levelKey: string | null; + achievements: number; + jira: number; + bitbucket: number; + feedback: number; + documents: number; + openConcerns: number; + themes: string[]; + /** Evidence dated in-window vs older on the dossier. */ + inWindowEvidence: number; + olderEvidence: number; + /** Manager draft links whose sources sit outside the window (stale cites). */ + linkedInWindow: number; + linkedStale: number; + staleOnlyCites: boolean; +} + +export interface SprintPulseDTO { + from: string; + to: string; + preset: string; + summary: { + people: number; + achievements: number; + jira: number; + bitbucket: number; + feedback: number; + documents: number; + openConcerns: number; + staleCitePeople: number; + }; + themes: Array<{ theme: string; count: number }>; + people: SprintPulsePersonDTO[]; +} + +export interface PersonTimelineDTO { + personId: string; + personName: string; + entries: TimelineEntryDTO[]; +} + +export interface RatingTrendPointDTO { + cycleId: string; + cycleName: string; + windowEnd: string; + value: number | null; +} + +export interface RatingTrendsDTO { + cycles: Array<{ cycleId: string; cycleName: string; windowEnd: string }>; + /** Team averages over cycles (overall + competencies that appear). */ + team: Array<{ + key: string; + label: string; + points: RatingTrendPointDTO[]; + }>; + people: Array<{ + personId: string; + personName: string; + overall: RatingTrendPointDTO[]; + competencies: Record; + }>; +} + +export interface CycleCompareDTO { + current: { + cycleId: string; + cycleName: string; + distribution: Record; + avgOverall: number | null; + ratedCount: number; + thinCount: number; + themes: Array<{ theme: string; count: number }>; + }; + prior: { + cycleId: string; + cycleName: string; + distribution: Record; + avgOverall: number | null; + ratedCount: number; + thinCount: number; + themes: Array<{ theme: string; count: number }>; + } | null; + movers: Array<{ + personId: string; + personName: string; + from: number | null; + to: number | null; + delta: number | null; + }>; + repeatThin: Array<{ personId: string; personName: string }>; + themeDrift: { + appeared: string[]; + disappeared: string[]; + grew: Array<{ theme: string; from: number; to: number }>; + shrank: Array<{ theme: string; from: number; to: number }>; + }; +} From 34c912bd05d260303066bfb113e580f6a45746a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:38:24 +0000 Subject: [PATCH 2/2] Remove duplicate page-header-actions CSS rule Co-authored-by: Damon --- apps/ui/src/styles.css | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index 97456d8..d79e50b 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -758,7 +758,6 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; } .compare-fill.current { background: var(--brand); top: 7px; } .person-timeline li { padding: 0.35rem 0; border-bottom: 1px solid var(--line); } .person-timeline li:last-child { border-bottom: 0; } -.page-header-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } @media (max-width: 900px) { .tp-grid { grid-template-columns: 1fr; }