perf: lazy-load xlsx/recharts, split estimate tabs, memoize nav

- xlsx dynamically imported via cached singleton in excel.ts and
  skillMatrixParser.ts (removes ~100 kB from 4 routes)
- recharts extracted into lazy-loaded SkillDistributionChart and
  PeakTimesChart components (removes ~60 kB from 3 routes)
- EstimateWorkspaceClient: 7 tab components + 2 editors loaded via
  next/dynamic (reduces /estimates/[id] from 323 kB to 138 kB)
- ImportModal lazy-loaded in ResourcesClient (deferred until open)
- NavItem memoized with React.memo, top 5 routes get prefetch=true
- Raw <img> replaced with next/image in ProjectsClient, CoverArtSection
- tRPC QueryClient: refetchOnWindowFocus/Reconnect disabled globally

Heaviest routes reduced 39-66% First Load JS:
  /analytics/skills: 383→132 kB (-66%)
  /estimates/[id]:   323→138 kB (-57%)
  /resources/[id]:   458→210 kB (-54%)
  /estimates:        310→170 kB (-45%)
  /resources:        363→222 kB (-39%)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 01:23:33 +01:00
parent f1f1be21c7
commit 5ffc0d92e4
15 changed files with 317 additions and 196 deletions
@@ -55,7 +55,7 @@ export function BatchSkillImport() {
try {
const buffer = await file.arrayBuffer();
const result = parseSkillMatrixWorkbook(buffer);
const result = await parseSkillMatrixWorkbook(buffer);
let roleId: string | undefined;
let matchedRoleName: string | undefined;
@@ -0,0 +1,38 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
// SVG fill colors for the bar chart (work in both light and dark contexts)
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
interface SkillDistributionChartProps {
data: { skill: string; count: number; avgProficiency: number }[];
}
export default function SkillDistributionChart({ data }: SkillDistributionChartProps) {
return (
<ResponsiveContainer width="100%" height={320}>
<BarChart data={data} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{data.map((entry) => (
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,24 +1,18 @@
"use client";
import { useState, useId } from "react";
import dynamic from "next/dynamic";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { trpc } from "~/lib/trpc/client.js";
import * as XLSX from "xlsx";
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"),
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
// SVG fill colors for the bar chart (work in both light and dark contexts)
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
// Tailwind class sets per proficiency level (15), dark-mode aware
const PROFICIENCY_CLASSES = [
@@ -87,8 +81,9 @@ export function SkillsAnalytics() {
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
function exportXlsx() {
async function exportXlsx() {
if (!data) return;
const XLSX = await import("xlsx");
const rows = data.aggregated.map((e) => ({
Skill: e.skill,
Category: e.category,
@@ -413,21 +408,7 @@ export function SkillsAnalytics() {
{top20.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
<Tooltip
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
contentStyle={{ fontSize: 12, borderRadius: 8 }}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{top20.map((entry) => (
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<SkillDistributionChart data={top20} />
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light dark = low high)</p>
</div>
)}
@@ -0,0 +1,55 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
const COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
];
interface PeakTimesChartProps {
chartData: Record<string, number | string>[];
groups: string[];
}
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
if (chartData.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,24 +1,14 @@
"use client";
import dynamic from "next/dynamic";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
const COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
];
const PeakTimesChart = dynamic(
() => import("~/components/dashboard/widgets/PeakTimesChart.js"),
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
);
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const granularity = (config.granularity as "week" | "month") || "month";
@@ -107,31 +97,7 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
{/* Chart */}
<div className="flex-1 min-h-0">
{chartData.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
)}
<PeakTimesChart chartData={chartData} groups={groups} />
</div>
</div>
);
@@ -2,26 +2,70 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { EstimateExportFormat } from "@planarchy/shared";
import { clsx } from "clsx";
import { EstimateWorkspaceDraftEditor } from "~/components/estimates/EstimateWorkspaceDraftEditor.js";
import { WeeklyPhasingView } from "~/components/estimates/WeeklyPhasingView.js";
import type {
EstimateWorkspaceView,
WorkspaceTab,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { OverviewTab } from "~/components/estimates/tabs/OverviewTab.js";
import { AssumptionsTab } from "~/components/estimates/tabs/AssumptionsTab.js";
import { ScopeTab } from "~/components/estimates/tabs/ScopeTab.js";
import { StaffingTab } from "~/components/estimates/tabs/StaffingTab.js";
import { FinancialsTab } from "~/components/estimates/tabs/FinancialsTab.js";
import { VersionsTab } from "~/components/estimates/tabs/VersionsTab.js";
import { ExportsTab } from "~/components/estimates/tabs/ExportsTab.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
const TabSkeleton = () => (
<div className="p-6 space-y-4">
<div className="h-8 w-48 shimmer-skeleton rounded" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
const EstimateWorkspaceDraftEditor = dynamic(
() => import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({ default: mod.EstimateWorkspaceDraftEditor })),
{ loading: TabSkeleton },
);
const WeeklyPhasingView = dynamic(
() => import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({ default: mod.WeeklyPhasingView })),
{ loading: TabSkeleton },
);
const OverviewTab = dynamic(
() => import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({ default: mod.OverviewTab })),
{ loading: TabSkeleton },
);
const AssumptionsTab = dynamic(
() => import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({ default: mod.AssumptionsTab })),
{ loading: TabSkeleton },
);
const ScopeTab = dynamic(
() => import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })),
{ loading: TabSkeleton },
);
const StaffingTab = dynamic(
() => import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({ default: mod.StaffingTab })),
{ loading: TabSkeleton },
);
const FinancialsTab = dynamic(
() => import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({ default: mod.FinancialsTab })),
{ loading: TabSkeleton },
);
const VersionsTab = dynamic(
() => import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({ default: mod.VersionsTab })),
{ loading: TabSkeleton },
);
const ExportsTab = dynamic(
() => import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({ default: mod.ExportsTab })),
{ loading: TabSkeleton },
);
const TABS: Array<{ id: WorkspaceTab; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "assumptions", label: "Assumptions" },
+86 -79
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js";
@@ -231,6 +231,60 @@ function NavTooltip({
);
}
/* ------------------------------------------------------------------ */
/* Memoized nav item — prevents re-render of inactive items */
/* ------------------------------------------------------------------ */
/** Routes that benefit from eager prefetching (loaded while user reads current page). */
const PREFETCH_ROUTES = new Set(["/dashboard", "/timeline", "/projects", "/resources", "/allocations"]);
const NavItemLink = memo(function NavItemLink({
href,
label,
icon,
isActive,
collapsed,
onClick,
}: {
href: string;
label: string;
icon: ReactNode;
isActive: boolean;
collapsed: boolean;
onClick?: (() => void) | undefined;
}) {
const linkProps = {
...(onClick ? { onClick } : {}),
...(PREFETCH_ROUTES.has(href) ? { prefetch: true as const } : {}),
};
return (
<NavTooltip label={label} show={collapsed}>
<Link
href={href as Route}
{...linkProps}
className={clsx(
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
collapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<IconFrame>{icon}</IconFrame>
{!collapsed && <span className="relative flex-1">{label}</span>}
</Link>
</NavTooltip>
);
});
/* ------------------------------------------------------------------ */
/* Sidebar component */
/* ------------------------------------------------------------------ */
@@ -370,34 +424,17 @@ function SidebarContent({
className="overflow-hidden"
>
<div className="space-y-0.5">
{section.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<NavTooltip key={item.href} label={item.label} show={sidebarCollapsed}>
<Link
href={item.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<IconFrame>{item.icon}</IconFrame>
{!sidebarCollapsed && <span className="relative flex-1">{item.label}</span>}
</Link>
</NavTooltip>
);
})}
{section.items.map((item) => (
<NavItemLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={activeHrefSet.has(item.href)}
collapsed={sidebarCollapsed}
onClick={handleLinkClick}
/>
))}
</div>
</motion.div>
)}
@@ -425,32 +462,17 @@ function SidebarContent({
if (sidebarCollapsed) {
// In collapsed mode, show sub-group items directly as icon-only
return entry.items.map((item) => {
const isActive = activeHrefSet.has(item.href);
return (
<NavTooltip key={item.href} label={item.label} show>
<Link
href={item.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center justify-center rounded-2xl px-2 py-2 text-sm font-medium transition-colors",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<IconFrame>{item.icon}</IconFrame>
</Link>
</NavTooltip>
);
});
return entry.items.map((item) => (
<NavItemLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={activeHrefSet.has(item.href)}
collapsed
onClick={handleLinkClick}
/>
));
}
return (
@@ -517,31 +539,16 @@ function SidebarContent({
);
}
const isActive = activeHrefSet.has(entry.href);
return (
<NavTooltip key={entry.href} label={entry.label} show={sidebarCollapsed}>
<Link
href={entry.href as Route}
onClick={handleLinkClick}
className={clsx(
"group relative flex items-center rounded-2xl text-sm font-medium transition-colors",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-3 px-3 py-2",
isActive
? "text-brand-800 dark:text-brand-200"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-0 rounded-2xl bg-gradient-to-r from-brand-100 to-brand-50/80 shadow-sm ring-1 ring-brand-200/80 dark:from-brand-900/30 dark:to-brand-800/20 dark:ring-brand-900/40"
transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
)}
<IconFrame>{entry.icon}</IconFrame>
{!sidebarCollapsed && <span className="relative flex-1">{entry.label}</span>}
</Link>
</NavTooltip>
<NavItemLink
key={entry.href}
href={entry.href}
label={entry.label}
icon={entry.icon}
isActive={activeHrefSet.has(entry.href)}
collapsed={sidebarCollapsed}
onClick={handleLinkClick}
/>
);
})}
</div>
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef } from "react";
import NextImage from "next/image";
import { trpc } from "~/lib/trpc/client.js";
interface CoverArtSectionProps {
@@ -149,14 +150,18 @@ export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, pr
{/* Cover image or placeholder */}
{imageUrl ? (
<div className="relative">
<img
<NextImage
src={imageUrl}
alt={`Cover art for ${projectName}`}
width={1024}
height={1024}
className="w-full object-cover"
style={{
height: "clamp(16rem, 22vw, 22rem)",
objectPosition: `center ${focusY}%`,
}}
unoptimized={imageUrl.startsWith("data:")}
priority
/>
{/* Gradient overlay at bottom for readability */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
@@ -2,14 +2,27 @@
import { useState } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate, formatMoney } from "~/lib/format.js";
import { ResourceModal } from "./ResourceModal.js";
import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.js";
const SkillRadarChart = dynamic(
() => import("~/components/resources/SkillRadarChart.js").then((mod) => ({ default: mod.SkillRadarChart })),
{ ssr: false, loading: () => <div className="h-64 shimmer-skeleton rounded-xl" /> },
);
const AiSummaryCard = dynamic(
() => import("~/components/resources/AiSummaryCard.js").then((mod) => ({ default: mod.AiSummaryCard })),
{ ssr: false },
);
const SkillMatrixUpload = dynamic(
() => import("~/components/resources/SkillMatrixUpload.js").then((mod) => ({ default: mod.SkillMatrixUpload })),
{ ssr: false },
);
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
import { FadeIn } from "~/components/ui/FadeIn.js";
@@ -47,7 +47,7 @@ export function SkillMatrixUpload({ resourceId, isOwner, onClose, onSuccess }: P
try {
const buffer = await file.arrayBuffer();
const parsed = parseSkillMatrixWorkbook(buffer);
const parsed = await parseSkillMatrixWorkbook(buffer);
// Fuzzy match areaOfExpertise → roleId
let roleId: string | undefined;