Skip to content
Merged
31 changes: 23 additions & 8 deletions app/(main)/(auth)/applications/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="mx-auto max-w-5xl">
Expand All @@ -79,20 +82,32 @@ export default async function MyApplicationDetailPage({
)
}
/>
<p className="text-muted-foreground mt-1 text-sm">
{isDraft ? 'Draft · last saved ' : 'Applied '}
<LocalTime
date={application.lastSavedAt ?? application.submittedAt}
precision="date"
/>{' '}
·{' '}
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
<span>
{isDraft ? 'Draft · last saved ' : 'Applied '}
<LocalTime
date={application.lastSavedAt ?? application.submittedAt}
precision="date"
/>
</span>
<span aria-hidden="true">·</span>
<Link
href={`/positions/${application.position.id}`}
className="underline"
>
View position
</Link>
</p>
{showDeadline && (
<>
<span aria-hidden="true">·</span>
<DeadlineIndicator
position={application.position}
now={now}
emphasizeUrgency
/>
</>
)}
</div>
</div>

<div className="flex flex-col gap-4">
Expand Down
3 changes: 2 additions & 1 deletion app/(main)/(auth)/applications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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 (
<div className="flex w-full flex-col gap-6">
<PageHeader
title="My Applications"
description="Track your drafts and submitted applications."
/>
<MyApplicationsTable applications={applications} />
<MyApplicationsTable applications={applications} now={now} />
</div>
);
}
5 changes: 5 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
84 changes: 84 additions & 0 deletions components/features/deadline-indicator.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="text-muted-foreground">—</span>;

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 = (
<span
className={cn(
'text-muted-foreground flex items-center gap-1.5',
variant === 'full' ? 'text-sm' : 'text-xs',
isPast && 'opacity-70',
)}
>
{variant === 'full' && <Icon className="size-4 shrink-0" />}
{info.tier === 'upcoming' && `${info.label} `}
<LocalTime date={info.date} precision="date" />
</span>
);

// Text, not Badge — a filled pill reads heavier than a metadata line; text-destructive-text passes AA at this weight.
const emphasizedLine = (children: ReactNode) => (
<span
className={cn(
'text-destructive-text flex items-center gap-1.5 font-semibold',
variant === 'full' ? 'text-sm' : 'text-xs',
)}
>
<STATE_ICONS.warning className="size-4 shrink-0" />
{children}
</span>
);

switch (info.tier) {
case 'upcoming':
return mutedLine;

case 'distant':
case 'soon':
Comment thread
cielbellerose marked this conversation as resolved.
if (!emphasizeUrgency) return mutedLine;
return emphasizedLine(<LocalTime date={info.date} precision="date" />);

case 'urgent':
if (!emphasizeUrgency) return mutedLine;
return emphasizedLine(
<LocalTime date={info.date} precision="date">
{`${info.compactCountdown} left`}
</LocalTime>,
);

case 'past':
return mutedLine;

default: {
const exhaustive: never = info.tier;
return exhaustive;
}
}
}
159 changes: 105 additions & 54 deletions components/features/my-applications-table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import Link from 'next/link';
import { useMemo } from 'react';

import {
APPLICATION_STATUS_LABELS,
Expand All @@ -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';
Expand All @@ -20,66 +23,107 @@ import { LocalTime } from '@/components/ui/local-time';

interface MyApplicationsTableProps {
applications: MyApplicationListItem[];
now: Date;
}

const COLUMNS: DataTableColumn<MyApplicationListItem>[] = [
{
key: 'position',
header: 'Position',
sortAccessor: (a) => a.position.title,
cell: (a) => (
<Link
href={`/applications/${a.id}`}
className="font-medium hover:underline"
>
{a.position.title}
</Link>
),
},
{
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) => <ApplicationStatusBadge status={a.status} />,
},
{
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' ? (
'—'
) : (
<LocalTime date={a.submittedAt} precision="date" />
function buildColumns(now: Date): DataTableColumn<MyApplicationListItem>[] {
return [
{
key: 'position',
header: 'Position',
sortAccessor: (a) => a.position.title,
cell: (a) => (
<Link
href={`/applications/${a.id}`}
className="font-medium hover:underline"
>
{a.position.title}
</Link>
),
},
{
key: 'action',
header: 'Action',
cell: (a) =>
TERMINAL_DECISION_STATUSES.includes(a.status) ? (
<span className="text-muted-foreground text-sm" aria-hidden="true">
—
</span>
) : (
<div className="flex items-center gap-2">
<MyApplicationPrimaryAction application={a} />
<MyApplicationRowActions
applicationId={a.id}
status={a.status}
positionTitle={a.position.title}
/>
</div>
},
{
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) => <ApplicationStatusBadge status={a.status} />,
},
{
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' ? (
'—'
) : (
<LocalTime date={a.submittedAt} precision="date" />
),
},
{
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) => (
<DeadlineIndicator
position={a.position}
now={now}
emphasizeUrgency={a.status === 'draft' || a.status === 'withdrawn'}
/>
),
},
];
},
{
key: 'action',
header: 'Action',
cell: (a) =>
TERMINAL_DECISION_STATUSES.includes(a.status) ? (
<span className="text-muted-foreground text-sm" aria-hidden="true">
—
</span>
) : (
<div className="flex items-center gap-2">
<MyApplicationPrimaryAction application={a} />
<MyApplicationRowActions
applicationId={a.id}
status={a.status}
positionTitle={a.position.title}
/>
</div>
),
},
];
}

// 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 (
<EmptyState
Expand All @@ -96,8 +140,8 @@ export function MyApplicationsTable({

return (
<DataTable
rows={applications}
columns={COLUMNS}
rows={rows}
columns={columns}
getRowKey={(a) => a.id}
caption="My applications"
mobileCard={(app) => (
Expand All @@ -111,6 +155,13 @@ export function MyApplicationsTable({
</Link>
<ApplicationStatusBadge status={app.status} />
</div>
<DeadlineIndicator
position={app.position}
now={now}
emphasizeUrgency={
app.status === 'draft' || app.status === 'withdrawn'
}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-sm">
{app.status === 'draft' ? (
Expand Down
Loading
Loading