From 3e3b5133eac342a534986a389e28d99f6fe6dbce Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 11 Sep 2026 10:13:37 -0400 Subject: [PATCH 01/10] #706 show position deadlines on draft applications Adds a Deadline column/mobile line and dashboard-widget trailing slot with amber/red urgency badges for at-risk drafts, and floats at-risk drafts ahead of recency order in both the table and getRecentMyApplications so a stale-but-closing-soon draft can't be hidden by a take-bounded query. Co-Authored-By: Claude Sonnet 5 --- app/(main)/(auth)/applications/page.tsx | 3 +- components/features/deadline-indicator.tsx | 82 +++++++++ components/features/my-applications-table.tsx | 159 ++++++++++++------ .../features/my-applications-widget.tsx | 27 ++- docs/WORKFLOWS.md | 9 +- lib/constants.ts | 5 + lib/types.ts | 14 ++ lib/utils.ts | 78 +++++++++ prisma/data/applications.ts | 64 ++++++- .../my-applications-deadline-ordering.test.ts | 147 ++++++++++++++++ tests/unit/utils.test.ts | 133 +++++++++++++++ 11 files changed, 651 insertions(+), 70 deletions(-) create mode 100644 components/features/deadline-indicator.tsx create mode 100644 tests/db/my-applications-deadline-ordering.test.ts diff --git a/app/(main)/(auth)/applications/page.tsx b/app/(main)/(auth)/applications/page.tsx index c1c7da3f..f46d7fad 100644 --- a/app/(main)/(auth)/applications/page.tsx +++ b/app/(main)/(auth)/applications/page.tsx @@ -12,6 +12,7 @@ export const metadata: Metadata = { title: 'My Applications' }; export default async function MyApplicationsPage() { const user = await getCurrentUser(); const applications = await getMyApplications(user.id); + const now = new Date(); return (
@@ -19,7 +20,7 @@ export default async function MyApplicationsPage() { title="My Applications" description="Track your drafts and submitted applications." /> - +
); } diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx new file mode 100644 index 00000000..d2079dac --- /dev/null +++ b/components/features/deadline-indicator.tsx @@ -0,0 +1,82 @@ +import { CONCEPT_ICONS, STATE_ICONS } from '@/lib/icons'; +import type { PositionWindow } from '@/lib/types'; +import { cn, getDeadlineInfo } from '@/lib/utils'; + +import { Badge } from '@/components/ui/badge'; +import { LocalTime } from '@/components/ui/local-time'; + +interface DeadlineIndicatorProps { + position: PositionWindow; + now: Date; + // Badges (soon/urgent/past) fire only when true — submitted rows and the + // table's non-draft cells pass false so they read as plain context. + emphasizeUrgency: boolean; + variant?: 'full' | 'compact'; +} + +// No 'use client' — LocalTime is the only client leaf this renders. +export function DeadlineIndicator({ + position, + now, + emphasizeUrgency, + variant = 'full', +}: DeadlineIndicatorProps) { + const info = getDeadlineInfo(position, now); + if (!info) return —; + + const mutedLine = ( + + {variant === 'full' && ( + + )} + {info.label} + + ); + + switch (info.tier) { + case 'upcoming': + case 'distant': + return mutedLine; + + case 'soon': + if (!emphasizeUrgency) return mutedLine; + return ( + + + {variant === 'compact' + ? `${info.compactCountdown} left` + : `${info.label} ${info.countdown}`} + + ); + + case 'urgent': + if (!emphasizeUrgency) return mutedLine; + return ( + + + {variant === 'compact' + ? `${info.compactCountdown} left` + : `${info.label} ${info.countdown}`} + + ); + + case 'past': + if (!emphasizeUrgency) return mutedLine; + return ( + + + {info.label} + + ); + + default: { + const exhaustive: never = info.tier; + return exhaustive; + } + } +} diff --git a/components/features/my-applications-table.tsx b/components/features/my-applications-table.tsx index 0f61a44c..cc4d2afc 100644 --- a/components/features/my-applications-table.tsx +++ b/components/features/my-applications-table.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { useMemo } from 'react'; import { APPLICATION_STATUS_LABELS, @@ -9,7 +10,9 @@ import { import { type DataTableColumn } from '@/lib/data-table'; import { CONCEPT_ICONS } from '@/lib/icons'; import { type MyApplicationListItem } from '@/lib/types'; +import { getDeadlineInfo } from '@/lib/utils'; +import { DeadlineIndicator } from '@/components/features/deadline-indicator'; import { MyApplicationPrimaryAction } from '@/components/features/my-application-primary-action'; import { MyApplicationRowActions } from '@/components/features/my-application-row-actions'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; @@ -20,66 +23,109 @@ import { LocalTime } from '@/components/ui/local-time'; interface MyApplicationsTableProps { applications: MyApplicationListItem[]; + now: Date; } -const COLUMNS: DataTableColumn[] = [ - { - key: 'position', - header: 'Position', - sortAccessor: (a) => a.position.title, - cell: (a) => ( - - {a.position.title} - - ), - }, - { - key: 'status', - header: 'Status', - // Sort by human label A-Z so order matches what the user reads in the badge. - sortAccessor: (a) => APPLICATION_STATUS_LABELS[a.status], - cell: (a) => , - }, - { - key: 'applied', - header: 'Applied', - // Drafts get createdAt as submittedAt, so null here for the null-last sort. - sortAccessor: (a) => (a.status === 'draft' ? null : a.submittedAt), - cellClassName: 'text-muted-foreground', - cell: (a) => - a.status === 'draft' ? ( - '—' - ) : ( - +function buildColumns(now: Date): DataTableColumn[] { + return [ + { + key: 'position', + header: 'Position', + sortAccessor: (a) => a.position.title, + cell: (a) => ( + + {a.position.title} + ), - }, - { - key: 'action', - header: 'Action', - cell: (a) => - TERMINAL_DECISION_STATUSES.includes(a.status) ? ( - - ) : ( -
- - -
+ }, + { + key: 'status', + header: 'Status', + // Sort by human label A-Z so order matches what the user reads in the badge. + sortAccessor: (a) => APPLICATION_STATUS_LABELS[a.status], + cell: (a) => , + }, + { + key: 'applied', + header: 'Applied', + // Drafts get createdAt as submittedAt, so null here for the null-last sort. + sortAccessor: (a) => (a.status === 'draft' ? null : a.submittedAt), + cellClassName: 'text-muted-foreground', + cell: (a) => + a.status === 'draft' ? ( + '—' + ) : ( + + ), + }, + { + key: 'deadline', + header: 'Deadline', + // Sorts by the date the cell actually shows (Opens/Closes/Closed), not raw closesAt. + sortAccessor: (a) => getDeadlineInfo(a.position, now)?.date ?? null, + cell: (a) => ( + ), - }, -]; + }, + { + key: 'action', + header: 'Action', + cell: (a) => + TERMINAL_DECISION_STATUSES.includes(a.status) ? ( + + ) : ( +
+ + +
+ ), + }, + ]; +} + +// Date only for a draft whose deadline tier is soon/urgent — the float target. +function atRiskDeadlineDate(a: MyApplicationListItem, now: Date): Date | null { + if (a.status !== 'draft') return null; + const info = getDeadlineInfo(a.position, now); + if (info?.tier !== 'soon' && info?.tier !== 'urgent') return null; + return info.date; +} export function MyApplicationsTable({ applications, + now, }: MyApplicationsTableProps) { + const columns = useMemo(() => buildColumns(now), [now]); + + // At-risk drafts float to the top, nearest deadline first; everything else + // keeps its incoming order. No defaultSort — sort.key stays null so a + // header click takes over completely. + const rows = useMemo(() => { + const atRisk = applications + .map((a) => ({ a, date: atRiskDeadlineDate(a, now) })) + .filter((x): x is { a: MyApplicationListItem; date: Date } => + Boolean(x.date), + ) + .sort((x, y) => x.date.getTime() - y.date.getTime()) + .map((x) => x.a); + const atRiskIds = new Set(atRisk.map((a) => a.id)); + const rest = applications.filter((a) => !atRiskIds.has(a.id)); + return [...atRisk, ...rest]; + }, [applications, now]); + if (applications.length === 0) return ( a.id} caption="My applications" mobileCard={(app) => ( @@ -111,6 +157,11 @@ export function MyApplicationsTable({ +
{app.status === 'draft' ? ( diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index d43d558f..ff84f5e6 100644 --- a/components/features/my-applications-widget.tsx +++ b/components/features/my-applications-widget.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { + getClosingSoonDraftCount, getMyApplicationStatusCounts, getRecentMyApplications, } from '@/prisma/data/applications'; @@ -9,6 +10,7 @@ import { APPLICATION_STATUS_LABELS } from '@/lib/constants'; import { CONCEPT_ICONS } from '@/lib/icons'; import { type MyApplicationListItem } from '@/lib/types'; +import { DeadlineIndicator } from '@/components/features/deadline-indicator'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; import { LocalTime } from '@/components/ui/local-time'; import { SectionCard, SectionCardEmpty } from '@/components/ui/section-card'; @@ -18,7 +20,10 @@ interface MyApplicationsWidgetProps { limit?: number; } -function buildCountsSummary(counts: Partial>): string { +function buildCountsSummary( + counts: Partial>, + closingSoonCount: number, +): string { // Drafts shown separately; skip zero counts. const draftCount = counts['draft'] ?? 0; const submittedParts: string[] = []; @@ -37,6 +42,7 @@ function buildCountsSummary(counts: Partial>): string { if (submittedParts.length > 0) parts.push(...submittedParts); if (draftCount > 0) parts.push(`${draftCount} ${draftCount === 1 ? 'draft' : 'drafts'}`); + if (closingSoonCount > 0) parts.push(`${closingSoonCount} closing soon`); return parts.join(' · '); } @@ -45,12 +51,14 @@ export async function MyApplicationsWidget({ userId, limit = 3, }: MyApplicationsWidgetProps) { - const [applications, counts] = await Promise.all([ - getRecentMyApplications(userId, limit), + const now = new Date(); + const [applications, counts, closingSoonCount] = await Promise.all([ + getRecentMyApplications(userId, limit, now), getMyApplicationStatusCounts(userId), + getClosingSoonDraftCount(userId, now), ]); - const summary = buildCountsSummary(counts); + const summary = buildCountsSummary(counts, closingSoonCount); return ( ) : ( - + )} ); @@ -86,8 +94,10 @@ export async function MyApplicationsWidget({ function ApplicationList({ applications, + now, }: { applications: MyApplicationListItem[]; + now: Date; }) { return (
    @@ -105,7 +115,12 @@ function ApplicationList({ {app.status === 'draft' ? ( - '—' + ) : ( )} diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 6c6f4023..25312a2b 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -193,12 +193,13 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row shows the deadline in its trailing slot for a draft — plain muted text normally, an amber or red badge once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — and its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). - Admin → `AdminDashboard`; manager → `ManagerDashboard` ([PM-1](#pm-1-see-your-dashboard)). - Nothing applied for yet → the widgets render their own empty states; the heading falls back to "Welcome to Aplio" when the name is missing. + - No at-risk draft → the subtitle omits the `closing soon` segment entirely; the widget's ordering is unchanged from plain recency. - **End state** — read-only. ### AP-2 Answer profile questions @@ -303,7 +304,10 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-10 Track your applications - **Trigger** — the My Applications nav item (`/applications`). -- **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status or applied date, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. +- **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status, applied date, or the new **Deadline** column, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. + - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). A position not yet open shows its `Opens ` muted, with no urgency; a submitted row's deadline is always the plain muted date or badge-free `Closed `, regardless of tier; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. + - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted `Closes `. `soon` (≤ 7 days): amber `Badge` "Closes in N days". `urgent` (≤ 48 hours): red `Badge` "Closes in N hours". `past` (closed): red `Badge` "Closed " — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. + - **At-risk float** — drafts with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. - **Failure / edge** - Nothing yet → `EmptyState` "No applications yet" · "Browse open positions to start your first application." with a **Browse positions** button. - A draft shows "—" for the applied date, **Continue** as its primary action and **Delete** as its row action. @@ -311,6 +315,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - A withdrawn row shows **Edit & resubmit**, or the plain text "Position closed" when the window has since closed; it has no row action. - `accepted` / `rejected` rows show "—" instead of a withdraw button. - Applications on soft-deleted or unpublished positions are excluded entirely. + - A not-yet-open draft or a past-due draft never floats to the top, even though both are drafts — only an actually-open, approaching window counts as at-risk. - **End state** — read-only. ### AP-11 View one of your applications diff --git a/lib/constants.ts b/lib/constants.ts index a4e9a208..e7f3c808 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -631,6 +631,11 @@ export function isTerminalDecisionApplicationStatus( export const RECENTLY_CLOSED_WINDOW_DAYS = 7; +// Sole owners of the applicant deadline-urgency boundaries — shared by +// getDeadlineInfo and the at-risk query in prisma/data/applications.ts. +export const DEADLINE_SOON_DAYS = 7; +export const DEADLINE_URGENT_HOURS = 48; + // Longer than the public window so managers and admins keep oversight during wrap-up. export const MANAGED_POSITIONS_WINDOW_DAYS = 30; diff --git a/lib/types.ts b/lib/types.ts index 9bd68e85..18645e18 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -255,6 +255,20 @@ export type PositionDateInfo = { emphasis: 'live' | 'calm' | 'stale'; }; +// Applicant-facing deadline urgency — distinct from PositionDateInfo's +// emphasis tiers (see lib/utils.ts#getDeadlineInfo for why they don't merge). +export type DeadlineTier = 'upcoming' | 'distant' | 'soon' | 'urgent' | 'past'; + +export type DeadlineInfo = { + tier: DeadlineTier; + label: 'Opens' | 'Closes' | 'Closed'; + date: Date; + // Long form ("in 5 days") for the table/card; null when no countdown applies. + countdown: string | null; + // Short form ("5d") for the widget's trailing slot; null when no countdown applies. + compactCountdown: string | null; +}; + // Admin-gated contexts only. export type OpenPositionSummaryItem = Prisma.PositionGetPayload<{ select: { id: true; title: true; _count: { select: { applications: true } } }; diff --git a/lib/utils.ts b/lib/utils.ts index e162c0b3..274d3df0 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -5,6 +5,8 @@ import type { $Enums } from '@/prisma/client'; import { APPLICATION_STATUS_LABELS, + DEADLINE_SOON_DAYS, + DEADLINE_URGENT_HOURS, DECISION_EMAIL_DELAY_SECONDS, EMAIL_STATUS_DESCRIPTIONS, MANAGED_POSITIONS_WINDOW_DAYS, @@ -16,6 +18,7 @@ import type { AnswerPartition, AnswerQuestion, ApplicationFilters, + DeadlineInfo, EmailLogFilters, ManagedPositionRow, PositionActivity, @@ -389,6 +392,81 @@ export function getPositionDateInfo( return null; } +const MS_PER_HOUR = 60 * 60 * 1000; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +/** + * Applicant-facing deadline urgency — distinct from `getPositionDateInfo`'s + * calm/live/stale emphasis, which stays unchanged so position cards don't + * inherit this ticket's amber/red tiering. `soon`/`urgent` only ever apply + * while the window is actually open; not-yet-open renders its Opens date + * with no tier. Past the draft-status check (excluded upstream via + * PUBLISHED_POSITION_WHERE), 'unavailable' can only mean status 'closed'. + */ +export function getDeadlineInfo( + position: PositionWindow, + now: Date = new Date(), +): DeadlineInfo | null { + const availability = getPositionAvailability(position, now); + + if (availability === 'upcoming' && position.opensAt) + return { + tier: 'upcoming', + label: 'Opens', + date: position.opensAt, + countdown: null, + compactCountdown: null, + }; + + if (availability === 'accepting' && position.closesAt) { + const diffMs = position.closesAt.getTime() - now.getTime(); + + if (diffMs <= DEADLINE_URGENT_HOURS * MS_PER_HOUR) { + const hours = Math.max(1, Math.ceil(diffMs / MS_PER_HOUR)); + return { + tier: 'urgent', + label: 'Closes', + date: position.closesAt, + countdown: `in ${hours} hour${hours === 1 ? '' : 's'}`, + compactCountdown: `${hours}h`, + }; + } + + if (diffMs <= DEADLINE_SOON_DAYS * MS_PER_DAY) { + const days = Math.max(1, Math.ceil(diffMs / MS_PER_DAY)); + return { + tier: 'soon', + label: 'Closes', + date: position.closesAt, + countdown: `in ${days} day${days === 1 ? '' : 's'}`, + compactCountdown: `${days}d`, + }; + } + + return { + tier: 'distant', + label: 'Closes', + date: position.closesAt, + countdown: null, + compactCountdown: null, + }; + } + + if ( + (availability === 'closed_by_date' || availability === 'unavailable') && + position.closesAt + ) + return { + tier: 'past', + label: 'Closed', + date: position.closesAt, + countdown: null, + compactCountdown: null, + }; + + return null; +} + /** * Single source of truth for active vs archived — a second implementation is * an authorization bug, not just a display bug. diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts index 076822f6..abf92f5d 100644 --- a/prisma/data/applications.ts +++ b/prisma/data/applications.ts @@ -13,6 +13,7 @@ import { } from '@/lib/auth/scopes'; import { APPLICATIONS_PAGE_SIZE, + DEADLINE_SOON_DAYS, PUBLIC_APPLICATION_STATUS, PUBLISHED_POSITION_WHERE, type PublicApplicationStatus, @@ -214,18 +215,67 @@ export async function getMyApplications( return applications.map(toPublicApplication); } +// Shared by getRecentMyApplications and getClosingSoonDraftCount so the two +// can never disagree about which drafts count as at-risk. The opens gate +// (OR opensAt null/past) keeps a not-yet-open position from floating — +// nothing is actionable on it yet. +function buildAtRiskDraftWhere( + userId: string, + now: Date, +): Prisma.ApplicationWhereInput { + const soonCutoff = new Date( + now.getTime() + DEADLINE_SOON_DAYS * 24 * 60 * 60 * 1000, + ); + + return { + userId, + deletedAt: null, + status: 'draft', + position: { + ...PUBLISHED_POSITION_WHERE, + status: 'open', + closesAt: { gt: now, lte: soonCutoff }, + OR: [{ opensAt: null }, { opensAt: { lte: now } }], + }, + }; +} + +// Floats at-risk drafts (closing within DEADLINE_SOON_DAYS) ahead of recency +// order — a `take`-bounded recency query alone can hide one entirely. Both +// queries stay take-bounded; never an unbounded fetch-then-sort. export async function getRecentMyApplications( userId: string, take = 5, + now: Date = new Date(), ): Promise { - const applications = await prisma.application.findMany({ - where: { userId, deletedAt: null, position: PUBLISHED_POSITION_WHERE }, - select: applicationSelect, - orderBy: [{ submittedAt: 'desc' }, { id: 'desc' }], - take, + const [atRisk, recent] = await Promise.all([ + prisma.application.findMany({ + where: buildAtRiskDraftWhere(userId, now), + select: applicationSelect, + orderBy: [{ position: { closesAt: 'asc' } }, { id: 'desc' }], + take, + }), + prisma.application.findMany({ + where: { userId, deletedAt: null, position: PUBLISHED_POSITION_WHERE }, + select: applicationSelect, + orderBy: [{ submittedAt: 'desc' }, { id: 'desc' }], + take, + }), + ]); + + const atRiskIds = new Set(atRisk.map((a) => a.id)); + const merged = [...atRisk, ...recent.filter((a) => !atRiskIds.has(a.id))]; + + return merged.slice(0, take).map(toPublicApplication); +} + +export async function getClosingSoonDraftCount( + userId: string, + now: Date = new Date(), +): Promise { + return prisma.application.count({ + where: buildAtRiskDraftWhere(userId, now), }); - - return applications.map(toPublicApplication); } // No status filter — caller needs draft/withdrawn too; one row per position via the [userId, positionId] unique constraint. diff --git a/tests/db/my-applications-deadline-ordering.test.ts b/tests/db/my-applications-deadline-ordering.test.ts new file mode 100644 index 00000000..434f1e86 --- /dev/null +++ b/tests/db/my-applications-deadline-ordering.test.ts @@ -0,0 +1,147 @@ +import { + cleanupFixtures, + createTestApplication, + createTestPosition, + createTestUser, +} from '@/tests/helpers/fixtures'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { Application, User } from '@/prisma/client'; +import { + getClosingSoonDraftCount, + getRecentMyApplications, +} from '@/prisma/data/applications'; + +// Fixed anchor so the fixtures' relative offsets never straddle a boundary. +const NOW = new Date('2026-08-15T12:00:00Z'); +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const OLD = new Date(NOW.getTime() - 21 * DAY); + +let applicant: User; + +let draftUrgent: Application; +let draftSoonOld: Application; +let draftPastDue: Application; +let draftNotYetOpen: Application; +let draftUnpublishedPosition: Application; +let draftDeletedPosition: Application; +let submittedRecent: Application; + +beforeAll(async () => { + const admin = await createTestUser({ isAdmin: true }); + applicant = await createTestUser(); + + const posUrgent = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() + 10 * HOUR), + }); + const posSoon = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() + 2 * DAY), + }); + const posPastDue = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() - 1 * DAY), + }); + const posNotYetOpen = await createTestPosition(admin, { + status: 'open', + opensAt: new Date(NOW.getTime() + 1 * DAY), + closesAt: new Date(NOW.getTime() + 3 * DAY), + }); + const posUnpublished = await createTestPosition(admin, { + status: 'draft', + closesAt: new Date(NOW.getTime() + 2 * DAY), + }); + const posDeleted = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() + 2 * DAY), + deletedAt: new Date(NOW.getTime() - 1 * HOUR), + }); + const posRecent = await createTestPosition(admin, { status: 'open' }); + + // Fresh submittedAt (default now()) so it also qualifies for the recency + // query — the row this ticket's dedupe must collapse to one entry. + draftUrgent = await createTestApplication(applicant, posUrgent, { + status: 'draft', + }); + // Created three weeks ago — the exact bug this ticket fixes: a stale + // submittedAt would otherwise sink this row out of a take-bounded recency query. + draftSoonOld = await createTestApplication(applicant, posSoon, { + status: 'draft', + submittedAt: OLD, + createdAt: OLD, + }); + draftPastDue = await createTestApplication(applicant, posPastDue, { + status: 'draft', + }); + draftNotYetOpen = await createTestApplication(applicant, posNotYetOpen, { + status: 'draft', + }); + draftUnpublishedPosition = await createTestApplication( + applicant, + posUnpublished, + { status: 'draft' }, + ); + draftDeletedPosition = await createTestApplication(applicant, posDeleted, { + status: 'draft', + }); + submittedRecent = await createTestApplication(applicant, posRecent, { + status: 'applied', + submittedAt: NOW, + }); +}); + +afterAll(async () => { + await cleanupFixtures(); +}); + +describe('getRecentMyApplications', () => { + it('floats at-risk drafts ahead of recency order, nearest deadline first', async () => { + const rows = await getRecentMyApplications(applicant.id, 10, NOW); + const ids = rows.map((r) => r.id); + expect(ids.slice(0, 2)).toEqual([draftUrgent.id, draftSoonOld.id]); + }); + + it('dedupes a row that qualifies for both the at-risk and recency queries', async () => { + const rows = await getRecentMyApplications(applicant.id, 10, NOW); + const occurrences = rows.filter((r) => r.id === draftUrgent.id).length; + expect(occurrences).toBe(1); + }); + + it('respects take, keeping the nearest at-risk drafts first', async () => { + const rows = await getRecentMyApplications(applicant.id, 2, NOW); + expect(rows.map((r) => r.id)).toEqual([draftUrgent.id, draftSoonOld.id]); + }); + + it('excludes a past-due draft from the float', async () => { + const rows = await getRecentMyApplications(applicant.id, 10, NOW); + expect(rows.slice(0, 2).map((r) => r.id)).not.toContain(draftPastDue.id); + }); + + it('excludes a not-yet-open draft from the float', async () => { + const rows = await getRecentMyApplications(applicant.id, 10, NOW); + expect(rows.slice(0, 2).map((r) => r.id)).not.toContain(draftNotYetOpen.id); + }); + + it('excludes drafts on unpublished or soft-deleted positions from the float', async () => { + const rows = await getRecentMyApplications(applicant.id, 10, NOW); + const floated = rows.slice(0, 2).map((r) => r.id); + expect(floated).not.toContain(draftUnpublishedPosition.id); + expect(floated).not.toContain(draftDeletedPosition.id); + }); + + it('still includes recency rows after the float', async () => { + const rows = await getRecentMyApplications(applicant.id, 10, NOW); + expect(rows.map((r) => r.id)).toContain(submittedRecent.id); + }); +}); + +describe('getClosingSoonDraftCount', () => { + // 2, not 6 — the other four drafts (past-due, not-yet-open, unpublished, + // soft-deleted) share buildAtRiskDraftWhere with the float above, so a + // count that disagreed with it would be a drift bug, not a design choice. + it('counts only the at-risk drafts, matching the float', async () => { + expect(await getClosingSoonDraftCount(applicant.id, NOW)).toBe(2); + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 8fcd40b2..fae77ca3 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -24,6 +24,7 @@ import { formatTableCount, getApplicantName, getBulkDecisionEmailWarning, + getDeadlineInfo, getDecisionEmailWarning, getEmailLogDescription, getEmailLogOccurredAt, @@ -250,6 +251,138 @@ describe('getPositionDateInfo', () => { }); }); +describe('getDeadlineInfo', () => { + it('returns null for an accepting position with no closesAt', () => { + expect( + getDeadlineInfo({ status: 'open', opensAt: null, closesAt: null }, NOW), + ).toBeNull(); + }); + + it('reads past for a closed position with a past closesAt', () => { + const closesAt = new Date(NOW); + closesAt.setDate(closesAt.getDate() - 5); + expect( + getDeadlineInfo({ status: 'closed', opensAt: null, closesAt }, NOW), + ).toEqual({ + tier: 'past', + label: 'Closed', + date: closesAt, + countdown: null, + compactCountdown: null, + }); + }); + + it('reads past for an open position past its close date', () => { + const closesAt = new Date(NOW.getTime() - 1); + expect( + getDeadlineInfo({ status: 'open', opensAt: null, closesAt }, NOW), + ).toEqual({ + tier: 'past', + label: 'Closed', + date: closesAt, + countdown: null, + compactCountdown: null, + }); + }); + + it('is urgent at exactly the 48-hour boundary (inclusive)', () => { + const closesAt = new Date(NOW.getTime() + 48 * 60 * 60 * 1000); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info).toEqual({ + tier: 'urgent', + label: 'Closes', + date: closesAt, + countdown: 'in 48 hours', + compactCountdown: '48h', + }); + }); + + it('is still urgent at 47 hours 59 minutes', () => { + const closesAt = new Date(NOW.getTime() + (47 * 60 + 59) * 60 * 1000); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info?.tier).toBe('urgent'); + }); + + it('is soon just past the 48-hour boundary', () => { + const closesAt = new Date(NOW.getTime() + 48 * 60 * 60 * 1000 + 1); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info?.tier).toBe('soon'); + }); + + it('is soon at exactly the 7-day boundary (inclusive)', () => { + const closesAt = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info).toEqual({ + tier: 'soon', + label: 'Closes', + date: closesAt, + countdown: 'in 7 days', + compactCountdown: '7d', + }); + }); + + it('is distant just past the 7-day boundary', () => { + const closesAt = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000 + 60_000); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info).toEqual({ + tier: 'distant', + label: 'Closes', + date: closesAt, + countdown: null, + compactCountdown: null, + }); + }); + + it('is upcoming for an open position not yet open, with no countdown', () => { + const opensAt = new Date(NOW); + opensAt.setDate(opensAt.getDate() + 5); + expect( + getDeadlineInfo({ status: 'open', opensAt, closesAt: null }, NOW), + ).toEqual({ + tier: 'upcoming', + label: 'Opens', + date: opensAt, + countdown: null, + compactCountdown: null, + }); + }); + + it('singularizes "1 hour" rather than "1 hours"', () => { + const closesAt = new Date(NOW.getTime() + 30 * 60 * 1000); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info?.countdown).toBe('in 1 hour'); + expect(info?.compactCountdown).toBe('1h'); + }); + + it('pluralizes hours and days once above 1', () => { + const closesAt = new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000); + const info = getDeadlineInfo( + { status: 'open', opensAt: null, closesAt }, + NOW, + ); + expect(info?.countdown).toBe('in 3 days'); + expect(info?.compactCountdown).toBe('3d'); + }); +}); + describe('isOpenPastCloseDate', () => { it('is true for an open position past its close date', () => { const closesAt = new Date(NOW); From 3f0f2b91f12021bd67d3a0060fd23a3dc37eb07b Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 11 Sep 2026 10:25:07 -0400 Subject: [PATCH 02/10] #706 address review feedback Co-Authored-By: Claude Sonnet 5 --- components/features/deadline-indicator.tsx | 16 ++++++++++------ components/ui/local-time.tsx | 9 +++++++-- lib/utils.ts | 11 +++-------- prisma/data/applications.ts | 5 +---- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx index d2079dac..6c6c784d 100644 --- a/components/features/deadline-indicator.tsx +++ b/components/features/deadline-indicator.tsx @@ -48,9 +48,11 @@ export function DeadlineIndicator({ return ( - {variant === 'compact' - ? `${info.compactCountdown} left` - : `${info.label} ${info.countdown}`} + + {variant === 'compact' + ? `${info.compactCountdown} left` + : `${info.label} ${info.countdown}`} + ); @@ -59,9 +61,11 @@ export function DeadlineIndicator({ return ( - {variant === 'compact' - ? `${info.compactCountdown} left` - : `${info.label} ${info.countdown}`} + + {variant === 'compact' + ? `${info.compactCountdown} left` + : `${info.label} ${info.countdown}`} + ); diff --git a/components/ui/local-time.tsx b/components/ui/local-time.tsx index ada48aae..497c12ac 100644 --- a/components/ui/local-time.tsx +++ b/components/ui/local-time.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { ReactNode } from 'react'; import { useSyncExternalStore } from 'react'; import { ORG_TIMEZONE } from '@/lib/constants'; @@ -10,6 +11,8 @@ interface LocalTimeProps { date: Date; precision?: 'date' | 'datetime' | 'relative'; className?: string; + // Overrides the visible text (e.g. a countdown) while dateTime/title still come from `date`. + children?: ReactNode; } // No-op subscribe: the viewer's timezone never changes during a session, so this @@ -33,15 +36,17 @@ export function LocalTime({ date, precision = 'date', className, + children, }: LocalTimeProps) { const timeZone = useViewerTimeZone(); const isoString = date.toISOString(); const title = formatInstant(date, { precision: 'datetime', timeZone }); const display = - precision === 'relative' + children ?? + (precision === 'relative' ? formatRelativeTime(date, new Date(), timeZone) - : formatInstant(date, { precision, timeZone }); + : formatInstant(date, { precision, timeZone })); return (
diff --git a/lib/icons.ts b/lib/icons.ts index 4e6a06e8..782946a5 100644 --- a/lib/icons.ts +++ b/lib/icons.ts @@ -9,6 +9,7 @@ import { Briefcase, CalendarCheck, CalendarClock, + CalendarX, Check, ChevronDown, CircleCheck, @@ -56,7 +57,7 @@ import { import type { $Enums } from '@/prisma/client'; -import type { PositionAvailability } from '@/lib/types'; +import type { DeadlineTier, PositionAvailability } from '@/lib/types'; type Concept = | 'home' @@ -83,6 +84,15 @@ export const CONCEPT_ICONS: Record = { email: Mail, }; +// Past reads "over" (crossed-out calendar), not "scheduled" like the rest. +export const DEADLINE_TIER_ICONS: Record = { + upcoming: CalendarClock, + distant: CalendarClock, + soon: CalendarClock, + urgent: CalendarClock, + past: CalendarX, +}; + export const APPLICATION_STATUS_ICONS: Record< $Enums.ApplicationStatus, LucideIcon From ba80b02a29fd14c0dd193c1fe243e4848c64d316 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 11 Sep 2026 16:49:21 -0400 Subject: [PATCH 04/10] #706 address review feedback Co-Authored-By: Claude Sonnet 5 --- components/features/deadline-indicator.tsx | 4 +--- docs/WORKFLOWS.md | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx index a44b19e3..55ae0ab2 100644 --- a/components/features/deadline-indicator.tsx +++ b/components/features/deadline-indicator.tsx @@ -43,9 +43,7 @@ export function DeadlineIndicator({
); - // Text, not a Badge — a filled pill next to the status badge reads heavier - // than a metadata line should; text-warning-text/destructive-text pass - // AA at 12px/14px, unlike --warning/--destructive used as plain text. + // Text, not Badge — a filled pill reads heavier than a metadata line; *-text tokens pass AA at this weight. const emphasizedLine = ( tone: 'warning' | 'destructive', children: ReactNode, diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 25312a2b..6e224405 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -193,7 +193,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row shows the deadline in its trailing slot for a draft — plain muted text normally, an amber or red badge once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — and its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row shows the deadline before the status badge for a draft — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — so the closing date lands in the same glance as the `Draft` tag; and its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -305,8 +305,8 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Trigger** — the My Applications nav item (`/applications`). - **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status, applied date, or the new **Deadline** column, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. - - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). A position not yet open shows its `Opens ` muted, with no urgency; a submitted row's deadline is always the plain muted date or badge-free `Closed `, regardless of tier; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. - - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted `Closes `. `soon` (≤ 7 days): amber `Badge` "Closes in N days". `urgent` (≤ 48 hours): red `Badge` "Closes in N hours". `past` (closed): red `Badge` "Closed " — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. + - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). A position not yet open shows its `Opens ` muted, with no urgency; a submitted row's deadline is always the plain muted date or muted `Closed `, regardless of tier; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. + - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted `Closes `. `soon` (≤ 7 days): bold `text-warning-text` "Closes in N days". `urgent` (≤ 48 hours): bold `text-destructive-text` "Closes in N hours". `past` (closed): bold `text-destructive-text` "Closed " — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. - **At-risk float** — drafts with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. - **Failure / edge** - Nothing yet → `EmptyState` "No applications yet" · "Browse open positions to start your first application." with a **Browse positions** button. From 0e3201799924a7956a2c8cfc0ea9e34c9b6d3776 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 11 Sep 2026 17:36:05 -0400 Subject: [PATCH 05/10] #706 drop deadline label, unify countdown to compact form Deadline column header already names the field, so drop the redundant Closes/Closed prefix (upcoming keeps Opens, which is load-bearing); render soon/urgent as the compact Nd/Nh-left countdown everywhere instead of a longer table-only form. Removes the now-unused DeadlineInfo.countdown field and its tests. Co-Authored-By: Claude Sonnet 5 --- components/features/deadline-indicator.tsx | 18 +++++++----------- docs/WORKFLOWS.md | 6 +++--- lib/types.ts | 4 +--- lib/utils.ts | 5 ----- tests/unit/utils.test.ts | 12 ++---------- 5 files changed, 13 insertions(+), 32 deletions(-) diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx index 55ae0ab2..c1ff48dc 100644 --- a/components/features/deadline-indicator.tsx +++ b/components/features/deadline-indicator.tsx @@ -28,6 +28,8 @@ export function DeadlineIndicator({ const isPast = info.tier === 'past'; const Icon = DEADLINE_TIER_ICONS[info.tier]; + // Deadline column header already says what the date is — the label would + // be redundant, except "Opens" which reads as the deadline without it. const mutedLine = ( {variant === 'full' && } - {info.label} - {isPast ? ' · ' : ' '} + {info.tier === 'upcoming' && `${info.label} `} + {isPast && '· '} ); @@ -70,9 +72,7 @@ export function DeadlineIndicator({ return emphasizedLine( 'warning', - {variant === 'compact' - ? `${info.compactCountdown} left` - : `${info.label} ${info.countdown}`} + {`${info.compactCountdown} left`} , ); @@ -81,9 +81,7 @@ export function DeadlineIndicator({ return emphasizedLine( 'destructive', - {variant === 'compact' - ? `${info.compactCountdown} left` - : `${info.label} ${info.countdown}`} + {`${info.compactCountdown} left`} , ); @@ -91,9 +89,7 @@ export function DeadlineIndicator({ if (!emphasizeUrgency) return mutedLine; return emphasizedLine( 'destructive', - <> - {info.label} - , + , ); default: { diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 6e224405..ff2bd717 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -193,7 +193,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row shows the deadline before the status badge for a draft — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — so the closing date lands in the same glance as the `Draft` tag; and its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row shows the deadline before the status badge for a draft — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — so the closing date lands in the same glance as the `Draft` tag; the deadline itself is the bare date or the compact countdown (`Nd left`/`Nh left`), with no `Closes`/`Closed` prefix. And its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -305,8 +305,8 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Trigger** — the My Applications nav item (`/applications`). - **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status, applied date, or the new **Deadline** column, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. - - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). A position not yet open shows its `Opens ` muted, with no urgency; a submitted row's deadline is always the plain muted date or muted `Closed `, regardless of tier; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. - - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted `Closes `. `soon` (≤ 7 days): bold `text-warning-text` "Closes in N days". `urgent` (≤ 48 hours): bold `text-destructive-text` "Closes in N hours". `past` (closed): bold `text-destructive-text` "Closed " — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. + - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). The **Deadline** column header already says what the date is, so the cell renders the bare date with no `Closes`/`Closed` prefix — except `upcoming`, which keeps its `Opens ` label since a bare date there would otherwise read as the deadline itself; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. A `past` deadline stays visually distinct from an upcoming one without the word: a `CalendarX` icon, `opacity-70` dimming, and a `·` separator ahead of the date. A submitted row's deadline is always this plain muted form, regardless of tier. + - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted bare date. `soon` (≤ 7 days): bold `text-warning-text` "Nd left". `urgent` (≤ 48 hours): bold `text-destructive-text` "Nh left" — the same compact countdown format the widget uses, unified across both surfaces. `past` (closed): bold `text-destructive-text` bare date — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. - **At-risk float** — drafts with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. - **Failure / edge** - Nothing yet → `EmptyState` "No applications yet" · "Browse open positions to start your first application." with a **Browse positions** button. diff --git a/lib/types.ts b/lib/types.ts index 18645e18..569f4a74 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -263,9 +263,7 @@ export type DeadlineInfo = { tier: DeadlineTier; label: 'Opens' | 'Closes' | 'Closed'; date: Date; - // Long form ("in 5 days") for the table/card; null when no countdown applies. - countdown: string | null; - // Short form ("5d") for the widget's trailing slot; null when no countdown applies. + // Short form ("5d"); null when no countdown applies. compactCountdown: string | null; }; diff --git a/lib/utils.ts b/lib/utils.ts index 2fdfe71f..ced7861a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -409,7 +409,6 @@ export function getDeadlineInfo( tier: 'upcoming', label: 'Opens', date: position.opensAt, - countdown: null, compactCountdown: null, }; @@ -422,7 +421,6 @@ export function getDeadlineInfo( tier: 'urgent', label: 'Closes', date: position.closesAt, - countdown: `in ${hours} hour${hours === 1 ? '' : 's'}`, compactCountdown: `${hours}h`, }; } @@ -433,7 +431,6 @@ export function getDeadlineInfo( tier: 'soon', label: 'Closes', date: position.closesAt, - countdown: `in ${days} day${days === 1 ? '' : 's'}`, compactCountdown: `${days}d`, }; } @@ -442,7 +439,6 @@ export function getDeadlineInfo( tier: 'distant', label: 'Closes', date: position.closesAt, - countdown: null, compactCountdown: null, }; } @@ -455,7 +451,6 @@ export function getDeadlineInfo( tier: 'past', label: 'Closed', date: position.closesAt, - countdown: null, compactCountdown: null, }; diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index fae77ca3..a9dc1092 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -267,7 +267,6 @@ describe('getDeadlineInfo', () => { tier: 'past', label: 'Closed', date: closesAt, - countdown: null, compactCountdown: null, }); }); @@ -280,7 +279,6 @@ describe('getDeadlineInfo', () => { tier: 'past', label: 'Closed', date: closesAt, - countdown: null, compactCountdown: null, }); }); @@ -295,7 +293,6 @@ describe('getDeadlineInfo', () => { tier: 'urgent', label: 'Closes', date: closesAt, - countdown: 'in 48 hours', compactCountdown: '48h', }); }); @@ -328,7 +325,6 @@ describe('getDeadlineInfo', () => { tier: 'soon', label: 'Closes', date: closesAt, - countdown: 'in 7 days', compactCountdown: '7d', }); }); @@ -343,7 +339,6 @@ describe('getDeadlineInfo', () => { tier: 'distant', label: 'Closes', date: closesAt, - countdown: null, compactCountdown: null, }); }); @@ -357,28 +352,25 @@ describe('getDeadlineInfo', () => { tier: 'upcoming', label: 'Opens', date: opensAt, - countdown: null, compactCountdown: null, }); }); - it('singularizes "1 hour" rather than "1 hours"', () => { + it('formats the compact countdown in hours below the day boundary', () => { const closesAt = new Date(NOW.getTime() + 30 * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, NOW, ); - expect(info?.countdown).toBe('in 1 hour'); expect(info?.compactCountdown).toBe('1h'); }); - it('pluralizes hours and days once above 1', () => { + it('formats the compact countdown in days above the hour boundary', () => { const closesAt = new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, NOW, ); - expect(info?.countdown).toBe('in 3 days'); expect(info?.compactCountdown).toBe('3d'); }); }); From fb868cf1086b78a131ff27713fb76b06cf908e19 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 18 Sep 2026 10:49:08 -0400 Subject: [PATCH 06/10] #706 address operator direction and scope addendum Restore the widget's title-badge-trailing order and add the draft deadline segment to the application detail page per the addendum. Co-Authored-By: Claude Sonnet 5 --- app/(main)/(auth)/applications/[id]/page.tsx | 30 ++++++++++++++----- .../features/my-applications-widget.tsx | 2 +- docs/WORKFLOWS.md | 3 +- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index e48a7c43..4e6ce241 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -12,6 +12,7 @@ import { import { CONCEPT_ICONS } from '@/lib/icons'; import { ApplicationAnswersList } from '@/components/features/application-answers-list'; +import { DeadlineIndicator } from '@/components/features/deadline-indicator'; import { MyApplicationPrimaryAction } from '@/components/features/my-application-primary-action'; import { MyApplicationRowActions } from '@/components/features/my-application-row-actions'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; @@ -54,6 +55,7 @@ export default async function MyApplicationDetailPage({ if (!application) notFound(); const isDraft = application.status === 'draft'; + const now = new Date(); return (
@@ -79,20 +81,32 @@ export default async function MyApplicationDetailPage({ ) } /> -

- {isDraft ? 'Draft · last saved ' : 'Applied '} - {' '} - ·{' '} +

+ + {isDraft ? 'Draft · last saved ' : 'Applied '} + + + View position -

+ {isDraft && ( + <> + + + + )} +
diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index ff146f4e..ff84f5e6 100644 --- a/components/features/my-applications-widget.tsx +++ b/components/features/my-applications-widget.tsx @@ -112,6 +112,7 @@ function ApplicationList({ > {app.position.title} + {app.status === 'draft' ? ( )} - ))} diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index ff2bd717..f16a261b 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -193,7 +193,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row shows the deadline before the status badge for a draft — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — so the closing date lands in the same glance as the `Draft` tag; the deadline itself is the bare date or the compact countdown (`Nd left`/`Nh left`), with no `Closes`/`Closed` prefix. And its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · status badge · trailing slot`; for a draft the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — with the bare date or the compact countdown (`Nd left`/`Nh left`), no `Closes`/`Closed` prefix. And its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -322,6 +322,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Trigger** — a position title link on `/applications`, or the redirect after submitting ([AP-9](#ap-9-submit-an-application)). - **Happy path** — `getMyApplication(id, user.id)` is scoped to the caller with the same visibility as the list, and returns the public status ([XC-8](#xc-8-applicant-facing-status-grouping)). The header shows the position title with the status badge directly beside it, the status sentence underneath, and the primary/row actions (Continue, Edit & resubmit, Withdraw, Delete draft — whichever applies) right-aligned on that same header row; below it, "Applied " (or "Draft · last saved ", from `lastSavedAt` — a draft's own `updatedAt`, never exposed for a submitted application) and a link to the position, then both answer groups — "Your Profile Answers" and "Your Answers for This Position" — full width. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a retyped or relabeled question still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. + - **Deadline (drafts only)** — a fourth segment after the **View position** link, `DeadlineIndicator` (`variant="full"`, `emphasizeUrgency`) tiered against a server-resolved `now` — same helper and thresholds as [AP-10](#ap-10-track-your-applications). A submitted or terminal application's meta line is unchanged; `getMyApplication`'s select already carries the fields needed, so no data-layer change. - **Failure / edge** - Not the caller's, soft-deleted, or on an unpublished position → `notFound()`, so a bookmarked URL cannot outlive its list row. - Empty profile answers → "No profile answers saved yet." From 09bfcb84623b9bbe3f24e283869bcc2e2e8c45e0 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 18 Sep 2026 10:50:47 -0400 Subject: [PATCH 07/10] =?UTF-8?q?#706=20compress=20leftover=20long=20comme?= =?UTF-8?q?nts=20per=20engineering=20=C2=A77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out three still-open review threads from earlier cycles (R4-L1, R4-L2, R5-L1) that were never trimmed. Co-Authored-By: Claude Sonnet 5 --- components/features/my-applications-table.tsx | 4 +--- prisma/data/applications.ts | 4 +--- tests/db/my-applications-deadline-ordering.test.ts | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/components/features/my-applications-table.tsx b/components/features/my-applications-table.tsx index cc4d2afc..51b2a599 100644 --- a/components/features/my-applications-table.tsx +++ b/components/features/my-applications-table.tsx @@ -110,9 +110,7 @@ export function MyApplicationsTable({ }: MyApplicationsTableProps) { const columns = useMemo(() => buildColumns(now), [now]); - // At-risk drafts float to the top, nearest deadline first; everything else - // keeps its incoming order. No defaultSort — sort.key stays null so a - // header click takes over completely. + // At-risk drafts float to the top, nearest deadline first; sort.key stays null so a header click still takes over. const rows = useMemo(() => { const atRisk = applications .map((a) => ({ a, date: atRiskDeadlineDate(a, now) })) diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts index 0f8a7c3d..f65d54d4 100644 --- a/prisma/data/applications.ts +++ b/prisma/data/applications.ts @@ -237,9 +237,7 @@ function buildAtRiskDraftWhere( }; } -// Floats at-risk drafts (closing within DEADLINE_SOON_DAYS) ahead of recency -// order — a `take`-bounded recency query alone can hide one entirely. Both -// queries stay take-bounded; never an unbounded fetch-then-sort. +// Floats at-risk drafts ahead of recency order — a take-bounded recency query alone can hide one entirely. export async function getRecentMyApplications( userId: string, take = 5, diff --git a/tests/db/my-applications-deadline-ordering.test.ts b/tests/db/my-applications-deadline-ordering.test.ts index 434f1e86..e379e214 100644 --- a/tests/db/my-applications-deadline-ordering.test.ts +++ b/tests/db/my-applications-deadline-ordering.test.ts @@ -138,9 +138,7 @@ describe('getRecentMyApplications', () => { }); describe('getClosingSoonDraftCount', () => { - // 2, not 6 — the other four drafts (past-due, not-yet-open, unpublished, - // soft-deleted) share buildAtRiskDraftWhere with the float above, so a - // count that disagreed with it would be a drift bug, not a design choice. + // 2, not 6 — the other four drafts share buildAtRiskDraftWhere, so a mismatch here is a drift bug. it('counts only the at-risk drafts, matching the float', async () => { expect(await getClosingSoonDraftCount(applicant.id, NOW)).toBe(2); }); From 49a8d4fe090caab1213d151be6c409865b5ffb17 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 18 Sep 2026 19:17:39 -0400 Subject: [PATCH 08/10] #706 address review feedback Move widget trailing slot before status badge and drop the orphaned separator dot on the past-deadline muted line. Co-Authored-By: Claude Sonnet 5 --- components/features/deadline-indicator.tsx | 1 - components/features/my-applications-widget.tsx | 2 +- docs/WORKFLOWS.md | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx index c1ff48dc..15ab7baa 100644 --- a/components/features/deadline-indicator.tsx +++ b/components/features/deadline-indicator.tsx @@ -40,7 +40,6 @@ export function DeadlineIndicator({ > {variant === 'full' && } {info.tier === 'upcoming' && `${info.label} `} - {isPast && '· '} ); diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index ff84f5e6..ff146f4e 100644 --- a/components/features/my-applications-widget.tsx +++ b/components/features/my-applications-widget.tsx @@ -112,7 +112,6 @@ function ApplicationList({ > {app.position.title} - {app.status === 'draft' ? ( )} + ))} diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index f16a261b..a5c4e038 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -193,7 +193,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · status badge · trailing slot`; for a draft the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — with the bare date or the compact countdown (`Nd left`/`Nh left`), no `Closes`/`Closed` prefix. And its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — with the bare date or the compact countdown (`Nd left`/`Nh left`), no `Closes`/`Closed` prefix. And its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -305,7 +305,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Trigger** — the My Applications nav item (`/applications`). - **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status, applied date, or the new **Deadline** column, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. - - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). The **Deadline** column header already says what the date is, so the cell renders the bare date with no `Closes`/`Closed` prefix — except `upcoming`, which keeps its `Opens ` label since a bare date there would otherwise read as the deadline itself; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. A `past` deadline stays visually distinct from an upcoming one without the word: a `CalendarX` icon, `opacity-70` dimming, and a `·` separator ahead of the date. A submitted row's deadline is always this plain muted form, regardless of tier. + - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). The **Deadline** column header already says what the date is, so the cell renders the bare date with no `Closes`/`Closed` prefix — except `upcoming`, which keeps its `Opens ` label since a bare date there would otherwise read as the deadline itself; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. A `past` deadline stays visually distinct from an upcoming one without the word: a `CalendarX` icon and `opacity-70` dimming ahead of the date. A submitted row's deadline is always this plain muted form, regardless of tier. - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted bare date. `soon` (≤ 7 days): bold `text-warning-text` "Nd left". `urgent` (≤ 48 hours): bold `text-destructive-text` "Nh left" — the same compact countdown format the widget uses, unified across both surfaces. `past` (closed): bold `text-destructive-text` bare date — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. - **At-risk float** — drafts with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. - **Failure / edge** From 6a8caf7ebf9abdbd8ebeeadead64a67cbdfc9221 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sun, 20 Sep 2026 23:53:25 -0400 Subject: [PATCH 09/10] #706 treat withdrawn like draft for deadline urgency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens every urgency gate to draft-or-withdrawn (widget, table, mobile card, detail page, at-risk float, closing-soon count) and renames buildAtRiskDraftWhere/getClosingSoonDraftCount to drop the now-stale "draft" qualifier. Also retires the destructive past-deadline badge — past always renders the plain muted date regardless of status, since #748 removes Continue from past-deadline drafts. Co-Authored-By: Claude Sonnet 5 --- app/(main)/(auth)/applications/[id]/page.tsx | 3 +- components/features/deadline-indicator.tsx | 9 +-- components/features/my-applications-table.tsx | 10 ++-- .../features/my-applications-widget.tsx | 6 +- docs/WORKFLOWS.md | 16 +++--- prisma/data/applications.ts | 16 +++--- .../my-applications-deadline-ordering.test.ts | 56 ++++++++++++++----- tests/unit/deadline-indicator.test.ts | 26 +++++++++ 8 files changed, 96 insertions(+), 46 deletions(-) create mode 100644 tests/unit/deadline-indicator.test.ts diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index 4e6ce241..2a6c135e 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -55,6 +55,7 @@ export default async function MyApplicationDetailPage({ if (!application) notFound(); const isDraft = application.status === 'draft'; + const showDeadline = isDraft || application.status === 'withdrawn'; const now = new Date(); return ( @@ -96,7 +97,7 @@ export default async function MyApplicationDetailPage({ > View position - {isDraft && ( + {showDeadline && ( <> , - ); + return mutedLine; default: { const exhaustive: never = info.tier; diff --git a/components/features/my-applications-table.tsx b/components/features/my-applications-table.tsx index 51b2a599..53913ea9 100644 --- a/components/features/my-applications-table.tsx +++ b/components/features/my-applications-table.tsx @@ -70,7 +70,7 @@ function buildColumns(now: Date): DataTableColumn[] { ), }, @@ -96,9 +96,9 @@ function buildColumns(now: Date): DataTableColumn[] { ]; } -// Date only for a draft whose deadline tier is soon/urgent — the float target. +// Date only for a draft/withdrawn app whose deadline tier is soon/urgent — the float target. function atRiskDeadlineDate(a: MyApplicationListItem, now: Date): Date | null { - if (a.status !== 'draft') return null; + if (a.status !== 'draft' && a.status !== 'withdrawn') return null; const info = getDeadlineInfo(a.position, now); if (info?.tier !== 'soon' && info?.tier !== 'urgent') return null; return info.date; @@ -158,7 +158,9 @@ export function MyApplicationsTable({
diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index ff146f4e..aced58f0 100644 --- a/components/features/my-applications-widget.tsx +++ b/components/features/my-applications-widget.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; import { - getClosingSoonDraftCount, + getClosingSoonCount, getMyApplicationStatusCounts, getRecentMyApplications, } from '@/prisma/data/applications'; @@ -55,7 +55,7 @@ export async function MyApplicationsWidget({ const [applications, counts, closingSoonCount] = await Promise.all([ getRecentMyApplications(userId, limit, now), getMyApplicationStatusCounts(userId), - getClosingSoonDraftCount(userId, now), + getClosingSoonCount(userId, now), ]); const summary = buildCountsSummary(counts, closingSoonCount); @@ -113,7 +113,7 @@ function ApplicationList({ {app.position.title} - {app.status === 'draft' ? ( + {app.status === 'draft' || app.status === 'withdrawn' ? ( " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold amber or red `text-warning-text`/`text-destructive-text` once the tier is `soon`/`urgent`/`past` ([AP-10](#ap-10-track-your-applications)) — with the bare date or the compact countdown (`Nd left`/`Nh left`), no `Closes`/`Closed` prefix. And its subtitle appends `N closing soon` when any at-risk draft exists, so a draft that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft or withdrawn application the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold amber `text-warning-text` once the tier is `soon`, bold red `text-destructive-text` once `urgent` ([AP-10](#ap-10-track-your-applications)) — with the bare date or the compact countdown (`Nd left`/`Nh left`), no `Closes`/`Closed` prefix. A `past` deadline always renders the plain muted date, regardless of status. And its subtitle appends `N closing soon` when any at-risk draft or withdrawn application exists, so one that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). - Admin → `AdminDashboard`; manager → `ManagerDashboard` ([PM-1](#pm-1-see-your-dashboard)). - Nothing applied for yet → the widgets render their own empty states; the heading falls back to "Welcome to Aplio" when the name is missing. - - No at-risk draft → the subtitle omits the `closing soon` segment entirely; the widget's ordering is unchanged from plain recency. + - No at-risk draft or withdrawn application → the subtitle omits the `closing soon` segment entirely; the widget's ordering is unchanged from plain recency. - **End state** — read-only. ### AP-2 Answer profile questions @@ -305,24 +305,24 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Trigger** — the My Applications nav item (`/applications`). - **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status, applied date, or the new **Deadline** column, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. - - **Deadline column** — every row (draft or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). The **Deadline** column header already says what the date is, so the cell renders the bare date with no `Closes`/`Closed` prefix — except `upcoming`, which keeps its `Opens ` label since a bare date there would otherwise read as the deadline itself; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. A `past` deadline stays visually distinct from an upcoming one without the word: a `CalendarX` icon and `opacity-70` dimming ahead of the date. A submitted row's deadline is always this plain muted form, regardless of tier. - - **Urgency tiers (draft rows only)** — `distant` (> 7 days): muted bare date. `soon` (≤ 7 days): bold `text-warning-text` "Nd left". `urgent` (≤ 48 hours): bold `text-destructive-text` "Nh left" — the same compact countdown format the widget uses, unified across both surfaces. `past` (closed): bold `text-destructive-text` bare date — the row still offers **Continue**, since a draft can always be resumed even though submitting it will be refused. - - **At-risk float** — drafts with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. + - **Deadline column** — every row (draft, withdrawn, or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). The **Deadline** column header already says what the date is, so the cell renders the bare date with no `Closes`/`Closed` prefix — except `upcoming`, which keeps its `Opens ` label since a bare date there would otherwise read as the deadline itself; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. A `past` deadline stays visually distinct from an upcoming one without the word: a `CalendarX` icon and `opacity-70` dimming ahead of the date, the same plain muted form for every status regardless of tier. + - **Urgency tiers (draft and withdrawn rows only)** — `distant` (> 7 days): muted bare date. `soon` (≤ 7 days): bold `text-warning-text` "Nd left". `urgent` (≤ 48 hours): bold `text-destructive-text` "Nh left" — the same compact countdown format the widget uses, unified across both surfaces. `past` (closed): the same plain muted bare date as any other status, no badge — a draft's row still offers **Continue**, but a passed deadline is no longer flagged since there is nothing left to prompt. + - **At-risk float** — drafts or withdrawn applications with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. - **Failure / edge** - Nothing yet → `EmptyState` "No applications yet" · "Browse open positions to start your first application." with a **Browse positions** button. - A draft shows "—" for the applied date, **Continue** as its primary action and **Delete** as its row action. - A deleted draft is never in this list — deleting one ([AP-15](#ap-15-delete-a-draft)) removes its row entirely; applying to the position again ([AP-5](#ap-5-start-an-application)) is the only way it reappears. - - A withdrawn row shows **Edit & resubmit**, or the plain text "Position closed" when the window has since closed; it has no row action. + - A withdrawn row shows **Edit & resubmit**, or the plain text "Position closed" when the window has since closed; it has no row action, and its deadline tiers exactly as a draft's does. - `accepted` / `rejected` rows show "—" instead of a withdraw button. - Applications on soft-deleted or unpublished positions are excluded entirely. - - A not-yet-open draft or a past-due draft never floats to the top, even though both are drafts — only an actually-open, approaching window counts as at-risk. + - A not-yet-open or past-due draft or withdrawn application never floats to the top — only an actually-open, approaching window counts as at-risk. - **End state** — read-only. ### AP-11 View one of your applications - **Trigger** — a position title link on `/applications`, or the redirect after submitting ([AP-9](#ap-9-submit-an-application)). - **Happy path** — `getMyApplication(id, user.id)` is scoped to the caller with the same visibility as the list, and returns the public status ([XC-8](#xc-8-applicant-facing-status-grouping)). The header shows the position title with the status badge directly beside it, the status sentence underneath, and the primary/row actions (Continue, Edit & resubmit, Withdraw, Delete draft — whichever applies) right-aligned on that same header row; below it, "Applied " (or "Draft · last saved ", from `lastSavedAt` — a draft's own `updatedAt`, never exposed for a submitted application) and a link to the position, then both answer groups — "Your Profile Answers" and "Your Answers for This Position" — full width. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a retyped or relabeled question still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. - - **Deadline (drafts only)** — a fourth segment after the **View position** link, `DeadlineIndicator` (`variant="full"`, `emphasizeUrgency`) tiered against a server-resolved `now` — same helper and thresholds as [AP-10](#ap-10-track-your-applications). A submitted or terminal application's meta line is unchanged; `getMyApplication`'s select already carries the fields needed, so no data-layer change. + - **Deadline (drafts and withdrawn applications only)** — a fourth segment after the **View position** link, `DeadlineIndicator` (`variant="full"`, `emphasizeUrgency`) tiered against a server-resolved `now` — same helper and thresholds as [AP-10](#ap-10-track-your-applications). The gate is separate from the "Draft · last saved" vs "Applied" prefix above it, so a withdrawn application keeps its "Applied " prefix while still gaining the deadline segment. A submitted or terminal application's meta line is unchanged; `getMyApplication`'s select already carries the fields needed, so no data-layer change. - **Failure / edge** - Not the caller's, soft-deleted, or on an unpublished position → `notFound()`, so a bookmarked URL cannot outlive its list row. - Empty profile answers → "No profile answers saved yet." diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts index f65d54d4..3c73c917 100644 --- a/prisma/data/applications.ts +++ b/prisma/data/applications.ts @@ -215,8 +215,8 @@ export async function getMyApplications( return applications.map(toPublicApplication); } -// Shared by getRecentMyApplications and getClosingSoonDraftCount so they can't disagree; opens gate keeps a not-yet-open position from floating. -function buildAtRiskDraftWhere( +// Shared by getRecentMyApplications and getClosingSoonCount so they can't disagree; opens gate keeps a not-yet-open position from floating. +function buildAtRiskWhere( userId: string, now: Date, ): Prisma.ApplicationWhereInput { @@ -227,7 +227,7 @@ function buildAtRiskDraftWhere( return { userId, deletedAt: null, - status: 'draft', + status: { in: ['draft', 'withdrawn'] }, position: { ...PUBLISHED_POSITION_WHERE, status: 'open', @@ -237,7 +237,7 @@ function buildAtRiskDraftWhere( }; } -// Floats at-risk drafts ahead of recency order — a take-bounded recency query alone can hide one entirely. +// Floats at-risk drafts/withdrawn ahead of recency order — a take-bounded recency query alone can hide one entirely. export async function getRecentMyApplications( userId: string, take = 5, @@ -245,7 +245,7 @@ export async function getRecentMyApplications( ): Promise { const [atRisk, recent] = await Promise.all([ prisma.application.findMany({ - where: buildAtRiskDraftWhere(userId, now), + where: buildAtRiskWhere(userId, now), select: applicationSelect, orderBy: [{ position: { closesAt: 'asc' } }, { id: 'desc' }], take, @@ -264,13 +264,11 @@ export async function getRecentMyApplications( return merged.slice(0, take).map(toPublicApplication); } -export async function getClosingSoonDraftCount( +export async function getClosingSoonCount( userId: string, now: Date = new Date(), ): Promise { - return prisma.application.count({ - where: buildAtRiskDraftWhere(userId, now), - }); + return prisma.application.count({ where: buildAtRiskWhere(userId, now) }); } // No status filter — caller needs draft/withdrawn too; one row per position via the [userId, positionId] unique constraint. diff --git a/tests/db/my-applications-deadline-ordering.test.ts b/tests/db/my-applications-deadline-ordering.test.ts index e379e214..4d05ad3c 100644 --- a/tests/db/my-applications-deadline-ordering.test.ts +++ b/tests/db/my-applications-deadline-ordering.test.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { Application, User } from '@/prisma/client'; import { - getClosingSoonDraftCount, + getClosingSoonCount, getRecentMyApplications, } from '@/prisma/data/applications'; @@ -26,6 +26,8 @@ let draftPastDue: Application; let draftNotYetOpen: Application; let draftUnpublishedPosition: Application; let draftDeletedPosition: Application; +let withdrawnUrgent: Application; +let withdrawnPastDue: Application; let submittedRecent: Application; beforeAll(async () => { @@ -58,6 +60,14 @@ beforeAll(async () => { closesAt: new Date(NOW.getTime() + 2 * DAY), deletedAt: new Date(NOW.getTime() - 1 * HOUR), }); + const posWithdrawnUrgent = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() + 15 * HOUR), + }); + const posWithdrawnPastDue = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() - 1 * DAY), + }); const posRecent = await createTestPosition(admin, { status: 'open' }); // Fresh submittedAt (default now()) so it also qualifies for the recency @@ -86,6 +96,14 @@ beforeAll(async () => { draftDeletedPosition = await createTestApplication(applicant, posDeleted, { status: 'draft', }); + withdrawnUrgent = await createTestApplication(applicant, posWithdrawnUrgent, { + status: 'withdrawn', + }); + withdrawnPastDue = await createTestApplication( + applicant, + posWithdrawnPastDue, + { status: 'withdrawn' }, + ); submittedRecent = await createTestApplication(applicant, posRecent, { status: 'applied', submittedAt: NOW, @@ -97,10 +115,14 @@ afterAll(async () => { }); describe('getRecentMyApplications', () => { - it('floats at-risk drafts ahead of recency order, nearest deadline first', async () => { + it('floats at-risk drafts and withdrawn apps ahead of recency order, nearest deadline first', async () => { const rows = await getRecentMyApplications(applicant.id, 10, NOW); const ids = rows.map((r) => r.id); - expect(ids.slice(0, 2)).toEqual([draftUrgent.id, draftSoonOld.id]); + expect(ids.slice(0, 3)).toEqual([ + draftUrgent.id, + withdrawnUrgent.id, + draftSoonOld.id, + ]); }); it('dedupes a row that qualifies for both the at-risk and recency queries', async () => { @@ -109,24 +131,30 @@ describe('getRecentMyApplications', () => { expect(occurrences).toBe(1); }); - it('respects take, keeping the nearest at-risk drafts first', async () => { - const rows = await getRecentMyApplications(applicant.id, 2, NOW); - expect(rows.map((r) => r.id)).toEqual([draftUrgent.id, draftSoonOld.id]); + it('respects take, keeping the nearest at-risk rows first', async () => { + const rows = await getRecentMyApplications(applicant.id, 3, NOW); + expect(rows.map((r) => r.id)).toEqual([ + draftUrgent.id, + withdrawnUrgent.id, + draftSoonOld.id, + ]); }); - it('excludes a past-due draft from the float', async () => { + it('excludes a past-due draft or withdrawn app from the float', async () => { const rows = await getRecentMyApplications(applicant.id, 10, NOW); - expect(rows.slice(0, 2).map((r) => r.id)).not.toContain(draftPastDue.id); + const floated = rows.slice(0, 3).map((r) => r.id); + expect(floated).not.toContain(draftPastDue.id); + expect(floated).not.toContain(withdrawnPastDue.id); }); it('excludes a not-yet-open draft from the float', async () => { const rows = await getRecentMyApplications(applicant.id, 10, NOW); - expect(rows.slice(0, 2).map((r) => r.id)).not.toContain(draftNotYetOpen.id); + expect(rows.slice(0, 3).map((r) => r.id)).not.toContain(draftNotYetOpen.id); }); it('excludes drafts on unpublished or soft-deleted positions from the float', async () => { const rows = await getRecentMyApplications(applicant.id, 10, NOW); - const floated = rows.slice(0, 2).map((r) => r.id); + const floated = rows.slice(0, 3).map((r) => r.id); expect(floated).not.toContain(draftUnpublishedPosition.id); expect(floated).not.toContain(draftDeletedPosition.id); }); @@ -137,9 +165,9 @@ describe('getRecentMyApplications', () => { }); }); -describe('getClosingSoonDraftCount', () => { - // 2, not 6 — the other four drafts share buildAtRiskDraftWhere, so a mismatch here is a drift bug. - it('counts only the at-risk drafts, matching the float', async () => { - expect(await getClosingSoonDraftCount(applicant.id, NOW)).toBe(2); +describe('getClosingSoonCount', () => { + // 3, not 8 — the other five apps share buildAtRiskWhere, so a mismatch here is a drift bug. + it('counts at-risk drafts and withdrawn apps, matching the float', async () => { + expect(await getClosingSoonCount(applicant.id, NOW)).toBe(3); }); }); diff --git a/tests/unit/deadline-indicator.test.ts b/tests/unit/deadline-indicator.test.ts new file mode 100644 index 00000000..627046c9 --- /dev/null +++ b/tests/unit/deadline-indicator.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { DeadlineIndicator } from '@/components/features/deadline-indicator'; + +const NOW = new Date('2026-03-10T00:00:00Z'); +const pastPosition = { + status: 'open' as const, + opensAt: null, + closesAt: new Date(NOW.getTime() - 5 * 24 * 60 * 60 * 1000), +}; + +describe('DeadlineIndicator', () => { + it('renders a past deadline identically regardless of emphasizeUrgency', () => { + const emphasized = DeadlineIndicator({ + position: pastPosition, + now: NOW, + emphasizeUrgency: true, + }); + const muted = DeadlineIndicator({ + position: pastPosition, + now: NOW, + emphasizeUrgency: false, + }); + expect(emphasized).toEqual(muted); + }); +}); From e7bea94ae3b4bc4b5d67a1c4845a3c7f975b5408 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 21 Sep 2026 02:21:57 -0400 Subject: [PATCH 10/10] #706 flatten deadline urgency to red-or-muted, drop amber distant/soon now render like urgent (red + warning icon) on draft/withdrawn rows, countdown text only inside 24h, and the now-dead --warning-text token is removed. Co-Authored-By: Claude Sonnet 5 --- app/globals.css | 4 -- components/features/deadline-indicator.tsx | 22 +++------- docs/WORKFLOWS.md | 4 +- lib/constants.ts | 2 +- lib/utils.ts | 8 ++-- .../my-applications-deadline-ordering.test.ts | 15 +++++-- tests/unit/deadline-indicator.test.ts | 42 +++++++++++++++++++ tests/unit/utils.test.ts | 23 +++++----- 8 files changed, 78 insertions(+), 42 deletions(-) diff --git a/app/globals.css b/app/globals.css index df9a2ead..a5300c37 100644 --- a/app/globals.css +++ b/app/globals.css @@ -45,7 +45,6 @@ --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); - --color-warning-text: var(--warning-text); --color-info: var(--info); --color-info-foreground: var(--info-foreground); --color-nav-hover: var(--nav-hover); @@ -93,8 +92,6 @@ --success-foreground: oklch(0.98 0 0); --warning: oklch(0.7 0.15 85); --warning-foreground: oklch(0.2 0 0); - /* Passes 4.5:1 on --card as plain text; --warning is fill/icon-only (≈2.7:1 as text). */ - --warning-text: oklch(0.52 0.15 85); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.98 0 0); --nav-hover: oklch(0.93 0 0); @@ -141,7 +138,6 @@ --success-foreground: oklch(0.98 0 0); --warning: oklch(0.7 0.15 85); --warning-foreground: oklch(0.15 0 0); - --warning-text: oklch(0.65 0.15 85); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.98 0 0); --nav-hover: oklch(0.27 0 0); diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx index ba1ca1a1..5ff21cc5 100644 --- a/components/features/deadline-indicator.tsx +++ b/components/features/deadline-indicator.tsx @@ -9,7 +9,7 @@ import { LocalTime } from '@/components/ui/local-time'; interface DeadlineIndicatorProps { position: PositionWindow; now: Date; - // soon/urgent badges fire only when true; past always renders muted regardless. + // Red line fires for distant/soon/urgent only when true; upcoming/past always muted. emphasizeUrgency: boolean; variant?: 'full' | 'compact'; } @@ -43,16 +43,12 @@ export function DeadlineIndicator({ ); - // Text, not Badge — a filled pill reads heavier than a metadata line; *-text tokens pass AA at this weight. - const emphasizedLine = ( - tone: 'warning' | 'destructive', - children: ReactNode, - ) => ( + // Text, not Badge — a filled pill reads heavier than a metadata line; text-destructive-text passes AA at this weight. + const emphasizedLine = (children: ReactNode) => ( @@ -62,22 +58,16 @@ export function DeadlineIndicator({ switch (info.tier) { case 'upcoming': - case 'distant': return mutedLine; + case 'distant': case 'soon': if (!emphasizeUrgency) return mutedLine; - return emphasizedLine( - 'warning', - - {`${info.compactCountdown} left`} - , - ); + return emphasizedLine(); case 'urgent': if (!emphasizeUrgency) return mutedLine; return emphasizedLine( - 'destructive', {`${info.compactCountdown} left`} , diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 6e0f931b..1ed48452 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -193,7 +193,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a ### AP-1 See your dashboard - **Trigger** — signing in, the logo, or the Home nav item (`/`). -- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft or withdrawn application the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold amber `text-warning-text` once the tier is `soon`, bold red `text-destructive-text` once `urgent` ([AP-10](#ap-10-track-your-applications)) — with the bare date or the compact countdown (`Nd left`/`Nh left`), no `Closes`/`Closed` prefix. A `past` deadline always renders the plain muted date, regardless of status. And its subtitle appends `N closing soon` when any at-risk draft or withdrawn application exists, so one that would otherwise sit outside the top-3 by recency still surfaces here. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams five independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, the three open positions closing soonest, and an activity feed. Each has its own skeleton. The applications widget's row is `title · trailing slot · status badge`; for a draft or withdrawn application the trailing slot carries the deadline instead of the usual submitted date — plain muted text normally, bold red `text-destructive-text` with the warning icon for any future deadline (`distant`, `soon`, or `urgent`) once the row is editable ([AP-10](#ap-10-track-your-applications)) — with the bare date, or the compact countdown (`Nh left`) once inside 24 hours, no `Closes`/`Closed` prefix. A `past` deadline always renders the plain muted date, regardless of status. And its subtitle appends `N closing soon` when any at-risk draft or withdrawn application exists, so one that would otherwise sit outside the top-3 by recency still surfaces here. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -306,7 +306,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a - **Trigger** — the My Applications nav item (`/applications`). - **Happy path** — `getMyApplications(user.id)` returns the caller's non-deleted applications on published positions, ordered by `submittedAt` (not `updatedAt` — [XC-8](#xc-8-applicant-facing-status-grouping)). The table sorts client-side by position, status, applied date, or the new **Deadline** column, and collapses to stacked cards below `md`. Each row links to the detail page and carries its primary action and row action. Every status shown is the public one — `reached_out`/`interview_scheduled`/`reviewing` all read as **Applied**. - **Deadline column** — every row (draft, withdrawn, or submitted) shows `position.closesAt` via `DeadlineIndicator`, computed from `getDeadlineInfo` (`lib/utils.ts`) against a server-resolved `now` passed down as a prop, never a client-side `new Date()` (that would hydration-mismatch near a tier boundary). The **Deadline** column header already says what the date is, so the cell renders the bare date with no `Closes`/`Closed` prefix — except `upcoming`, which keeps its `Opens ` label since a bare date there would otherwise read as the deadline itself; a position with no `closesAt` shows the same neutral "—" the table already uses for missing values. A `past` deadline stays visually distinct from an upcoming one without the word: a `CalendarX` icon and `opacity-70` dimming ahead of the date, the same plain muted form for every status regardless of tier. - - **Urgency tiers (draft and withdrawn rows only)** — `distant` (> 7 days): muted bare date. `soon` (≤ 7 days): bold `text-warning-text` "Nd left". `urgent` (≤ 48 hours): bold `text-destructive-text` "Nh left" — the same compact countdown format the widget uses, unified across both surfaces. `past` (closed): the same plain muted bare date as any other status, no badge — a draft's row still offers **Continue**, but a passed deadline is no longer flagged since there is nothing left to prompt. + - **Urgency tiers (draft and withdrawn rows only)** — any future deadline renders bold `text-destructive-text` with the warning icon, regardless of distance: `distant` and `soon` show the bare date, `urgent` (≤ 24 hours) shows the compact countdown "Nh left" instead — the same format the widget uses, unified across both surfaces. There is no amber tier; `soon` is retained only to drive the at-risk float and the `N closing soon` count, not a distinct look. `past` (closed): the same plain muted bare date as any other status, no red — a draft's row still offers **Continue**, but a passed deadline is no longer flagged since there is nothing left to prompt. - **At-risk float** — drafts or withdrawn applications with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward. - **Failure / edge** - Nothing yet → `EmptyState` "No applications yet" · "Browse open positions to start your first application." with a **Browse positions** button. diff --git a/lib/constants.ts b/lib/constants.ts index e7f3c808..d7ab53b8 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -634,7 +634,7 @@ export const RECENTLY_CLOSED_WINDOW_DAYS = 7; // Sole owners of the applicant deadline-urgency boundaries — shared by // getDeadlineInfo and the at-risk query in prisma/data/applications.ts. export const DEADLINE_SOON_DAYS = 7; -export const DEADLINE_URGENT_HOURS = 48; +export const DEADLINE_URGENT_HOURS = 24; // Longer than the public window so managers and admins keep oversight during wrap-up. export const MANAGED_POSITIONS_WINDOW_DAYS = 30; diff --git a/lib/utils.ts b/lib/utils.ts index ced7861a..a3b0b553 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -395,7 +395,7 @@ export function getPositionDateInfo( const MS_PER_HOUR = 60 * 60 * 1000; const MS_PER_DAY = 24 * MS_PER_HOUR; -/** Applicant-facing deadline urgency — kept separate from `getPositionDateInfo`'s calm/live/stale emphasis so position cards don't inherit this ticket's amber/red tiering. */ +/** Applicant-facing deadline urgency — kept separate from `getPositionDateInfo`'s calm/live/stale emphasis so position cards don't inherit this ticket's red tiering. */ export function getDeadlineInfo( position: PositionWindow, now: Date = new Date(), @@ -425,15 +425,13 @@ export function getDeadlineInfo( }; } - if (diffMs <= DEADLINE_SOON_DAYS * MS_PER_DAY) { - const days = Math.max(1, Math.ceil(diffMs / MS_PER_DAY)); + if (diffMs <= DEADLINE_SOON_DAYS * MS_PER_DAY) return { tier: 'soon', label: 'Closes', date: position.closesAt, - compactCountdown: `${days}d`, + compactCountdown: null, }; - } return { tier: 'distant', diff --git a/tests/db/my-applications-deadline-ordering.test.ts b/tests/db/my-applications-deadline-ordering.test.ts index 4d05ad3c..85045698 100644 --- a/tests/db/my-applications-deadline-ordering.test.ts +++ b/tests/db/my-applications-deadline-ordering.test.ts @@ -27,6 +27,7 @@ let draftNotYetOpen: Application; let draftUnpublishedPosition: Application; let draftDeletedPosition: Application; let withdrawnUrgent: Application; +let withdrawnSoon: Application; let withdrawnPastDue: Application; let submittedRecent: Application; @@ -64,6 +65,10 @@ beforeAll(async () => { status: 'open', closesAt: new Date(NOW.getTime() + 15 * HOUR), }); + const posWithdrawnSoon = await createTestPosition(admin, { + status: 'open', + closesAt: new Date(NOW.getTime() + 4 * DAY), + }); const posWithdrawnPastDue = await createTestPosition(admin, { status: 'open', closesAt: new Date(NOW.getTime() - 1 * DAY), @@ -99,6 +104,9 @@ beforeAll(async () => { withdrawnUrgent = await createTestApplication(applicant, posWithdrawnUrgent, { status: 'withdrawn', }); + withdrawnSoon = await createTestApplication(applicant, posWithdrawnSoon, { + status: 'withdrawn', + }); withdrawnPastDue = await createTestApplication( applicant, posWithdrawnPastDue, @@ -118,10 +126,11 @@ describe('getRecentMyApplications', () => { it('floats at-risk drafts and withdrawn apps ahead of recency order, nearest deadline first', async () => { const rows = await getRecentMyApplications(applicant.id, 10, NOW); const ids = rows.map((r) => r.id); - expect(ids.slice(0, 3)).toEqual([ + expect(ids.slice(0, 4)).toEqual([ draftUrgent.id, withdrawnUrgent.id, draftSoonOld.id, + withdrawnSoon.id, ]); }); @@ -166,8 +175,8 @@ describe('getRecentMyApplications', () => { }); describe('getClosingSoonCount', () => { - // 3, not 8 — the other five apps share buildAtRiskWhere, so a mismatch here is a drift bug. + // 4, not 10 — the other six apps share buildAtRiskWhere, so a mismatch here is a drift bug. it('counts at-risk drafts and withdrawn apps, matching the float', async () => { - expect(await getClosingSoonCount(applicant.id, NOW)).toBe(3); + expect(await getClosingSoonCount(applicant.id, NOW)).toBe(4); }); }); diff --git a/tests/unit/deadline-indicator.test.ts b/tests/unit/deadline-indicator.test.ts index 627046c9..b133eb2e 100644 --- a/tests/unit/deadline-indicator.test.ts +++ b/tests/unit/deadline-indicator.test.ts @@ -8,6 +8,20 @@ const pastPosition = { opensAt: null, closesAt: new Date(NOW.getTime() - 5 * 24 * 60 * 60 * 1000), }; +const distantPosition = { + status: 'open' as const, + opensAt: null, + closesAt: new Date(NOW.getTime() + 30 * 24 * 60 * 60 * 1000), +}; +const soonPosition = { + status: 'open' as const, + opensAt: null, + closesAt: new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000), +}; + +function classNameOf(element: unknown): string { + return (element as { props: { className: string } }).props.className; +} describe('DeadlineIndicator', () => { it('renders a past deadline identically regardless of emphasizeUrgency', () => { @@ -23,4 +37,32 @@ describe('DeadlineIndicator', () => { }); expect(emphasized).toEqual(muted); }); + + it.each([ + ['distant', distantPosition], + ['soon', soonPosition], + ])( + 'renders %s red with the warning icon when emphasizeUrgency is true', + (_tier, position) => { + const element = DeadlineIndicator({ + position, + now: NOW, + emphasizeUrgency: true, + }); + expect(classNameOf(element)).toContain('text-destructive-text'); + expect(classNameOf(element)).not.toContain('text-warning-text'); + }, + ); + + it.each([ + ['distant', distantPosition], + ['soon', soonPosition], + ])('renders %s muted when emphasizeUrgency is false', (_tier, position) => { + const element = DeadlineIndicator({ + position, + now: NOW, + emphasizeUrgency: false, + }); + expect(classNameOf(element)).toContain('text-muted-foreground'); + }); }); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index a9dc1092..40f57241 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -283,8 +283,8 @@ describe('getDeadlineInfo', () => { }); }); - it('is urgent at exactly the 48-hour boundary (inclusive)', () => { - const closesAt = new Date(NOW.getTime() + 48 * 60 * 60 * 1000); + it('is urgent at exactly the 24-hour boundary (inclusive)', () => { + const closesAt = new Date(NOW.getTime() + 24 * 60 * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, NOW, @@ -293,12 +293,12 @@ describe('getDeadlineInfo', () => { tier: 'urgent', label: 'Closes', date: closesAt, - compactCountdown: '48h', + compactCountdown: '24h', }); }); - it('is still urgent at 47 hours 59 minutes', () => { - const closesAt = new Date(NOW.getTime() + (47 * 60 + 59) * 60 * 1000); + it('is still urgent at 23 hours 59 minutes', () => { + const closesAt = new Date(NOW.getTime() + (23 * 60 + 59) * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, NOW, @@ -306,8 +306,8 @@ describe('getDeadlineInfo', () => { expect(info?.tier).toBe('urgent'); }); - it('is soon just past the 48-hour boundary', () => { - const closesAt = new Date(NOW.getTime() + 48 * 60 * 60 * 1000 + 1); + it('is soon at 25 hours, just past the 24-hour boundary', () => { + const closesAt = new Date(NOW.getTime() + 25 * 60 * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, NOW, @@ -315,7 +315,7 @@ describe('getDeadlineInfo', () => { expect(info?.tier).toBe('soon'); }); - it('is soon at exactly the 7-day boundary (inclusive)', () => { + it('is soon at exactly the 7-day boundary (inclusive), with no countdown', () => { const closesAt = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, @@ -325,7 +325,7 @@ describe('getDeadlineInfo', () => { tier: 'soon', label: 'Closes', date: closesAt, - compactCountdown: '7d', + compactCountdown: null, }); }); @@ -365,13 +365,14 @@ describe('getDeadlineInfo', () => { expect(info?.compactCountdown).toBe('1h'); }); - it('formats the compact countdown in days above the hour boundary', () => { + it('has no compact countdown at the soon tier', () => { const closesAt = new Date(NOW.getTime() + 3 * 24 * 60 * 60 * 1000); const info = getDeadlineInfo( { status: 'open', opensAt: null, closesAt }, NOW, ); - expect(info?.compactCountdown).toBe('3d'); + expect(info?.tier).toBe('soon'); + expect(info?.compactCountdown).toBeNull(); }); });