Skip to content

Make The Closing Date Prominent On Position Cards And Detail Pages #626

Description

@b-at-neu

Problem

The closing date is the single most decision-relevant fact on a position, and it's currently the least visible thing on the page. In both places it renders it's small, muted, and positioned after other content.

position-card.tsx — below the description, at the bottom of the card:

{dateLabel && <p className="text-muted-foreground mt-1 text-xs">{dateLabel}</p>}

text-xs + text-muted-foreground is the app's caption treatment — the weakest pairing in docs/DESIGN.md's type scale — applied to the deadline.

/positions/[id] — inline in the action row, beside the buttons:

{position.closesAt && (
  <span className="text-muted-foreground text-sm">
    Closes <LocalTime date={position.closesAt} precision="datetime" />
  </span>
)}

So on the detail page the deadline competes with Apply and View Details for attention, and loses.

Scope

Move the date directly under the title on both surfaces, and give it weight:

  • Position — immediately below the title, above the description.
  • Weight — drop text-muted-foreground; use font-medium (or font-semibold) at text-sm rather than text-xs. It should read as content, not as a caption.
  • Icon — a leading calendar/clock icon. Take it from CONCEPT_ICONS in lib/icons.ts rather than importing from lucide directly, per Standardize Lucide Iconography Across The App #572's direction.
  • Spacing — give it its own breathing room instead of mt-1 tucked against the block above.

dateLabel already computes the right copy for every availability state — Closes / Opens / Closed — so this is a placement and emphasis change, not new logic. Keep all four states working; "Closed <date>" must not be styled as urgently as "Closes <date>", since one is a call to action and the other is a fact.

Conflict to sequence — #613

#613 touches the same header region of position-card.tsx. It regroups the title and badges because justify-between on a variable child count strands the Open badge mid-card. This ticket inserts a new row directly beneath that same title.

They will conflict. Either do #613 first and build on its corrected header, or merge the two. Don't run them in parallel.

Non-goals

Acceptance criteria

  • On the position card, the date sits directly under the title, above the description.
  • On /positions/[id], the date sits under the title rather than in the action row.
  • It renders at text-sm or larger with a medium-or-heavier weight and is not muted.
  • A leading icon is present, sourced from CONCEPT_ICONS.
  • All four states still render correctly: accepting-with-close-date, upcoming, closed-by-date, unavailable.
  • A position with no closesAt shows no empty row or stray icon.
  • Verified at 375px, 768px and 1280px in both themes.
  • npm run prettier:check, eslint:check, tsc:check, test all pass.

Implementation Plan

Overview

The card and the detail page each hand-roll the same three-branch date copy, so moving it twice would leave two copies to drift. Extract one presentational server component, PositionDateLine, that takes a PositionWindow and renders the whole line — icon + "Closes / Opens / Closed <date>" — or null when there's no relevant date, then place it directly under the title on both surfaces and delete both call sites' local logic. The availability rules and the copy are lifted verbatim from the card's existing dateLabel; only placement, weight and the new icon change.

Emphasis is carried by weight and icon tint, not by tint on the text — every state stays text-foreground (so nothing is muted, per the AC), the live states ("Closes" / "Opens") get font-semibold with a text-foreground icon, and the past state ("Closed") gets font-medium with a text-muted-foreground icon. That reads calmer without demoting it back to a caption.

#613's header regrouping is already on origin/dev, so build on the header as it stands there — the badge grouping is done and there is nothing left to sequence.

Changes

  • components/features/position-date-line.tsxnew. The single renderer for a position's window date: derives availability, picks label + emphasis, returns null when no date applies.
  • lib/icons.ts — add deadline to the Concept union and deadline: CalendarClock to CONCEPT_ICONS; per DESIGN.md §6 an icon may only exist as a vocabulary key, so this entry is the sanctioned way to introduce it.
  • components/features/position-card.tsx — delete the local dateLabel block and the caption <p>; render <PositionDateLine> as a second row inside CardHeader. Drops the now-unused LocalTime and ReactNode imports.
  • app/(main)/positions/[id]/page.tsx — render <PositionDateLine> under the h1 + badge row; remove all three date renderings from the action row, plus the now-unused isClosed const and the Badge / LocalTime imports.
  • app/(main)/positions/[id]/loading.tsx — add a matching skeleton row under the title so the detail page doesn't shift on resolve.
  • docs/WORKFLOWS.md — AN-2 states the window shows as "a secondary badge Opens <date>" in the action row; that badge is gone.

Implementation

  • Add CalendarClock to the lucide-react import in lib/icons.ts, 'deadline' to the Concept union, and deadline: CalendarClock to CONCEPT_ICONS. CalendarClock is used nowhere else in the file, so tests/unit/icons.test.ts's no-duplicate-icons assertion keeps passing unchanged.
  • Create components/features/position-date-line.tsx exporting PositionDateLine({ position, className }: { position: PositionWindow; className?: string }). No 'use client' — it's a server component that renders the existing LocalTime client leaf, exactly as PositionCard does today.
  • Inside it, call getPositionAvailability(position) and branch identically to the card's current dateLabel: accepting + closesAtCloses; upcoming + opensAtOpens; (closed_by_date | unavailable) + closesAtClosed. Anything else → return null, which is what satisfies "no empty row or stray icon".
  • Render <p className={cn('flex items-center gap-1.5 text-sm', isPast ? 'font-medium' : 'font-semibold', className)}> with <CONCEPT_ICONS.deadline className={cn('size-4 shrink-0', isPast && 'text-muted-foreground')} /> then the label and <LocalTime date={…} precision="datetime" />. size-4 is the inline-with-text size from DESIGN.md §6; shrink-0 keeps the icon from squashing when a long date wraps. Never write aria-hidden on the icon — lucide emits it itself.
  • In position-card.tsx, delete the let dateLabel: ReactNode = null; block and the {dateLabel && <p …>} line in CardContent, then drop the ReactNode and LocalTime imports.
  • In the same file's CardHeader, add <PositionDateLine position={position} className="mt-1" /> as a sibling after the title/badge row div, and change the header's padding from p-4 pb-2 to p-4 pb-3. Result: title → ~10px → date → ~12px → description, i.e. its own breathing room rather than mt-1 against the block above.
  • In app/(main)/positions/[id]/page.tsx, add <PositionDateLine position={position} className="mt-3 text-base" /> immediately after the mt-2 flex flex-wrap items-center gap-3 div that holds the h1 and PositionStatusBadge. text-base overrides the component's text-sm so the line holds its own under a text-2xl title.
  • Strip the action row on that page down to buttons only: remove the Closes <span> from inside the isAccepting fragment (leaving just the Apply button), the availability === 'upcoming' Badge, and the isClosed <span>. The upcoming state is not losing information — PositionStatusBadge beside the h1 already reads Upcoming, and the date it carried now renders in the date line.
  • Remove the now-dead isClosed const and the Badge / LocalTime imports from that page; tsc:check and eslint:check will catch either if missed.
  • In app/(main)/positions/[id]/loading.tsx, add <Skeleton className="mt-3 h-5 w-48" /> after the title row's div so the skeleton keeps matching the resolved layout.
  • Update AN-2 in docs/WORKFLOWS.md: in the happy path list the date line among what's rendered ("the title, availability badge, the application window date, markdown description, …"); in Failure / edge replace "Window not open yet → a secondary badge "Opens <date>"; already closed → "Closed <date>"" with a line saying the window date renders under the title as Opens <date> / Closed <date>, and that there is still no Apply button in either case.
  • npm run prettier:check, eslint:check, tsc:check, test.

UX states

The line is one <p> sitting under the title on both surfaces. Text is text-foreground in every state — the emphasis difference is weight + icon tint only.

  • Accepting, with a close dateCalendarClock (foreground) + Closes 18 September 2026, 11:59 PM, text-sm font-semibold on the card / text-base font-semibold on the detail page. The strongest of the four: it's the state the deadline is actionable in.
  • Upcoming — same treatment, Opens 11 September 2026, 12:00 AM. Equally live (the user is waiting on it), so it keeps the semibold weight. On the detail page this replaces the old secondary badge.
  • Closed by date / unavailableClosed 25 August 2026, 11:59 PM, font-medium with a text-muted-foreground icon. Reads as a fact, not a call to action, which is the ticket's "must not be styled as urgently" requirement, while still clearing "not muted, medium-or-heavier".
  • No relevant date (an accepting position with no closesAt, an unavailable one with no closesAt, the seeded Chief of Staff draft) — the component returns null. No wrapper, no icon, no reserved height; the description simply moves up.
  • Long dates / 375pxflex items-center gap-1.5 with shrink-0 on the icon: the text wraps to a second line under itself and the icon stays put and top-aligned with the first line. No truncation — the date is the point.
  • Loading — no new async surface; both routes' existing loading.tsx cover it, and the detail skeleton gains the matching row so nothing shifts on resolve. Error / empty — unchanged: no fetch, no action, no toast; the global boundary still owns render failures.
  • Accessibility — visible text carries the meaning, so the icon is decorative and lucide's own aria-hidden is correct (never add one). LocalTime keeps emitting <time dateTime title> with the exact instant. Nothing interactive is added, so there's no focus, tab-order or touch-target change; text-foreground on bg-card / bg-background clears AA in both themes.

Testing

Manual, on the Vercel preview (/login/bypass is available for VERCEL_ENV=preview), against the seeded positions.

  • /positions signed out — Senator — College of Engineering (open, closes in 21d) shows Closes <date> directly under the title, above the description, semibold with the calendar-clock icon; nothing remains at the bottom of the card.
  • Same page — Director of Technology (closes in 2d) reads the same way; Senator — College of Science (opens in 14d) reads Opens <date>.
  • Recently Closed → Director of External Relations (closed 3d ago) reads Closed <date>, visibly calmer than the Closes cards but still clearly not a caption.
  • /positions/[id] for Senator — College of EngineeringCloses <date> sits under the title/badge row at text-base; the action row below holds Apply and nothing else.
  • /positions/[id] for Senator — College of ScienceOpens <date> under the title, the Upcoming badge still beside the title, no Apply button, and no leftover secondary badge in the action row.
  • /positions/[id] for Student Advocate (status closed) — Closed <date> under the title, action row has no date span.
  • Sign in as Admin/positions/[id] for the Chief of Staff draft (no dates): no date line at all — no icon, no blank row, description sits straight under the title.
  • /login/bypassPosition Manager/my-positions: the date line appears on manager cards too, in the left column, without disturbing the right-hand stat cluster; expand Archived and confirm the closed cards read Closed <date>.
  • /login/bypassApplicant/positions: a card with your own application still shows the status badge beside the title, with the date line on the row beneath it — the two don't collide.
  • Hard-refresh /positions/[id] and watch the skeleton → content transition: no visible jump in the title block.
  • Repeat the /positions and /positions/[id] checks at 375px, 768px and 1280px, in both themes. At 375px force a wrap by checking a long title — icon stays aligned, text wraps under itself, no horizontal overflow.
  • npm run prettier:check, npm run eslint:check, npm run tsc:check, npm run test.

Risks / notes

  • Position Card Badges Spread Apart When An Application Badge Is Present #613 is already on origin/dev — the card header there is flex flex-wrap items-center gap-2 with the title + ApplicationStatusBadge grouped and PositionStatusBadge separate. Branch from origin/dev and build on that; there is no sequencing left to do. The new row is a sibling of that row inside CardHeader, not a child of it, so the badge grouping is untouched.
  • The local dev checkout may be stale (it was ~68 commits behind when this was planned, with no lib/icons.ts and no myApplication prop). git fetch origin && git rebase origin/dev before reading the files, or the code will not match this plan.
  • The detail page's "Closed" condition changes slightly. It uses availability === 'closed_by_date' || position.status === 'closed'; the shared component uses the card's closed_by_date || unavailable. unavailable also covers draft, so a draft position that has a closesAt will now show Closed <future date> on its detail page, as it already does on its card. This is a pre-existing oddity in dateLabel that the ticket's non-goals explicitly put out of scope ("no change to the dateLabel copy"), so it is preserved rather than fixed — worth a follow-up ticket to label a draft's window as planned rather than past. It's manager/admin-only and not reachable from the seed (Chief of Staff has no dates).
  • No automated coverage is possible here. vitest.config.ts has only node-environment projects (tests/unit pure functions, tests/db real Postgres); there is no DOM/component test setup, so adding a render test would mean introducing that infrastructure. tests/unit/icons.test.ts does cover the new CONCEPT_ICONS entry's uniqueness for free. Everything else is visual.
  • The /positions list skeleton is deliberately not touched. app/(main)/positions/loading.tsx already renders header-only card stubs with no description or buttons, so it is a rough placeholder by design; adding a date row would not make it match.

Metadata

Metadata

Assignees

Labels

auto planPlan gate skipped: auto-approve plan for this ticketbugSomething isn't workingclaudeWill be worked on by Claudepr openedPull request has been opened

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions