mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-29 20:31:24 +02:00
Closes the Phase 4 batch from cowork/frontend-design-audit.html: skeleton
primitive, route-level lazy splitting + vendor manualChunks, mega-page
split (OnboardingWizard), targeted memoization for dashboard charts,
useTransition for filter-toolbar.
═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed facts from the live repo before implementing (not the audit's
stamped numbers — those drifted):
• Pre-Phase-4 index-*.js = 1,121,868 B raw / 288,238 B gz
(audit said 980 KB / 247 KB — drifted UP since the audit was written)
• React.lazy sites = 1 (CommandPaletteHost from Phase 3); zero route-
level lazy boundaries before this commit
• vite.config.ts had NO rollupOptions.output.manualChunks
• Mega-page LOCs: OnboardingWizard 1043 / CertificateDetailPage 977 /
SCEPAdminPage 806 / CertificatesPage 812 / ESTAdminPage 646
(audit said 1033 / 936 / 806 / 751 / 646 — all grew due to Phase 1-3
additions; still mega)
• Memoization tally: React.memo 0, useMemo 22, useCallback 5,
useTransition 0, useDeferredValue 0
• DashboardPage useQuery sites = 9 (audit said 10 — overcount)
• OnboardingWizard step structure = 4 step fns (issuer / agent /
certificate / complete) + StepIndicator + WizardFooter +
CodeBlock + 2 inline create modals. The audit's "6-way split"
suggestion = 6 files post-split (shell + indicator/shell helpers
+ 4 step files), which is what this commit ships.
═════════════════════════════ CLOSURES ═══════════════════════════════
UX-M1 — Skeleton primitive (web/src/components/Skeleton.tsx, +6 tests)
• Four variants: page / table / card / stat
• Each uses Tailwind animate-pulse on layout-shaped divs so eventual
content lands without CLS
• role="status" + aria-busy="true" + aria-label for SR users
• DataTable.tsx now uses Skeleton variant="table" with columns prop
instead of the centered "Loading..." spinner — every DataTable
consumer gets layout-shape-preserving loading without code changes.
The skeleton sizes the table to the actual column count + adds a
selectable-column slot when relevant.
FE-M5 + SCALE-H1 — route-level code split + vendor manualChunks
• main.tsx: every page route except DashboardPage (landing route, kept
eager) is now React.lazy() + wrapped in <Suspense fallback={
<Skeleton variant="page" />}> via lazyRoute() helper. 35 lazy
routes total.
• OnboardingWizard is also lazy-imported inside DashboardPage —
keeps its 29 KB step-form code off the dashboard hot path for every
operator who already dismissed the first-run wizard.
• vite.config.ts: rollupOptions.output.manualChunks splits
react+react-dom (132 KB), react-router-dom (24 KB),
@tanstack/react-query (28 KB), recharts (383 KB!), and lucide-react
(16 KB) into named vendor chunks. Vite 8 rolldown requires the
function-shape manualChunks (id) => string; not the Vite-5 object
shape — confirmed against the actual build error before writing
the function.
Bundle profile (raw / gz):
pre-Phase-4 single index-*.js = 1,121,868 / 288,238
post-Phase-4 index-*.js = 91,978 / 25,867 (-92% raw)
vendor-react = 132,821 / 43,113
vendor-router = 23,835 / 8,763
vendor-query = 28,029 / 8,693
vendor-icons = 15,663 / 6,149
vendor-recharts = 382,953 / 110,251 (Dashboard-only)
per-route chunks = 1.4-26 KB raw each
Non-Dashboard cold load: vendor-react + vendor-router + vendor-query
+ vendor-icons + index + per-route chunk ≈ 95 KB gz first-load.
Dashboard cold load adds vendor-recharts (110 KB gz) on demand.
Audit target was <100 KB gz first-load for non-Dashboard routes — hit.
FE-M3 + P-M2 (partial) — OnboardingWizard mega-page split
• 1043 LOC monolith → src/pages/OnboardingWizard.tsx (100 LOC shell) +
src/pages/onboarding/{types.ts, StepShell.tsx, IssuerStep.tsx,
AgentStep.tsx, CertificateStep.tsx, CompleteStep.tsx} (6 files,
largest = CertificateStep at 504 LOC for the certificate form +
two inline create-team/create-owner modals it owns).
• Behavior preserved byte-equivalent — DashboardPage's lazy-import
path is unchanged because OnboardingWizard.tsx still exists at the
same location with the same default-export prop shape.
• CertificateDetailPage / SCEPAdminPage / ESTAdminPage / CertificatesPage
splits deferred: each is already in its own lazy chunk (the bundle-
size win is achieved). Splitting them adds maintenance benefit but
requires careful URL-preservation work (especially CertDetail tab
routing — /certificates/:id must redirect to /overview to preserve
deep links). Documented as Phase 4 follow-up; not blocking on this
closure.
PERF-M1 + P-H3 — memoized dashboard chart panels + useTransition filter
• src/pages/dashboard/charts.tsx — 4 React.memo()-wrapped chart panels
(CertsByStatusPieChart, ExpirationTimelineBarChart, JobTrendsLine-
Chart, IssuanceRateBarChart) + ChartCard + CustomTooltip + shared
helpers. Pre-Phase-4 these lived as inline JSX in DashboardPage's
return; any of the 9 useQuery refetches forced all four Recharts
subtrees to reconcile. Post-Phase-4 each panel only re-renders when
its specific data prop's reference changes.
• DashboardPage useMemo wraps pieData + weeklyExpiration so the
memo'd children's prop-equality check works (without useMemo a
fresh array on every render defeats the memo).
• Rules-of-Hooks: useMemo hooks live BEFORE the wizard early-return —
not after. (First implementation put them after; vitest caught it
with "Rendered more hooks than during the previous render" — fixed.)
• useListParams hook now wraps setSearchParams in useTransition so
URL-resident filter / sort / page updates are marked low-priority.
React can preempt the result-table reconciliation when the operator
toggles dropdowns rapidly. Affects every list page that uses the
hook (CertificatesPage is the main consumer post-Bundle-8).
═══════════════════════════ VERIFICATION ═════════════════════════════
• npx tsc --noEmit — exits 0
• Skeleton primitive: 6/6 tests green
• Component suite (12 files): 137/137 green
• Auth-page suite (13 files): 130/130 green
• Dashboard + Onboarding + Certificates + CertificateDetail + Targets
+ Agents + Issuers + Jobs + SCEPAdmin + ESTAdmin: 71/71 green
• npm run build clean; chunk inventory verified (vendor-react,
vendor-router, vendor-query, vendor-recharts, vendor-icons emitted
as named chunks; 35 per-route lazy chunks emitted; index-*.js
shrunk to 91.66 KB raw / 25.92 KB gz).
═══════════════════════════ RESIDUAL RISK ════════════════════════════
• Vite 8 + rolldown's manualChunks signature differs from Vite 5;
upgrading Vite again would re-break this config. Comment in
vite.config.ts pins the function-shape requirement.
• CertificateDetailPage / SCEP / EST / CertificatesPage splits remain
open. Mega-LOC files but already lazy-chunked, so deferring is safe.
• Recharts ResizeObserver mis-fires when memo'd panels resize at the
same time the parent re-renders. The audit flagged this; no
repro observed in vitest but worth monitoring in the demo.
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
// Phase 4 closure (PERF-M1 + P-H3): memoized dashboard chart panels.
|
|
//
|
|
// Pre-Phase-4 the four chart panels lived as inline JSX inside
|
|
// DashboardPage's return statement. DashboardPage has 9 useQuery hooks
|
|
// (health / summary / issuers / statusCounts / expirationTimeline /
|
|
// jobTrends / issuanceRate / certs / jobs) and each refetch — including
|
|
// the per-tab refocus refetches the Phase 2 work narrowed but didn't
|
|
// eliminate for the live-tile cohort — forced React to re-evaluate every
|
|
// chart's JSX subtree, including the Recharts ResponsiveContainer
|
|
// reconciliation that the library uses under the hood (~10-50 ms each
|
|
// for charts with non-trivial data).
|
|
//
|
|
// Post-Phase-4 each chart is its own React.memo-wrapped component. When
|
|
// only `summary` updates, the four chart panels skip re-render entirely
|
|
// because their `data` prop didn't change. When `jobTrends` updates,
|
|
// only `JobTrendsLineChart` re-renders; the other three panels skip.
|
|
//
|
|
// React.memo's default equality is referential (Object.is). The parent
|
|
// DashboardPage passes the query result's `.data` arrays directly — TanStack
|
|
// Query returns a stable reference until the underlying data actually
|
|
// changes (it caches via queryKey), so referential equality is the
|
|
// correct check for this layer. No custom areEqual function needed.
|
|
|
|
import { memo } from 'react';
|
|
import {
|
|
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
|
} from 'recharts';
|
|
|
|
// ─── Shared helpers ──────────────────────────────────────
|
|
|
|
/** PascalCase → space-separated for display ("RenewalInProgress" → "Renewal In Progress"). */
|
|
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
|
|
/** "2026-05-10" → "5/10" for compact x-axis labels. */
|
|
const formatShortDate = (dateStr: string) => {
|
|
const d = new Date(dateStr + 'T00:00:00');
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
};
|
|
|
|
interface TooltipPayloadEntry {
|
|
color?: string;
|
|
name?: string;
|
|
value?: number | string;
|
|
}
|
|
|
|
interface CustomTooltipProps {
|
|
active?: boolean;
|
|
payload?: TooltipPayloadEntry[];
|
|
label?: string;
|
|
}
|
|
|
|
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|
if (!active || !payload?.length) return null;
|
|
return (
|
|
<div className="bg-surface border border-surface-border rounded px-3 py-2 text-xs shadow-lg">
|
|
<p className="text-ink mb-1">{label}</p>
|
|
{payload.map((entry, i) => (
|
|
<p key={i} style={{ color: entry.color }}>
|
|
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ChartCardProps {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function ChartCard({ title, children }: ChartCardProps) {
|
|
return (
|
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
|
<h3 className="text-sm font-semibold text-ink-muted mb-4">{title}</h3>
|
|
<div className="h-64">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Memoized chart panels ───────────────────────────────
|
|
|
|
export interface PieDatum {
|
|
name: string;
|
|
value: number;
|
|
fill: string;
|
|
}
|
|
|
|
/** Certificates-by-Status pie chart. Re-renders only when `data` ref changes. */
|
|
export const CertsByStatusPieChart = memo(function CertsByStatusPieChart({ data }: { data: PieDatum[] }) {
|
|
return (
|
|
<ChartCard title="Certificates by Status">
|
|
{data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={data}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={60}
|
|
outerRadius={90}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
|
|
labelLine={false}
|
|
>
|
|
{data.map((entry, index) => (
|
|
<Cell key={index} fill={entry.fill} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Legend
|
|
verticalAlign="bottom"
|
|
height={36}
|
|
formatter={(value: string) => <span className="text-xs text-ink-muted">{formatStatus(value)}</span>}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No certificate data</div>
|
|
)}
|
|
</ChartCard>
|
|
);
|
|
});
|
|
|
|
export interface WeeklyExpirationDatum {
|
|
week: string;
|
|
count: number;
|
|
}
|
|
|
|
/** Expiration Heatmap bar chart. Re-renders only when `data` ref changes. */
|
|
export const ExpirationTimelineBarChart = memo(function ExpirationTimelineBarChart({ data }: { data: WeeklyExpirationDatum[] }) {
|
|
return (
|
|
<ChartCard title="Expiration Timeline (Next 90 Days)">
|
|
{data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={data}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
<XAxis dataKey="week" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No expiration data</div>
|
|
)}
|
|
</ChartCard>
|
|
);
|
|
});
|
|
|
|
export interface JobTrendDatum {
|
|
date: string;
|
|
completed_count: number;
|
|
failed_count: number;
|
|
}
|
|
|
|
/** Job Success/Failure trend line chart. Re-renders only when `data` ref changes. */
|
|
export const JobTrendsLineChart = memo(function JobTrendsLineChart({ data }: { data: JobTrendDatum[] }) {
|
|
return (
|
|
<ChartCard title="Job Success/Failure Trends (30 Days)">
|
|
{data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={data}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Legend formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>} />
|
|
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
|
|
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No job trend data</div>
|
|
)}
|
|
</ChartCard>
|
|
);
|
|
});
|
|
|
|
export interface IssuanceRateDatum {
|
|
date: string;
|
|
issued_count: number;
|
|
}
|
|
|
|
/** Certificate Issuance Rate bar chart. Re-renders only when `data` ref changes. */
|
|
export const IssuanceRateBarChart = memo(function IssuanceRateBarChart({ data }: { data: IssuanceRateDatum[] }) {
|
|
return (
|
|
<ChartCard title="Certificate Issuance Rate (30 Days)">
|
|
{data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={data}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Bar dataKey="issued_count" name="Issued" fill="#2ea88f" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No issuance data</div>
|
|
)}
|
|
</ChartCard>
|
|
);
|
|
});
|