From abf5fa70a48febe387ba0476295725dcd3b6d182 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 7 Sep 2026 21:03:40 -0400 Subject: [PATCH 1/6] #682 replace position card stat tiles with four status circles The old cluster omitted reached_out/reviewing from its four tiles, so Total never matched their sum. The new buckets partition every status getPositionApplicationStats counts, so the circles always sum to Total. Co-Authored-By: Claude Sonnet 4.6 --- components/features/position-card.tsx | 69 +------------------ components/features/position-stat-circles.tsx | 69 +++++++++++++++++++ docs/WORKFLOWS.md | 2 +- lib/constants.ts | 48 ++++++++++--- tests/unit/constants.test.ts | 15 ++++ 5 files changed, 128 insertions(+), 75 deletions(-) create mode 100644 components/features/position-stat-circles.tsx diff --git a/components/features/position-card.tsx b/components/features/position-card.tsx index ab5e0b2a..48a73293 100644 --- a/components/features/position-card.tsx +++ b/components/features/position-card.tsx @@ -1,12 +1,6 @@ import Link from 'next/link'; -import { - APPLICANT_EDITABLE_APPLICATION_STATUSES, - APPLICATION_STATUS_BADGE_VARIANT, - APPLICATION_STATUS_LABELS, - POSITION_CARD_STAT_STATUSES, - STATUS_BADGE_VARIANT_TO_DOT, -} from '@/lib/constants'; +import { APPLICANT_EDITABLE_APPLICATION_STATUSES } from '@/lib/constants'; import { ACTION_ICONS, CONCEPT_ICONS } from '@/lib/icons'; import type { MyPositionApplication, @@ -16,6 +10,7 @@ import type { import { cn, getPositionAvailability, markdownToPlainText } from '@/lib/utils'; import { PositionDateLine } from '@/components/features/position-date-line'; +import { PositionStatCircles } from '@/components/features/position-stat-circles'; import { ApplicationStatusBadge, PositionStatusBadge, @@ -32,64 +27,6 @@ interface PositionCardProps { myApplication?: MyPositionApplication; } -interface PositionStatClusterProps { - stats: PositionApplicationStats; -} - -// Zero-count tiles are dimmed rather than hidden, so the cluster keeps a stable shape. -function PositionStatCluster({ stats }: PositionStatClusterProps) { - return ( -
- {/* Total tile — col-span-2 lead row with hairline divider below */} -
-
-
-

- {stats.total} -

-
- - {/* 2x2 grid of key pipeline statuses */} -
- {POSITION_CARD_STAT_STATUSES.map((status) => { - const count = stats.counts[status] ?? 0; - const isDimmed = count === 0; - const variant = APPLICATION_STATUS_BADGE_VARIANT[status]; - const dotClass = STATUS_BADGE_VARIANT_TO_DOT[variant]; - - return ( -
-
-
-

- {count} -

-
- ); - })} -
-
- ); -} - export function PositionCard({ position, canManage = false, @@ -238,7 +175,7 @@ export function PositionCard({ sits beside it at sm+ in a two-column layout */} {applicationStats && (
- +
)} diff --git a/components/features/position-stat-circles.tsx b/components/features/position-stat-circles.tsx new file mode 100644 index 00000000..6edb7bd3 --- /dev/null +++ b/components/features/position-stat-circles.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { + POSITION_STAT_BUCKETS, + STATUS_BADGE_VARIANT_TO_FILL, +} from '@/lib/constants'; +import type { PositionApplicationStats } from '@/lib/types'; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +interface PositionStatCirclesProps { + stats: PositionApplicationStats; +} + +// Zero-count circles are dimmed rather than hidden, so the grid keeps a stable shape. +export function PositionStatCircles({ stats }: PositionStatCirclesProps) { + return ( + +
+
+ {POSITION_STAT_BUCKETS.map((bucket) => { + const count = bucket.statuses.reduce( + (sum, status) => sum + (stats.counts[status] ?? 0), + 0, + ); + const isDimmed = count === 0; + + return ( +
+ + + + + + {bucket.label} — {count} + + + +
+ ); + })} +
+

+ Total: {stats.total} +

+
+
+ ); +} diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 07115e13..ad0b30d8 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -395,7 +395,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-2 See the positions you manage - **Trigger** — **Manage Positions** under **Manage** (`/manage/positions`). -- **Happy path** — `requireManagerOrAdminOr404()` gates the route. `getManagedPositions(user.id)` plus per-position application stats render as three sections, in order — **Open**, **Closed**, **Draft** — each with its own heading, omitted entirely when it has no positions. Grouping keys off derived availability (`groupManagedPositions`, `lib/utils.ts`): an `open` position past its `closesAt` lands under Closed, matching its "Closed" badge, not under Open. Within Open the soonest `closesAt` sorts first (nulls last); within Closed the most recently closed sorts first; within Draft, `opensAt` sorts first (nulls last). Closed nests the active/archived split — non-archived cards first, then a collapsed **Archived (N)** disclosure for anything `!isPositionActive`. A **New position** action sits in the header, under "Manage Positions" · "Track applications and edit the positions you manage." Positions you manage also keep appearing in the Open Positions list on `/positions` ([AN-1](#an-1-browse-positions)) — the browse page never filters them out. +- **Happy path** — `requireManagerOrAdminOr404()` gates the route. `getManagedPositions(user.id)` plus per-position application stats render as three sections, in order — **Open**, **Closed**, **Draft** — each with its own heading, omitted entirely when it has no positions. Grouping keys off derived availability (`groupManagedPositions`, `lib/utils.ts`): an `open` position past its `closesAt` lands under Closed, matching its "Closed" badge, not under Open. Within Open the soonest `closesAt` sorts first (nulls last); within Closed the most recently closed sorts first; within Draft, `opensAt` sorts first (nulls last). Closed nests the active/archived split — non-archived cards first, then a collapsed **Archived (N)** disclosure for anything `!isPositionActive`. A **New position** action sits in the header, under "Manage Positions" · "Track applications and edit the positions you manage." Positions you manage also keep appearing in the Open Positions list on `/positions` ([AN-1](#an-1-browse-positions)) — the browse page never filters them out. Each card's per-position stats render as four status circles in a 2×2 grid — Applied, In progress (`reached_out` + `interview_scheduled` + `reviewing`), Accepted, Rejected — each showing only its count, with the category name on hover or keyboard focus and a `Total: N` line beneath; the four circles always sum to the total. Zero-count circles stay visible, dimmed rather than hidden. - **Failure / edge** - Not a manager or admin → `notFound()` ([XC-4](#xc-4-denial-shape)); the nav item is not rendered for them either. - Closed has no non-archived positions but does have archived ones → "Nothing closed recently — expand Archived below to see older positions." diff --git a/lib/constants.ts b/lib/constants.ts index dec15994..82bacf6c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -963,14 +963,6 @@ export const STATUS_BADGE_VARIANT_TO_DOT: Record = { outline: 'bg-border', }; -// Order is meaningful — rendered left to right on position cards. -export const POSITION_CARD_STAT_STATUSES = [ - 'applied', - 'interview_scheduled', - 'accepted', - 'rejected', -] as const satisfies $Enums.ApplicationStatus[]; - export const EMAIL_STATUS_VALUES = [ 'scheduled', 'sent', @@ -1050,3 +1042,43 @@ export const EMAIL_FAILURE_STATUSES = [ 'complained', 'failed', ] as const satisfies $Enums.EmailStatus[]; + +export const STATUS_BADGE_VARIANT_TO_FILL: Record = { + info: 'bg-info text-info-foreground', + warning: 'bg-warning text-warning-foreground', + success: 'bg-success text-success-foreground', + destructive: 'bg-destructive text-destructive-foreground', + secondary: 'bg-secondary text-secondary-foreground', + default: 'bg-primary text-primary-foreground', + outline: 'bg-background text-foreground', +}; + +// Reading order (top-left to bottom-right) in the position card's stat +// circles. Buckets partition every status getPositionApplicationStats counts +// (everything but draft/withdrawn) so the circles sum to Total. +export const POSITION_STAT_BUCKETS = [ + { key: 'applied', label: 'Applied', variant: 'info', statuses: ['applied'] }, + { + key: 'in_progress', + label: 'In progress', + variant: 'warning', + statuses: ['reached_out', 'interview_scheduled', 'reviewing'], + }, + { + key: 'accepted', + label: 'Accepted', + variant: 'success', + statuses: ['accepted'], + }, + { + key: 'rejected', + label: 'Rejected', + variant: 'destructive', + statuses: ['rejected'], + }, +] as const satisfies { + key: string; + label: string; + variant: BadgeVariant; + statuses: readonly $Enums.ApplicationStatus[]; +}[]; diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts index 97d67003..03066bb6 100644 --- a/tests/unit/constants.test.ts +++ b/tests/unit/constants.test.ts @@ -4,6 +4,7 @@ import { ANSWER_LONG_MAX_LENGTH, ANSWER_OTHER_MAX_LENGTH, ANSWER_SHORT_MAX_LENGTH, + APPLICATION_STATUS_LABELS, APPLICATION_STATUS_VALUES, EMAIL_STATUS_BADGE_VARIANT, EMAIL_STATUS_DESCRIPTIONS, @@ -18,6 +19,7 @@ import { POSITION_CLOSES_AT_PAST_ERROR, POSITION_OPENS_AT_ORDER_ERROR, POSITION_OPENS_AT_PAST_ERROR, + POSITION_STAT_BUCKETS, REVIEWER_APPLICATION_STATUSES, TERMINAL_DECISION_STATUSES, UNRESOLVED_APPLICATION_STATUSES, @@ -289,6 +291,19 @@ describe('status-set invariants', () => { for (const status of TERMINAL_DECISION_STATUSES) expect(UNRESOLVED_APPLICATION_STATUSES).not.toContain(status); }); + + it('POSITION_STAT_BUCKETS statuses union to every status but draft/withdrawn', () => { + const bucketed = POSITION_STAT_BUCKETS.flatMap((bucket) => bucket.statuses); + const expected = Object.keys(APPLICATION_STATUS_LABELS).filter( + (status) => status !== 'draft' && status !== 'withdrawn', + ); + expect(bucketed.sort()).toEqual(expected.sort()); + }); + + it('POSITION_STAT_BUCKETS statuses are pairwise disjoint', () => { + const bucketed = POSITION_STAT_BUCKETS.flatMap((bucket) => bucket.statuses); + expect(new Set(bucketed).size).toBe(bucketed.length); + }); }); describe('EMAIL_STATUS_* maps', () => { From bb1d7b774f87447082edb3da3bc51269bf13034d Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 7 Sep 2026 21:11:42 -0400 Subject: [PATCH 2/6] #682 address review feedback Trim the oversize bucket comment (R1-L1) and, per the ticket owner's follow-up, make the stat circles tangent with a stacking bump on hover/focus and a springy overshoot easing. Co-Authored-By: Claude Sonnet 4.6 --- components/features/position-stat-circles.tsx | 4 ++-- lib/constants.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/components/features/position-stat-circles.tsx b/components/features/position-stat-circles.tsx index 6edb7bd3..81912c84 100644 --- a/components/features/position-stat-circles.tsx +++ b/components/features/position-stat-circles.tsx @@ -26,7 +26,7 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { aria-label="Application stats" className="w-fit shrink-0" > -
+
{POSITION_STAT_BUCKETS.map((bucket) => { const count = bucket.statuses.reduce( (sum, status) => sum + (stats.counts[status] ?? 0), @@ -41,7 +41,7 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { diff --git a/lib/constants.ts b/lib/constants.ts index 82bacf6c..6fe9f87b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1053,9 +1053,7 @@ export const STATUS_BADGE_VARIANT_TO_FILL: Record = { outline: 'bg-background text-foreground', }; -// Reading order (top-left to bottom-right) in the position card's stat -// circles. Buckets partition every status getPositionApplicationStats counts -// (everything but draft/withdrawn) so the circles sum to Total. +// Buckets partition every status getPositionApplicationStats counts, so circles sum to Total. export const POSITION_STAT_BUCKETS = [ { key: 'applied', label: 'Applied', variant: 'info', statuses: ['applied'] }, { From cfd9d8512a4e53573049cac56bd2faebfb36773d Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 7 Sep 2026 21:19:03 -0400 Subject: [PATCH 3/6] #682 replace in-progress circle amber fill with violet Adds a --progress token (identical light/dark) so all four status circles carry AA-compliant white text; --warning is untouched since it still drives the callout/banner/date-line caution uses. Co-Authored-By: Claude Sonnet 4.6 --- app/globals.css | 6 +++++ components/features/position-stat-circles.tsx | 7 ++--- lib/constants.ts | 27 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7b10ffdd..1956fdd6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -46,6 +46,8 @@ --color-warning-foreground: var(--warning-foreground); --color-info: var(--info); --color-info-foreground: var(--info-foreground); + --color-progress: var(--progress); + --color-progress-foreground: var(--progress-foreground); --color-nav-hover: var(--nav-hover); --color-header-bg: var(--header-bg); --color-header-border: var(--header-border); @@ -91,6 +93,8 @@ --warning-foreground: oklch(0.2 0 0); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.98 0 0); + --progress: oklch(0.52 0.18 300); + --progress-foreground: oklch(0.98 0 0); --nav-hover: oklch(0.93 0 0); --header-bg: oklch(1 0 0); --header-border: oklch(0.92 0 0); @@ -135,6 +139,8 @@ --warning-foreground: oklch(0.15 0 0); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.98 0 0); + --progress: oklch(0.52 0.18 300); + --progress-foreground: oklch(0.98 0 0); --nav-hover: oklch(0.27 0 0); --header-bg: oklch(0.17 0 0); --header-border: oklch(0.27 0 0); diff --git a/components/features/position-stat-circles.tsx b/components/features/position-stat-circles.tsx index 81912c84..2ef953a9 100644 --- a/components/features/position-stat-circles.tsx +++ b/components/features/position-stat-circles.tsx @@ -1,9 +1,6 @@ 'use client'; -import { - POSITION_STAT_BUCKETS, - STATUS_BADGE_VARIANT_TO_FILL, -} from '@/lib/constants'; +import { POSITION_STAT_BUCKETS } from '@/lib/constants'; import type { PositionApplicationStats } from '@/lib/types'; import { @@ -41,7 +38,7 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { diff --git a/lib/constants.ts b/lib/constants.ts index 6fe9f87b..620423d0 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1043,40 +1043,37 @@ export const EMAIL_FAILURE_STATUSES = [ 'failed', ] as const satisfies $Enums.EmailStatus[]; -export const STATUS_BADGE_VARIANT_TO_FILL: Record = { - info: 'bg-info text-info-foreground', - warning: 'bg-warning text-warning-foreground', - success: 'bg-success text-success-foreground', - destructive: 'bg-destructive text-destructive-foreground', - secondary: 'bg-secondary text-secondary-foreground', - default: 'bg-primary text-primary-foreground', - outline: 'bg-background text-foreground', -}; - // Buckets partition every status getPositionApplicationStats counts, so circles sum to Total. +// fillClassName is the circle's non-zero fill; unlike badge variants, "in progress" isn't +// one (violet reads better than amber at 16px semibold white text — see PR #685 discussion). export const POSITION_STAT_BUCKETS = [ - { key: 'applied', label: 'Applied', variant: 'info', statuses: ['applied'] }, + { + key: 'applied', + label: 'Applied', + fillClassName: 'bg-info text-info-foreground', + statuses: ['applied'], + }, { key: 'in_progress', label: 'In progress', - variant: 'warning', + fillClassName: 'bg-progress text-progress-foreground', statuses: ['reached_out', 'interview_scheduled', 'reviewing'], }, { key: 'accepted', label: 'Accepted', - variant: 'success', + fillClassName: 'bg-success text-success-foreground', statuses: ['accepted'], }, { key: 'rejected', label: 'Rejected', - variant: 'destructive', + fillClassName: 'bg-destructive text-destructive-foreground', statuses: ['rejected'], }, ] as const satisfies { key: string; label: string; - variant: BadgeVariant; + fillClassName: string; statuses: readonly $Enums.ApplicationStatus[]; }[]; From 559d176e0102fcd44c314fb02b7c72dde83fb7a6 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 7 Sep 2026 21:24:01 -0400 Subject: [PATCH 4/6] #682 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 620423d0..675034f3 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1044,8 +1044,6 @@ export const EMAIL_FAILURE_STATUSES = [ ] as const satisfies $Enums.EmailStatus[]; // Buckets partition every status getPositionApplicationStats counts, so circles sum to Total. -// fillClassName is the circle's non-zero fill; unlike badge variants, "in progress" isn't -// one (violet reads better than amber at 16px semibold white text — see PR #685 discussion). export const POSITION_STAT_BUCKETS = [ { key: 'applied', From 7c20e93396304d09bb5766fcfdd1d26cdca4233c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 7 Sep 2026 21:40:52 -0400 Subject: [PATCH 5/6] #682 redesign stat circles as a quiet outline row Replace the 2x2 filled grid with four smaller border-only circles in a horizontal row, per the Cycle 3 owner feedback. Border-only fill removes the need for text-on-color contrast, so in_progress reverts to the stock warning token and --progress/--progress-foreground are deleted entirely (resolves R3-M1) rather than replaced. Co-Authored-By: Claude Sonnet 4.6 --- app/globals.css | 6 ------ components/features/position-stat-circles.tsx | 16 ++++++++++------ lib/constants.ts | 10 +++++----- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/app/globals.css b/app/globals.css index 1956fdd6..7b10ffdd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -46,8 +46,6 @@ --color-warning-foreground: var(--warning-foreground); --color-info: var(--info); --color-info-foreground: var(--info-foreground); - --color-progress: var(--progress); - --color-progress-foreground: var(--progress-foreground); --color-nav-hover: var(--nav-hover); --color-header-bg: var(--header-bg); --color-header-border: var(--header-border); @@ -93,8 +91,6 @@ --warning-foreground: oklch(0.2 0 0); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.98 0 0); - --progress: oklch(0.52 0.18 300); - --progress-foreground: oklch(0.98 0 0); --nav-hover: oklch(0.93 0 0); --header-bg: oklch(1 0 0); --header-border: oklch(0.92 0 0); @@ -139,8 +135,6 @@ --warning-foreground: oklch(0.15 0 0); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.98 0 0); - --progress: oklch(0.52 0.18 300); - --progress-foreground: oklch(0.98 0 0); --nav-hover: oklch(0.27 0 0); --header-bg: oklch(0.17 0 0); --header-border: oklch(0.27 0 0); diff --git a/components/features/position-stat-circles.tsx b/components/features/position-stat-circles.tsx index 2ef953a9..785a4893 100644 --- a/components/features/position-stat-circles.tsx +++ b/components/features/position-stat-circles.tsx @@ -14,7 +14,7 @@ interface PositionStatCirclesProps { stats: PositionApplicationStats; } -// Zero-count circles are dimmed rather than hidden, so the grid keeps a stable shape. +// Zero-count circles are dimmed rather than hidden, so the row keeps a stable shape. export function PositionStatCircles({ stats }: PositionStatCirclesProps) { return ( @@ -23,7 +23,7 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { aria-label="Application stats" className="w-fit shrink-0" > -
+
{POSITION_STAT_BUCKETS.map((bucket) => { const count = bucket.statuses.reduce( (sum, status) => sum + (stats.counts[status] ?? 0), @@ -38,9 +38,13 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { @@ -49,7 +53,7 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { @@ -57,7 +61,7 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { ); })}
-

+

Total: {stats.total}

diff --git a/lib/constants.ts b/lib/constants.ts index 675034f3..5e9da77c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1048,30 +1048,30 @@ export const POSITION_STAT_BUCKETS = [ { key: 'applied', label: 'Applied', - fillClassName: 'bg-info text-info-foreground', + ringClassName: 'border-info', statuses: ['applied'], }, { key: 'in_progress', label: 'In progress', - fillClassName: 'bg-progress text-progress-foreground', + ringClassName: 'border-warning', statuses: ['reached_out', 'interview_scheduled', 'reviewing'], }, { key: 'accepted', label: 'Accepted', - fillClassName: 'bg-success text-success-foreground', + ringClassName: 'border-success', statuses: ['accepted'], }, { key: 'rejected', label: 'Rejected', - fillClassName: 'bg-destructive text-destructive-foreground', + ringClassName: 'border-destructive', statuses: ['rejected'], }, ] as const satisfies { key: string; label: string; - fillClassName: string; + ringClassName: string; statuses: readonly $Enums.ApplicationStatus[]; }[]; From 0f7e6334f1e4423e29c755bd751224699ca7e7db Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 7 Sep 2026 22:23:49 -0400 Subject: [PATCH 6/6] #682 move total left of the stat circles on the same line Total now sits in a min-h-11 flex-centered box beside the row so it stays aligned to the circles' centre line even when pointer-coarse labels render beneath them. Co-Authored-By: Claude Sonnet 4.6 --- components/features/position-stat-circles.tsx | 10 +++++----- docs/WORKFLOWS.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/features/position-stat-circles.tsx b/components/features/position-stat-circles.tsx index 785a4893..1e72f47c 100644 --- a/components/features/position-stat-circles.tsx +++ b/components/features/position-stat-circles.tsx @@ -21,9 +21,12 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) {
-
+

+ Total: {stats.total} +

+
{POSITION_STAT_BUCKETS.map((bucket) => { const count = bucket.statuses.reduce( (sum, status) => sum + (stats.counts[status] ?? 0), @@ -61,9 +64,6 @@ export function PositionStatCircles({ stats }: PositionStatCirclesProps) { ); })}
-

- Total: {stats.total} -

); diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index ad0b30d8..6cc7159d 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -395,7 +395,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-2 See the positions you manage - **Trigger** — **Manage Positions** under **Manage** (`/manage/positions`). -- **Happy path** — `requireManagerOrAdminOr404()` gates the route. `getManagedPositions(user.id)` plus per-position application stats render as three sections, in order — **Open**, **Closed**, **Draft** — each with its own heading, omitted entirely when it has no positions. Grouping keys off derived availability (`groupManagedPositions`, `lib/utils.ts`): an `open` position past its `closesAt` lands under Closed, matching its "Closed" badge, not under Open. Within Open the soonest `closesAt` sorts first (nulls last); within Closed the most recently closed sorts first; within Draft, `opensAt` sorts first (nulls last). Closed nests the active/archived split — non-archived cards first, then a collapsed **Archived (N)** disclosure for anything `!isPositionActive`. A **New position** action sits in the header, under "Manage Positions" · "Track applications and edit the positions you manage." Positions you manage also keep appearing in the Open Positions list on `/positions` ([AN-1](#an-1-browse-positions)) — the browse page never filters them out. Each card's per-position stats render as four status circles in a 2×2 grid — Applied, In progress (`reached_out` + `interview_scheduled` + `reviewing`), Accepted, Rejected — each showing only its count, with the category name on hover or keyboard focus and a `Total: N` line beneath; the four circles always sum to the total. Zero-count circles stay visible, dimmed rather than hidden. +- **Happy path** — `requireManagerOrAdminOr404()` gates the route. `getManagedPositions(user.id)` plus per-position application stats render as three sections, in order — **Open**, **Closed**, **Draft** — each with its own heading, omitted entirely when it has no positions. Grouping keys off derived availability (`groupManagedPositions`, `lib/utils.ts`): an `open` position past its `closesAt` lands under Closed, matching its "Closed" badge, not under Open. Within Open the soonest `closesAt` sorts first (nulls last); within Closed the most recently closed sorts first; within Draft, `opensAt` sorts first (nulls last). Closed nests the active/archived split — non-archived cards first, then a collapsed **Archived (N)** disclosure for anything `!isPositionActive`. A **New position** action sits in the header, under "Manage Positions" · "Track applications and edit the positions you manage." Positions you manage also keep appearing in the Open Positions list on `/positions` ([AN-1](#an-1-browse-positions)) — the browse page never filters them out. Each card's per-position stats render as a row of four status circles — Applied, In progress (`reached_out` + `interview_scheduled` + `reviewing`), Accepted, Rejected — each showing only its count, with the category name on hover or keyboard focus, and a `Total: N` label to their left on the same line; the four circles always sum to the total. Zero-count circles stay visible, dimmed rather than hidden. - **Failure / edge** - Not a manager or admin → `notFound()` ([XC-4](#xc-4-denial-shape)); the nav item is not rendered for them either. - Closed has no non-archived positions but does have archived ones → "Nothing closed recently — expand Archived below to see older positions."