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..1e72f47c --- /dev/null +++ b/components/features/position-stat-circles.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { POSITION_STAT_BUCKETS } 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 row keeps a stable shape. +export function PositionStatCircles({ stats }: PositionStatCirclesProps) { + return ( + +
+

+ Total: {stats.total} +

+
+ {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} + + + +
+ ); + })} +
+
+
+ ); +} 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', () => {