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({
+
{app.status === 'draft' ? ( diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index d43d558f..aced58f0 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 { + getClosingSoonCount, 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), + getClosingSoonCount(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 (
    @@ -102,14 +112,19 @@ function ApplicationList({ > {app.position.title} - - {app.status === 'draft' ? ( - '—' + {app.status === 'draft' || app.status === 'withdrawn' ? ( + ) : ( )} + ))}
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 (