diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx
index e48a7c43..2a6c135e 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,8 @@ export default async function MyApplicationDetailPage({
if (!application) notFound();
const isDraft = application.status === 'draft';
+ const showDeadline = isDraft || application.status === 'withdrawn';
+ const now = new Date();
return (
@@ -79,20 +82,32 @@ export default async function MyApplicationDetailPage({
)
}
/>
-
- {isDraft ? 'Draft · last saved ' : 'Applied '}
- {' '}
- ·{' '}
+
+
+ {isDraft ? 'Draft · last saved ' : 'Applied '}
+
+
+ ·
View position
-
+ {showDeadline && (
+ <>
+ ·
+
+ >
+ )}
+
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/app/globals.css b/app/globals.css
index 7b10ffdd..a5300c37 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -24,6 +24,7 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
+ --color-destructive-text: var(--destructive-text);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -74,6 +75,8 @@
--muted-foreground: oklch(0.5 0 0);
--destructive: oklch(0.55 0.22 28);
--destructive-foreground: oklch(0.98 0 0);
+ /* Passes 4.5:1 on --card as plain text; --destructive is fill/icon-only. */
+ --destructive-text: oklch(0.55 0.22 28);
--border: oklch(0.88 0 0);
--sidebar-border: oklch(0.88 0 0);
--ring: oklch(0.55 0.18 27);
@@ -119,6 +122,8 @@
--muted-foreground: oklch(0.65 0 0);
--destructive: oklch(0.55 0.22 28);
--destructive-foreground: oklch(0.98 0 0);
+ /* Lightened vs. light mode — --card inverts, --destructive alone drops to ≈3.4:1 here. */
+ --destructive-text: oklch(0.66 0.22 28);
--border: oklch(0.31 0 0);
--sidebar-border: oklch(0.31 0 0);
--ring: oklch(0.5 0.18 27);
diff --git a/components/features/deadline-indicator.tsx b/components/features/deadline-indicator.tsx
new file mode 100644
index 00000000..5ff21cc5
--- /dev/null
+++ b/components/features/deadline-indicator.tsx
@@ -0,0 +1,84 @@
+import type { ReactNode } from 'react';
+
+import { DEADLINE_TIER_ICONS, STATE_ICONS } from '@/lib/icons';
+import type { PositionWindow } from '@/lib/types';
+import { cn, getDeadlineInfo } from '@/lib/utils';
+
+import { LocalTime } from '@/components/ui/local-time';
+
+interface DeadlineIndicatorProps {
+ position: PositionWindow;
+ now: Date;
+ // Red line fires for distant/soon/urgent only when true; upcoming/past always muted.
+ 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 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.tier === 'upcoming' && `${info.label} `}
+
+
+ );
+
+ // Text, not Badge — a filled pill reads heavier than a metadata line; text-destructive-text passes AA at this weight.
+ const emphasizedLine = (children: ReactNode) => (
+
+
+ {children}
+
+ );
+
+ switch (info.tier) {
+ case 'upcoming':
+ return mutedLine;
+
+ case 'distant':
+ case 'soon':
+ if (!emphasizeUrgency) return mutedLine;
+ return emphasizedLine(
);
+
+ case 'urgent':
+ if (!emphasizeUrgency) return mutedLine;
+ return emphasizedLine(
+
+ {`${info.compactCountdown} left`}
+ ,
+ );
+
+ case 'past':
+ return mutedLine;
+
+ 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..53913ea9 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,107 @@ 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/withdrawn app whose deadline tier is soon/urgent — the float target.
+function atRiskDeadlineDate(a: MyApplicationListItem, now: Date): Date | 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;
+}
export function MyApplicationsTable({
applications,
+ now,
}: MyApplicationsTableProps) {
+ const columns = useMemo(() => buildColumns(now), [now]);
+
+ // 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) }))
+ .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 +155,13 @@ export function MyApplicationsTable({
+