-
- {app.submittedAt ? (
-
- ) : (
- 'Draft'
- )}
-
-
-
-
+ mobileCard={(app) => {
+ const entry = app.status === 'draft' ? completion[app.id] : undefined;
+ return (
+
+
+
+ {app.position.title}
+
+
+
+
+
+
+ {app.submittedAt ? (
+
+ ) : (
+ 'Draft'
+ )}
+
+
+
+
+
-
- )}
+ );
+ }}
/>
);
}
diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx
index 89293c33..32538977 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,46 @@ 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}
+
+
+ {entry && }
+
+ {app.status === 'draft' || app.status === 'withdrawn' ? (
+
+ ) : 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 ? 'Continue application' : 'Edit & resubmit'}
-
-
+ <>
+
+
+
+ {isDraft
+ ? 'Continue application'
+ : 'Edit & resubmit'}
+
+
+ {isDraft && myApplication.completion && (
+
+ )}
+ >
) : (
+
+
+ {/* r makes the circumference exactly 100, so the offset is 100 - percent. */}
+
+
+
+ {percent}%
+
+
+ );
+}
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
index d0261e8d..264182c4 100644
--- a/docs/WORKFLOWS.md
+++ b/docs/WORKFLOWS.md
@@ -217,7 +217,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
### AP-1 See your dashboard
- **Trigger** — signing in, the logo, or the Home nav item (`/`).
-- **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 · trailing slot · status badge`; for a draft or withdrawn application the trailing slot carries the deadline 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. 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).
@@ -334,7 +334,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
- **At-risk float** — drafts or withdrawn applications with an approaching deadline (tier `soon`/`urgent`) sort to the top, nearest deadline first; everything else keeps the table's normal order. Clicking any column header (including **Deadline**) takes over the sort completely — the float never reasserts itself afterward.
- **Failure / edge**
- Nothing yet → `EmptyState` "No applications yet" · "Browse open positions to start your first application." with a **Browse positions** button.
- - A draft shows "—" for the applied date, **Continue** as its primary action and **Delete** as its row action. On a position no longer accepting, **Continue** stays in place but is disabled, with the reason in a tooltip on hover/focus (Deadline passed / Position closed / Not open yet, from `getApplicantActionBlockedReason`); **Delete** remains.
+ - A draft shows "—" for the applied date, a `ProgressRing` beside the Draft badge (required-question completion, matching the apply page's own stepper), **Continue** as its primary action and **Delete** as its row action. On a position no longer accepting, **Continue** stays in place but is disabled, with the reason in a tooltip on hover/focus (Deadline passed / Position closed / Not open yet, from `getApplicantActionBlockedReason`); **Delete** remains.
- A deleted draft is never in this list — deleting one ([AP-15](#ap-15-delete-a-draft)) removes its row entirely; applying to the position again ([AP-5](#ap-5-start-an-application)) is the only way it reappears.
- A withdrawn row shows **Edit & resubmit**, disabled with the same reason tooltip once the window has closed; it has no row action, and its deadline tiers exactly as a draft's does.
- `accepted` / `rejected` rows show "—" instead of a withdraw button.
@@ -404,7 +404,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
### AP-17 See which positions you've already applied to
- **Trigger** — landing on `/positions` while signed in.
-- **Happy path** — `getMyApplicationsByPosition(user.id)` returns the caller's applications keyed by position id; the browse page passes each card its matching entry via `myApplication`. A matched card shows the application's status badge — including `draft` — directly beside the title, left-aligned, while the position's availability badge stays on the right where it always sits; and swaps the applicant CTA: `draft` → **Continue application**, or `withdrawn` while the position still accepts → **Edit & resubmit** — both to the apply stepper; every other status, including a `withdrawn` application on a since-closed position, → **View application** to `/applications/[id]`, with no Apply button.
+- **Happy path** — `getMyApplicationsByPosition(user.id)` returns the caller's applications keyed by position id; the browse page passes each card its matching entry via `myApplication`. A matched card shows the application's status badge — including `draft` — directly beside the title, left-aligned, while the position's availability badge stays on the right where it always sits; and swaps the applicant CTA: `draft` → **Continue application**, with a `ProgressRing` (required-question completion) beside it, or `withdrawn` while the position still accepts → **Edit & resubmit** — both to the apply stepper; every other status, including a `withdrawn` application on a since-closed position, → **View application** to `/applications/[id]`, with no Apply button.
- **Failure / edge**
- Anonymous viewer → no badge, no CTA change; the ordinary Apply/View Details pair renders.
- A manager or admin browsing a position they manage still gets their own applicant card and marker here — the manage affordances live on [PM-2](#pm-2-see-the-positions-you-manage) and the detail page instead.
@@ -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 next to 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: `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), 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, 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 ."**
diff --git a/lib/types.ts b/lib/types.ts
index 9a33e20d..1418ff75 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -175,9 +175,9 @@ export type AdminApplicationListItem = WithSubmittedAt<
}>
>;
-// Identity and timestamps only — no status, applicantName or answer relation,
-// so no answer/file/completion signal is reachable from a component using
-// this. submittedAt is always null here — every row is a draft.
+// Identity and timestamps only — no status, applicantName, or answer relation.
+// completion travels as a sibling Record, never here.
+// submittedAt is always null here — every row is a draft.
export type DraftApplicationListItem = Prisma.ApplicationGetPayload<{
select: {
id: true;
@@ -334,6 +334,14 @@ export type ProfileCompleteness = {
requiredCount: number;
};
+// Counts only — safe on both a reviewer payload and an applicant's own, since
+// neither answer content nor a per-question breakdown crosses in it.
+export type ApplicationCompletion = {
+ answeredCount: number;
+ requiredCount: number;
+ percent: number;
+};
+
// questionId/type/isGlobal address a file answer without a file-metadata model.
// Shared by the reviewer application view and the applicant's own MyApplicationDetail.
export type ApplicationReviewAnswer = {
@@ -494,12 +502,16 @@ export type PositionApplicationStats = {
// Feeds the browse page's applied marker on PositionCard. No applicant
// identity, no answers — safe for a client leaf. status is the public value.
+// completion is null for a non-draft row; a count-only aggregate for a draft.
export type MyPositionApplication = Omit<
Prisma.ApplicationGetPayload<{
select: { id: true; positionId: true; status: true };
}>,
'status'
-> & { status: PublicApplicationStatus };
+> & {
+ status: PublicApplicationStatus;
+ completion: ApplicationCompletion | null;
+};
export interface NavIdentity {
name: string | null;
diff --git a/lib/utils.ts b/lib/utils.ts
index 8934f118..5b3a8ce3 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -18,6 +18,7 @@ import {
import type {
AnswerPartition,
AnswerQuestion,
+ ApplicationCompletion,
ApplicationFilters,
DeadlineInfo,
EmailLogFilters,
@@ -236,6 +237,34 @@ export function isAnswered(question: AnswerQuestion, value: string[]): boolean {
return partitionAnswerValue(question, value).fitted.length > 0;
}
+/**
+ * Required-question completion, via `isAnswered` — the same rule Submit
+ * enforces. Zero required → 100%; otherwise 100%/0% only at full/no answers.
+ */
+export function calculateAnswerCompletion(
+ questions: AnswerQuestion[],
+ values: Map,
+): ApplicationCompletion {
+ const required = questions.filter((q) => q.required);
+ const requiredCount = required.length;
+ if (requiredCount === 0)
+ return { answeredCount: 0, requiredCount: 0, percent: 100 };
+
+ const answeredCount = required.filter((q) =>
+ isAnswered(q, values.get(q.id) ?? []),
+ ).length;
+
+ if (answeredCount === requiredCount)
+ return { answeredCount, requiredCount, percent: 100 };
+ if (answeredCount === 0) return { answeredCount, requiredCount, percent: 0 };
+
+ const percent = Math.min(
+ 99,
+ Math.max(1, Math.round((answeredCount / requiredCount) * 100)),
+ );
+ return { answeredCount, requiredCount, percent };
+}
+
/** An application row wins even when empty (a deliberate clear); no row falls back to the profile. */
export function resolveGlobalAnswerValues(
questionIds: string[],
diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts
index 638f0851..86ce82b7 100644
--- a/prisma/data/applications.ts
+++ b/prisma/data/applications.ts
@@ -23,6 +23,7 @@ import { prisma } from '@/lib/prisma';
import {
type AdminApplicationListItem,
type ApplicantOtherApplication,
+ type ApplicationCompletion,
type ApplicationEmailEntry,
type ApplicationFilters,
type ApplicationForReview,
@@ -41,10 +42,12 @@ import {
type Reviewer,
} from '@/lib/types';
import {
+ calculateAnswerCompletion,
canReviewPosition,
displayUserName,
getEmailLogOccurredAt,
isPositionActive,
+ resolveGlobalAnswerValues,
} from '@/lib/utils';
// Drafts are the only null source, and every caller below excludes them via
@@ -306,10 +309,19 @@ export async function getMyApplicationsByPosition(
select: { id: true, positionId: true, status: true },
});
+ const drafts = applications.filter((a) => a.status === 'draft');
+ const completion = await getApplicationCompletion(
+ drafts.map((a) => ({ id: a.id, positionId: a.positionId, userId })),
+ );
+
return new Map(
applications.map((a) => [
a.positionId,
- { ...a, status: PUBLIC_APPLICATION_STATUS[a.status] },
+ {
+ ...a,
+ status: PUBLIC_APPLICATION_STATUS[a.status],
+ completion: completion[a.id] ?? null,
+ },
]),
);
}
@@ -871,7 +883,8 @@ function buildDraftListOrderBy(
return [{ updatedAt: 'desc' }, { id: 'desc' }];
}
-// Identity/timestamps only — no answers/files/status; DraftApplicationListItem's select is the privacy contract.
+// Identity/timestamps only — no answers/files/status; DraftApplicationListItem's
+// select is the privacy contract. getApplicationCompletion adds a sibling count-only aggregate, never widening this select.
export async function getDraftApplications(
user: Reviewer,
filters: ApplicationFilters,
@@ -1013,3 +1026,134 @@ export async function getPositionApplicationStats(
return map;
}
+
+// Cross-user aggregate — pass only applications the caller may see. Five
+// queries flat, never per row; only calculateAnswerCompletion's counts leave.
+export async function getApplicationCompletion(
+ applications: { id: string; positionId: string; userId: string }[],
+): Promise> {
+ if (applications.length === 0) return {};
+
+ const applicationIds = applications.map((a) => a.id);
+ const positionIds = [...new Set(applications.map((a) => a.positionId))];
+ const userIds = [...new Set(applications.map((a) => a.userId))];
+
+ const [
+ globalQuestions,
+ positionQuestions,
+ globalAnswers,
+ positionAnswers,
+ profileAnswers,
+ ] = await Promise.all([
+ prisma.globalQuestion.findMany({
+ where: { required: true, deletedAt: null },
+ select: {
+ id: true,
+ label: true,
+ type: true,
+ required: true,
+ options: true,
+ allowOther: true,
+ format: true,
+ },
+ }),
+ prisma.positionQuestion.findMany({
+ where: {
+ positionId: { in: positionIds },
+ required: true,
+ deletedAt: null,
+ },
+ select: {
+ id: true,
+ positionId: true,
+ label: true,
+ type: true,
+ required: true,
+ options: true,
+ allowOther: true,
+ format: true,
+ },
+ }),
+ prisma.globalApplicationAnswer.findMany({
+ where: { applicationId: { in: applicationIds }, deletedAt: null },
+ select: { applicationId: true, globalQuestionId: true, value: true },
+ }),
+ prisma.positionApplicationAnswer.findMany({
+ where: { applicationId: { in: applicationIds }, deletedAt: null },
+ select: { applicationId: true, positionQuestionId: true, value: true },
+ }),
+ prisma.globalAnswer.findMany({
+ where: { userId: { in: userIds }, deletedAt: null },
+ select: { userId: true, globalQuestionId: true, value: true },
+ }),
+ ]);
+
+ const globalQuestionIds = globalQuestions.map((q) => q.id);
+
+ const positionQuestionsByPosition = new Map<
+ string,
+ typeof positionQuestions
+ >();
+ for (const q of positionQuestions) {
+ const list = positionQuestionsByPosition.get(q.positionId) ?? [];
+ list.push(q);
+ positionQuestionsByPosition.set(q.positionId, list);
+ }
+
+ const globalAnswersByApplication = new Map<
+ string,
+ { globalQuestionId: string; value: string[] }[]
+ >();
+ for (const a of globalAnswers) {
+ const list = globalAnswersByApplication.get(a.applicationId) ?? [];
+ list.push(a);
+ globalAnswersByApplication.set(a.applicationId, list);
+ }
+
+ const positionAnswersByApplication = new Map>();
+ for (const a of positionAnswers) {
+ const map =
+ positionAnswersByApplication.get(a.applicationId) ??
+ new Map();
+ map.set(a.positionQuestionId, a.value);
+ positionAnswersByApplication.set(a.applicationId, map);
+ }
+
+ const profileAnswersByUser = new Map<
+ string,
+ { globalQuestionId: string; value: string[] }[]
+ >();
+ for (const a of profileAnswers) {
+ const list = profileAnswersByUser.get(a.userId) ?? [];
+ list.push(a);
+ profileAnswersByUser.set(a.userId, list);
+ }
+
+ const result: Record = {};
+
+ for (const app of applications) {
+ const positionRequired =
+ positionQuestionsByPosition.get(app.positionId) ?? [];
+
+ // Resolves through the profile, or a plain answer count under-reports
+ // every draft whose globals are only answered there.
+ const resolvedGlobals = resolveGlobalAnswerValues(
+ globalQuestionIds,
+ globalAnswersByApplication.get(app.id) ?? [],
+ profileAnswersByUser.get(app.userId) ?? [],
+ );
+ const positionValues =
+ positionAnswersByApplication.get(app.id) ?? new Map();
+
+ const values = new Map(resolvedGlobals);
+ for (const q of positionRequired)
+ values.set(q.id, positionValues.get(q.id) ?? []);
+
+ result[app.id] = calculateAnswerCompletion(
+ [...globalQuestions, ...positionRequired],
+ values,
+ );
+ }
+
+ return result;
+}
diff --git a/tests/db/draft-completion.test.ts b/tests/db/draft-completion.test.ts
new file mode 100644
index 00000000..12a4fe05
--- /dev/null
+++ b/tests/db/draft-completion.test.ts
@@ -0,0 +1,210 @@
+import {
+ answerAllRequiredGlobalQuestions,
+ cleanupFixtures,
+ createTestApplication,
+ createTestGlobalQuestion,
+ createTestPosition,
+ createTestPositionQuestion,
+ createTestUser,
+} from '@/tests/helpers/fixtures';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import type {
+ Application,
+ GlobalQuestion,
+ Position,
+ PositionQuestion,
+ User,
+} from '@/prisma/client';
+import {
+ getApplicationCompletion,
+ getDraftApplications,
+} from '@/prisma/data/applications';
+
+import { prisma } from '@/lib/prisma';
+
+let admin: User;
+let managerA: User;
+let managerB: User;
+
+let positionA: Position;
+let positionB: Position;
+let positionNoRequired: Position;
+let positionQuestionA: PositionQuestion;
+
+let applicantA: User;
+let draftA: Application;
+let draftB: Application;
+let noRequiredApplicant: User;
+let noRequiredDraft: Application;
+
+// Isolates the denominator: any required global question left over from
+// another run (or a locally seeded DB) would make every percentage in this
+// file nondeterministic. Soft-deleted here, restored in the outer afterAll.
+let preexistingGlobalQuestionIds: string[] = [];
+
+beforeAll(async () => {
+ const preexisting = await prisma.globalQuestion.findMany({
+ where: { required: true, deletedAt: null },
+ select: { id: true },
+ });
+ preexistingGlobalQuestionIds = preexisting.map((q) => q.id);
+ if (preexistingGlobalQuestionIds.length > 0)
+ await prisma.globalQuestion.updateMany({
+ where: { id: { in: preexistingGlobalQuestionIds } },
+ data: { deletedAt: new Date() },
+ });
+
+ admin = await createTestUser({ isAdmin: true });
+ managerA = await createTestUser();
+ managerB = await createTestUser();
+
+ positionA = await createTestPosition(admin, { managers: [managerA] });
+ positionB = await createTestPosition(admin, { managers: [managerB] });
+ positionNoRequired = await createTestPosition(admin, {
+ managers: [managerA],
+ });
+
+ positionQuestionA = await createTestPositionQuestion(positionA, admin, {
+ required: true,
+ });
+ await createTestPositionQuestion(positionB, admin, { required: true });
+ await createTestPositionQuestion(positionNoRequired, admin, {
+ required: false,
+ });
+
+ applicantA = await createTestUser();
+ draftA = await createTestApplication(applicantA, positionA, {
+ status: 'draft',
+ });
+
+ const applicantB = await createTestUser();
+ draftB = await createTestApplication(applicantB, positionB, {
+ status: 'draft',
+ });
+
+ noRequiredApplicant = await createTestUser();
+ noRequiredDraft = await createTestApplication(
+ noRequiredApplicant,
+ positionNoRequired,
+ { status: 'draft' },
+ );
+});
+
+afterAll(async () => {
+ await cleanupFixtures();
+ if (preexistingGlobalQuestionIds.length > 0)
+ await prisma.globalQuestion.updateMany({
+ where: { id: { in: preexistingGlobalQuestionIds } },
+ data: { deletedAt: null },
+ });
+});
+
+describe('getApplicationCompletion', () => {
+ it('returns one entry per draft in scope and none outside it', async () => {
+ const rows = await getDraftApplications(managerA, {});
+ const ids = rows.map((r) => r.id);
+ expect(ids).toContain(draftA.id);
+ expect(ids).not.toContain(draftB.id);
+
+ const completion = await getApplicationCompletion(
+ rows.map((r) => ({
+ id: r.id,
+ positionId: r.position.id,
+ userId: r.user.id,
+ })),
+ );
+
+ expect(Object.keys(completion).sort()).toEqual([...ids].sort());
+ expect(completion[draftB.id]).toBeUndefined();
+ });
+
+ it('reports 0% for an unanswered required position question', async () => {
+ const completion = await getApplicationCompletion([
+ { id: draftA.id, positionId: positionA.id, userId: applicantA.id },
+ ]);
+ expect(completion[draftA.id]).toEqual({
+ answeredCount: 0,
+ requiredCount: 1,
+ percent: 0,
+ });
+ });
+
+ it('reports 100% once the required position question is answered', async () => {
+ await prisma.positionApplicationAnswer.create({
+ data: {
+ applicationId: draftA.id,
+ positionQuestionId: positionQuestionA.id,
+ questionLabel: positionQuestionA.label,
+ questionType: positionQuestionA.type,
+ value: ['an answer'],
+ createdById: applicantA.id,
+ updatedById: applicantA.id,
+ },
+ });
+
+ const completion = await getApplicationCompletion([
+ { id: draftA.id, positionId: positionA.id, userId: applicantA.id },
+ ]);
+ expect(completion[draftA.id]).toEqual({
+ answeredCount: 1,
+ requiredCount: 1,
+ percent: 100,
+ });
+ });
+
+ it('reports 100% with no NaN when nothing is required', async () => {
+ const completion = await getApplicationCompletion([
+ {
+ id: noRequiredDraft.id,
+ positionId: positionNoRequired.id,
+ userId: noRequiredApplicant.id,
+ },
+ ]);
+ expect(completion[noRequiredDraft.id]).toEqual({
+ answeredCount: 0,
+ requiredCount: 0,
+ percent: 100,
+ });
+ });
+
+ describe('a required global answered only on the profile', () => {
+ let globalQuestion: GlobalQuestion;
+ let applicant: User;
+ let draft: Application;
+
+ beforeAll(async () => {
+ globalQuestion = await createTestGlobalQuestion(admin, {
+ required: true,
+ });
+ applicant = await createTestUser();
+ draft = await createTestApplication(applicant, positionNoRequired, {
+ status: 'draft',
+ });
+ // Profile only — no GlobalApplicationAnswer row on the draft itself.
+ await answerAllRequiredGlobalQuestions(applicant);
+ });
+
+ afterAll(async () => {
+ await prisma.globalQuestion.update({
+ where: { id: globalQuestion.id },
+ data: { deletedAt: new Date() },
+ });
+ });
+
+ it('counts the global as answered through the profile fallback', async () => {
+ const completion = await getApplicationCompletion([
+ {
+ id: draft.id,
+ positionId: positionNoRequired.id,
+ userId: applicant.id,
+ },
+ ]);
+ expect(completion[draft.id]).toEqual({
+ answeredCount: 1,
+ requiredCount: 1,
+ percent: 100,
+ });
+ });
+ });
+});
diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts
index ebd9cd83..a45b94ca 100644
--- a/tests/unit/utils.test.ts
+++ b/tests/unit/utils.test.ts
@@ -13,6 +13,7 @@ import {
answerFieldIds,
buildApplicationsHref,
buildEmailLogHref,
+ calculateAnswerCompletion,
canReviewPosition,
classifyDecisionEmailStatus,
countBulkEmailRecipients,
@@ -1244,6 +1245,124 @@ describe('isAnswered', () => {
});
});
+const fileUploadQuestion: AnswerQuestion = {
+ id: 'q4',
+ label: 'Q',
+ type: 'file_upload',
+ required: true,
+ options: [],
+ allowOther: false,
+ format: null,
+};
+
+const optionalShortAnswerQuestion: AnswerQuestion = {
+ ...shortAnswerQuestion,
+ id: 'q5',
+ required: false,
+};
+
+describe('calculateAnswerCompletion', () => {
+ it('is 0% when none are answered', () => {
+ expect(calculateAnswerCompletion([shortAnswerQuestion], new Map())).toEqual(
+ { answeredCount: 0, requiredCount: 1, percent: 0 },
+ );
+ });
+
+ it('is 100% when all required questions are answered', () => {
+ expect(
+ calculateAnswerCompletion(
+ [shortAnswerQuestion],
+ new Map([['q1', ['hi']]]),
+ ),
+ ).toEqual({ answeredCount: 1, requiredCount: 1, percent: 100 });
+ });
+
+ it('rounds a partial count but never to the 100/0 clamps', () => {
+ const questions = Array.from({ length: 3 }, (_, i) => ({
+ ...shortAnswerQuestion,
+ id: `q${i}`,
+ }));
+ const values = new Map([['q0', ['hi']]]);
+ // 1/3 rounds to 33%, well clear of either clamp.
+ expect(calculateAnswerCompletion(questions, values)).toEqual({
+ answeredCount: 1,
+ requiredCount: 3,
+ percent: 33,
+ });
+ });
+
+ it('never reports 100% unless every required question is answered', () => {
+ const questions = Array.from({ length: 200 }, (_, i) => ({
+ ...shortAnswerQuestion,
+ id: `q${i}`,
+ }));
+ const values = new Map(questions.slice(0, 199).map((q) => [q.id, ['hi']]));
+ // 199/200 rounds to 100 — clamped to 99 since one is still unanswered.
+ expect(calculateAnswerCompletion(questions, values)).toEqual({
+ answeredCount: 199,
+ requiredCount: 200,
+ percent: 99,
+ });
+ });
+
+ it('never reports 0% while at least one required question is answered', () => {
+ const questions = Array.from({ length: 200 }, (_, i) => ({
+ ...shortAnswerQuestion,
+ id: `q${i}`,
+ }));
+ const values = new Map([['q0', ['hi']]]);
+ // 1/200 rounds to 0 — clamped to 1 since one is answered.
+ expect(calculateAnswerCompletion(questions, values)).toEqual({
+ answeredCount: 1,
+ requiredCount: 200,
+ percent: 1,
+ });
+ });
+
+ it('is 100% with no NaN when there are no required questions', () => {
+ expect(
+ calculateAnswerCompletion([optionalShortAnswerQuestion], new Map()),
+ ).toEqual({ answeredCount: 0, requiredCount: 0, percent: 100 });
+ });
+
+ it('an unanswered optional question does not affect the denominator', () => {
+ expect(
+ calculateAnswerCompletion(
+ [shortAnswerQuestion, optionalShortAnswerQuestion],
+ new Map([['q1', ['hi']]]),
+ ),
+ ).toEqual({ answeredCount: 1, requiredCount: 1, percent: 100 });
+ });
+
+ it('an empty-string value does not count as answered', () => {
+ expect(
+ calculateAnswerCompletion([shortAnswerQuestion], new Map([['q1', []]])),
+ ).toEqual({ answeredCount: 0, requiredCount: 1, percent: 0 });
+ });
+
+ it('a file_upload value counts as answered', () => {
+ expect(
+ calculateAnswerCompletion(
+ [fileUploadQuestion],
+ new Map([['q4', ['https://blob.example/file']]]),
+ ),
+ ).toEqual({ answeredCount: 1, requiredCount: 1, percent: 100 });
+ });
+
+ it('counts a global answered only through the resolved profile fallback', () => {
+ const resolved = resolveGlobalAnswerValues(
+ ['q1'],
+ [],
+ [{ globalQuestionId: 'q1', value: ['profile answer'] }],
+ );
+ expect(calculateAnswerCompletion([shortAnswerQuestion], resolved)).toEqual({
+ answeredCount: 1,
+ requiredCount: 1,
+ percent: 100,
+ });
+ });
+});
+
describe('answerFieldIds', () => {
it('derives every id from the question id', () => {
expect(answerFieldIds('q1')).toEqual({