Skip to content
Closed
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
69 changes: 3 additions & 66 deletions components/features/position-card.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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 (
<div role="region" aria-label="Application stats" className="shrink-0">
{/* Total tile — col-span-2 lead row with hairline divider below */}
<div className="border-border mb-2 border-b pb-2">
<div className="flex items-center gap-1.5">
<span
className="bg-primary size-2 shrink-0 rounded-full"
aria-hidden="true"
/>
<p className="text-muted-foreground text-[11px] leading-tight">
Total
</p>
</div>
<p className="mt-1 text-xl leading-none font-semibold tabular-nums">
{stats.total}
</p>
</div>

{/* 2x2 grid of key pipeline statuses */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{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 (
<div key={status}>
<div className="flex items-center gap-1.5">
<span
className={`size-1.5 shrink-0 rounded-full ${dotClass} ${isDimmed ? 'opacity-40' : ''}`}
aria-hidden="true"
/>
<p
className={`text-[11px] leading-tight ${isDimmed ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
>
{APPLICATION_STATUS_LABELS[status]}
</p>
</div>
<p
className={`mt-0.5 text-xl leading-none font-semibold tabular-nums ${isDimmed ? 'text-muted-foreground/60' : ''}`}
>
{count}
</p>
</div>
);
})}
</div>
</div>
);
}

export function PositionCard({
position,
canManage = false,
Expand Down Expand Up @@ -238,7 +175,7 @@ export function PositionCard({
sits beside it at sm+ in a two-column layout */}
{applicationStats && (
<div className="px-4 pb-4 sm:flex sm:shrink-0 sm:items-start sm:p-6 sm:pl-0">
<PositionStatCluster stats={applicationStats} />
<PositionStatCircles stats={applicationStats} />
</div>
)}
</div>
Expand Down
70 changes: 70 additions & 0 deletions components/features/position-stat-circles.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TooltipProvider delayDuration={300}>
<div
role="region"
aria-label="Application stats"
className="flex w-fit shrink-0 items-start gap-2"
>
<p className="text-muted-foreground flex min-h-11 items-center text-[11px] whitespace-nowrap">
Total: <span className="tabular-nums">{stats.total}</span>
</p>
<div className="flex gap-1.5">
{POSITION_STAT_BUCKETS.map((bucket) => {
const count = bucket.statuses.reduce(
(sum, status) => sum + (stats.counts[status] ?? 0),
0,
);
const isDimmed = count === 0;

return (
<div key={bucket.key} className="flex flex-col items-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${bucket.label}: ${count}`}
className="group focus-visible:ring-ring/50 relative flex min-h-11 min-w-11 items-center justify-center outline-none hover:z-10 focus-visible:z-10 focus-visible:ring-[3px]"
>
<span
className={`flex size-7 items-center justify-center rounded-full border-2 text-xs font-semibold tabular-nums transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover:scale-110 group-focus-visible:scale-110 motion-reduce:transition-none ${isDimmed ? 'border-border text-muted-foreground' : `${bucket.ringClassName} text-foreground`}`}
>
{count}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{bucket.label} — {count}
</TooltipContent>
</Tooltip>
<span
aria-hidden="true"
className="text-muted-foreground hidden max-w-11 text-center text-[10px] leading-tight break-words pointer-coarse:block"
>
{bucket.label}
</span>
</div>
);
})}
</div>
</div>
</TooltipProvider>
);
}
2 changes: 1 addition & 1 deletion docs/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
41 changes: 33 additions & 8 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,14 +963,6 @@ export const STATUS_BADGE_VARIANT_TO_DOT: Record<BadgeVariant, string> = {
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',
Expand Down Expand Up @@ -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[];
}[];
15 changes: 15 additions & 0 deletions tests/unit/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading