+
+ );
+}
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
index 07115e13..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.
+- **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."
diff --git a/lib/constants.ts b/lib/constants.ts
index dec15994..5e9da77c 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,36 @@ export const EMAIL_FAILURE_STATUSES = [
'complained',
'failed',
] as const satisfies $Enums.EmailStatus[];
+
+// Buckets partition every status getPositionApplicationStats counts, so circles sum to Total.
+export const POSITION_STAT_BUCKETS = [
+ {
+ key: 'applied',
+ label: 'Applied',
+ ringClassName: 'border-info',
+ statuses: ['applied'],
+ },
+ {
+ key: 'in_progress',
+ label: 'In progress',
+ ringClassName: 'border-warning',
+ statuses: ['reached_out', 'interview_scheduled', 'reviewing'],
+ },
+ {
+ key: 'accepted',
+ label: 'Accepted',
+ ringClassName: 'border-success',
+ statuses: ['accepted'],
+ },
+ {
+ key: 'rejected',
+ label: 'Rejected',
+ ringClassName: 'border-destructive',
+ statuses: ['rejected'],
+ },
+] as const satisfies {
+ key: string;
+ label: string;
+ ringClassName: string;
+ 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', () => {