feat(web): instant-shell loading UX — table skeletons + route loading fallbacks

Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.

- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
  loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
  table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
  auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
  triggered the MSAL redirect), usage-metrics, fleet-health, sla and
  admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
  pages) left untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-29 15:17:48 +02:00
parent adb103fc9e
commit 26ecbf33d6
33 changed files with 335 additions and 87 deletions
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface DistressReport {
tenantId: string;
@@ -108,7 +109,9 @@ export function DistressReportsSection({
setError,
}: DistressReportsSectionProps) {
const [reports, setReports] = useState<DistressReport[]>([]);
const [loading, setLoading] = useState(false);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the "No distress reports" empty state for one paint.
const [loading, setLoading] = useState(true);
const [selectedReport, setSelectedReport] = useState<DistressReport | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const [sortKey, setSortKey] = useState<SortKey>("ingestedAt");
@@ -203,7 +206,9 @@ export function DistressReportsSection({
</div>
</div>
{/* Summary Cards */}
{/* Summary Cards — intentionally hidden while loading: distress is often
empty on a healthy system, and a skeleton band that then vanishes
shifts the layout more than one that appears. */}
{!loading && reports.length > 0 && (
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-amber-50/50 dark:bg-gray-800/50">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
@@ -234,10 +239,7 @@ export function DistressReportsSection({
{/* Reports Table */}
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading distress reports...</span>
</div>
<TableSkeleton columns={7} rows={15} />
) : reports.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -28,7 +28,9 @@ interface FeedbackSectionProps {
}
export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) {
const [loading, setLoading] = useState(false);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the empty state for one paint.
const [loading, setLoading] = useState(true);
const [entries, setEntries] = useState<FeedbackEntry[]>([]);
const [activeTab, setActiveTab] = useState<FeedbackTab>("InApp");
const [inAppFilter, setInAppFilter] = useState<InAppFilter>("all");
@@ -8,6 +8,7 @@ import { extractContinuation } from "@/lib/paginationLink";
import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
const PAGE_SIZE = 20;
@@ -83,7 +84,9 @@ export function SessionReportsSection({
// Reader may view reports + notes but not edit them.
const canMutate = useCanMutatePlatform();
const [reports, setReports] = useState<SessionReport[]>([]);
const [loading, setLoading] = useState(false);
// true from the start: the fetch fires on mount, and a false initial value
// flashes the empty state for one paint.
const [loading, setLoading] = useState(true);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
const [adminNoteValue, setAdminNoteValue] = useState("");
@@ -310,11 +313,10 @@ export function SessionReportsSection({
{/* Reports Table */}
<div className="p-6 pt-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading reports...</span>
</div>
{/* Skeleton only on the initial (empty) load — a Refresh keeps the
populated table on screen; the button already shows "Loading...". */}
{loading && reports.length === 0 ? (
<TableSkeleton columns={7} rows={8} />
) : reports.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={8} />;
}
@@ -0,0 +1,5 @@
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
export default function Loading() {
return <SectionSkeleton columns={6} rows={8} />;
}
@@ -0,0 +1,5 @@
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
export default function Loading() {
return <SectionSkeleton columns={7} rows={10} />;
}
@@ -0,0 +1,5 @@
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
export default function Loading() {
return <SectionSkeleton columns={6} rows={8} />;
}
@@ -0,0 +1,5 @@
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
export default function Loading() {
return <SectionSkeleton columns={6} rows={8} />;
}
@@ -5,6 +5,7 @@ import { useAuth } from "../../../../contexts/AuthContext";
import { useAdminConfig } from "../../AdminConfigContext";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface PlanTierDefinition {
name: string;
@@ -117,15 +118,6 @@ export function SectionUsagePlans() {
setHasChanges(true);
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600" />
<span className="ml-3 text-sm text-gray-500">Loading plan definitions...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
@@ -139,7 +131,8 @@ export function SectionUsagePlans() {
<div className="flex items-center gap-2">
<button
onClick={addTier}
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
disabled={loading}
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
+ Add Tier
</button>
@@ -175,8 +168,11 @@ export function SectionUsagePlans() {
</div>
)}
{/* Initial load — header stays visible, tier list gets the skeleton */}
{loading && <TableSkeleton wrapped columns={4} rows={3} header={false} />}
{/* Empty State */}
{tiers.length === 0 && (
{!loading && tiers.length === 0 && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-500 mb-4">No plan tiers defined yet.</p>
<button
@@ -0,0 +1,5 @@
import { SectionSkeleton } from "@/components/skeletons/SectionSkeleton";
export default function Loading() {
return <SectionSkeleton columns={6} rows={10} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}
@@ -1,6 +1,7 @@
'use client';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { TableSkeleton } from '@/components/skeletons/TableSkeleton';
import { useRouter } from 'next/navigation';
import { useAuth } from '../../contexts/AuthContext';
import { useNotifications } from '../../contexts/NotificationContext';
@@ -237,17 +238,6 @@ export default function AuditPage() {
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading audit logs...</p>
</div>
</div>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
@@ -263,8 +253,12 @@ export default function AuditPage() {
<div>
<h1 className="text-2xl font-normal text-gray-900">Audit Log</h1>
<p className="text-sm text-gray-600 mt-1">
{filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'} on this page
{filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}
{loading ? '…' : (
<>
{filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'} on this page
{filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}
</>
)}
</p>
</div>
</div>
@@ -347,9 +341,12 @@ export default function AuditPage() {
</select>
</div>
{/* Audit Table */}
{/* Audit Table — skeleton on the initial load only; refetches use
`refreshing` and keep the populated table on screen. */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{filteredLogs.length === 0 ? (
{loading ? (
<TableSkeleton columns={6} rows={10} />
) : filteredLogs.length === 0 ? (
<div className="p-12 text-center">
<svg className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -25,6 +25,7 @@ import { useDashboardFilters } from "./hooks/useDashboardFilters";
import { useDashboardSessions } from "./hooks/useDashboardSessions";
import { useDashboardStats } from "./hooks/useDashboardStats";
import { DOCS_URL } from "@/utils/config";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
export default function Home() {
// useSearchParams() in HomeContent requires a Suspense boundary for static prerender.
@@ -425,7 +426,12 @@ function HomeContent() {
{/* Welcome message - only show when no sessions */}
{!loading && sessions.length === 0 && <WelcomeMessage />}
{/* Sessions List */}
{/* Sessions List — skeleton fills the slot on the initial fetch so
the page has no blank gap; once data (or the empty states above)
resolve, it never reappears. */}
{loading && sessions.length === 0 && (
<TableSkeleton wrapped columns={7} rows={8} className="mt-4" />
)}
{sessions.length > 0 && (
<SessionTable
sessions={effectiveSessions}
@@ -17,6 +17,8 @@ import { useFleetHealth } from "./hooks/useFleetHealth";
import { useAggregatedAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
interface AppMetric {
appName: string;
@@ -224,16 +226,11 @@ export default function FleetHealthPage() {
[failureReasons],
);
// Spinner on the initial load and while a scope/range switch refetches (data is
// reset to null). On error we fall through and render the page shell — the hook
// surfaces the failure via a notification, matching the previous behavior.
if (!data && !error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-600">Loading fleet health data...</div>
</div>
);
}
// Skeleton on the initial load and while a scope/range switch refetches (data
// is reset to null) — the header with the scope/range controls stays
// interactive. On error the shell renders with empty data; the hook surfaces
// the failure via a notification, matching the previous behavior.
const pendingInitial = !data && !error;
return (
<ProtectedRoute>
@@ -287,6 +284,17 @@ export default function FleetHealthPage() {
</header>
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{pendingInitial ? (
<div aria-busy="true" aria-label="Loading" className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-5">
{Array.from({ length: 5 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
<TableSkeleton wrapped columns={4} rows={8} />
</div>
) : (
<>
{/* Top Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-5 mb-8">
<FleetStatCard
@@ -750,6 +758,8 @@ export default function FleetHealthPage() {
</div>
)}
</div>
</>
)}
</main>
</div>
</ProtectedRoute>
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={4} rows={4} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={6} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={6} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={10} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={6} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={6} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={0} rows={8} />;
}
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={4} rows={4} />;
}
+13 -13
View File
@@ -28,6 +28,7 @@ import { useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import Link from "next/link";
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
interface SlaSnapshot {
week: string;
@@ -185,17 +186,6 @@ export default function SlaPage() {
(metrics.targetSuccessRate != null || metrics.targetMaxDurationMinutes != null ||
metrics.targetAppInstallSuccessRate != null);
if (loading && !metrics) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading SLA metrics...</p>
</div>
</div>
);
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -258,8 +248,18 @@ export default function SlaPage() {
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* No targets state */}
{!hasTargets && (
{/* Initial load — header with scope/months controls stays interactive */}
{loading && !metrics && (
<div aria-busy="true" aria-label="Loading" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
)}
{/* No targets state — suppressed while the initial fetch is pending
so it doesn't flash before metrics arrive */}
{!(loading && !metrics) && !hasTargets && (
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 text-center">
<svg className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
@@ -0,0 +1,5 @@
import { PageSkeleton } from "@/components/skeletons/PageSkeleton";
export default function Loading() {
return <PageSkeleton cards={4} rows={6} />;
}
@@ -10,6 +10,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { useGlobalAdminScope } from "@/hooks";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
interface SessionMetrics {
total: number;
@@ -169,21 +170,6 @@ export default function UsageMetricsPage() {
return date.toLocaleString();
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading usage metrics...</p>
</div>
</div>
);
}
if (!metrics) {
return null;
}
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
@@ -198,12 +184,16 @@ export default function UsageMetricsPage() {
{isGlobalOverride && selectedTenantName
? `Tenant: ${selectedTenantName} · `
: ''}
Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
From Cache
</span>
)}
{metrics ? (
<>
Computed at {formatTimestamp(metrics.computedAt)} in {metrics.computeDurationMs}ms
{metrics.fromCache && (
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">
From Cache
</span>
)}
</>
) : '…'}
</p>
</div>
</div>
@@ -224,6 +214,26 @@ export default function UsageMetricsPage() {
</div>
</header>
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Skeleton also covers a tenant switch (loading with stale metrics) so
the previous tenant's numbers never linger under the new scope. */}
{loading ? (
<div aria-busy="true" aria-label="Loading" className="grid grid-cols-1 gap-4 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : !metrics ? (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-500">Unable to load usage metrics.</p>
<button
onClick={() => fetchMetrics()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
</div>
) : (
<>
{/* Session Statistics */}
<div className="mb-6">
@@ -489,6 +499,8 @@ export default function UsageMetricsPage() {
</div>
</div>
</div>
</>
)}
</div>
</div>
</ProtectedRoute>
@@ -3,7 +3,7 @@
// destination's client bundle downloads and hydrates. Shared shell used by the
// per-route loading.tsx files.
function Bar({ className = "" }: { className?: string }) {
export function Bar({ className = "" }: { className?: string }) {
return (
<div
className={`animate-pulse rounded bg-gray-200 dark:bg-gray-700 ${className}`}
@@ -11,7 +11,7 @@ function Bar({ className = "" }: { className?: string }) {
);
}
function CardSkeleton() {
export function CardSkeleton() {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<Bar className="h-3 w-24" />
@@ -0,0 +1,39 @@
// Server component — card-wrapped table skeleton with an optional title bar.
// Used by admin-area loading.tsx fallbacks (renders inside the persistent area
// sidebar layout) and by section components while their data loads.
import { Bar } from "./PageSkeleton";
import { TableSkeleton } from "./TableSkeleton";
interface SectionSkeletonProps {
columns?: number;
rows?: number;
/** Render a title bar above the table. */
title?: boolean;
className?: string;
}
export function SectionSkeleton({
columns = 6,
rows = 8,
title = true,
className = "",
}: SectionSkeletonProps) {
return (
<div
className={`rounded-lg border border-gray-200 bg-white shadow dark:border-gray-700 dark:bg-gray-800 ${className}`}
aria-busy="true"
aria-label="Loading"
>
{title && (
<div className="border-b border-gray-200 p-6 dark:border-gray-700">
<Bar className="h-5 w-48" />
<Bar className="mt-3 h-3 w-72" />
</div>
)}
<TableSkeleton columns={columns} rows={rows} />
</div>
);
}
export default SectionSkeleton;
@@ -0,0 +1,82 @@
// Server component (no "use client", no hooks) so it can serve both as part of
// a route-level loading.tsx fallback and as the in-page data-slot placeholder
// while a client component fetches.
//
// Loading-UX convention (applies to every page with fetched table data):
// 1. Never early-return the whole page on `loading` — render the page shell
// and put this skeleton in the data slot only.
// 2. Any loading UI must live INSIDE <ProtectedRoute>; an early return above
// it hangs forever on a fresh tab because the MSAL redirect never fires
// (see the comment in app/diagnosis/page.tsx).
// 3. Show the skeleton only when `loading && items.length === 0` so a
// filter-driven refetch keeps the populated table on screen.
// 4. Initialise `loading` to true when a fetch fires on mount, otherwise the
// empty state flashes for one paint before the fetch starts.
import { Bar } from "./PageSkeleton";
interface TableSkeletonProps {
/** Number of columns to render. */
columns?: number;
/** Number of placeholder body rows. */
rows?: number;
/** Render a shimmer header row. */
header?: boolean;
/** Wrap in the standard card chrome (rounded border + bg + shadow). */
wrapped?: boolean;
className?: string;
}
// Deterministic pseudo-random widths so rows look organic without hydration
// mismatches (no Math.random in render).
const WIDTHS = ["w-24", "w-16", "w-32", "w-20", "w-28", "w-12", "w-36"];
export function TableSkeleton({
columns = 5,
rows = 8,
header = true,
wrapped = false,
className = "",
}: TableSkeletonProps) {
const table = (
<div className="overflow-x-auto" aria-busy="true" aria-label="Loading">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
{header && (
<thead className="bg-gray-50 dark:bg-gray-900/40">
<tr>
{Array.from({ length: columns }).map((_, c) => (
<th key={c} className="px-4 py-3 text-left">
<Bar className="h-3 w-16" />
</th>
))}
</tr>
</thead>
)}
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: columns }).map((_, c) => (
<td key={c} className="px-4 py-3.5">
<Bar className={`h-4 ${WIDTHS[(r * columns + c) % WIDTHS.length]}`} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
if (!wrapped) {
return className ? <div className={className}>{table}</div> : table;
}
return (
<div
className={`rounded-lg border border-gray-200 bg-white shadow dark:border-gray-700 dark:bg-gray-800 ${className}`}
>
{table}
</div>
);
}
export default TableSkeleton;
@@ -7,7 +7,7 @@ export const PAGE_LASTMOD: Record<string, string> = {
"/about": "2026-07-25T12:43:42+02:00",
"/roadmap": "2026-07-06T18:31:00+02:00",
"/changelog": "2026-07-06T18:12:31+02:00",
"/privacy": "2026-07-25T12:43:42+02:00",
"/privacy": "2026-07-29T13:53:12+02:00",
"/terms": "2026-07-23T08:37:42+02:00",
"/docs": "2026-07-06T18:12:31+02:00",
"/docs/private-preview": "2026-07-06T18:12:31+02:00",