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) => (
+ -
+
+
+ {idx + 2}. {s.title}
+
+
+ {s.detail}
+
+
+
+
+ {s.cta}
+
+
+
+ ))}
+
+
+ ) : 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 ? (