From fb0a7765afe01adae3e86b149bde0b396a8d4435 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:09:14 -0400 Subject: [PATCH 1/3] #740 show draft completion progress rings Adds a server-side aggregate (getApplicationCompletion) that counts required-question completion for draft applications, reusing isAnswered and resolveGlobalAnswerValues so the ring can never disagree with whether Submit is enabled. A new ProgressRing server primitive renders it on all four surfaces: the reviewer drafts table, the merged applications table, the applicant's own applications table/widget, and the position card. Only three integers ever cross to a client; answer content never does. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/page.tsx | 16 +- components/features/applications-results.tsx | 25 ++- components/features/applications-table.tsx | 33 ++- components/features/my-applications-table.tsx | 104 +++++---- .../features/my-applications-widget.tsx | 87 +++++--- components/features/position-card.tsx | 22 +- components/ui/progress-ring.tsx | 55 +++++ docs/WORKFLOWS.md | 8 +- lib/types.ts | 20 +- lib/utils.ts | 29 +++ prisma/data/applications.ts | 148 +++++++++++- tests/db/draft-completion.test.ts | 210 ++++++++++++++++++ tests/unit/utils.test.ts | 119 ++++++++++ 13 files changed, 784 insertions(+), 92 deletions(-) create mode 100644 components/ui/progress-ring.tsx create mode 100644 tests/db/draft-completion.test.ts diff --git a/app/(main)/(auth)/applications/page.tsx b/app/(main)/(auth)/applications/page.tsx index f46d7fad..fa0ee1f3 100644 --- a/app/(main)/(auth)/applications/page.tsx +++ b/app/(main)/(auth)/applications/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; -import { getMyApplications } from '@/prisma/data/applications'; +import { + getApplicationCompletion, + getMyApplications, +} from '@/prisma/data/applications'; import { getCurrentUser } from '@/lib/auth/server'; @@ -13,6 +16,11 @@ export default async function MyApplicationsPage() { const user = await getCurrentUser(); const applications = await getMyApplications(user.id); const now = new Date(); + const completion = await getApplicationCompletion( + applications + .filter((a) => a.status === 'draft') + .map((a) => ({ id: a.id, positionId: a.positionId, userId: user.id })), + ); return (
@@ -20,7 +28,11 @@ export default async function MyApplicationsPage() { title="My Applications" description="Track your drafts and submitted applications." /> - +
); } diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index cd9aae38..2640e05e 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -4,6 +4,7 @@ import { compareMergedApplicationRows, getAllApplications, getAllDraftApplications, + getApplicationCompletion, getApplications, getApplicationsCount, getDraftApplications, @@ -103,18 +104,27 @@ export async function ApplicationsResults({ filters, page, ); + const completion = await getApplicationCompletion( + rows.map((r) => ({ + id: r.id, + positionId: r.position.id, + userId: r.user.id, + })), + ); return (

- You can see who started an application, not what they've written. - Draft answers stay private until the applicant submits. + You can see who started an application and how far along it is, not + what they've written. Draft answers stay private until the + applicant submits.

r.isDraft) + .map((r) => ({ + id: r.id, + positionId: r.position.id, + userId: r.user.id, + })), + ); return (
({ ...a, isDraft: false as const }))} + completion={{}} hasActiveFilters={hasActiveFilters} sort={filters.sort} isAdmin={user.isAdmin} diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index c26f88ac..89d793de 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -14,6 +14,7 @@ import { STATE_ICONS, } from '@/lib/icons'; import type { + ApplicationCompletion, ApplicationSort, ApplicationSortDirection, ApplicationSortField, @@ -35,11 +36,13 @@ import { Checkbox } from '@/components/ui/checkbox'; import { DataTable } from '@/components/ui/data-table'; import { EmptyState } from '@/components/ui/empty-state'; import { LocalTime } from '@/components/ui/local-time'; +import { ProgressRing } from '@/components/ui/progress-ring'; interface BaseApplicationsTableProps { hasActiveFilters: boolean; sort?: ApplicationSort; isAdmin: boolean; + completion: Record; } // Discriminated on isDraftView: true is the explicit "Draft" filter (pure @@ -59,7 +62,7 @@ function isAdminRow( } export function ApplicationsTable(props: ApplicationsTableProps) { - const { hasActiveFilters, sort, isAdmin } = props; + const { hasActiveFilters, sort, isAdmin, completion } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -172,6 +175,17 @@ export function ApplicationsTable(props: ApplicationsTableProps) { ), }, + { + key: 'progress', + header: 'Progress', + // Server-side pagination sorts on ApplicationSortField, which progress isn't. + cell: (app) => { + const entry = completion[app.id]; + return entry ? ( + + ) : null; + }, + }, { key: 'started', header: 'Started', @@ -274,9 +288,11 @@ export function ApplicationsTable(props: ApplicationsTableProps) { sortAccessor: (a) => (a.isDraft ? 'draft' : a.status), cell: (app) => { if (app.isDraft) { + const entry = completion[app.id]; return (
+ {entry && } {app.position.title} - - Started · - Updated + + + Started · + Updated + + {completion[app.id] && ( + + )}
)} @@ -422,6 +443,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) { onSortToggle={(key) => toggleSort(key as ApplicationSortField)} mobileCard={(app) => { if (app.isDraft) { + const entry = completion[app.id]; return (
@@ -432,6 +454,9 @@ export function ApplicationsTable(props: ApplicationsTableProps) {
+ {entry && ( + + )} ; } -function buildColumns(now: Date): DataTableColumn[] { +function buildColumns( + now: Date, + completion: Record, +): DataTableColumn[] { return [ { key: 'position', @@ -46,7 +54,15 @@ function buildColumns(now: Date): DataTableColumn[] { 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) => , + cell: (a) => { + const entry = a.status === 'draft' ? completion[a.id] : undefined; + return ( +
+ + {entry && } +
+ ); + }, }, { key: 'applied', @@ -106,8 +122,12 @@ function atRiskDeadlineDate(a: MyApplicationListItem, now: Date): Date | null { export function MyApplicationsTable({ applications, now, + completion, }: MyApplicationsTableProps) { - const columns = useMemo(() => buildColumns(now), [now]); + const columns = useMemo( + () => buildColumns(now, completion), + [now, completion], + ); // At-risk drafts float to the top, nearest deadline first; sort.key stays null so a header click still takes over. const rows = useMemo(() => { @@ -143,43 +163,49 @@ export function MyApplicationsTable({ columns={columns} getRowKey={(a) => a.id} caption="My applications" - mobileCard={(app) => ( -
-
- - {app.position.title} - - -
- -
- - {app.submittedAt ? ( - - ) : ( - 'Draft' - )} - -
- - + mobileCard={(app) => { + const entry = app.status === 'draft' ? completion[app.id] : undefined; + return ( +
+
+ + {app.position.title} + +
+ + {entry && } +
+
+ +
+ + {app.submittedAt ? ( + + ) : ( + 'Draft' + )} + +
+ + +
-
- )} + ); + }} /> ); } diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index 89293c33..cfd0a49f 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 { + getApplicationCompletion, getClosingSoonCount, getMyApplicationStatusCounts, getRecentMyApplications, @@ -8,11 +9,15 @@ import { import { APPLICATION_STATUS_LABELS } from '@/lib/constants'; import { CONCEPT_ICONS } from '@/lib/icons'; -import { type MyApplicationListItem } from '@/lib/types'; +import { + type ApplicationCompletion, + 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 { ProgressRing } from '@/components/ui/progress-ring'; import { SectionCard, SectionCardEmpty } from '@/components/ui/section-card'; interface MyApplicationsWidgetProps { @@ -58,6 +63,18 @@ export async function MyApplicationsWidget({ getClosingSoonCount(userId, now), ]); + const draftRows = applications.filter((a) => a.status === 'draft'); + const completion = + draftRows.length > 0 + ? await getApplicationCompletion( + draftRows.map((a) => ({ + id: a.id, + positionId: a.positionId, + userId, + })), + ) + : {}; + const summary = buildCountsSummary(counts, closingSoonCount); return ( @@ -86,7 +103,11 @@ export async function MyApplicationsWidget({ } /> ) : ( - + )} ); @@ -95,40 +116,48 @@ export async function MyApplicationsWidget({ function ApplicationList({ applications, now, + completion, }: { applications: MyApplicationListItem[]; now: Date; + completion: Record; }) { return (
    - {applications.map((app) => ( -
  • - { + const entry = app.status === 'draft' ? completion[app.id] : undefined; + return ( +
  • - {app.position.title} - - - {app.status === 'draft' || app.status === 'withdrawn' ? ( - - ) : app.submittedAt ? ( - - ) : ( - '—' - )} - - -
  • - ))} + + {app.position.title} + + + + {app.status === 'draft' || app.status === 'withdrawn' ? ( + <> + + {entry && } + + ) : app.submittedAt ? ( + + ) : ( + '—' + )} + + + ); + })}
); } diff --git a/components/features/position-card.tsx b/components/features/position-card.tsx index 82ee7765..3ad650db 100644 --- a/components/features/position-card.tsx +++ b/components/features/position-card.tsx @@ -23,6 +23,7 @@ import { import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Markdown } from '@/components/ui/markdown'; +import { ProgressRing } from '@/components/ui/progress-ring'; import { Skeleton } from '@/components/ui/skeleton'; interface PositionCardProps { @@ -187,12 +188,21 @@ export function PositionCard({ <> {myApplication ? ( canContinueOrResubmit ? ( - + <> + + {isDraft && myApplication.completion && ( + + )} + ) : (
{app.user.name && ( diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index cfd0a49f..32538977 100644 --- a/components/features/my-applications-widget.tsx +++ b/components/features/my-applications-widget.tsx @@ -138,17 +138,15 @@ function ApplicationList({ {app.position.title} + {entry && } {app.status === 'draft' || app.status === 'withdrawn' ? ( - <> - - {entry && } - + ) : app.submittedAt ? ( ) : ( diff --git a/components/ui/progress-ring.tsx b/components/ui/progress-ring.tsx index e0552163..0b2bf330 100644 --- a/components/ui/progress-ring.tsx +++ b/components/ui/progress-ring.tsx @@ -18,7 +18,10 @@ export function ProgressRing({ " and streams four independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, and the three open positions closing soonest. Each has its own skeleton. The applications widget's row is `title · status badge · trailing slot`; for a draft or withdrawn application the trailing slot carries the deadline indicator 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. A draft row additionally renders a `ProgressRing` (required-question completion, no answer content — just the count) side by side with the deadline indicator in that same trailing slot; withdrawn rows get the deadline indicator only, no ring. 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. Recent activity lives in the activity panel, reachable from every page ([XC-10](#xc-10-activity-panel)), not on the dashboard. +- **Happy path** — `UserDashboard` renders "Welcome back, " and streams four independently-suspended sections: the profile-completeness banner, an application summary, the three most recent applications, and the three open positions closing soonest. Each has its own skeleton. The applications widget's row is `title · status badge · trailing slot`; for a draft or withdrawn application the trailing slot carries the deadline indicator 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. A draft row additionally renders a `ProgressRing` (required-question completion, no answer content — just the count) directly beside the status badge, ahead of the trailing slot; withdrawn rows get the deadline indicator only, no ring. The deadline indicator itself always sits at the far right of the row. 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. Recent activity lives in the activity panel, reachable from every page ([XC-10](#xc-10-activity-panel)), not on the dashboard. - **Failure / edge** - Anonymous → `redirect('/positions')` — routing, not denial. - No name → [XC-2](#xc-2-name-gate). @@ -510,8 +510,8 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-8 Work the application queue - **Trigger** — Applications under **Manage** (`/manage/applications`), or the **Applications** button on a managed position, which pre-applies `?positionId=`. -- **Happy path** — `requireManagerOrAdminOr404` gates the role (the `(auth)` layout only gates profile completeness). Query params are parsed with `.catch(undefined)` per field, so one malformed param never sinks the rest. The toolbar offers a position filter ("All positions"), an applicant filter ("All applicants"), a status filter ("All statuses", now including "Draft"), a debounced search over "Name, email, position, or date", **Clear filters**, and sortable columns (date, name, status). Non-draft results are scoped by `buildApplicationWhere(user, 'listable')` — a manager sees only their positions' applications (withdrawn rows are kept). The position title in each row links to `/positions/[id]`. The applicant option list (`getReviewableApplicants`) is scoped the same way the results are, so a manager only ever sees applicants who applied to positions they manage. Every row — including `accepted`, `rejected`, `withdrawn` and, in the merged "All statuses" view, `draft` — renders the same `⋯` opening the shared `ApplicationStatusMenu` ([PM-11](#pm-11-move-one-application-through-the-status-path)): the four unresolved statuses get the next step as the first item instead of hoisted, then **See more**, with no separator between the next step and Reject when the next step already is Accept (`reviewing`); every other status gets a See-more-only menu with no leading separator, so history is reachable from every row, not just the unresolved ones. The row's `aria-label` reflects that split — "Change status for " where a move is still possible, "Status history for " for `draft`/`withdrawn`. Opening the dialog via **See more** calls the read-only `loadApplicationStatusHistory` action since the table has no pre-fetched history per row, showing the dialog's loading skeleton while the fetch is in flight. - - **Drafts.** With no status filter active (the default "All statuses" view), drafts merge into the same table instead of needing the Draft filter to be seen: `getAllDraftApplications` and `getAllApplications` run in parallel, unpaginated, are merged into one array, and that merged array is sorted by `compareMergedApplicationRows` — a comparator mirroring the DB-level `buildApplicationListOrderBy` ordering (a draft's `submittedAt` is null and it has no `status` column at all, so it compares as null/`'draft'`) — before being paginated in memory; with no explicit sort, this clusters drafts at the front (newest `updatedAt` first) ahead of the rest (newest `submittedAt` first), and toggling any sortable column (Name, Submitted, Status) reorders drafts and applications together as one list. Each draft row renders inertly within the regular columns rather than swapping the table's shape: no checkbox, no link on the applicant name (the detail page still 404s a draft), a plain **Draft** status badge with a `ProgressRing` beside it (required-question completion — a count, not the answers themselves), then the same `⋯` (See-more-only, since a draft has no reviewer transition), and `updatedAt` in the date column in place of `submittedAt`. The status filter's **Draft** option carries no count, just the label. Picking **Draft** narrows to a pure drafts view instead, unchanged from before except for one addition: `getDraftApplications` — identity and timestamps only, via `buildApplicationScopeWhere(user)` plus `status: 'draft'`, never `buildApplicationWhere` — with its own two-column swap (**Started**/**Last updated** in place of Status/Submitted) plus a **Progress** column between Position and Started (the same `ProgressRing`, not sortable), no checkbox/`⋯`/link on any row, and the bulk bar never appearing — that dedicated view has no status column to host a control, and every row in it is a draft with provably identical (empty) history. Only while the Draft filter is selected, a muted line directly above the table states the privacy boundary: "You can see who started an application and how far along it is, not what they've written. Draft answers stay private until the applicant submits." — it's contextual, not a persistent page-level notice. Choosing any other specific status still excludes drafts entirely. `/manage/applications/[id]` 404s for a draft in every case. +- **Happy path** — `requireManagerOrAdminOr404` gates the role (the `(auth)` layout only gates profile completeness). Query params are parsed with `.catch(undefined)` per field, so one malformed param never sinks the rest. The toolbar offers a position filter ("All positions"), an applicant filter ("All applicants"), a status filter ("All statuses", now including "Draft"), a debounced search over "Name, email, position, or date", **Clear filters**, and sortable columns (date, name, status). Non-draft results are scoped by `buildApplicationWhere(user, 'listable')` — a manager sees only their positions' applications (withdrawn rows are kept). The position title in each row links to `/positions/[id]`. The applicant option list (`getReviewableApplicants`) is scoped the same way the results are, so a manager only ever sees applicants who applied to positions they manage. Every non-draft row — `accepted`, `rejected`, `withdrawn`, and the rest — renders the same `⋯` opening the shared `ApplicationStatusMenu` ([PM-11](#pm-11-move-one-application-through-the-status-path)): the four unresolved statuses get the next step as the first item instead of hoisted, then **See more**, with no separator between the next step and Reject when the next step already is Accept (`reviewing`); every other status gets a See-more-only menu with no leading separator, so history is reachable from every row, not just the unresolved ones. `draft` rows, in the merged "All statuses" view, get no `⋯` at all — a draft has no reviewer transition and no history to see. The row's `aria-label` reflects that split — "Change status for " where a move is still possible, "Status history for " for `withdrawn`. Opening the dialog via **See more** calls the read-only `loadApplicationStatusHistory` action since the table has no pre-fetched history per row, showing the dialog's loading skeleton while the fetch is in flight. + - **Drafts.** With no status filter active (the default "All statuses" view), drafts merge into the same table instead of needing the Draft filter to be seen: `getAllDraftApplications` and `getAllApplications` run in parallel, unpaginated, are merged into one array, and that merged array is sorted by `compareMergedApplicationRows` — a comparator mirroring the DB-level `buildApplicationListOrderBy` ordering (a draft's `submittedAt` is null and it has no `status` column at all, so it compares as null/`'draft'`) — before being paginated in memory; with no explicit sort, this clusters drafts at the front (newest `updatedAt` first) ahead of the rest (newest `submittedAt` first), and toggling any sortable column (Name, Submitted, Status) reorders drafts and applications together as one list. Each draft row renders inertly within the regular columns rather than swapping the table's shape: no checkbox, no link on the applicant name (the detail page still 404s a draft), a plain **Draft** status badge with a `ProgressRing` beside it (required-question completion — a count, not the answers themselves) and no `⋯` at all (a draft has no reviewer transition and no history to see), and `updatedAt` in the date column in place of `submittedAt`. The status filter's **Draft** option carries no count, just the label. Picking **Draft** narrows to a pure drafts view instead, unchanged from before except for one addition: `getDraftApplications` — identity and timestamps only, via `buildApplicationScopeWhere(user)` plus `status: 'draft'`, never `buildApplicationWhere` — with its own two-column swap (**Started**/**Last updated** in place of Status/Submitted) plus a **Progress** column between Position and Started (the same `ProgressRing`, not sortable), no checkbox/`⋯`/link on any row, and the bulk bar never appearing — that dedicated view has no status column to host a control, and every row in it is a draft with provably identical (empty) history. Only while the Draft filter is selected, a muted line directly above the table states the privacy boundary: "You can see who started an application and how far along it is, not what they've written. Draft answers stay private until the applicant submits." — it's contextual, not a persistent page-level notice. Choosing any other specific status still excludes drafts entirely. `/manage/applications/[id]` 404s for a draft in every case. - **Failure / edge** - Not a manager or admin → `notFound()`. - More than 100 matches → the list is truncated to 100 and the toolbar says so; there is no pagination. @@ -583,7 +583,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-14 Override a status, undo, or review its history -- **Trigger** — on the detail page, the split button's caret **See more** item for the four unresolved statuses, or the standalone caret's **See more** item for terminal decisions and non-reviewable statuses alike; on a table row ([PM-8](#pm-8-work-the-application-queue)), **See more** at the end of the `⋯` menu, from any status including `accepted`/`rejected`/`withdrawn`/`draft`. +- **Trigger** — on the detail page, the split button's caret **See more** item for the four unresolved statuses, or the standalone caret's **See more** item for terminal decisions and non-reviewable statuses alike; on a table row ([PM-8](#pm-8-work-the-application-queue)), **See more** at the end of the `⋯` menu, from any non-draft status including `accepted`/`rejected`/`withdrawn` (a draft row has no `⋯` and so no route into this dialog from the table). - **Happy path** — the dialog shows two or three stacked regions. **Change status** — a `Select` over every reviewer status except the current one, plus **Apply**; this is the only route to any backward move (`reviewing → interview_scheduled`, `accepted`/`rejected → reviewing`, etc.) and to any other off-path target, going through `updateApplicationStatus` with `override: true`, which bypasses `isAllowedApplicationStatusTransition` but still authenticates, scopes to the caller's reviewable positions, and CAS-writes the row plus its event in one transaction. Choosing `accepted`/`rejected` shows the same 10-second delayed-send warning as the quick actions before Apply, and on success surfaces the same toast **Undo** action described in [PM-11](#pm-11-move-one-application-through-the-status-path) ([XC-9](#xc-9-applicant-email)) — there's no separate "Undo last change" control in this dialog; reverting any move, decision or not, is just a second **Change status** pick back to the prior value. **Force withdraw** — admin-only, hidden entirely for a manager or for `draft`/`withdrawn`: a destructive block below Change status, above History, described in full at [AD-13](#ad-13-force-withdraw-an-application). **History** — every `ApplicationStatusEvent` for the application, newest first, each row showing ` → `, the actor's name, and the time; a row with no `from` (the one-time migration backfill) reads "Status recorded as · before history tracking" instead. Opened from the detail page, history arrives pre-fetched; opened from a table row, the dialog opens immediately and shows three skeleton rows in an `aria-busy` region while `loadApplicationStatusHistory` fetches, re-fetching on every open. Accept/Reject picked from the Select still confirm through the same `ConfirmDialog` as the header's quick actions, and a resulting move to `accepted` fires the same reduced-motion-aware confetti burst as [PM-11](#pm-11-move-one-application-through-the-status-path), for the acting reviewer only, while the dialog is still open. - **Failure / edge** - The target already matches the current status → **"This application is already