+
+
+
+ {hasAdornment && }
+
+ {hasDescription &&
}
- {actions && (
-
- {actions}
+ {actions && actions.length > 0 && (
+
+ {actions.map((widthClass, i) => (
+
+ ))}
)}
diff --git a/components/ui/data-table-skeleton.tsx b/components/ui/data-table-skeleton.tsx
new file mode 100644
index 00000000..fdbaba58
--- /dev/null
+++ b/components/ui/data-table-skeleton.tsx
@@ -0,0 +1,257 @@
+import {
+ DATA_TABLE_DESKTOP_CLASS,
+ DATA_TABLE_MOBILE_CLASS,
+ DATA_TABLE_SHELL_CLASS,
+ DATA_TABLE_STACK_CLASS,
+} from '@/lib/data-table';
+import { cn } from '@/lib/utils';
+
+import { Card } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+
+type DataTableSkeletonShape = 'text' | 'badge' | 'action' | 'checkbox';
+type DataTableSkeletonMobileRole =
+ | 'leading'
+ | 'primary'
+ | 'trailing'
+ | 'line'
+ | 'lineTrailing'
+ | 'hidden';
+
+export interface DataTableSkeletonColumn {
+ head: string;
+ cell?: string;
+ subCell?: string;
+ shape?: DataTableSkeletonShape;
+ headClassName?: string;
+ cellClassName?: string;
+ mobile?: DataTableSkeletonMobileRole;
+}
+
+type DataTableSkeletonGap = 'gap-0' | 'gap-1' | 'gap-2' | 'gap-3';
+
+interface DataTableSkeletonProps {
+ columns: DataTableSkeletonColumn[];
+ rows?: number;
+ mobileRows?: number;
+ hasReorderHandle?: boolean;
+ // Vertical gap between stacked lines inside a mobile card — match the real table's mobile card.
+ mobileGap?: DataTableSkeletonGap;
+ // Gap between the leading column/handle and the card body, when either is present.
+ mobileRowGap?: DataTableSkeletonGap;
+}
+
+function shapeSkeleton(
+ shape: DataTableSkeletonShape | undefined,
+ widthClass: string,
+ { mobile = false }: { mobile?: boolean } = {},
+) {
+ const resolved: DataTableSkeletonShape = shape ?? 'text';
+ switch (resolved) {
+ case 'checkbox':
+ return
;
+ case 'badge':
+ return
;
+ case 'action':
+ return (
+
+ );
+ case 'text':
+ return
;
+ default: {
+ const exhaustiveCheck: never = resolved;
+ return exhaustiveCheck;
+ }
+ }
+}
+
+// Resolves each column's mobile role: explicit wins; otherwise the first
+// column defaults to `primary` and the first `badge` column to `trailing`.
+function resolveMobileRoles(
+ columns: DataTableSkeletonColumn[],
+): DataTableSkeletonMobileRole[] {
+ let trailingAssigned = false;
+ return columns.map((column, index) => {
+ if (column.mobile) {
+ if (column.mobile === 'trailing') trailingAssigned = true;
+ return column.mobile;
+ }
+ if (index === 0) return 'primary';
+ if (column.shape === 'badge' && !trailingAssigned) {
+ trailingAssigned = true;
+ return 'trailing';
+ }
+ return 'line';
+ });
+}
+
+function DataTableSkeletonMobileRow({
+ columns,
+ roles,
+ hasReorderHandle,
+ mobileGap,
+ mobileRowGap,
+}: {
+ columns: DataTableSkeletonColumn[];
+ roles: DataTableSkeletonMobileRole[];
+ hasReorderHandle: boolean;
+ mobileGap: DataTableSkeletonGap;
+ mobileRowGap: DataTableSkeletonGap;
+}) {
+ const leading = columns.filter((_, i) => roles[i] === 'leading');
+ const primaryColumn = columns.find((_, i) => roles[i] === 'primary');
+ const trailingColumn = columns.find((_, i) => roles[i] === 'trailing');
+ const lineItems: {
+ main: DataTableSkeletonColumn;
+ trailing?: DataTableSkeletonColumn;
+ }[] = [];
+ columns.forEach((column, i) => {
+ if (roles[i] === 'line') lineItems.push({ main: column });
+ else if (roles[i] === 'lineTrailing') {
+ const last = lineItems.at(-1);
+ if (last) last.trailing = column;
+ else lineItems.push({ main: column });
+ }
+ });
+
+ const body = (
+
+ {(primaryColumn || trailingColumn) && (
+
+ {primaryColumn ? (
+
+ {shapeSkeleton(
+ primaryColumn.shape,
+ primaryColumn.cell ?? primaryColumn.head,
+ )}
+ {primaryColumn.subCell && (
+
+ )}
+
+ ) : (
+
+ )}
+ {trailingColumn &&
+ shapeSkeleton(
+ trailingColumn.shape,
+ trailingColumn.cell ?? trailingColumn.head,
+ { mobile: true },
+ )}
+
+ )}
+ {lineItems.map(({ main, trailing }, i) =>
+ trailing ? (
+
+ {shapeSkeleton(main.shape, main.cell ?? main.head)}
+ {shapeSkeleton(trailing.shape, trailing.cell ?? trailing.head, {
+ mobile: true,
+ })}
+
+ ) : (
+
{shapeSkeleton(main.shape, main.cell ?? main.head)}
+ ),
+ )}
+
+ );
+
+ if (!hasReorderHandle && leading.length === 0)
+ return
{body}
;
+
+ return (
+
+ {hasReorderHandle && (
+
+ )}
+ {leading.map((column, i) => (
+
+ {shapeSkeleton(column.shape, column.cell ?? column.head)}
+
+ ))}
+ {body}
+
+ );
+}
+
+export function DataTableSkeleton({
+ columns,
+ rows = 5,
+ mobileRows = 3,
+ hasReorderHandle = false,
+ mobileGap = 'gap-2',
+ mobileRowGap = 'gap-2',
+}: DataTableSkeletonProps) {
+ const mobileRoles = resolveMobileRoles(columns);
+
+ return (
+
+
+
+
+
+
+ {hasReorderHandle && (
+
+ Reorder
+
+ )}
+ {columns.map((column, i) => (
+
+
+
+ ))}
+
+
+
+ {Array.from({ length: rows }).map((_, rowIndex) => (
+
+ {hasReorderHandle && (
+
+
+
+ )}
+ {columns.map((column, i) => (
+
+ {column.subCell ? (
+
+
+
+
+ ) : (
+ shapeSkeleton(column.shape, column.cell ?? column.head)
+ )}
+
+ ))}
+
+ ))}
+
+
+
+
+
+ {Array.from({ length: mobileRows }).map((_, rowIndex) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/ui/data-table-toolbar.tsx b/components/ui/data-table-toolbar.tsx
index f1a8cc78..cad1881d 100644
--- a/components/ui/data-table-toolbar.tsx
+++ b/components/ui/data-table-toolbar.tsx
@@ -3,17 +3,18 @@ import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+
+const WRAPPER_CLASS =
+ 'flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end';
+const FIELD_CLASS = 'flex w-full flex-col gap-1.5 sm:w-48';
interface DataTableToolbarProps {
children: ReactNode;
}
export function DataTableToolbar({ children }: DataTableToolbarProps) {
- return (
-
- {children}
-
- );
+ return
{children}
;
}
interface DataTableToolbarFieldProps {
@@ -30,9 +31,34 @@ export function DataTableToolbarField({
children,
}: DataTableToolbarFieldProps) {
return (
-
+
{label}
{children}
);
}
+
+interface DataTableToolbarSkeletonProps {
+ // Per-field width utility, e.g. 'sm:w-48' — mirrors DataTableToolbarField's className.
+ fields: string[];
+ hasTrailingCount?: boolean;
+}
+
+export function DataTableToolbarSkeleton({
+ fields,
+ hasTrailingCount = false,
+}: DataTableToolbarSkeletonProps) {
+ return (
+
+ {fields.map((widthClass, i) => (
+
+
+
+
+ ))}
+ {hasTrailingCount && (
+
+ )}
+
+ );
+}
diff --git a/components/ui/data-table.tsx b/components/ui/data-table.tsx
index c5be5666..582b8bc6 100644
--- a/components/ui/data-table.tsx
+++ b/components/ui/data-table.tsx
@@ -6,6 +6,10 @@ import { useCallback, useMemo } from 'react';
import { parseAsStringEnum, parseAsStringLiteral, useQueryStates } from 'nuqs';
import {
+ DATA_TABLE_DESKTOP_CLASS,
+ DATA_TABLE_MOBILE_CLASS,
+ DATA_TABLE_SHELL_CLASS,
+ DATA_TABLE_STACK_CLASS,
type DataTableColumn,
type SortDirection,
type SortState,
@@ -289,15 +293,15 @@ export function DataTable
({
const columnCount = columns.length + (showReorderColumn ? 1 : 0);
return (
-
+
{showReorderColumn && !sortedByOrder && (
{reorder.sortHint}
)}
{/* overflow-hidden clips the header hover highlight to the card's rounded corners */}
-
+
{/* DndContext must wrap this div, not nest inside (a11y live region renders as a sibling). */}
({
{/* Mobile stacked cards — sort order from sortedRows reflects active sort */}
` + skeleton for loading, `error.tsx`/inline error for failure, a designed empty state (icon + one line + primary action) for zero items. Secondary cards on a detail page may opt into `SectionCardEmpty`'s compact variant (one muted line, no icon/action) instead — the roomy form stays the default for dashboards.
+- **Skeleton adjacency.** A skeleton lives beside the component it mirrors and shares that component's layout constants (`SectionCard`, `PageHeader`, `PositionCard`, `DataTable`) — never hand-drawn markup that happens to look similar. A `'use client'` component's skeleton lives in a sibling **server** module, and both sides import their shared class constants from the feature's `lib/` module — a server `loading.tsx` importing a client module would pull its bundle into that route and still couldn't read its constants, since Next turns every export of a client module into a client reference.
+- **Skeleton row counts:** tables default to **5 rows desktop / 3 mobile**; flat card lists to **3 cards**; grouped card lists to **2 groups (2 cards, then 1)**; `SectionCardSkeleton` keeps its own 3-row default. Override only where a surface is known to be shorter.
- **Focus & overlays:** never `outline-none` without a visible replacement; rely on Radix focus trapping in dialogs/sheets — don't break it with custom wrappers.
- **Section sub-nav.** A page section opts into the sidebar's in-page nav by giving its outermost `` an `id` plus `data-section-nav=""` (matching its visible heading) and `scroll-mt-6`. The sidebar discovers these from the DOM under the active nav item — nothing to register elsewhere, and a group that never renders (e.g. an empty position status) contributes nothing. Fewer than two opted-in sections on a page renders no sub-nav. This is secondary navigation only — never the sole way to reach a section; every opted-in section must also be reachable by scrolling. The highlighted item is the last section whose top has crossed the middle of the scroll area. At the top of the page it is the first section, at the bottom the last, and after a click the clicked section until the user scrolls again.
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
index d53c7533..d82d17e5 100644
--- a/docs/WORKFLOWS.md
+++ b/docs/WORKFLOWS.md
@@ -229,7 +229,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
### AP-2 Answer profile questions
- **Trigger** — the Profile item in the user menu, the completeness banner, or the apply page's **Go to Profile** button.
-- **Happy path** — `getProfileData(user.id)` returns every non-deleted global question with the caller's answer. Each field autosaves on blur through `updateGlobalAnswer`, which validates format and option membership and upserts scoped to the caller. Answers are shared across every application: "Your answers are shared across every application."
+- **Happy path** — `getProfileData(user.id)` returns every non-deleted global question with the caller's answer. Each field autosaves on blur through `updateGlobalAnswer`, which validates format and option membership and upserts scoped to the caller. A `phone_number`-formatted answer is additionally normalized to digits with an optional leading `+` before it's stored. Answers are shared across every application: "Your answers are shared across every application."
- **Failure / edge**
- Format mismatch on a `short_answer` with a `format` → the format's message from `SHORT_ANSWER_FORMAT_ERROR_MESSAGES`, inline; the autosave is skipped so the error isn't also toasted.
- Option not in the question's list, too many values, or over the length limit → the message from `getAnswerValueError`.
@@ -277,7 +277,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
### AP-6 Answer application questions
- **Trigger** — the stepper on `/positions/[id]/apply` for a `draft` or `withdrawn` application.
-- **Happy path** — step 1 holds the global (profile) questions, step 2 the position questions; the stepper collapses to one step when the position has no questions of its own. Each field autosaves on blur via `createOrUpdateApplicationAnswer`, which re-reads the question's label and shape from the database (never from the client) and upserts the answer with that label snapshotted onto the row.
+- **Happy path** — step 1 holds the global (profile) questions, step 2 the position questions; the stepper collapses to one step when the position has no questions of its own. Each field autosaves on blur via `createOrUpdateApplicationAnswer`, which re-reads the question's label and shape from the database (never from the client) and upserts the answer with that label snapshotted onto the row. A `phone_number`-formatted answer is normalized to digits with an optional leading `+` the same way as [AP-2](#ap-2-answer-profile-questions).
- **Failure / edge**
- Format mismatch, bad option, too many values, or over-length → the specific message inline and as a toast; the value is not persisted.
- Application no longer applicant-editable → **"This application has already been submitted. Withdraw it to make changes."**
@@ -345,7 +345,7 @@ Any signed-in user. Every user is an applicant; manager and admin capabilities a
### AP-11 View one of your applications
- **Trigger** — a position title link on `/applications`, or the redirect after submitting ([AP-9](#ap-9-submit-an-application)).
-- **Happy path** — `getMyApplication(id, user.id)` is scoped to the caller with the same visibility as the list, and returns the public status ([XC-8](#xc-8-applicant-facing-status-grouping)). The header shows the position title with the status badge directly beside it, the status sentence underneath, and the primary/row actions (Continue, Edit & resubmit, Withdraw, Delete draft — whichever applies) right-aligned on that same header row; below it, "Applied " (or "Draft · last saved ", from `lastSavedAt` — a draft's own `updatedAt`, never exposed for a submitted application) and a link to the position, then both answer groups — "Your Profile Answers" and "Your Answers for This Position" — full width. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a retyped or relabeled question still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The header's primary action follows the same rule as the list ([AP-10](#ap-10-track-your-applications)): on a draft or withdrawn application whose position is no longer accepting, Continue/Edit & resubmit is disabled with the reason in a tooltip on hover/focus, and Delete/Withdraw still render alongside it.
+- **Happy path** — `getMyApplication(id, user.id)` is scoped to the caller with the same visibility as the list, and returns the public status ([XC-8](#xc-8-applicant-facing-status-grouping)). The header shows the position title with the status badge directly beside it, the status sentence underneath, and the primary/row actions (Continue, Edit & resubmit, Withdraw, Delete draft — whichever applies) right-aligned on that same header row; below it, "Applied " (or "Draft · last saved ", from `lastSavedAt` — a draft's own `updatedAt`, never exposed for a submitted application) and a link to the position, then both answer groups — "Your Profile Answers" and "Your Answers for This Position" — full width. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a retyped or relabeled question still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The header's primary action follows the same rule as the list ([AP-10](#ap-10-track-your-applications)): on a draft or withdrawn application whose position is no longer accepting, Continue/Edit & resubmit is disabled with the reason in a tooltip on hover/focus, and Delete/Withdraw still render alongside it. A `short_answer` with `format: 'phone_number'` renders 10-digit and 11-digit-leading-`1` values masked as `(555) 123-4567`; anything else — international numbers, legacy shapes that don't fit the mask — renders exactly as stored.
- **Deadline (drafts and withdrawn applications only)** — a fourth segment after the **View position** link, `DeadlineIndicator` (`variant="full"`, `emphasizeUrgency`) tiered against a server-resolved `now` — same helper and thresholds as [AP-10](#ap-10-track-your-applications). The gate is separate from the "Draft · last saved" vs "Applied" prefix above it, so a withdrawn application keeps its "Applied " prefix while still gaining the deadline segment. A submitted or terminal application's meta line is unchanged; `getMyApplication`'s select already carries the fields needed, so no data-layer change.
- **Failure / edge**
- Not the caller's, soft-deleted, or on an unpublished position → `notFound()`, so a bookmarked URL cannot outlive its list row.
@@ -523,7 +523,7 @@ A user who manages at least one non-deleted position. Manager status is **derive
### PM-9 Open an application for review
- **Trigger** — a row on `/manage/applications` (`/manage/applications/[id]`).
-- **Happy path** — `getApplicationForReview(id, user)` uses the `listable` scope — withdrawn rows are kept, drafts are not — and `getApplicationStatusHistory(id, user)` fetches alongside it. The page shows a "Back to Applications" link, then a header row with the applicant's snapshotted name and status badge together, their email underneath, and a header action appropriate to status, right-aligned on that same row — a split button for the four unresolved statuses (its caret dropdown ends in **See more**, which opens the status dialog), or for terminal decisions and non-reviewable statuses alike, the same explanatory note plus a standalone caret whose dropdown menu also ends in **See more** — there is no separate standalone `⋯` for any status, since that would be a second control shape doing the same job as the caret; below it, a linked position title and the applied date, then an "Other Applications" section (`getApplicantOtherApplications`) followed by the profile and position answer groups, then an "Email History" section (`getApplicationEmailHistory`) last, each full width. The "Other Applications" section lists this applicant's other applications platform-wide — including positions the viewer doesn't manage — with precise status, applied date, and the position title linked to `/positions/[id]`; a row links to `/manage/applications/[id]` only when the viewer can actually open it (admin, or a manager of that position) — otherwise the row shows no link at all. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a question retyped or relabeled after submission still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The "Email History" section lists every `EmailLog` row for this application — subject, a status badge, and a meta line of `{Template label} · {timestamp} · {status description}` (`getEmailLogOccurredAt`) — through the same `listable` scope as the rest of the page, applied through the `application` relation; OTP rows never appear, since they're written with no `applicationId` and so can never match the equality filter (no template filter exists to be forgotten or bypassed). The template label is what tells `application_accepted` and `application_rejected` apart here, since both now render an identical subject ([XC-9](#xc-9-applicant-email)). `sent` is shown distinctly from `delivered` — a sentence under the timestamp states that delivery isn't confirmed yet, since a provider hand-off is not proof of receipt. See `PERMISSIONS.md` → "Cross-scope disclosure" for the authorization rule. A manager working this queue may separately receive a daily digest of new arrivals or a weekly reminder of everything still unresolved across their positions ([XC-10](#xc-10-manager-digests)) — neither email is tied to any one application, so neither appears in this page's Email History section.
+- **Happy path** — `getApplicationForReview(id, user)` uses the `listable` scope — withdrawn rows are kept, drafts are not — and `getApplicationStatusHistory(id, user)` fetches alongside it. The page shows a "Back to Applications" link, then a header row with the applicant's snapshotted name and status badge together, their email underneath, and a header action appropriate to status, right-aligned on that same row — a split button for the four unresolved statuses (its caret dropdown ends in **See more**, which opens the status dialog), or for terminal decisions and non-reviewable statuses alike, the same explanatory note plus a standalone caret whose dropdown menu also ends in **See more** — there is no separate standalone `⋯` for any status, since that would be a second control shape doing the same job as the caret; below it, a linked position title and the applied date, then an "Other Applications" section (`getApplicantOtherApplications`) followed by the profile and position answer groups, then an "Email History" section (`getApplicationEmailHistory`) last, each full width. The "Other Applications" section lists this applicant's other applications platform-wide — including positions the viewer doesn't manage — with precise status, applied date, and the position title linked to `/positions/[id]`; a row links to `/manage/applications/[id]` only when the viewer can actually open it (admin, or a manager of that position) — otherwise the row shows no link at all. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a question retyped or relabeled after submission still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. A `short_answer` with `format: 'phone_number'` renders 10-digit and 11-digit-leading-`1` values masked as `(555) 123-4567`, same as [AP-11](#ap-11-view-one-of-your-applications); anything else renders exactly as stored. The "Email History" section lists every `EmailLog` row for this application — subject, a status badge, and a meta line of `{Template label} · {timestamp} · {status description}` (`getEmailLogOccurredAt`) — through the same `listable` scope as the rest of the page, applied through the `application` relation; OTP rows never appear, since they're written with no `applicationId` and so can never match the equality filter (no template filter exists to be forgotten or bypassed). The template label is what tells `application_accepted` and `application_rejected` apart here, since both now render an identical subject ([XC-9](#xc-9-applicant-email)). `sent` is shown distinctly from `delivered` — a sentence under the timestamp states that delivery isn't confirmed yet, since a provider hand-off is not proof of receipt. See `PERMISSIONS.md` → "Cross-scope disclosure" for the authorization rule. A manager working this queue may separately receive a daily digest of new arrivals or a weekly reminder of everything still unresolved across their positions ([XC-10](#xc-10-manager-digests)) — neither email is tied to any one application, so neither appears in this page's Email History section.
- **Failure / edge**
- Outside the caller's scope, a draft, or missing → `notFound()`; unauthorized and missing are indistinguishable.
- The applicant renamed themselves since submitting → the heading reads " ()".
diff --git a/lib/constants.ts b/lib/constants.ts
index 09e17daa..428e8ddc 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -162,6 +162,44 @@ export function matchesShortAnswerFormat(
return SHORT_ANSWER_FORMAT_PATTERNS[format].test(value.trim());
}
+// Digits + optional leading +; 00 promotes to + (validator treats them as the same prefix).
+export function normalizePhoneNumber(value: string): string {
+ const trimmed = value.trim();
+ if (!matchesShortAnswerFormat(trimmed, 'phone_number')) return trimmed;
+
+ const hasPlus = trimmed.startsWith('+');
+ const digits = trimmed.replace(/\D/g, '');
+ if (hasPlus) return `+${digits}`;
+ if (digits.startsWith('00')) return `+${digits.slice(2)}`;
+ return digits;
+}
+
+// Digit-strips first so legacy, unnormalized rows also mask. Anything else renders unchanged.
+export function formatPhoneNumber(value: string): string {
+ const digits = value.replace(/\D/g, '');
+ if (digits.length === 10)
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
+ if (digits.length === 11 && digits.startsWith('1'))
+ return `(${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
+ return value;
+}
+
+// Trims every format; phone_number additionally normalizes to digits + optional leading +.
+export function normalizeShortAnswerValue(
+ value: string,
+ format: ShortAnswerFormatValue,
+): string {
+ return format === 'phone_number' ? normalizePhoneNumber(value) : value.trim();
+}
+
+// Identity for every format except phone_number, which masks US-shaped values.
+export function formatShortAnswerValue(
+ value: string,
+ format: ShortAnswerFormatValue | null,
+): string {
+ return format === 'phone_number' ? formatPhoneNumber(value) : value;
+}
+
export const baseQuestionSchema = z.object({
label: z.string().min(1, 'Label is required'),
type: z.enum(QUESTION_TYPE_VALUES),
diff --git a/lib/data-table.ts b/lib/data-table.ts
index a62a60fe..33c80689 100644
--- a/lib/data-table.ts
+++ b/lib/data-table.ts
@@ -1,5 +1,12 @@
import type { ReactNode } from 'react';
+export const DATA_TABLE_STACK_CLASS = 'flex flex-col gap-2';
+export const DATA_TABLE_SHELL_CLASS = 'gap-0 overflow-hidden p-0';
+export const DATA_TABLE_DESKTOP_CLASS = 'hidden md:block';
+export const DATA_TABLE_MOBILE_CLASS = 'flex flex-col divide-y md:hidden';
+// Wraps a table plus its adjoining pagination/filter-summary line.
+export const DATA_TABLE_RESULTS_CLASS = 'flex flex-col gap-3';
+
export type SortDirection = 'asc' | 'desc';
export interface SortState {
diff --git a/lib/types.ts b/lib/types.ts
index aecb391a..9a33e20d 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -342,6 +342,7 @@ export type ApplicationReviewAnswer = {
questionLabel: string;
value: string[];
type: QuestionType;
+ format: ShortAnswerFormat | null;
isGlobal: boolean;
};
diff --git a/package-lock.json b/package-lock.json
index a6d2683b..2bf85761 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aplio",
- "version": "1.14.0",
+ "version": "1.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aplio",
- "version": "1.14.0",
+ "version": "1.14.1",
"dependencies": {
"@better-auth/prisma-adapter": "^1.6.29",
"@dnd-kit/core": "^6.3.1",
diff --git a/package.json b/package.json
index 482c1a6c..a64cf465 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aplio",
- "version": "1.14.0",
+ "version": "1.14.1",
"private": true,
"engines": {
"node": "24.x",
diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts
index 8d1a3f14..ee36776c 100644
--- a/prisma/actions/applications.ts
+++ b/prisma/actions/applications.ts
@@ -34,6 +34,7 @@ import {
isAllowedApplicationStatusTransition,
isApplicantEditableApplicationStatus,
matchesShortAnswerFormat,
+ normalizeShortAnswerValue,
} from '@/lib/constants';
import {
type DecisionEmailRecipient,
@@ -273,10 +274,11 @@ export async function createOrUpdateApplicationAnswer(params: {
const answerError = getAnswerValueError(question, value);
if (answerError) return { error: answerError };
- // matchesShortAnswerFormat trims internally, so save the trimmed value.
+ // Trims every short-answer format; phone_number additionally normalizes to digits.
+ const format = question.format;
const persistedValue =
- question.type === 'short_answer' && question.format
- ? value.map((v) => v.trim())
+ question.type === 'short_answer' && format
+ ? value.map((v) => normalizeShortAnswerValue(v, format))
: value;
if (isGlobal) {
diff --git a/prisma/actions/profile.ts b/prisma/actions/profile.ts
index 79e6cb88..50f0e24e 100644
--- a/prisma/actions/profile.ts
+++ b/prisma/actions/profile.ts
@@ -14,6 +14,7 @@ import {
getAnswerValueError,
matchesShortAnswerFormat,
nameSchema,
+ normalizeShortAnswerValue,
} from '@/lib/constants';
import { prisma } from '@/lib/prisma';
import { type ErrorType, type ResponseType } from '@/lib/utils';
@@ -72,10 +73,11 @@ export async function updateGlobalAnswer(
const answerError = getAnswerValueError(question, parsed.data.value);
if (answerError) return { error: answerError };
- // matchesShortAnswerFormat trims internally, so save the trimmed value.
+ // Trims every short-answer format; phone_number additionally normalizes to digits.
+ const format = question.format;
const persistedValue =
- question.type === 'short_answer' && question.format
- ? parsed.data.value.map((v) => v.trim())
+ question.type === 'short_answer' && format
+ ? parsed.data.value.map((v) => normalizeShortAnswerValue(v, format))
: parsed.data.value;
const result = await prisma.globalAnswer.upsert({
diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts
index 12434dcb..638f0851 100644
--- a/prisma/data/applications.ts
+++ b/prisma/data/applications.ts
@@ -107,6 +107,7 @@ const applicationAnswersSelect = {
questionLabel: true,
questionType: true,
value: true,
+ globalQuestion: { select: { format: true } },
},
},
positionAnswers: {
@@ -118,6 +119,7 @@ const applicationAnswersSelect = {
questionLabel: true,
questionType: true,
value: true,
+ positionQuestion: { select: { format: true } },
},
},
} as const;
@@ -127,7 +129,7 @@ const positionQuestionsSelect = {
questions: {
where: { deletedAt: null },
orderBy: { order: 'asc' },
- select: { id: true, label: true, type: true },
+ select: { id: true, label: true, type: true, format: true },
},
} as const;
@@ -162,6 +164,7 @@ function normalizeApplicationAnswers(
questionLabel: answer.questionLabel,
value: answer.value,
type: answer.questionType,
+ format: question.format,
isGlobal: false,
};
return {
@@ -170,6 +173,7 @@ function normalizeApplicationAnswers(
questionLabel: question.label,
value: [],
type: question.type,
+ format: question.format,
isGlobal: false,
};
},
@@ -183,6 +187,7 @@ function normalizeApplicationAnswers(
questionLabel: a.questionLabel,
value: a.value,
type: a.questionType,
+ format: a.positionQuestion.format,
isGlobal: false,
}));
@@ -193,6 +198,7 @@ function normalizeApplicationAnswers(
questionLabel: a.questionLabel,
value: a.value,
type: a.questionType,
+ format: a.globalQuestion.format,
isGlobal: true,
})),
positionAnswers: [...liveQuestionAnswers, ...orphanedAnswers],
diff --git a/tests/db/phone-number-normalization.test.ts b/tests/db/phone-number-normalization.test.ts
new file mode 100644
index 00000000..5d857975
--- /dev/null
+++ b/tests/db/phone-number-normalization.test.ts
@@ -0,0 +1,82 @@
+import {
+ cleanupFixtures,
+ createTestApplication,
+ createTestGlobalQuestion,
+ createTestPosition,
+ createTestUser,
+} from '@/tests/helpers/fixtures';
+import { actAs } from '@/tests/stubs/auth-server';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { createOrUpdateApplicationAnswer } from '@/prisma/actions/applications';
+import { updateGlobalAnswer } from '@/prisma/actions/profile';
+import type { Position, User } from '@/prisma/client';
+
+import { prisma } from '@/lib/prisma';
+import { isError } from '@/lib/utils';
+
+let admin: User;
+let openPosition: Position;
+
+beforeAll(async () => {
+ admin = await createTestUser({ isAdmin: true });
+ openPosition = await createTestPosition(admin);
+});
+
+afterAll(async () => {
+ await cleanupFixtures();
+});
+
+describe('phone_number normalization on write', () => {
+ it('updateGlobalAnswer stores a digits-only value', async () => {
+ const question = await createTestGlobalQuestion(admin, {
+ format: 'phone_number',
+ });
+ const applicant = await createTestUser();
+
+ actAs(applicant);
+ const result = await updateGlobalAnswer(question.id, ['(617) 555-0100']);
+ expect(isError(result)).toBe(false);
+
+ const answer = await prisma.globalAnswer.findUniqueOrThrow({
+ where: {
+ userId_globalQuestionId: {
+ userId: applicant.id,
+ globalQuestionId: question.id,
+ },
+ },
+ select: { value: true },
+ });
+ expect(answer.value).toEqual(['6175550100']);
+ });
+
+ it('createOrUpdateApplicationAnswer stores a digits-only value', async () => {
+ const question = await createTestGlobalQuestion(admin, {
+ format: 'phone_number',
+ required: false,
+ });
+ const applicant = await createTestUser();
+ const draft = await createTestApplication(applicant, openPosition, {
+ status: 'draft',
+ });
+
+ actAs(applicant);
+ const result = await createOrUpdateApplicationAnswer({
+ applicationId: draft.id,
+ questionId: question.id,
+ value: ['(617) 555-0100'],
+ });
+ expect(isError(result)).toBe(false);
+
+ const answer = await prisma.globalApplicationAnswer.findUniqueOrThrow({
+ where: {
+ applicationId_globalQuestionId: {
+ applicationId: draft.id,
+ globalQuestionId: question.id,
+ },
+ },
+ select: { value: true },
+ });
+ expect(answer.value).toEqual(['6175550100']);
+ });
+});
diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts
index f1637197..0a97acd7 100644
--- a/tests/unit/constants.test.ts
+++ b/tests/unit/constants.test.ts
@@ -26,11 +26,15 @@ import {
REVIEWER_APPLICATION_STATUSES,
TERMINAL_DECISION_STATUSES,
UNRESOLVED_APPLICATION_STATUSES,
+ formatPhoneNumber,
+ formatShortAnswerValue,
getAnswerBlurError,
getAnswerValueError,
getStatusOptions,
makePositionFormSchema,
matchesShortAnswerFormat,
+ normalizePhoneNumber,
+ normalizeShortAnswerValue,
positionDateOrderIssues,
positionPastDateIssues,
positionScheduleIssues,
@@ -277,6 +281,93 @@ describe('matchesShortAnswerFormat', () => {
});
});
+describe('normalizePhoneNumber / formatPhoneNumber', () => {
+ it('round-trips a bare 10-digit number', () => {
+ expect(normalizePhoneNumber('5551234567')).toBe('5551234567');
+ expect(formatPhoneNumber(normalizePhoneNumber('5551234567'))).toBe(
+ '(555) 123-4567',
+ );
+ });
+
+ it('round-trips a legacy punctuated 10-digit number', () => {
+ expect(normalizePhoneNumber('(555) 123-4567')).toBe('5551234567');
+ expect(formatPhoneNumber(normalizePhoneNumber('(555) 123-4567'))).toBe(
+ '(555) 123-4567',
+ );
+ });
+
+ it('round-trips an 11-digit number with a leading 1 and +', () => {
+ expect(normalizePhoneNumber('+1 555 123 4567')).toBe('+15551234567');
+ expect(formatPhoneNumber(normalizePhoneNumber('+1 555 123 4567'))).toBe(
+ '(555) 123-4567',
+ );
+ });
+
+ it('leaves an already-normalized value unchanged', () => {
+ expect(normalizePhoneNumber('+15551234567')).toBe('+15551234567');
+ expect(formatPhoneNumber('+15551234567')).toBe('(555) 123-4567');
+ });
+
+ it('round-trips a + international number by rendering unchanged', () => {
+ expect(normalizePhoneNumber('+44 20 7123 4567')).toBe('+442071234567');
+ expect(formatPhoneNumber(normalizePhoneNumber('+44 20 7123 4567'))).toBe(
+ '+442071234567',
+ );
+ });
+
+ it('round-trips a 00 international number, promoting the prefix to +', () => {
+ expect(normalizePhoneNumber('0044 20 7123 4567')).toBe('+442071234567');
+ expect(formatPhoneNumber(normalizePhoneNumber('0044 20 7123 4567'))).toBe(
+ '+442071234567',
+ );
+ });
+
+ it('round-trips a 7-digit local number by rendering unchanged', () => {
+ expect(normalizePhoneNumber('555-0100')).toBe('5550100');
+ expect(formatPhoneNumber(normalizePhoneNumber('555-0100'))).toBe('5550100');
+ });
+
+ it('round-trips a validator-passing value that fits no mask', () => {
+ expect(normalizePhoneNumber('12345678901234')).toBe('12345678901234');
+ expect(formatPhoneNumber(normalizePhoneNumber('12345678901234'))).toBe(
+ '12345678901234',
+ );
+ });
+
+ it('returns unparseable input unchanged from normalize', () => {
+ expect(normalizePhoneNumber('abc')).toBe('abc');
+ });
+});
+
+describe('normalizeShortAnswerValue / formatShortAnswerValue', () => {
+ it('normalizes a phone_number value', () => {
+ expect(normalizeShortAnswerValue('(617) 555-0100', 'phone_number')).toBe(
+ '6175550100',
+ );
+ });
+
+ it('formats a phone_number value', () => {
+ expect(formatShortAnswerValue('6175550100', 'phone_number')).toBe(
+ '(617) 555-0100',
+ );
+ });
+
+ it('trims but otherwise leaves email/url/zip_code unchanged on normalize', () => {
+ expect(normalizeShortAnswerValue(' a@b.com ', 'email')).toBe('a@b.com');
+ expect(normalizeShortAnswerValue(' example.com ', 'url')).toBe(
+ 'example.com',
+ );
+ expect(normalizeShortAnswerValue(' 02115 ', 'zip_code')).toBe('02115');
+ });
+
+ it('is the identity on format for email/url/zip_code and a null format', () => {
+ expect(formatShortAnswerValue('a@b.com', 'email')).toBe('a@b.com');
+ expect(formatShortAnswerValue('example.com', 'url')).toBe('example.com');
+ expect(formatShortAnswerValue('02115', 'zip_code')).toBe('02115');
+ expect(formatShortAnswerValue('6175550100', null)).toBe('6175550100');
+ });
+});
+
describe('status-set invariants', () => {
it('REVIEWER_APPLICATION_STATUSES excludes draft and withdrawn', () => {
expect(REVIEWER_APPLICATION_STATUSES).not.toContain('draft');
diff --git a/tests/unit/loading-skeletons.test.ts b/tests/unit/loading-skeletons.test.ts
new file mode 100644
index 00000000..d3eff572
--- /dev/null
+++ b/tests/unit/loading-skeletons.test.ts
@@ -0,0 +1,69 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+// Bespoke header, corrected in place against a page that hand-rolls its own
+// header rather than PageHeader — see components/layouts/page-header.tsx.
+const BESPOKE_HEADER_EXCEPTIONS = [
+ join('app', '(main)', 'positions', '[id]', 'loading.tsx'),
+];
+// Owned by a follow-up ticket rewriting this page's tier — leave untouched.
+const OWNED_BY_OTHER_TICKET_EXCEPTIONS = [
+ join(
+ 'app',
+ '(main)',
+ '(auth)',
+ 'manage',
+ 'positions',
+ '[id]',
+ 'edit',
+ 'loading.tsx',
+ ),
+];
+
+function findLoadingFiles(dir: string, root: string): string[] {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ const results: string[] = [];
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...findLoadingFiles(fullPath, root));
+ } else if (entry.name === 'loading.tsx') {
+ results.push(fullPath.slice(root.length + 1));
+ }
+ }
+ return results;
+}
+
+const appDir = join(process.cwd(), 'app');
+const loadingFiles = findLoadingFiles(appDir, process.cwd());
+
+describe('loading.tsx skeletons', () => {
+ it('finds every route loading.tsx', () => {
+ // Guards the guard: a refactor that renames/moves app/ must not silently
+ // shrink this list to zero and pass everything by omission.
+ expect(loadingFiles.length).toBeGreaterThanOrEqual(14);
+ });
+
+ for (const file of loadingFiles) {
+ const source = readFileSync(join(process.cwd(), file), 'utf-8');
+ const isExempt =
+ BESPOKE_HEADER_EXCEPTIONS.includes(file) ||
+ OWNED_BY_OTHER_TICKET_EXCEPTIONS.includes(file);
+
+ it(`${file} provides the shell markup itself, not via shared skeletons`, () => {
+ expect(source).not.toMatch(/@\/components\/ui\/card/);
+ expect(source).not.toMatch(/@\/components\/ui\/table/);
+ });
+
+ it(`${file} never hand-draws with animate-pulse`, () => {
+ expect(source).not.toMatch(/animate-pulse/);
+ });
+
+ if (!isExempt) {
+ it(`${file} composes PageHeaderSkeleton`, () => {
+ expect(source).toMatch(/PageHeaderSkeleton/);
+ });
+ }
+ }
+});