diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f14ca..a459344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - **Cursor Cloud Agents** AI provider — use dashboard `crsr_…` keys via the Cloud Agents API (no-repo agents for digests/drafts) - Guided **/setup** onboarding: team → assign-first roles → optional AI → integrations with credential guides → evidence backfill with progress - **1:1 sessions** on the person dossier — manual Q&A agenda plus optional AI-suggested questions grounded in evidence +- Home **suggested next actions** queue: prioritized primary NextStep plus “Up next” list that refreshes when you return to the tab ### Changed diff --git a/apps/ui/src/components/HomeSuggestionQueue.tsx b/apps/ui/src/components/HomeSuggestionQueue.tsx new file mode 100644 index 0000000..cebbe4a --- /dev/null +++ b/apps/ui/src/components/HomeSuggestionQueue.tsx @@ -0,0 +1,98 @@ +import { Link } from "react-router-dom"; +import type { ReactNode } from "react"; +import type { HomeSuggestion } from "../lib/homeSuggestions"; +import { NextStep } from "./PageChrome"; + +function SuggestionLink({ + href, + className, + children, +}: { + href: string; + className?: string; + children: ReactNode; +}) { + if (href.startsWith("#")) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +/** Primary NextStep + remaining “up next” queue that shrinks as work completes. */ +export function HomeSuggestionQueue({ + suggestions, + refreshing, + onRefresh, +}: { + suggestions: HomeSuggestion[]; + refreshing?: boolean; + onRefresh?: () => void; +}) { + if (suggestions.length === 0) return null; + const [primary, ...rest] = suggestions; + + return ( +
+ + + {primary.cta} + + {primary.secondaryHref && primary.secondaryCta ? ( + + {primary.secondaryCta} + + ) : null} + {onRefresh ? ( + + ) : null} + + } + /> + + {rest.length > 0 ? ( +
+
+

Up next

+ + {suggestions.length} suggestion{suggestions.length === 1 ? "" : "s"} · updates as you clear work + +
+
    + {rest.map((s, idx) => ( +
  1. +
    +
    + {idx + 2}. {s.title} +
    +

    + {s.detail} +

    +
    +
    + + {s.cta} + +
    +
  2. + ))} +
+
+ ) : null} +
+ ); +} diff --git a/apps/ui/src/lib/homeSuggestions.test.ts b/apps/ui/src/lib/homeSuggestions.test.ts new file mode 100644 index 0000000..fa3f987 --- /dev/null +++ b/apps/ui/src/lib/homeSuggestions.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildHomeSuggestions } from "./homeSuggestions.js"; + +describe("buildHomeSuggestions", () => { + it("returns empty when no cycle is selected", () => { + assert.deepEqual( + buildHomeSuggestions({ + cycleId: null, + thinEvidenceCount: 2, + items: [], + nudgeCount: 0, + nudgeDays: null, + attentionCount: 0, + }), + [], + ); + }); + + it("prioritizes role assignment before self bundles and writing", () => { + const suggestions = buildHomeSuggestions({ + cycleId: "cyc_1", + thinEvidenceCount: 1, + nudgeCount: 0, + nudgeDays: null, + attentionCount: 0, + items: [ + { + person: { id: "p1", name: "Alex", roleTitle: null }, + hasSelf: false, + managerStatus: "missing", + }, + { + person: { id: "p2", name: "Blake", roleTitle: "IC3" }, + hasSelf: true, + managerStatus: "draft", + selfStatus: "returned", + }, + ], + }); + assert.equal(suggestions[0]?.id, "assign-roles"); + assert.ok(suggestions.some((s) => s.id === "self-bundles")); + assert.ok(suggestions.some((s) => s.id === "write-p2")); + assert.ok(suggestions.some((s) => s.id === "backfill")); + }); + + it("drops cleared items as pipeline advances", () => { + const before = buildHomeSuggestions({ + cycleId: "cyc_1", + thinEvidenceCount: 0, + nudgeCount: 0, + nudgeDays: null, + attentionCount: 0, + items: [ + { + person: { id: "p1", name: "Alex", roleTitle: "IC3" }, + hasSelf: false, + managerStatus: "missing", + selfStatus: "pending", + }, + ], + }); + assert.equal(before[0]?.id, "self-bundles"); + + const after = buildHomeSuggestions({ + cycleId: "cyc_1", + thinEvidenceCount: 0, + nudgeCount: 0, + nudgeDays: null, + attentionCount: 0, + items: [ + { + person: { id: "p1", name: "Alex", roleTitle: "IC3" }, + hasSelf: true, + managerStatus: "missing", + selfStatus: "returned", + }, + ], + }); + assert.equal(after[0]?.id, "write-p1"); + assert.equal(after.some((s) => s.id === "self-bundles"), false); + }); + + it("shows cycle-complete when everyone is finalized", () => { + const suggestions = buildHomeSuggestions({ + cycleId: "cyc_1", + thinEvidenceCount: 0, + nudgeCount: 0, + nudgeDays: null, + attentionCount: 0, + items: [ + { + person: { id: "p1", name: "Alex", roleTitle: "IC3" }, + hasSelf: true, + managerStatus: "finalized", + selfStatus: "returned", + }, + ], + }); + assert.equal(suggestions[0]?.id, "cycle-complete"); + }); + + it("falls back to year-round capture when the queue is idle", () => { + const suggestions = buildHomeSuggestions({ + cycleId: "cyc_1", + thinEvidenceCount: 0, + nudgeCount: 0, + nudgeDays: null, + attentionCount: 0, + items: [], + }); + assert.equal(suggestions[0]?.id, "capture-evidence"); + }); +}); diff --git a/apps/ui/src/lib/homeSuggestions.ts b/apps/ui/src/lib/homeSuggestions.ts new file mode 100644 index 0000000..841709a --- /dev/null +++ b/apps/ui/src/lib/homeSuggestions.ts @@ -0,0 +1,197 @@ +export type HomeSuggestionTone = "brand" | "warn" | "ok"; + +export type HomeSuggestion = { + id: string; + tone: HomeSuggestionTone; + title: string; + detail: string; + href: string; + cta: string; + secondaryHref?: string; + secondaryCta?: string; +}; + +export type HomeSuggestionPerson = { + id: string; + name: string; + roleTitle: string | null; +}; + +export type HomeSuggestionCommandItem = { + person: HomeSuggestionPerson | null; + hasSelf: boolean; + managerStatus: string; + selfStatus?: string; +}; + +export type BuildHomeSuggestionsInput = { + cycleId: string | null; + thinEvidenceCount: number; + items: HomeSuggestionCommandItem[]; + nudgeCount: number; + nudgeDays: number | null; + attentionCount: number; + /** Cap writing-desk suggestions so the list stays scannable. */ + maxWriteSuggestions?: number; +}; + +function selfMissing(item: HomeSuggestionCommandItem): boolean { + if (!item.person) return false; + const self = item.selfStatus ?? (item.hasSelf ? "returned" : "pending"); + return self !== "returned" && self !== "waived"; +} + +/** + * Prioritized “what should I do next?” queue for the command center. + * Cleared / reordered automatically as pipeline state changes. + */ +export function buildHomeSuggestions(input: BuildHomeSuggestionsInput): HomeSuggestion[] { + const { + cycleId, + thinEvidenceCount, + items, + nudgeCount, + nudgeDays, + attentionCount, + maxWriteSuggestions = 3, + } = input; + + if (!cycleId) return []; + + const withPerson = items.filter((i): i is HomeSuggestionCommandItem & { person: HomeSuggestionPerson } => + Boolean(i.person), + ); + const unassigned = withPerson.filter((i) => !i.person.roleTitle); + const needSelf = withPerson.filter(selfMissing); + const readyToWrite = withPerson.filter( + (i) => i.hasSelf && i.managerStatus !== "finalized" && i.managerStatus !== "shared", + ); + const done = withPerson.filter((i) => i.managerStatus === "finalized" || i.managerStatus === "shared"); + const out: HomeSuggestion[] = []; + + if (unassigned.length > 0) { + const first = unassigned[0].person; + out.push({ + id: "assign-roles", + tone: "warn", + title: + unassigned.length === 1 + ? `Assign a role for ${first.name}` + : `Assign roles for ${unassigned.length} people`, + detail: `${first.name}${unassigned.length > 1 ? ` and ${unassigned.length - 1} more` : ""} need a RoleDefinition before fair ratings.`, + href: "/roles", + cta: "Assign roles", + }); + } + + if (needSelf.length > 0) { + const first = needSelf[0].person; + out.push({ + id: "self-bundles", + tone: "brand", + title: + needSelf.length === 1 + ? `Get ${first.name}'s self-review in` + : `Get self-reviews in (${needSelf.length})`, + detail: `Export JSON, send outside the app, then import — ICs never log in.`, + href: "/cycles", + cta: "Export / import bundles", + secondaryHref: `/cycles/${cycleId}/write/${first.id}`, + secondaryCta: "Write anyway", + }); + } + + if (nudgeCount > 0 && nudgeDays != null && nudgeDays <= 14) { + out.push({ + id: "deadline-nudges", + tone: "warn", + title: `Nudge ${nudgeCount} missing self-review${nudgeCount === 1 ? "" : "s"}`, + detail: + nudgeDays <= 0 + ? "Window end is today or past — send mailto nudges from Home." + : `${nudgeDays} day${nudgeDays === 1 ? "" : "s"} until window end.`, + href: "#home-nudges", + cta: "Jump to nudges", + }); + } + + if (thinEvidenceCount > 0) { + const writeAnyway = readyToWrite[0]?.person; + out.push({ + id: "backfill", + tone: "brand", + title: `Backfill ${thinEvidenceCount} thin dossier${thinEvidenceCount === 1 ? "" : "s"}`, + detail: "Cold-start dossiers block grounded reviews. Capture achievements or notes first.", + href: "/backfill", + cta: "Open backfill", + ...(writeAnyway + ? { + secondaryHref: `/cycles/${cycleId}/write/${writeAnyway.id}`, + secondaryCta: "Write anyway", + } + : {}), + }); + } + + for (const item of readyToWrite.slice(0, maxWriteSuggestions)) { + const p = item.person; + const isDraft = item.managerStatus === "draft"; + out.push({ + id: `write-${p.id}`, + tone: "brand", + title: `${isDraft ? "Continue" : "Start"} writing: ${p.name}`, + detail: isDraft + ? "Draft in progress — finish competencies and finalize when ready." + : "Self import is in. Draft the manager review against the assigned role.", + href: `/cycles/${cycleId}/write/${p.id}`, + cta: isDraft ? "Continue writing" : "Start writing", + }); + } + + if (readyToWrite.length > maxWriteSuggestions) { + const leftover = readyToWrite.length - maxWriteSuggestions; + out.push({ + id: "write-more", + tone: "brand", + title: `${leftover} more review${leftover === 1 ? "" : "s"} ready to write`, + detail: "See the writing queue below for the full list.", + href: "#home-queue", + cta: "Jump to queue", + }); + } + + if (attentionCount > 0) { + out.push({ + id: "consistency", + tone: "warn", + title: `Review ${attentionCount} consistency flag${attentionCount === 1 ? "" : "s"}`, + detail: "Director-attention signals before you finalize or export packets.", + href: `/consistency?cycleId=${cycleId}`, + cta: "Open consistency", + }); + } + + if (withPerson.length > 0 && done.length === withPerson.length) { + out.push({ + id: "cycle-complete", + tone: "ok", + title: "Cycle writing complete", + detail: "All manager reviews are finalized. Export subject packets, or open promotions where readiness is set.", + href: "/promotions", + cta: "Review promotions", + }); + } + + if (out.length === 0) { + out.push({ + id: "capture-evidence", + tone: "brand", + title: "Capture evidence year-round", + detail: "Between cycles, keep dossiers current — achievements, feedback, and documents make writing faster.", + href: "/team", + cta: "Open team", + }); + } + + return out; +} diff --git a/apps/ui/src/pages/HomePage.tsx b/apps/ui/src/pages/HomePage.tsx index dc00ae8..55604c1 100644 --- a/apps/ui/src/pages/HomePage.tsx +++ b/apps/ui/src/pages/HomePage.tsx @@ -1,11 +1,13 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; import type { CycleDTO, CycleStatsDTO, DeadlineNudge, DirectorAttentionDTO, DueThisWeekItem, WorkspaceStatus } from "@prm/shared"; import { api } from "../lib/api"; -import { NextStep, PageHeader, Section } from "../components/PageChrome"; +import { buildHomeSuggestions } from "../lib/homeSuggestions"; +import { PageHeader, Section } from "../components/PageChrome"; import { PageSectionsLayout } from "../components/PageSectionsLayout"; import { OnboardingWizard } from "../components/OnboardingWizard"; import { AppSelect } from "../components/AppSelect"; +import { HomeSuggestionQueue } from "../components/HomeSuggestionQueue"; type CommandItem = { person: { id: string; name: string; roleTitle: string | null } | null; @@ -47,24 +49,95 @@ export function HomePage() { const [cycleStats, setCycleStats] = useState(null); const [attention, setAttention] = useState(null); const [setupActive, setSetupActive] = useState(false); + const [refreshing, setRefreshing] = useState(false); - useEffect(() => { - api("/api/cycles").then((c) => { - setCycles(c); + const refreshWorkspace = useCallback(async () => { + const [c, s, due] = await Promise.all([ + api("/api/cycles"), + api("/api/workspace/status"), + api<{ items: DueThisWeekItem[] }>("/api/due-this-week?days=7").catch(() => ({ items: [] as DueThisWeekItem[] })), + ]); + setCycles(c); + setThinEvidenceCount(s.thinEvidenceCount ?? 0); + setPersonCount(s.personCount ?? 0); + setDueWeek(due.items); + setCycleId((prev) => { + if (prev && c.some((x) => x.id === prev)) return prev; const preferred = c.find((x) => x.status === "manager_open") || c.find((x) => x.status !== "closed") || c[0]; - if (preferred) setCycleId(preferred.id); + return preferred?.id ?? null; }); - api("/api/workspace/status").then((s) => { + }, []); + + const refreshCycleData = useCallback(async (id: string) => { + const [command, nudgeRes, statsRes, attentionRes] = await Promise.all([ + api<{ items: CommandItem[] }>(`/api/cycles/${id}/command-center`), + api<{ nudges: DeadlineNudge[]; daysUntilDue: number }>(`/api/cycles/${id}/nudges`).catch(() => ({ + nudges: [] as DeadlineNudge[], + daysUntilDue: null as number | null, + })), + api(`/api/cycles/${id}/stats`).catch(() => null), + api(`/api/director-attention?cycleId=${encodeURIComponent(id)}`).catch(() => null), + ]); + setItems(command.items); + setNudges(nudgeRes.nudges); + setNudgeDays(nudgeRes.daysUntilDue); + setCycleStats(statsRes); + setAttention(attentionRes); + }, []); + + const refreshAll = useCallback(async () => { + setRefreshing(true); + try { + const [c, s, due] = await Promise.all([ + api("/api/cycles"), + api("/api/workspace/status"), + api<{ items: DueThisWeekItem[] }>("/api/due-this-week?days=7").catch(() => ({ + items: [] as DueThisWeekItem[], + })), + ]); + setCycles(c); setThinEvidenceCount(s.thinEvidenceCount ?? 0); setPersonCount(s.personCount ?? 0); - }); - api<{ items: DueThisWeekItem[] }>("/api/due-this-week?days=7") - .then(async (r) => { - setDueWeek(r.items); - const actionable = r.items.filter((i) => i.missingSelf || i.managerStatus === "missing" || i.managerStatus === "draft"); + setDueWeek(due.items); + + let selected: string | null = null; + setCycleId((prev) => { + if (prev && c.some((x) => x.id === prev)) { + selected = prev; + return prev; + } + const preferred = + c.find((x) => x.status === "manager_open") || + c.find((x) => x.status !== "closed") || + c[0]; + selected = preferred?.id ?? null; + return selected; + }); + if (selected) await refreshCycleData(selected); + else { + setItems([]); + setNudges([]); + setNudgeDays(null); + setCycleStats(null); + setAttention(null); + } + } finally { + setRefreshing(false); + } + }, [refreshCycleData]); + + useEffect(() => { + void refreshWorkspace() + .then(async () => { + const due = await api<{ items: DueThisWeekItem[] }>("/api/due-this-week?days=7").catch(() => ({ + items: [] as DueThisWeekItem[], + })); + const actionable = due.items.filter( + (i) => i.missingSelf || i.managerStatus === "missing" || i.managerStatus === "draft", + ); if (actionable.length && typeof Notification !== "undefined") { const key = `prm_due_notify_${new Date().toISOString().slice(0, 10)}`; if (sessionStorage.getItem(key)) return; @@ -81,132 +154,59 @@ export function HomePage() { } } }) - .catch(() => setDueWeek([])); + .catch(() => undefined); + }, [refreshWorkspace]); - return () => undefined; - }, []); + useEffect(() => { + if (!cycleId) { + setItems([]); + setNudges([]); + setNudgeDays(null); + setCycleStats(null); + setAttention(null); + return; + } + void refreshCycleData(cycleId); + }, [cycleId, refreshCycleData]); + // Recompute suggestions when the EM returns from Roles / Cycles / Writing desk. useEffect(() => { - if (!cycleId) return; - api<{ items: CommandItem[] }>(`/api/cycles/${cycleId}/command-center`).then((r) => setItems(r.items)); - api<{ nudges: DeadlineNudge[]; daysUntilDue: number }>(`/api/cycles/${cycleId}/nudges`) - .then((r) => { - setNudges(r.nudges); - setNudgeDays(r.daysUntilDue); - }) - .catch(() => { - setNudges([]); - setNudgeDays(null); - }); - api(`/api/cycles/${cycleId}/stats`) - .then(setCycleStats) - .catch(() => setCycleStats(null)); - api(`/api/director-attention?cycleId=${encodeURIComponent(cycleId)}`) - .then(setAttention) - .catch(() => setAttention(null)); - }, [cycleId]); + const onFocus = () => { + void refreshAll(); + }; + const onVisibility = () => { + if (document.visibilityState === "visible") onFocus(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refreshAll]); const active = cycles.find((c) => c.id === cycleId); - const stats = useMemo(() => { - const unassigned = items.filter((i) => i.person && !i.person.roleTitle); - const needSelf = items.filter((i) => { - if (!i.person) return false; - const self = i.selfStatus ?? (i.hasSelf ? "returned" : "pending"); - return self !== "returned" && self !== "waived"; + const suggestions = useMemo(() => { + if (setupActive || personCount === 0) return []; + return buildHomeSuggestions({ + cycleId, + thinEvidenceCount, + items, + nudgeCount: nudges.length, + nudgeDays, + attentionCount: attention?.items.length ?? 0, }); - const readyToWrite = items.filter( - (i) => i.person && i.hasSelf && i.managerStatus !== "finalized" && i.managerStatus !== "shared", - ); - const drafting = items.filter((i) => i.managerStatus === "draft"); - const done = items.filter((i) => i.managerStatus === "finalized" || i.managerStatus === "shared"); - return { unassigned, needSelf, readyToWrite, drafting, done }; - }, [items]); - - const next = (() => { - // OnboardingWizard owns the empty / first-cycle path — avoid duplicate banners. - if (setupActive || personCount === 0 || !cycleId) return null; - if (stats.unassigned[0]?.person) { - const p = stats.unassigned[0].person; - return ( - 1 ? ` and ${stats.unassigned.length - 1} more` : ""} need a RoleDefinition before fair ratings.`} - action={Assign roles} - /> - ); - } - if (stats.needSelf[0]?.person) { - const p = stats.needSelf[0].person; - return ( - 1 ? ` (+${stats.needSelf.length - 1})` : ""} still missing a self bundle. Export JSON, send outside the app, then import.`} - action={ - <> - Export / import bundles - Write anyway - - } - /> - ); - } - if (thinEvidenceCount > 0) { - return ( - - Open backfill wizard - {stats.readyToWrite[0]?.person && ( - - Write anyway - - )} - - } - /> - ); - } - if (stats.readyToWrite[0]?.person) { - const p = stats.readyToWrite[0].person; - const verb = stats.drafting.length > 0 ? "Continue writing" : "Start writing"; - return ( - 0 - ? `${stats.drafting.length} draft${stats.drafting.length === 1 ? "" : "s"} in progress · ${stats.done.length} finalized.` - : "Self imports are in. Draft the manager review against the assigned role." - } - action={{verb}} - /> - ); - } - if (items.length > 0 && stats.done.length === items.length) { - return ( - Review promotions} - /> - ); - } - return ( - Open team} - /> - ); - })(); + }, [ + setupActive, + personCount, + cycleId, + thinEvidenceCount, + items, + nudges.length, + nudgeDays, + attention?.items.length, + ]); const showSetupOnly = personCount === 0 || (!cycleId && (setupActive || cycles.length === 0)); @@ -217,7 +217,7 @@ export function HomePage() { job={ showSetupOnly ? "Set up your team and first cycle — then this page becomes your review pipeline." - : "Unblock the next person in your review pipeline — roles, bundles, then writing." + : "Suggested next actions update as you clear roles, bundles, and drafts." } primary={ !showSetupOnly && cycleId ? ( @@ -230,7 +230,13 @@ export function HomePage() { - {next} + {!showSetupOnly ? ( + void refreshAll()} + /> + ) : null} {showSetupOnly ? (