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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc

**Intent:** Answer "who is blocked, and what should I do next?" without hunting through every page.

**How it works:** Pick a cycle to see pipeline status (self / peer / manager), deadline nudges for missing self reviews, and a compact cycle dashboard (writing time, evidence coverage, thin dossiers). Links out to writing desks, backfill, and team performance when the numbers look thin. **Needs director attention** surfaces flags only (stagnant ratings, thin evidence, promo-ready, open concern counts) with a jump into consistency / director packs — no private note bodies.
**How it works:** Pick a cycle to see pipeline status (self / peer / manager), deadline nudges for missing self reviews, and a compact cycle dashboard (writing time, evidence coverage, thin dossiers). Links out to writing desks, backfill, and team performance when the numbers look thin. **Needs director attention** surfaces flags only (stagnant ratings, thin evidence, promo-ready, open concern counts) with a jump into consistency / director packs — no private note bodies. Sticky **On this page** jumps between cycle, attention, dashboard, and queue.

![Command center](./docs/screenshots/02-home.png)

Expand All @@ -111,7 +111,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). 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).
**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). Sticky **On this page** jumps across timeline, integrations, and capture sections.

![Person dossier](./docs/screenshots/04-person.png)

Expand Down
9 changes: 8 additions & 1 deletion apps/ui/src/components/PageSectionNav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";

export type SectionNavItem = {
id: string;
Expand All @@ -14,6 +14,11 @@ export function PageSectionNav({
}) {
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const sectionKey = items.map((item) => item.id).join("|");
const clickLockUntil = useRef(0);

useEffect(() => {
if (items[0]?.id) setActiveId(items[0].id);
}, [sectionKey, items]);

useEffect(() => {
const nodes = items
Expand All @@ -24,6 +29,7 @@ export function PageSectionNav({
const ratios = new Map<string, number>();
const observer = new IntersectionObserver(
(entries) => {
if (Date.now() < clickLockUntil.current) return;
for (const entry of entries) {
ratios.set(entry.target.id, entry.isIntersecting ? entry.intersectionRatio : 0);
}
Expand Down Expand Up @@ -55,6 +61,7 @@ export function PageSectionNav({
function goTo(id: string) {
const el = document.getElementById(id);
if (!el) return;
clickLockUntil.current = Date.now() + 700;
setActiveId(id);
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
Expand Down
25 changes: 25 additions & 0 deletions apps/ui/src/components/PageSectionsLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { PageSectionNav, type SectionNavItem } from "./PageSectionNav";

/** Sticky right-rail (or top chips on phone) for long multi-section pages. */
export function PageSectionsLayout({
sections,
children,
ariaLabel = "On this page",
}: {
sections: SectionNavItem[];
children: ReactNode;
ariaLabel?: string;
}) {
if (sections.length < 2) {
return <div className="stack">{children}</div>;
}
return (
<div className="page-sections-layout">
<div className="page-sections-main stack">{children}</div>
<PageSectionNav items={sections} ariaLabel={ariaLabel} />
</div>
);
}

export type { SectionNavItem };
26 changes: 16 additions & 10 deletions apps/ui/src/pages/ConsistencyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { competencyLabel } from "@prm/shared";
import { api } from "../lib/api";
import { downloadBinaryExport, openHtmlExport } from "../lib/exports";
import { NextStep, PageHeader } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";

export function ConsistencyPage() {
const [params, setParams] = useSearchParams();
Expand Down Expand Up @@ -94,8 +95,18 @@ export function ConsistencyPage() {
</div>
</div>

{data && (
<div className="panel stack">

{!data ? (
<p>Loading…</p>
) : (
<PageSectionsLayout
sections={[
{ id: "cons-calib", label: "Calibration" },
{ id: "cons-distribution", label: "Distribution" },
{ id: "cons-cards", label: "People" },
]}
>
<div className="panel stack page-section" id="cons-calib">
<h2>Calibration bundles</h2>
<p className="muted" style={{ margin: 0 }}>
Director round-trip: export cards, capture decisions in the form (or paste JSON), then import
Expand Down Expand Up @@ -309,12 +320,7 @@ export function ConsistencyPage() {
</button>
{calibMsg && <p className="muted">{calibMsg}</p>}
</div>
)}

{!data ? (
<p>Loading…</p>
) : (
<>
{thin.length > 0 ? (
<NextStep
tone="warn"
Expand All @@ -335,7 +341,7 @@ export function ConsistencyPage() {
/>
)}

<div className="panel">
<div className="panel page-section" id="cons-distribution">
<h2>Overall distribution</h2>
<div className="consistency-bars">
{["1", "2", "3", "4", "5", "unset"].map((bucket) => {
Expand All @@ -356,7 +362,7 @@ export function ConsistencyPage() {
</div>
</div>

<div className="consistency-grid">
<div className="consistency-grid page-section" id="cons-cards">
{data.cards.map((card) => (
<div key={card.personId} className="panel consistency-card">
<div className="row" style={{ justifyContent: "space-between" }}>
Expand Down Expand Up @@ -395,7 +401,7 @@ export function ConsistencyPage() {
</div>
))}
</div>
</>
</PageSectionsLayout>
)}
</div>
);
Expand Down
16 changes: 13 additions & 3 deletions apps/ui/src/pages/CyclesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import type { CycleDTO, PersonDTO } from "@prm/shared";
import { api } from "../lib/api";
import { NextStep, PageHeader } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";

const PHASE_STATUSES = [
{ value: "self_open", label: "Self open" },
Expand Down Expand Up @@ -81,7 +82,15 @@ export function CyclesPage() {
/>
)}

<div className="panel">
<PageSectionsLayout
sections={[
{ id: "cycles-list", label: "Cycles" },
...(selected ? [{ id: "cycles-phases", label: "Phases" }] : []),
{ id: "cycles-export", label: "Export" },
{ id: "import-bundle", label: "Import" },
]}
>
<div className="panel page-section" id="cycles-list">
<h2>Cycles</h2>
{cycles.length === 0 ? (
<p className="muted">No cycles yet.</p>
Expand Down Expand Up @@ -156,7 +165,7 @@ export function CyclesPage() {
</div>

{selected && (
<div className="panel stack">
<div className="panel stack page-section" id="cycles-phases">
<h2>Phase wizard — {selected.name}</h2>
<p className="muted">
Soft gates only: advance phases when ready. Waive missing self/peer from the writing desk with a reason.
Expand Down Expand Up @@ -266,7 +275,7 @@ export function CyclesPage() {
<button className="btn" disabled={!selectedCycle || !subjectId}>Download JSON bundle</button>
</form>

<div className="panel stack" id="import-bundle">
<div className="panel stack page-section" id="import-bundle">
<h2>2. Import completed bundle</h2>
<div className="field">
<label>Paste completed bundle JSON</label>
Expand Down Expand Up @@ -320,6 +329,7 @@ export function CyclesPage() {
action={<Link className="btn" to="/">Open command center</Link>}
/>
)}
</PageSectionsLayout>
</div>
);
}
26 changes: 20 additions & 6 deletions apps/ui/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
import { OnboardingWizard } from "../components/OnboardingWizard";

type CommandItem = {
Expand Down Expand Up @@ -246,7 +247,19 @@ export function HomePage() {

{next}

<div className="panel row">
<PageSectionsLayout
sections={[
{ id: "home-cycle", label: "Cycle" },
...(attention && attention.items.length > 0
? [{ id: "home-attention", label: "Attention" }]
: []),
...(dueWeek.length > 0 ? [{ id: "home-due", label: "Due" }] : []),
...(cycleStats ? [{ id: "home-dashboard", label: "Dashboard" }] : []),
...(cycleId && nudges.length > 0 ? [{ id: "home-nudges", label: "Nudges" }] : []),
{ id: "home-queue", label: "Queue" },
]}
>
<div className="panel row page-section" id="home-cycle">
<label className="muted">Cycle</label>
<select value={cycleId ?? ""} onChange={(e) => setCycleId(e.target.value || null)}>
{cycles.length === 0 && <option value="">No cycles yet</option>}
Expand All @@ -258,7 +271,7 @@ export function HomePage() {
</div>

{attention && attention.items.length > 0 && (
<div className="panel stack">
<div className="panel stack page-section" id="home-attention">
<h2 style={{ margin: 0 }}>Needs director attention</h2>
<p className="muted" style={{ margin: 0 }}>
Flags only — stagnant ratings, thin evidence, promo-ready, open concern counts. No private note bodies.
Expand Down Expand Up @@ -286,7 +299,7 @@ export function HomePage() {
)}

{dueWeek.length > 0 && (
<div className="panel stack" style={{ borderColor: "var(--warn, #c4a35a)" }}>
<div className="panel stack page-section" id="home-due" style={{ borderColor: "var(--warn, #c4a35a)" }}>
<h2 style={{ margin: 0 }}>Due this week</h2>
<p className="muted" style={{ margin: 0 }}>
Sticky tray for cycle work ending within 7 days. OS notification fires once per session when permission is
Expand All @@ -309,7 +322,7 @@ export function HomePage() {
)}

{cycleStats && (
<div className="panel stack">
<div className="panel stack page-section" id="home-dashboard">
<h2 style={{ margin: 0 }}>Cycle dashboard</h2>
<p className="muted" style={{ margin: 0 }}>
Writing time, evidence coverage, and thin dossiers for {cycleStats.cycleName}.
Expand Down Expand Up @@ -362,7 +375,7 @@ export function HomePage() {
)}

{cycleId && nudges.length > 0 && (
<div className="panel stack">
<div className="panel stack page-section" id="home-nudges">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h2 style={{ margin: 0 }}>Deadline nudges</h2>
<button
Expand Down Expand Up @@ -400,7 +413,7 @@ export function HomePage() {
</div>
)}

<div className="panel">
<div className="panel page-section" id="home-queue">
{items.length === 0 ? (
<p className="muted" style={{ margin: 0 }}>
{cycleId ? "No participants in this cycle." : "Create a cycle to see your writing queue."}
Expand Down Expand Up @@ -469,6 +482,7 @@ export function HomePage() {
</table>
)}
</div>
</PageSectionsLayout>
</div>
);
}
33 changes: 24 additions & 9 deletions apps/ui/src/pages/PersonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from "@prm/shared";
import { api, getToken, apiUrl } from "../lib/api";
import { NextStep, PageHeader } from "../components/PageChrome";
import { PageSectionsLayout, type SectionNavItem } from "../components/PageSectionsLayout";
import { DeliveryRollupPanel } from "../components/DeliveryRollupPanel";

export function PersonPage() {
Expand Down Expand Up @@ -109,6 +110,18 @@ export function PersonPage() {
const activeCycle = cycles[0];
const needsRole = !person.roleDefinitionId;

const sections: SectionNavItem[] = [
...(activeCycle ? [{ id: "person-reassign", label: "Reassign" }] : []),
{ id: "timeline", label: "Timeline" },
{ id: "integrations", label: "Integrations" },
{ id: "capture-achievements", label: "Achievements" },
{ id: "person-goals", label: "Goals" },
{ id: "person-feedback", label: "Feedback" },
{ id: "person-documents", label: "Documents" },
{ id: "person-meetings", label: "Meetings" },
{ id: "person-concerns", label: "Concerns" },
];

return (
<div className="stack">
<PageHeader
Expand Down Expand Up @@ -155,8 +168,9 @@ export function PersonPage() {
/>
)}

<PageSectionsLayout sections={sections}>
{activeCycle && (
<div className="panel stack">
<div className="panel stack page-section" id="person-reassign">
<h2 style={{ margin: 0 }}>Mid-cycle manager change</h2>
<p className="muted" style={{ margin: 0 }}>
Reassign for <strong>{activeCycle.name}</strong>: supersedes prior manager drafts/finals as contribution
Expand Down Expand Up @@ -216,7 +230,7 @@ export function PersonPage() {
</div>
)}

<div className="panel stack" id="timeline">
<div className="panel stack page-section" id="timeline">
<h2 style={{ margin: 0 }}>Timeline</h2>
<p className="muted" style={{ margin: 0 }}>
Chronological dossier, reviews, concerns, and rating decisions. Use this to prep a 1:1 or reconstruct
Expand All @@ -237,7 +251,7 @@ export function PersonPage() {
)}
</div>

<div className="panel stack" id="integrations">
<div className="panel stack page-section" id="integrations">
<h2>Integrations</h2>
<p className="muted">
Link remote accounts, then pull tickets / merged PRs for the active review window into achievements.
Expand Down Expand Up @@ -398,7 +412,7 @@ export function PersonPage() {
{id ? <DeliveryRollupPanel personId={id} cycleId={cycles[0]?.id} /> : null}

<div className="grid-2">
<div className="panel" id="capture-achievements">
<div className="panel page-section" id="capture-achievements">
<h2>Achievements</h2>
<ul>
{dossier.achievements.map((a) => (
Expand Down Expand Up @@ -430,7 +444,7 @@ export function PersonPage() {
<button className="btn">Add achievement</button>
</form>
</div>
<div className="panel">
<div className="panel page-section" id="person-goals">
<h2>Goals</h2>
<ul>
{dossier.goals.map((g) => (
Expand All @@ -453,7 +467,7 @@ export function PersonPage() {
</div>
</div>

<div className="panel stack">
<div className="panel stack page-section" id="person-feedback">
<h2>Feedback</h2>
<p className="muted">EM-entered notes from 1:1s, partners, or incidents. Author names stay on your workspace.</p>
{dossier.feedback.length === 0 && <p className="muted">No feedback yet — capture a coaching or praise note.</p>}
Expand Down Expand Up @@ -520,7 +534,7 @@ export function PersonPage() {
</form>
</div>

<div className="panel stack">
<div className="panel stack page-section" id="person-documents">
<h2>Documents</h2>
<p className="muted">PDF/DOCX/PPTX/CSV/images — allowlisted. Sealed into the encrypted vault when you lock the workspace.</p>
<ul>
Expand Down Expand Up @@ -582,7 +596,7 @@ export function PersonPage() {

{captureMsg && <p className="muted">{captureMsg}</p>}

<div className="panel stack">
<div className="panel stack page-section" id="person-meetings">
<h2>Meeting summaries</h2>
<p className="muted">
Structured notes from 1:1s / meetings. Default visibility is <strong>EM only</strong>. Raw transcripts stay
Expand Down Expand Up @@ -709,7 +723,7 @@ export function PersonPage() {
</form>
</div>

<div className="panel stack">
<div className="panel stack page-section" id="person-concerns">
<h2>Concern log</h2>
<p className="muted">
Private performance concerns and follow-up plans. AI is off by default — these stay out of digests and drafts unless you later opt a concern in.
Expand Down Expand Up @@ -824,6 +838,7 @@ export function PersonPage() {
action={<Link className="btn" to="/promotions">Start promotion packet</Link>}
/>
)}
</PageSectionsLayout>
</div>
);
}
Loading
Loading