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
139 changes: 139 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, number> = {};
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<string, unknown>;
overall = typeof payload.overall === "number" ? payload.overall : null;
competencies = (payload.competencies ?? {}) as Record<string, number>;
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<string, unknown>;
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();
Expand Down
110 changes: 110 additions & 0 deletions apps/api/src/teamPerformance.test.ts
Original file line number Diff line number Diff line change
@@ -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, []);
});
});
Loading
Loading