diff --git a/.gitignore b/.gitignore index 830f26d..562f402 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ apps/ui/dist apps/api/dist apps/desktop/src-tauri/target apps/desktop/src-tauri/sidecars/ +*.tsbuildinfo diff --git a/apps/api/src/faithfulness.test.ts b/apps/api/src/faithfulness.test.ts index e723132..f34782d 100644 --- a/apps/api/src/faithfulness.test.ts +++ b/apps/api/src/faithfulness.test.ts @@ -1,31 +1,117 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { mergeLintFindings, runFaithfulnessLint } from "../src/faithfulness.js"; +import { + applyFaithfulnessFix, + mergeLintFindings, + runFaithfulnessLint, +} from "../src/faithfulness.js"; + +/** Golden drafts with known invention patterns — regression set for faithfulness depth. */ +const GOLDEN = { + clean: { + text: "Led the checkout migration [ach_1] and coached two engineers through the rollout [fb_2].", + allowlist: ["ach_1", "fb_2"], + expectInvention: false, + }, + inventedCite: { + text: "Shipped the migration [ach_real] and invented support [ach_fake].", + allowlist: ["ach_real"], + expectIds: ["unknown_cite_ach_fake"], + }, + ungroundedAbsolutes: { + text: [ + "They completely transformed the platform architecture across every squad this half.", + "Customer satisfaction improved dramatically while latency dropped for all regions.", + "See [ach_1] for the migration they led.", + ].join(" "), + allowlist: ["ach_1"], + expectIds: ["ungrounded_claims"], + }, + bareMetric: { + text: "Reduced p99 latency by 40% across checkout.", + allowlist: [] as string[], + expectMetric: "40%", + }, + metricWithCite: { + text: "Reduced p99 latency by 40% across checkout [ach_1].", + allowlist: ["ach_1"], + expectInvention: false, + }, +}; describe("faithfulness lint", () => { it("flags unknown citations", () => { - const findings = runFaithfulnessLint("Shipped [ach_real] and [ach_fake].", ["ach_real"]); + const findings = runFaithfulnessLint(GOLDEN.inventedCite.text, GOLDEN.inventedCite.allowlist); assert.ok(findings.some((f) => f.category === "invention" && /ach_fake/.test(f.message))); + assert.equal(findings.find((f) => f.id.startsWith("unknown_cite_"))?.suggestedFix?.action, "strip_cite"); }); it("flags ungrounded long sentences", () => { - const text = [ - "They completely transformed the platform architecture across every squad this half.", - "Customer satisfaction improved dramatically while latency dropped for all regions.", - "See [ach_1] for the migration they led.", - ].join(" "); - const findings = runFaithfulnessLint(text, ["ach_1"]); + const findings = runFaithfulnessLint( + GOLDEN.ungroundedAbsolutes.text, + GOLDEN.ungroundedAbsolutes.allowlist, + ); assert.ok(findings.some((f) => f.id === "ungrounded_claims")); + assert.ok(findings.find((f) => f.id === "ungrounded_claims")?.suggestedFix); }); it("flags bare metrics", () => { - const findings = runFaithfulnessLint("Reduced p99 latency by 40% across checkout.", []); + const findings = runFaithfulnessLint(GOLDEN.bareMetric.text, GOLDEN.bareMetric.allowlist); assert.ok(findings.some((f) => f.category === "invention" && /40%/.test(f.message))); }); + it("accepts clean cited prose", () => { + const findings = runFaithfulnessLint(GOLDEN.clean.text, GOLDEN.clean.allowlist); + assert.equal( + findings.filter((f) => f.category === "invention" && f.severity === "warn").length, + 0, + ); + }); + + it("does not flag metrics next to a citation", () => { + const findings = runFaithfulnessLint(GOLDEN.metricWithCite.text, GOLDEN.metricWithCite.allowlist); + assert.ok(!findings.some((f) => f.id.startsWith("metric_"))); + }); + it("merges findings without dupes", () => { const a = runFaithfulnessLint("Reduced cost by 25% somehow.", []); const merged = mergeLintFindings(a, a); assert.equal(merged.length, a.length); }); + + it("applies soften and cite fixes", () => { + const findings = runFaithfulnessLint(GOLDEN.bareMetric.text, ["ach_1"]); + const metric = findings.find((f) => f.id.startsWith("metric_")); + assert.ok(metric?.suggestedFix); + const next = applyFaithfulnessFix(GOLDEN.bareMetric.text, metric!); + assert.ok(next); + assert.match(next!, /\[ach_1\]/); + }); + + it("strips unknown cites via suggested fix", () => { + const findings = runFaithfulnessLint(GOLDEN.inventedCite.text, GOLDEN.inventedCite.allowlist); + const bad = findings.find((f) => f.id === "unknown_cite_ach_fake"); + assert.ok(bad); + const next = applyFaithfulnessFix(GOLDEN.inventedCite.text, bad!); + assert.ok(next); + assert.ok(!next!.includes("[ach_fake]")); + assert.ok(next!.includes("[ach_real]")); + }); +}); + +describe("faithfulness golden eval set", () => { + it("scores known invention drafts", () => { + const cases = [ + GOLDEN.inventedCite, + GOLDEN.ungroundedAbsolutes, + GOLDEN.bareMetric, + ]; + for (const c of cases) { + const findings = runFaithfulnessLint(c.text, c.allowlist); + assert.ok( + findings.some((f) => f.category === "invention"), + `expected invention in: ${c.text.slice(0, 60)}`, + ); + } + }); }); diff --git a/apps/api/src/faithfulness.ts b/apps/api/src/faithfulness.ts index 2e400ea..2032a29 100644 --- a/apps/api/src/faithfulness.ts +++ b/apps/api/src/faithfulness.ts @@ -3,6 +3,28 @@ import { extractCitationIds } from "./citations.js"; const SENTENCE_SPLIT = /(?<=[.!?])\s+(?=[A-Z("[])|(?:\n+)/; +const ABSOLUTE_WORDS = + /\b(always|never|completely|entirely|dramatically|everyone|no one|all regions|every squad)\b/gi; + +function softenText(excerpt: string): string { + return excerpt + .replace(/\balways\b/gi, "often") + .replace(/\bnever\b/gi, "rarely") + .replace(/\bcompletely\b/gi, "substantially") + .replace(/\bentirely\b/gi, "largely") + .replace(/\bdramatically\b/gi, "notably") + .replace(/\beveryone\b/gi, "many teammates") + .replace(/\bno one\b/gi, "few people") + .replace(/\ball regions\b/gi, "several regions") + .replace(/\bevery squad\b/gi, "multiple squads") + .replace(/(\d+%)/g, "about $1 (verify)") + .replace(/(\$\d[\d,]*(?:\.\d+)?)/g, "about $1 (verify)"); +} + +function pickCiteId(allowlist: string[]): string | undefined { + return allowlist.find((id) => /^(ach|fb|goal|doc|rexp)_/.test(id)); +} + /** Claims that look substantive but lack any [id] citation nearby. */ export function runFaithfulnessLint(text: string, allowlist: string[] = []): BiasFinding[] { const findings: BiasFinding[] = []; @@ -19,6 +41,11 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia category: "invention", message: `Citation [${id}] is not in the evidence allowlist — remove or replace with a real evidence id.`, excerpt: `[${id}]`, + suggestedFix: { + action: "strip_cite", + label: "Remove unknown cite", + newText: "", + }, }); } } @@ -33,9 +60,7 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia for (const sentence of sentences) { if (/^(summary|strengths|growth|overall|note|private)\b/i.test(sentence)) continue; if (!/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(sentence)) { - // Skip questions / instructions if (/\?$/.test(sentence) || /^(please|consider|remember)\b/i.test(sentence)) continue; - // Skip name-ish lists (mostly Title Case tokens, almost no verbs/stopwords) const words = sentence.split(/\s+/).filter(Boolean); const titleish = words.filter((w) => /^[A-Z][a-z]+/.test(w.replace(/[.,]/g, ""))).length; if (words.length >= 3 && titleish / words.length >= 0.7) continue; @@ -45,16 +70,32 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia } if (ungrounded >= 2) { + const sample = samples[0]; + const citeId = pickCiteId(allowlist); + const softened = sample ? softenText(sample) : undefined; findings.push({ id: "ungrounded_claims", severity: "warn", category: "invention", message: `${ungrounded} long sentence(s) lack evidence citations. Prefer claims tied to [ach_…]/[fb_…]/[goal_…] ids.`, - excerpt: samples[0], + excerpt: sample, + suggestedFix: citeId + ? { + action: "cite", + label: `Cite [${citeId}]`, + citeId, + newText: sample ? `${sample.replace(/\.*$/, "")} [${citeId}].` : undefined, + } + : softened && sample + ? { + action: "soften", + label: "Soften absolute language", + newText: softened, + } + : undefined, }); } - // Numeric claims without citations are high invention risk (% is non-word — no trailing \b) const metricClaims = [ ...body.matchAll(/(\d+%|\$\d[\d,]*(?:\.\d+)?|\b\d{2,}\s*(?:users|PRs|tickets|customers)\b)/gi), ]; @@ -62,16 +103,50 @@ export function runFaithfulnessLint(text: string, allowlist: string[] = []): Bia const idx = m.index ?? 0; const window = body.slice(Math.max(0, idx - 80), idx + 80); if (!/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(window)) { + const citeId = pickCiteId(allowlist); + const excerpt = m[0]; findings.push({ id: `metric_${idx}`, severity: "warn", category: "invention", - message: `Metric “${m[0]}” appears without a nearby evidence citation — verify or cite the source.`, - excerpt: m[0], + message: `Metric “${excerpt}” appears without a nearby evidence citation — verify or cite the source.`, + excerpt, + suggestedFix: citeId + ? { + action: "cite", + label: `Cite [${citeId}] near metric`, + citeId, + newText: `${excerpt} [${citeId}]`, + } + : { + action: "soften", + label: "Soften / qualify metric", + newText: softenText(excerpt), + }, }); } } + // Absolute phrasing without cites + for (const m of body.matchAll(ABSOLUTE_WORDS)) { + const idx = m.index ?? 0; + const window = body.slice(Math.max(0, idx - 40), idx + (m[0]?.length ?? 0) + 40); + if (/\[[a-z]+_[A-Za-z0-9_-]+\]/.test(window)) continue; + const word = m[0]; + findings.push({ + id: `absolute_${idx}`, + severity: "info", + category: "invention", + message: `Absolute wording “${word}” is easy to overclaim without evidence.`, + excerpt: word, + suggestedFix: { + action: "soften", + label: "Soften wording", + newText: softenText(word), + }, + }); + } + return findings; } @@ -88,3 +163,20 @@ export function mergeLintFindings(...groups: BiasFinding[][]): BiasFinding[] { } return out; } + +/** Apply a suggested faithfulness fix to draft text. Returns null if excerpt not found. */ +export function applyFaithfulnessFix( + text: string, + finding: Pick, +): string | null { + const fix = finding.suggestedFix; + const excerpt = finding.excerpt; + if (!fix || !excerpt) return null; + if (fix.action === "strip_cite") { + if (!text.includes(excerpt)) return null; + return text.replace(excerpt, "").replace(/ +/g, " ").replace(/ \./g, "."); + } + if (!fix.newText) return null; + if (!text.includes(excerpt)) return null; + return text.replace(excerpt, fix.newText); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6e8146d..5cb0345 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -38,6 +38,7 @@ import type { RoleFrameworkInfo, WorkspaceStatus, ConcernLogDTO, + CycleStatsDTO, DeadlineNudge, } from "@prm/shared"; import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared"; @@ -1097,6 +1098,115 @@ app.get("/api/cycles/:id/nudges", (c) => { return c.json({ cycleId, windowEnd: cycle.windowEnd, daysUntilDue, nudges }); }); +app.get("/api/cycles/:id/stats", (c) => { + const db = getDb(); + const cycleId = c.req.param("id"); + const cycle = db.select().from(cycles).where(eq(cycles.id, cycleId)).all()[0]; + if (!cycle) return c.json({ error: "Not found" }, 404); + const participants = db.select().from(cycleParticipants).where(eq(cycleParticipants.cycleId, cycleId)).all(); + const BUDGET = 45 * 60; + const peopleStats: CycleStatsDTO["people"] = []; + let totalSeconds = 0; + let peopleWithTime = 0; + let under45 = 0; + let thin = 0; + let linkedTotal = 0; + let peopleWithDraft = 0; + let coverageHits = 0; + let coverageNeed = 0; + + 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 fb = db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, person.id)).all().length; + 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 + fb + docs + goalsN; + const isThin = evidenceCount < 2; + if (isThin) thin += 1; + + const manager = db + .select() + .from(reviews) + .where(and(eq(reviews.cycleId, cycleId), eq(reviews.subjectPersonId, person.id), eq(reviews.type, "manager"))) + .all() + .find((r) => r.status !== "superseded"); + + let writingSeconds = 0; + let linkedSources = 0; + let managerStatus = "not_started"; + if (manager) { + managerStatus = manager.status; + peopleWithDraft += 1; + try { + const payload = JSON.parse(manager.payloadJson) as Record; + if (typeof payload.writing_seconds_total === "number") { + writingSeconds = payload.writing_seconds_total; + } + const comps = (payload.competencies ?? {}) as Record; + const overall = typeof payload.overall === "number" ? payload.overall : null; + const links = db.select().from(evidenceLinks).where(eq(evidenceLinks.reviewId, manager.id)).all(); + linkedSources = new Set(links.map((l) => l.sourceId)).size; + linkedTotal += linkedSources; + const scored: Array<{ key: string; score: number }> = [ + ...Object.entries(comps).map(([key, score]) => ({ key, score })), + ...(overall != null ? [{ key: "overall", score: overall }] : []), + ]; + for (const s of scored) { + if (s.score === 3) 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 */ + } + } + if (writingSeconds > 0) { + peopleWithTime += 1; + totalSeconds += writingSeconds; + if (writingSeconds <= BUDGET) under45 += 1; + } + peopleStats.push({ + personId: person.id, + personName: person.name, + writingSeconds, + under45Min: writingSeconds > 0 ? writingSeconds <= BUDGET : true, + evidenceCount, + thin: isThin, + linkedSources, + managerStatus, + }); + } + + const avgSeconds = peopleWithTime ? Math.round(totalSeconds / peopleWithTime) : 0; + const stats: CycleStatsDTO = { + cycleId, + cycleName: cycle.name, + participantCount: participants.length, + thinDossierCount: thin, + writing: { + peopleWithTime, + totalSeconds, + avgSeconds, + under45MinCount: under45, + under45MinPct: peopleWithTime ? Math.round((under45 / peopleWithTime) * 100) : 100, + }, + evidence: { + peopleWithDraft, + avgLinkedSources: peopleWithDraft ? Math.round((linkedTotal / peopleWithDraft) * 10) / 10 : 0, + coveragePct: coverageNeed ? Math.round((coverageHits / coverageNeed) * 100) : 100, + }, + people: peopleStats.sort((a, b) => a.personName.localeCompare(b.personName)), + }; + return c.json(stats); +}); + app.get("/api/cycles/:id/command-center", (c) => { const db = getDb(); const cycleId = c.req.param("id"); @@ -1829,10 +1939,24 @@ app.patch("/api/concerns/:id", async (c) => { app.get("/api/people/:id/dossier", (c) => { const personId = c.req.param("id"); const db = getDb(); + const feedback = db + .select() + .from(feedbackItems) + .where(eq(feedbackItems.toPersonId, personId)) + .all() + .map((f) => { + let themes: string[] = []; + try { + themes = JSON.parse(f.themesJson || "[]") as string[]; + } catch { + themes = []; + } + return { ...f, themes }; + }); return c.json({ goals: db.select().from(goals).where(eq(goals.personId, personId)).all(), achievements: db.select().from(achievements).where(eq(achievements.personId, personId)).all(), - feedback: db.select().from(feedbackItems).where(eq(feedbackItems.toPersonId, personId)).all(), + feedback, documents: db.select().from(documents).where(eq(documents.personId, personId)).all(), }); }); @@ -1877,10 +2001,15 @@ app.post("/api/people/:id/feedback", async (c) => { kind?: string; visibility?: string; occurredAt?: string; + themes?: string[]; }>(); if (!body.body?.trim()) return c.json({ error: "Feedback body required" }, 400); const db = getDb(); const fbId = id("fb"); + const themes = (body.themes ?? []) + .map((t) => String(t).trim()) + .filter(Boolean) + .slice(0, 8); db.insert(feedbackItems).values({ id: fbId, toPersonId: personId, @@ -1890,8 +2019,9 @@ app.post("/api/people/:id/feedback", async (c) => { visibility: body.visibility ?? "em_and_subject", occurredAt: body.occurredAt ?? nowIso().slice(0, 10), createdAt: nowIso(), + themesJson: JSON.stringify(themes), }).run(); - logActivity("feedback.create", "person", personId, { feedbackId: fbId }); + logActivity("feedback.create", "person", personId, { feedbackId: fbId, themes }); return c.json({ id: fbId }); }); @@ -2070,7 +2200,28 @@ function buildEvidenceContext(personId: string, cycleId?: string) { if (item.visibility === "em_only" && allowPrivateNotes && !dataClassesSent.includes("private_notes")) { dataClassesSent.push("private_notes"); } - lines.push(`Feedback [${item.id}] from ${item.fromName ?? "someone"}: ${item.body}`); + let themes: string[] = []; + try { + themes = JSON.parse(item.themesJson || "[]") as string[]; + } catch { + themes = []; + } + const themeBit = themes.length ? ` themes=[${themes.join(", ")}]` : ""; + lines.push(`Feedback [${item.id}] from ${item.fromName ?? "someone"}${themeBit}: ${item.body}`); + } + const optedConcerns = db + .select() + .from(concernLogs) + .where(eq(concernLogs.personId, personId)) + .all() + .filter((r) => r.aiAllowed); + for (const c of optedConcerns) { + if (!dataClassesSent.includes("concern_opt_in")) dataClassesSent.push("concern_opt_in"); + lines.push( + `Concern (AI-opted-in) [${c.id}] ${c.occurredAt} (${c.status}): ${c.title}. ${c.body}` + + (c.expectationText ? ` Expectation: ${c.expectationText}.` : "") + + (c.planText ? ` Plan: ${c.planText}.` : ""), + ); } for (const d of docs) { const meetingLike = looksLikeMeetingDoc(d.title, d.filename); @@ -2128,6 +2279,7 @@ function buildEvidenceContext(personId: string, cycleId?: string) { ...ach.map((x) => x.id), ...g.map((x) => x.id), ...fb.filter((x) => allowPrivateNotes || x.visibility !== "em_only").map((x) => x.id), + ...optedConcerns.map((c) => c.id), ...docs .filter((d) => (allowPrivateNotes || d.visibility !== "em_only") && (allowMeeting || !looksLikeMeetingDoc(d.title, d.filename))) .map((x) => x.id), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4847b77..89f8ce5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,7 +6,9 @@ "scripts": { "tauri": "tauri", "dev": "tauri dev", - "build": "tauri build" + "build": "tauri build", + "prepare:build": "node ./src-tauri/run-hook.mjs build", + "prepare:dev": "node ./src-tauri/run-hook.mjs dev" }, "devDependencies": { "@tauri-apps/cli": "^2.4.0", diff --git a/apps/desktop/src-tauri/run-hook.mjs b/apps/desktop/src-tauri/run-hook.mjs new file mode 100644 index 0000000..36fc721 --- /dev/null +++ b/apps/desktop/src-tauri/run-hook.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Cwd-independent Tauri hook. Lives in src-tauri so `node run-hook.mjs …` works + * when Tauri's cwd is src-tauri; also invokable as `node src-tauri/run-hook.mjs` + * when cwd is apps/desktop (CI / tauri-action). + */ +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const srcTauri = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = join(srcTauri, ".."); +const repoRoot = join(desktopRoot, "../.."); +const hook = process.argv[2] ?? "build"; + +function run(command, args, opts = {}) { + const result = spawnSync(command, args, { + stdio: "inherit", + cwd: opts.cwd ?? desktopRoot, + shell: process.platform === "win32", + env: process.env, + }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +if (hook === "dev") { + run("bash", [join(desktopRoot, "scripts/desktop-dev.sh")], { cwd: desktopRoot }); +} else { + run(process.execPath, [join(desktopRoot, "scripts/bundle-api-sidecar.mjs"), "--node"], { + cwd: desktopRoot, + shell: false, + }); + run("pnpm", ["--dir", repoRoot, "--filter", "@prm/ui", "build"], { cwd: desktopRoot }); +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 3ce8f79..552bf3c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "0.1.0", "identifier": "com.prm.workbench", "build": { - "beforeDevCommand": "bash ../scripts/desktop-dev.sh", + "beforeDevCommand": "pnpm prepare:dev", "devUrl": "http://localhost:5173", - "beforeBuildCommand": "node ../scripts/bundle-api-sidecar.mjs --node && pnpm --dir ../.. --filter @prm/ui build", + "beforeBuildCommand": "pnpm prepare:build", "frontendDist": "../ui/dist" }, "app": { diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index 033ef8e..e40854b 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; -import type { CycleDTO, DeadlineNudge, WorkspaceStatus } from "@prm/shared"; +import type { CycleDTO, CycleStatsDTO, DeadlineNudge, WorkspaceStatus } from "@prm/shared"; import { api } from "../lib/api"; import { NextStep, PageHeader } from "../components/PageChrome"; @@ -39,6 +39,7 @@ export function HomePage() { const [nudges, setNudges] = useState([]); const [nudgeDays, setNudgeDays] = useState(null); const [nudgeMsg, setNudgeMsg] = useState(null); + const [cycleStats, setCycleStats] = useState(null); useEffect(() => { api("/api/cycles").then((c) => { @@ -62,6 +63,9 @@ export function HomePage() { setNudges([]); setNudgeDays(null); }); + api(`/api/cycles/${cycleId}/stats`) + .then(setCycleStats) + .catch(() => setCycleStats(null)); }, [cycleId]); const active = cycles.find((c) => c.id === cycleId); @@ -195,6 +199,51 @@ export function HomePage() { {active && {active.windowStart} → {active.windowEnd} · {active.status}} + {cycleStats && ( +
+

Cycle dashboard

+

+ Writing time, evidence coverage, and thin dossiers for {cycleStats.cycleName}. +

+
+
+
Avg writing time
+ + {cycleStats.writing.peopleWithTime + ? `${Math.floor(cycleStats.writing.avgSeconds / 60)}m` + : "—"} + + + {" "} + · {cycleStats.writing.under45MinPct}% ≤45 min + {cycleStats.writing.peopleWithTime + ? ` (${cycleStats.writing.under45MinCount}/${cycleStats.writing.peopleWithTime})` + : ""} + +
+
+
Evidence coverage
+ {cycleStats.evidence.coveragePct}% + + {" "} + · avg {cycleStats.evidence.avgLinkedSources} linked sources / draft + +
+
+
Thin dossiers
+ {cycleStats.thinDossierCount} + / {cycleStats.participantCount} people + {cycleStats.thinDossierCount > 0 ? ( + <> + {" "} + Backfill + + ) : null} +
+
+
+ )} + {cycleId && nudges.length > 0 && (
diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx index dfae672..40a35d9 100644 --- a/apps/ui/src/pages/PersonPage.tsx +++ b/apps/ui/src/pages/PersonPage.tsx @@ -11,7 +11,7 @@ export function PersonPage() { const [dossier, setDossier] = useState<{ goals: Array<{ id: string; title: string; status: string }>; achievements: Array<{ id: string; title: string; occurredAt: string; description: string | null }>; - feedback: Array<{ id: string; fromName: string | null; body: string; kind: string }>; + feedback: Array<{ id: string; fromName: string | null; body: string; kind: string; themes?: string[] }>; documents: Array<{ id: string; title: string; filename: string; visibility: string; occurredAt: string }>; } | null>(null); const [achTitle, setAchTitle] = useState(""); @@ -19,6 +19,7 @@ export function PersonPage() { const [fbBody, setFbBody] = useState(""); const [fbFrom, setFbFrom] = useState(""); const [fbKind, setFbKind] = useState("coaching"); + const [fbThemes, setFbThemes] = useState(""); const [docTitle, setDocTitle] = useState(""); const [docFile, setDocFile] = useState(null); const [docMsg, setDocMsg] = useState(null); @@ -149,18 +150,34 @@ export function PersonPage() {

EM-entered notes from 1:1s, partners, or incidents. Author names stay on your workspace.

{dossier.feedback.length === 0 &&

No feedback yet — capture a coaching or praise note.

} {dossier.feedback.map((f) => ( -

{f.fromName ?? "Someone"} ({f.kind}): {f.body}

+

+ {f.fromName ?? "Someone"} ({f.kind}) + {f.themes && f.themes.length > 0 ? ( + · {f.themes.join(", ")} + ) : null} + : {f.body} +

))}
{ e.preventDefault(); + const themes = fbThemes + .split(/[,;]/) + .map((t) => t.trim()) + .filter(Boolean); await api(`/api/people/${id}/feedback`, { method: "POST", - body: JSON.stringify({ body: fbBody, fromName: fbFrom || undefined, kind: fbKind }), + body: JSON.stringify({ + body: fbBody, + fromName: fbFrom || undefined, + kind: fbKind, + themes: themes.length ? themes : undefined, + }), }); setFbBody(""); setFbFrom(""); + setFbThemes(""); setCaptureMsg("Feedback saved"); await load(); }} @@ -173,12 +190,20 @@ export function PersonPage() {
+
+ + setFbThemes(e.target.value)} + placeholder="growth, stakeholder mgmt, on-call" + /> +