From df65cbb4cabb1d6b6050c93c6290d06bfa293186 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:04:09 -0400 Subject: [PATCH 1/8] #720 rebuild loading skeletons on shared components Replace fourteen hand-drawn loading.tsx blocks with composition of new PageHeaderSkeleton, DataTableSkeleton, DataTableToolbarSkeleton and PositionCardSkeleton, each living beside and sharing layout constants with the real component it mirrors. Absorbs the two standalone table skeletons into ApplicationsResultsSkeleton and EmailLogResultsSkeleton so the pagination line is no longer missing mid-stream. Adds a filesystem guard test so the fix can't rot. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/applications/[id]/loading.tsx | 25 +- app/(main)/(auth)/applications/loading.tsx | 57 +--- app/(main)/(auth)/emails/loading.tsx | 34 +-- app/(main)/(auth)/emails/page.tsx | 8 +- .../(auth)/global-questions/loading.tsx | 87 ++----- .../manage/applications/[id]/loading.tsx | 25 +- .../(auth)/manage/applications/loading.tsx | 37 +-- .../(auth)/manage/applications/page.tsx | 8 +- .../(auth)/manage/positions/loading.tsx | 40 +-- .../(auth)/positions/[id]/apply/loading.tsx | 10 +- app/(main)/(auth)/users/loading.tsx | 104 ++------ app/(main)/loading.tsx | 12 +- app/(main)/positions/[id]/loading.tsx | 8 +- app/(main)/positions/loading.tsx | 27 +- app/(main)/profile/loading.tsx | 7 +- components/features/applications-results.tsx | 47 +++- .../features/applications-table-skeleton.tsx | 61 ----- components/features/email-log-results.tsx | 26 +- .../features/email-log-table-skeleton.tsx | 48 ---- components/features/position-card.tsx | 74 ++++++ components/layouts/page-header.tsx | 67 ++++- components/ui/data-table-skeleton.tsx | 243 ++++++++++++++++++ components/ui/data-table-toolbar.tsx | 38 ++- components/ui/data-table.tsx | 12 +- docs/DESIGN.md | 2 + lib/data-table.ts | 5 + tests/unit/loading-skeletons.test.ts | 69 +++++ 27 files changed, 717 insertions(+), 464 deletions(-) delete mode 100644 components/features/applications-table-skeleton.tsx delete mode 100644 components/features/email-log-table-skeleton.tsx create mode 100644 components/ui/data-table-skeleton.tsx create mode 100644 tests/unit/loading-skeletons.test.ts diff --git a/app/(main)/(auth)/applications/[id]/loading.tsx b/app/(main)/(auth)/applications/[id]/loading.tsx index fb9c13ef..9a2eb187 100644 --- a/app/(main)/(auth)/applications/[id]/loading.tsx +++ b/app/(main)/(auth)/applications/[id]/loading.tsx @@ -1,26 +1,19 @@ import { AnswersCardSkeleton } from '@/components/features/application-answers-skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { Skeleton } from '@/components/ui/skeleton'; export default function MyApplicationDetailLoading() { return (
- {/* Back link skeleton */} - -
-
-
- - -
- -
-
- - -
-
- + +
diff --git a/app/(main)/(auth)/applications/loading.tsx b/app/(main)/(auth)/applications/loading.tsx index 1f05817a..9c5c0286 100644 --- a/app/(main)/(auth)/applications/loading.tsx +++ b/app/(main)/(auth)/applications/loading.tsx @@ -1,50 +1,21 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; + +const COLUMNS: DataTableSkeletonColumn[] = [ + { head: 'w-20', cell: 'w-40', mobile: 'primary' }, + { head: 'w-16', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', mobile: 'line' }, + { head: 'w-16', shape: 'action', cell: 'w-20', mobile: 'lineTrailing' }, +]; export default function MyApplicationsLoading() { return (
-
- - -
- - - -
-
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - - - -
-
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
-
- - -
-
- ))} -
-
-
+ +
); } diff --git a/app/(main)/(auth)/emails/loading.tsx b/app/(main)/(auth)/emails/loading.tsx index 9d132ff2..539236bf 100644 --- a/app/(main)/(auth)/emails/loading.tsx +++ b/app/(main)/(auth)/emails/loading.tsx @@ -1,37 +1,17 @@ import { EmailFailureStripSkeleton } from '@/components/features/email-failure-strip'; -import { EmailLogTableSkeleton } from '@/components/features/email-log-table-skeleton'; +import { EmailLogResultsSkeleton } from '@/components/features/email-log-results'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { DataTableToolbarSkeleton } from '@/components/ui/data-table-toolbar'; import { Skeleton } from '@/components/ui/skeleton'; export default function EmailsLoading() { return (
- {/* Header skeleton */} -
- - -
- + - - {/* Toolbar skeleton — Status Select + Template Select + Search Input */} -
-
- - -
-
- - -
-
- - -
-
- - - - + + +
); } diff --git a/app/(main)/(auth)/emails/page.tsx b/app/(main)/(auth)/emails/page.tsx index ecbc9726..707123a1 100644 --- a/app/(main)/(auth)/emails/page.tsx +++ b/app/(main)/(auth)/emails/page.tsx @@ -12,8 +12,10 @@ import { EmailFailureStrip, EmailFailureStripSkeleton, } from '@/components/features/email-failure-strip'; -import { EmailLogResults } from '@/components/features/email-log-results'; -import { EmailLogTableSkeleton } from '@/components/features/email-log-table-skeleton'; +import { + EmailLogResults, + EmailLogResultsSkeleton, +} from '@/components/features/email-log-results'; import { EmailLogToolbar } from '@/components/features/email-log-toolbar'; import { PageHeader } from '@/components/layouts/page-header'; @@ -66,7 +68,7 @@ export default async function EmailsPage({ searchParams }: EmailsPageProps) { } + fallback={} > - {/* PageHeader skeleton */} -
-
- - -
- -
- - {/* Table skeleton */} - - -
-
- - - - - -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - - - - -
- - -
-
- ))} -
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- - -
-
- - -
-
- - -
-
- ))} -
-
-
+ +
); } diff --git a/app/(main)/(auth)/manage/applications/[id]/loading.tsx b/app/(main)/(auth)/manage/applications/[id]/loading.tsx index 619c6f6b..aee08d21 100644 --- a/app/(main)/(auth)/manage/applications/[id]/loading.tsx +++ b/app/(main)/(auth)/manage/applications/[id]/loading.tsx @@ -1,4 +1,5 @@ import { AnswersCardSkeleton } from '@/components/features/application-answers-skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { SectionCardSkeleton } from '@/components/ui/section-card'; import { Skeleton } from '@/components/ui/skeleton'; @@ -6,22 +7,14 @@ export default function ApplicationDetailLoading() { return (
- {/* Back link skeleton */} - -
-
-
- - -
- -
-
- - -
-
- + +
diff --git a/app/(main)/(auth)/manage/applications/loading.tsx b/app/(main)/(auth)/manage/applications/loading.tsx index bfc4c24e..7db6aa46 100644 --- a/app/(main)/(auth)/manage/applications/loading.tsx +++ b/app/(main)/(auth)/manage/applications/loading.tsx @@ -1,36 +1,15 @@ -import { ApplicationsTableSkeleton } from '@/components/features/applications-table-skeleton'; -import { Skeleton } from '@/components/ui/skeleton'; +import { ApplicationsResultsSkeleton } from '@/components/features/applications-results'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { DataTableToolbarSkeleton } from '@/components/ui/data-table-toolbar'; export default function ApplicationsLoading() { return (
- {/* Header skeleton */} -
- - -
- - {/* Toolbar skeleton — Position Select + Applicant Select + Status Select + Search Input */} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - + + +
); } diff --git a/app/(main)/(auth)/manage/applications/page.tsx b/app/(main)/(auth)/manage/applications/page.tsx index b6327bf7..f542d647 100644 --- a/app/(main)/(auth)/manage/applications/page.tsx +++ b/app/(main)/(auth)/manage/applications/page.tsx @@ -16,8 +16,10 @@ import { } from '@/lib/constants'; import type { ApplicationFilters } from '@/lib/types'; -import { ApplicationsResults } from '@/components/features/applications-results'; -import { ApplicationsTableSkeleton } from '@/components/features/applications-table-skeleton'; +import { + ApplicationsResults, + ApplicationsResultsSkeleton, +} from '@/components/features/applications-results'; import { ApplicationsToolbar } from '@/components/features/applications-toolbar'; import { PageHeader } from '@/components/layouts/page-header'; @@ -105,7 +107,7 @@ export default async function ApplicationsPage({ } + fallback={} > -
-
- - -
- +
+
+ +
- - {Array.from({ length: 3 }).map((_, i) => ( - - -
- - -
-
-
+ {Array.from({ length: count }).map((_, i) => ( + ))}
); } + +export default function ManagePositionsLoading() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/app/(main)/(auth)/positions/[id]/apply/loading.tsx b/app/(main)/(auth)/positions/[id]/apply/loading.tsx index ae649d40..45912231 100644 --- a/app/(main)/(auth)/positions/[id]/apply/loading.tsx +++ b/app/(main)/(auth)/positions/[id]/apply/loading.tsx @@ -1,13 +1,15 @@ +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { Skeleton } from '@/components/ui/skeleton'; + export default function ApplyLoading() { return (
-
-
-
+
+
{[0, 1, 2].map((i) => ( -
+ ))}
diff --git a/app/(main)/(auth)/users/loading.tsx b/app/(main)/(auth)/users/loading.tsx index 0eb16a94..f0758488 100644 --- a/app/(main)/(auth)/users/loading.tsx +++ b/app/(main)/(auth)/users/loading.tsx @@ -1,85 +1,33 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; +import { DataTableToolbarSkeleton } from '@/components/ui/data-table-toolbar'; + +const COLUMNS: DataTableSkeletonColumn[] = [ + { head: 'w-24', cell: 'w-32', subCell: 'w-44', mobile: 'primary' }, + { head: 'w-16', shape: 'badge', mobile: 'trailing' }, + { head: 'w-20', mobile: 'hidden' }, + { head: 'w-8', mobile: 'hidden' }, + { head: 'w-20', shape: 'badge', mobile: 'line' }, + { + head: 'w-24', + shape: 'action', + headClassName: 'text-right', + mobile: 'lineTrailing', + }, +]; export default function UsersLoading() { return (
- {/* Header skeleton */} -
- - -
- - {/* Toolbar skeleton — role/position filters + search input + count line */} -
-
- - -
-
- - -
-
- - -
- -
- - {/* Table skeleton */} - - -
-
- - - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- - - - -
- - -
-
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
- - -
- -
- -
- - -
-
- ))} -
-
-
+ + +
); } diff --git a/app/(main)/loading.tsx b/app/(main)/loading.tsx index f9c91e30..4f613c52 100644 --- a/app/(main)/loading.tsx +++ b/app/(main)/loading.tsx @@ -1,17 +1,9 @@ -import { SectionCardSkeleton } from '@/components/ui/section-card'; -import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; export default function HomeLoading() { return (
- {/* Heading */} -
- - -
- - - +
); } diff --git a/app/(main)/positions/[id]/loading.tsx b/app/(main)/positions/[id]/loading.tsx index 902d82eb..e324a902 100644 --- a/app/(main)/positions/[id]/loading.tsx +++ b/app/(main)/positions/[id]/loading.tsx @@ -4,12 +4,12 @@ export default function PositionDetailLoading() { return (
- -
+ +
- +
- +
diff --git a/app/(main)/positions/loading.tsx b/app/(main)/positions/loading.tsx index d5da2c89..5426525b 100644 --- a/app/(main)/positions/loading.tsx +++ b/app/(main)/positions/loading.tsx @@ -1,24 +1,21 @@ -import { Card, CardHeader } from '@/components/ui/card'; +import { PositionCardSkeleton } from '@/components/features/position-card'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { Skeleton } from '@/components/ui/skeleton'; export default function PositionsLoading() { return (
-
- - -
+
- {Array.from({ length: 4 }).map((_, i) => ( - - -
- - -
-
-
- ))} +
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
); diff --git a/app/(main)/profile/loading.tsx b/app/(main)/profile/loading.tsx index 56da7dec..1bbb4f84 100644 --- a/app/(main)/profile/loading.tsx +++ b/app/(main)/profile/loading.tsx @@ -1,14 +1,11 @@ +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { Skeleton } from '@/components/ui/skeleton'; export default function ProfileLoading() { return (
- {/* PageHeader skeleton */} -
- - -
+ {/* Edit toggle skeleton */}
diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index 549b229d..2565654d 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -20,8 +20,15 @@ import type { import { buildApplicationsHref, getPaginationBounds } from '@/lib/utils'; import { ApplicationsTable } from '@/components/features/applications-table'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; import { TablePagination } from '@/components/ui/table-pagination'; +const RESULTS_CLASS = 'flex flex-col gap-3'; + interface ApplicationsResultsProps { user: Reviewer; filters: ApplicationFilters; @@ -99,7 +106,7 @@ export async function ApplicationsResults({ ); return ( -
+

You can see who started an application, not what they've written. @@ -148,7 +155,7 @@ export async function ApplicationsResults({ paginateRows(merged, page); return ( -

+
+
({ ...a, isDraft: false as const }))} hasActiveFilters={hasActiveFilters} @@ -189,3 +196,37 @@ export async function ApplicationsResults({
); } + +interface ApplicationsResultsSkeletonProps { + isDraftView?: boolean; +} + +export function ApplicationsResultsSkeleton({ + isDraftView = false, +}: ApplicationsResultsSkeletonProps) { + const columns: DataTableSkeletonColumn[] = [ + ...(isDraftView + ? [] + : ([ + { + head: 'w-10', + shape: 'checkbox', + headClassName: 'w-10', + cellClassName: 'w-10', + mobile: 'leading', + }, + ] satisfies DataTableSkeletonColumn[])), + { head: 'w-24', cell: 'w-36', subCell: 'w-48', mobile: 'primary' }, + { head: 'w-20', cell: 'w-28' }, + { head: 'w-16', cell: 'w-20', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', cell: 'w-20' }, + ]; + + return ( +
+ {isDraftView && } + + +
+ ); +} diff --git a/components/features/applications-table-skeleton.tsx b/components/features/applications-table-skeleton.tsx deleted file mode 100644 index 469a7ed1..00000000 --- a/components/features/applications-table-skeleton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; - -interface ApplicationsTableSkeletonProps { - // Off for the drafts view, whose table renders no checkbox column. - showSelection?: boolean; -} - -export function ApplicationsTableSkeleton({ - showSelection = true, -}: ApplicationsTableSkeletonProps = {}) { - return ( - - -
-
- {showSelection && } - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
- {showSelection && } -
- - -
- - - -
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
- {showSelection && ( - - )} -
-
- - -
- - - -
-
- ))} -
-
-
- ); -} diff --git a/components/features/email-log-results.tsx b/components/features/email-log-results.tsx index f94a87a8..79b73198 100644 --- a/components/features/email-log-results.tsx +++ b/components/features/email-log-results.tsx @@ -7,8 +7,15 @@ import type { EmailLogFilters } from '@/lib/types'; import { buildEmailLogHref, getPaginationBounds } from '@/lib/utils'; import { EmailLogTable } from '@/components/features/email-log-table'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; import { TablePagination } from '@/components/ui/table-pagination'; +const RESULTS_CLASS = 'flex flex-col gap-3'; + interface EmailLogResultsProps { filters: EmailLogFilters; page: number; @@ -37,7 +44,7 @@ export async function EmailLogResults({ : initialRows; return ( -
+
buildEmailLogHref(filters, p)} @@ -52,3 +59,20 @@ export async function EmailLogResults({
); } + +const EMAIL_LOG_SKELETON_COLUMNS: DataTableSkeletonColumn[] = [ + { head: 'w-32', cell: 'w-48', subCell: 'w-32', mobile: 'primary' }, + { head: 'w-24', mobile: 'hidden' }, + { head: 'w-40', mobile: 'line' }, + { head: 'w-20', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', mobile: 'line' }, +]; + +export function EmailLogResultsSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/components/features/email-log-table-skeleton.tsx b/components/features/email-log-table-skeleton.tsx deleted file mode 100644 index 06df7b96..00000000 --- a/components/features/email-log-table-skeleton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; - -export function EmailLogTableSkeleton() { - return ( - - -
-
- - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- - - - -
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
- - -
- ))} -
-
-
- ); -} diff --git a/components/features/position-card.tsx b/components/features/position-card.tsx index ab5e0b2a..fddcf913 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 { Skeleton } from '@/components/ui/skeleton'; interface PositionCardProps { position: PositionWithQuestions; @@ -245,3 +246,76 @@ export function PositionCard({ ); } + +interface PositionCardSkeletonProps { + hasStats?: boolean; + actions?: number; +} + +// Mirrors PositionStatCluster's total tile + 2x2 grid — update alongside it. +function PositionStatClusterSkeleton() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ); +} + +export function PositionCardSkeleton({ + hasStats = false, + actions = 2, +}: PositionCardSkeletonProps) { + return ( + +
+
+ +
+ + +
+ +
+ + +
+ + + +
+ +
+ {Array.from({ length: actions }).map((_, i) => ( + + ))} +
+
+
+ + {hasStats && ( +
+ +
+ )} +
+
+ ); +} diff --git a/components/layouts/page-header.tsx b/components/layouts/page-header.tsx index be7f5ece..62deb69c 100644 --- a/components/layouts/page-header.tsx +++ b/components/layouts/page-header.tsx @@ -2,8 +2,17 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { ACTION_ICONS } from '@/lib/icons'; +import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; + +const WRAPPER_CLASS = 'flex flex-col gap-1'; +const ROW_CLASS = + 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'; +const TITLE_ROW_CLASS = 'flex flex-wrap items-center gap-2'; +const DESCRIPTION_CLASS = 'text-muted-foreground mt-1 text-sm'; +const ACTIONS_CLASS = 'flex shrink-0 flex-wrap items-center gap-2'; interface PageHeaderProps { title: string; @@ -23,7 +32,7 @@ export function PageHeader({ backLabel = 'Back', }: PageHeaderProps) { return ( -
+
{backHref && ( )} -
+
-
+

{title}

{titleAdornment}
- {description && ( -

{description}

- )} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+
+ ); +} + +interface PageHeaderSkeletonProps { + titleWidth?: string; + hasDescription?: boolean; + hasAdornment?: boolean; + hasBack?: boolean; + actions?: string[]; + actionSize?: 'sm' | 'default'; +} + +export function PageHeaderSkeleton({ + titleWidth = 'w-48', + hasDescription = true, + hasAdornment = false, + hasBack = false, + actions, + actionSize = 'default', +}: PageHeaderSkeletonProps) { + return ( +
+ {hasBack && } +
+
+
+ + {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..d3a0adba --- /dev/null +++ b/components/ui/data-table-skeleton.tsx @@ -0,0 +1,243 @@ +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; +} + +interface DataTableSkeletonProps { + columns: DataTableSkeletonColumn[]; + rows?: number; + mobileRows?: number; + hasReorderHandle?: boolean; +} + +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, +}: { + columns: DataTableSkeletonColumn[]; + roles: DataTableSkeletonMobileRole[]; + hasReorderHandle: boolean; +}) { + 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, +}: 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 ( -
+
{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 495eefae..cd4503b8 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, @@ -299,15 +303,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="
); } diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index 2565654d..ce5187d3 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -11,6 +11,7 @@ import { } from '@/prisma/data/applications'; import { APPLICATIONS_PAGE_SIZE } from '@/lib/constants'; +import { DATA_TABLE_RESULTS_CLASS } from '@/lib/data-table'; import { STATE_ICONS } from '@/lib/icons'; import type { ApplicationFilters, @@ -27,8 +28,6 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { TablePagination } from '@/components/ui/table-pagination'; -const RESULTS_CLASS = 'flex flex-col gap-3'; - interface ApplicationsResultsProps { user: Reviewer; filters: ApplicationFilters; @@ -106,7 +105,7 @@ export async function ApplicationsResults({ ); return ( -
+

You can see who started an application, not what they've written. @@ -155,7 +154,7 @@ export async function ApplicationsResults({ paginateRows(merged, page); return ( -

+
+
({ ...a, isDraft: false as const }))} hasActiveFilters={hasActiveFilters} @@ -223,9 +222,13 @@ export function ApplicationsResultsSkeleton({ ]; return ( -
+
{isDraftView && } - +
); diff --git a/components/features/email-log-results.tsx b/components/features/email-log-results.tsx index 79b73198..b253a7a4 100644 --- a/components/features/email-log-results.tsx +++ b/components/features/email-log-results.tsx @@ -3,6 +3,7 @@ import 'server-only'; import { getEmailLogs, getEmailLogsCount } from '@/prisma/data/emails'; import { EMAIL_LOG_PAGE_SIZE } from '@/lib/constants'; +import { DATA_TABLE_RESULTS_CLASS } from '@/lib/data-table'; import type { EmailLogFilters } from '@/lib/types'; import { buildEmailLogHref, getPaginationBounds } from '@/lib/utils'; @@ -14,8 +15,6 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { TablePagination } from '@/components/ui/table-pagination'; -const RESULTS_CLASS = 'flex flex-col gap-3'; - interface EmailLogResultsProps { filters: EmailLogFilters; page: number; @@ -44,7 +43,7 @@ export async function EmailLogResults({ : initialRows; return ( -
+
buildEmailLogHref(filters, p)} @@ -70,8 +69,11 @@ const EMAIL_LOG_SKELETON_COLUMNS: DataTableSkeletonColumn[] = [ export function EmailLogResultsSkeleton() { return ( -
- +
+
); diff --git a/components/features/position-card.tsx b/components/features/position-card.tsx index fddcf913..82ee7765 100644 --- a/components/features/position-card.tsx +++ b/components/features/position-card.tsx @@ -283,8 +283,15 @@ export function PositionCardSkeleton({ className={cn(hasStats && 'sm:flex sm:min-w-0 sm:flex-1 sm:flex-col')} > -
- +
+
+ +
diff --git a/components/ui/data-table-skeleton.tsx b/components/ui/data-table-skeleton.tsx index d3a0adba..46f18680 100644 --- a/components/ui/data-table-skeleton.tsx +++ b/components/ui/data-table-skeleton.tsx @@ -36,11 +36,17 @@ export interface DataTableSkeletonColumn { mobile?: DataTableSkeletonMobileRole; } +type DataTableSkeletonGap = '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( @@ -93,10 +99,14 @@ 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'); @@ -115,7 +125,7 @@ function DataTableSkeletonMobileRow({ }); const body = ( -
+
{(primaryColumn || trailingColumn) && (
{primaryColumn ? ( @@ -155,10 +165,10 @@ function DataTableSkeletonMobileRow({ ); if (!hasReorderHandle && leading.length === 0) - return
{body}
; + return
{body}
; return ( -
+
{hasReorderHandle && ( )} @@ -177,6 +187,8 @@ export function DataTableSkeleton({ rows = 5, mobileRows = 3, hasReorderHandle = false, + mobileGap = 'gap-2', + mobileRowGap = 'gap-2', }: DataTableSkeletonProps) { const mobileRoles = resolveMobileRoles(columns); @@ -234,6 +246,8 @@ export function DataTableSkeleton({ columns={columns} roles={mobileRoles} hasReorderHandle={hasReorderHandle} + mobileGap={mobileGap} + mobileRowGap={mobileRowGap} /> ))}
diff --git a/lib/data-table.ts b/lib/data-table.ts index c65fd295..f441a978 100644 --- a/lib/data-table.ts +++ b/lib/data-table.ts @@ -4,6 +4,8 @@ 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'; diff --git a/tests/unit/loading-skeletons.test.ts b/tests/unit/loading-skeletons.test.ts index 8011816c..d3eff572 100644 --- a/tests/unit/loading-skeletons.test.ts +++ b/tests/unit/loading-skeletons.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; const BESPOKE_HEADER_EXCEPTIONS = [ join('app', '(main)', 'positions', '[id]', 'loading.tsx'), ]; -// Owned by #666 (PR #690) — leave untouched until that follow-up. +// Owned by a follow-up ticket rewriting this page's tier — leave untouched. const OWNED_BY_OTHER_TICKET_EXCEPTIONS = [ join( 'app', From bcd6c8efefb1d076292e9290bb127d42d49671ae Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 23 Sep 2026 20:02:25 -0400 Subject: [PATCH 3/8] #720 address review feedback Document the unavoidable pagination-skeleton height gap and rebase onto dev to pick up the submittedAt migration for the preview build. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-results.tsx | 1 + components/features/email-log-results.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index ce5187d3..d5d733f8 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -229,6 +229,7 @@ export function ApplicationsResultsSkeleton({ mobileGap="gap-1" mobileRowGap="gap-3" /> + {/* Single-page summary line only — multi-page controls row height is unknowable until fetch. */}
); diff --git a/components/features/email-log-results.tsx b/components/features/email-log-results.tsx index b253a7a4..b819932e 100644 --- a/components/features/email-log-results.tsx +++ b/components/features/email-log-results.tsx @@ -74,6 +74,7 @@ export function EmailLogResultsSkeleton() { columns={EMAIL_LOG_SKELETON_COLUMNS} mobileGap="gap-1" /> + {/* Single-page summary line only — multi-page controls row height is unknowable until fetch. */}
); From 5bc2cc7f43bf5cb7df379c73a4f54e29707edf35 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 23 Sep 2026 20:28:49 -0400 Subject: [PATCH 4/8] #720 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/global-questions/loading.tsx | 7 ++++++- components/ui/data-table-skeleton.tsx | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/(main)/(auth)/global-questions/loading.tsx b/app/(main)/(auth)/global-questions/loading.tsx index c6ffbdfe..bde63f1f 100644 --- a/app/(main)/(auth)/global-questions/loading.tsx +++ b/app/(main)/(auth)/global-questions/loading.tsx @@ -28,7 +28,12 @@ export default function GlobalQuestionsLoading() { return (
- +
); } diff --git a/components/ui/data-table-skeleton.tsx b/components/ui/data-table-skeleton.tsx index 46f18680..fdbaba58 100644 --- a/components/ui/data-table-skeleton.tsx +++ b/components/ui/data-table-skeleton.tsx @@ -36,7 +36,7 @@ export interface DataTableSkeletonColumn { mobile?: DataTableSkeletonMobileRole; } -type DataTableSkeletonGap = 'gap-1' | 'gap-2' | 'gap-3'; +type DataTableSkeletonGap = 'gap-0' | 'gap-1' | 'gap-2' | 'gap-3'; interface DataTableSkeletonProps { columns: DataTableSkeletonColumn[]; From 2ce6f77359c3e10947b1b91a6dd97e888e156ff6 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sun, 20 Sep 2026 23:57:54 -0400 Subject: [PATCH 5/8] #731 normalize and format phone_number short answers Store new phone_number answers as digits with an optional leading +, and mask 10/11-digit US-shaped values on every short-answer renderer. Unparseable and international values render exactly as stored, and existing rows are never backfilled. Co-Authored-By: Claude Sonnet 4.6 --- components/features/answer-display.tsx | 5 +- .../features/application-answers-list.tsx | 11 ++- docs/WORKFLOWS.md | 8 +- lib/constants.ts | 40 ++++++++ lib/types.ts | 1 + prisma/actions/applications.ts | 8 +- prisma/actions/profile.ts | 8 +- prisma/data/applications.ts | 8 +- tests/db/phone-number-normalization.test.ts | 82 +++++++++++++++++ tests/unit/constants.test.ts | 91 +++++++++++++++++++ 10 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 tests/db/phone-number-normalization.test.ts diff --git a/components/features/answer-display.tsx b/components/features/answer-display.tsx index 0cc1211e..206a04c3 100644 --- a/components/features/answer-display.tsx +++ b/components/features/answer-display.tsx @@ -1,3 +1,4 @@ +import { formatShortAnswerValue } from '@/lib/constants'; import type { AnswerQuestion, QuestionFileTarget } from '@/lib/types'; import { answerFieldIds, partitionAnswerValue } from '@/lib/utils'; @@ -46,7 +47,9 @@ export function AnswerDisplay({ ))}
) : ( -

{value[0]}

+

+ {formatShortAnswerValue(value[0] ?? '', question.format)} +

)} ); diff --git a/components/features/application-answers-list.tsx b/components/features/application-answers-list.tsx index 36dcd265..67b17e1b 100644 --- a/components/features/application-answers-list.tsx +++ b/components/features/application-answers-list.tsx @@ -1,3 +1,4 @@ +import { formatShortAnswerValue } from '@/lib/constants'; import { type ApplicationReviewAnswer } from '@/lib/types'; import { AnswerFileLink } from '@/components/features/answer-file-link'; @@ -75,10 +76,16 @@ function AnswerValue({ case 'short_answer': return answer.value.length > 1 ? (
- + + formatShortAnswerValue(v, answer.format), + )} + />
) : ( -
{answer.value[0]}
+
+ {formatShortAnswerValue(answer.value[0] ?? '', answer.format)} +
); default: { const exhaustiveCheck: never = answer.type; 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..b1b8ff22 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -162,6 +162,46 @@ 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.trim()) + : 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/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/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'); From 7bf8e7577d0247c8d6ee71d9750f5c2882ec540d Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Mon, 21 Sep 2026 00:07:07 -0400 Subject: [PATCH 6/8] #731 address review feedback Run orphaned short_answer values through formatShortAnswerValue in AnswerMismatchNotice so a legacy multi-value phone_number row masks consistently in the mismatch notice. Co-Authored-By: Claude Sonnet 4.6 --- components/features/answer-display.tsx | 1 + components/features/answer-field.tsx | 1 + components/features/answer-mismatch-notice.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/features/answer-display.tsx b/components/features/answer-display.tsx index 206a04c3..5c51f509 100644 --- a/components/features/answer-display.tsx +++ b/components/features/answer-display.tsx @@ -28,6 +28,7 @@ export function AnswerDisplay({ id={noticeId} values={orphaned} questionType={question.type} + format={question.format} /> )} diff --git a/components/features/answer-field.tsx b/components/features/answer-field.tsx index 735c3e4b..51d2bb1a 100644 --- a/components/features/answer-field.tsx +++ b/components/features/answer-field.tsx @@ -82,6 +82,7 @@ export function AnswerField({ id={noticeId} values={orphaned} questionType={question.type} + format={question.format} /> ); diff --git a/components/features/answer-mismatch-notice.tsx b/components/features/answer-mismatch-notice.tsx index a43cc648..21aee36e 100644 --- a/components/features/answer-mismatch-notice.tsx +++ b/components/features/answer-mismatch-notice.tsx @@ -1,11 +1,17 @@ import type { QuestionType } from '@/prisma/client'; +import { + type ShortAnswerFormatValue, + formatShortAnswerValue, +} from '@/lib/constants'; + import { WarningCallout } from '@/components/ui/warning-callout'; interface AnswerMismatchNoticeProps { id: string; values: string[]; questionType: QuestionType; + format: ShortAnswerFormatValue | null; } // Callers wire `id` to the control's `aria-describedby`. @@ -13,6 +19,7 @@ export function AnswerMismatchNotice({ id, values, questionType, + format, }: AnswerMismatchNoticeProps) { if (values.length === 0) return null; @@ -39,7 +46,7 @@ export function AnswerMismatchNotice({
    {values.map((v, i) => (
  • - {v} + {formatShortAnswerValue(v, format)}
  • ))}
From e3c1561915d200513b47230a33a820c9afe2a875 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 23 Sep 2026 19:56:01 -0400 Subject: [PATCH 7/8] #731 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index b1b8ff22..428e8ddc 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -189,9 +189,7 @@ export function normalizeShortAnswerValue( value: string, format: ShortAnswerFormatValue, ): string { - return format === 'phone_number' - ? normalizePhoneNumber(value.trim()) - : value.trim(); + return format === 'phone_number' ? normalizePhoneNumber(value) : value.trim(); } // Identity for every format except phone_number, which masks US-shaped values. From 9293f99739d12d4a0ddc786b73c96ff3b5f3e13f Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Fri, 25 Sep 2026 10:27:14 -0400 Subject: [PATCH 8/8] bump version to v1.14.1 Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",