diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx
index 049853c..073240b 100644
--- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx
+++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx
@@ -6,6 +6,7 @@ import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef } from "@planarchy/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
import Link from "next/link";
+import Image from "next/image";
import { clsx } from "clsx";
import { motion } from "framer-motion";
import { trpc } from "~/lib/trpc/client.js";
@@ -359,7 +360,7 @@ export function ProjectsClient() {
{project.coverImageUrl ? (
-
+
) : (
import("~/components/resources/ImportModal.js").then((mod) => mod.ImportModal),
+ { ssr: false },
+);
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
diff --git a/apps/web/src/components/admin/BatchSkillImport.tsx b/apps/web/src/components/admin/BatchSkillImport.tsx
index 7a6a51e..c28511d 100644
--- a/apps/web/src/components/admin/BatchSkillImport.tsx
+++ b/apps/web/src/components/admin/BatchSkillImport.tsx
@@ -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;
diff --git a/apps/web/src/components/analytics/SkillDistributionChart.tsx b/apps/web/src/components/analytics/SkillDistributionChart.tsx
new file mode 100644
index 0000000..0d0c4e5
--- /dev/null
+++ b/apps/web/src/components/analytics/SkillDistributionChart.tsx
@@ -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 (
+
+
+
+
+ [`${value ?? 0} resources`, "Count"] as [string, string]}
+ contentStyle={{ fontSize: 12, borderRadius: 8 }}
+ />
+
+ {data.map((entry) => (
+ |
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/analytics/SkillsAnalytics.tsx b/apps/web/src/components/analytics/SkillsAnalytics.tsx
index 48a4b88..5e0ef8f 100644
--- a/apps/web/src/components/analytics/SkillsAnalytics.tsx
+++ b/apps/web/src/components/analytics/SkillsAnalytics.tsx
@@ -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: () => },
+);
-// 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 (1–5), 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 && (
Top Skills by Resource Count
-
-
-
-
- [`${value ?? 0} resources`, "Count"] as [string, string]}
- contentStyle={{ fontSize: 12, borderRadius: 8 }}
- />
-
- {top20.map((entry) => (
- |
- ))}
-
-
-
+
Bar color = average proficiency (light → dark = low → high)
)}
diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx
new file mode 100644
index 0000000..91429b6
--- /dev/null
+++ b/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx
@@ -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[];
+ groups: string[];
+}
+
+export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
+ if (chartData.length === 0) {
+ return (
+
+ No allocation data in selected period.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+
+ {groups.map((g, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx
index c33fcb0..15b971d 100644
--- a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx
+++ b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx
@@ -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: () => },
+);
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 */}
- {chartData.length === 0 ? (
-
- No allocation data in selected period.
-
- ) : (
-
-
-
-
-
-
-
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
- {groups.map((g, i) => (
-
- ))}
-
-
- )}
+
);
diff --git a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
index a1d7987..68e6100 100644
--- a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
+++ b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
@@ -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 = () => (
+
+);
+
+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" },
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
index fd4da18..4093936 100644
--- a/apps/web/src/components/layout/AppShell.tsx
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -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 (
+
+
+ {isActive && (
+
+ )}
+ {icon}
+ {!collapsed && {label}}
+
+
+ );
+});
+
/* ------------------------------------------------------------------ */
/* Sidebar component */
/* ------------------------------------------------------------------ */
@@ -370,34 +424,17 @@ function SidebarContent({
className="overflow-hidden"
>
- {section.items.map((item) => {
- const isActive = activeHrefSet.has(item.href);
- return (
-
-
- {isActive && (
-
- )}
- {item.icon}
- {!sidebarCollapsed && {item.label}}
-
-
- );
- })}
+ {section.items.map((item) => (
+
+ ))}
)}
@@ -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 (
-
-
- {isActive && (
-
- )}
- {item.icon}
-
-
- );
- });
+ return entry.items.map((item) => (
+
+ ));
}
return (
@@ -517,31 +539,16 @@ function SidebarContent({
);
}
- const isActive = activeHrefSet.has(entry.href);
return (
-
-
- {isActive && (
-
- )}
- {entry.icon}
- {!sidebarCollapsed && {entry.label}}
-
-
+
);
})}
diff --git a/apps/web/src/components/projects/CoverArtSection.tsx b/apps/web/src/components/projects/CoverArtSection.tsx
index 9d01c77..a5a4142 100644
--- a/apps/web/src/components/projects/CoverArtSection.tsx
+++ b/apps/web/src/components/projects/CoverArtSection.tsx
@@ -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 ? (
- 
{/* Gradient overlay at bottom for readability */}
diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx
index 28cf979..376aee5 100644
--- a/apps/web/src/components/resources/ResourceDetail.tsx
+++ b/apps/web/src/components/resources/ResourceDetail.tsx
@@ -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: () => },
+);
+
+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";
diff --git a/apps/web/src/components/resources/SkillMatrixUpload.tsx b/apps/web/src/components/resources/SkillMatrixUpload.tsx
index 04c8e5d..371018d 100644
--- a/apps/web/src/components/resources/SkillMatrixUpload.tsx
+++ b/apps/web/src/components/resources/SkillMatrixUpload.tsx
@@ -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;
diff --git a/apps/web/src/lib/excel.ts b/apps/web/src/lib/excel.ts
index 68a3f4f..ba1e044 100644
--- a/apps/web/src/lib/excel.ts
+++ b/apps/web/src/lib/excel.ts
@@ -1,37 +1,32 @@
-import * as XLSX from "xlsx";
+let _xlsx: typeof import("xlsx") | null = null;
+
+async function getXLSX() {
+ if (!_xlsx) {
+ _xlsx = await import("xlsx");
+ }
+ return _xlsx;
+}
/**
* Parse an Excel (.xlsx, .xls) or CSV file to an array of row objects.
* Keys come from the first row (headers).
*/
-export function parseSpreadsheet(file: File): Promise []> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = (e) => {
- try {
- const data = new Uint8Array(e.target!.result as ArrayBuffer);
- const workbook = XLSX.read(data, { type: "array" });
- const sheetName = workbook.SheetNames[0];
- if (!sheetName) {
- resolve([]);
- return;
- }
- const sheet = workbook.Sheets[sheetName];
- if (!sheet) {
- resolve([]);
- return;
- }
- const rows = XLSX.utils.sheet_to_json>(sheet, {
- raw: false,
- defval: "",
- });
- resolve(rows);
- } catch (err) {
- reject(err);
- }
- };
- reader.onerror = () => reject(reader.error);
- reader.readAsArrayBuffer(file);
+export async function parseSpreadsheet(file: File): Promise[]> {
+ const XLSX = await getXLSX();
+ const buffer = await file.arrayBuffer();
+ const data = new Uint8Array(buffer);
+ const workbook = XLSX.read(data, { type: "array" });
+ const sheetName = workbook.SheetNames[0];
+ if (!sheetName) {
+ return [];
+ }
+ const sheet = workbook.Sheets[sheetName];
+ if (!sheet) {
+ return [];
+ }
+ return XLSX.utils.sheet_to_json>(sheet, {
+ raw: false,
+ defval: "",
});
}
diff --git a/apps/web/src/lib/skillMatrixParser.ts b/apps/web/src/lib/skillMatrixParser.ts
index 223c018..fe44e3f 100644
--- a/apps/web/src/lib/skillMatrixParser.ts
+++ b/apps/web/src/lib/skillMatrixParser.ts
@@ -1,6 +1,14 @@
-import * as XLSX from "xlsx";
import type { SkillEntry } from "@planarchy/shared";
+let _xlsx: typeof import("xlsx") | null = null;
+
+async function getXLSX() {
+ if (!_xlsx) {
+ _xlsx = await import("xlsx");
+ }
+ return _xlsx;
+}
+
export interface ParsedEmployeeInfo {
displayName?: string;
areaOfExpertise?: string;
@@ -82,7 +90,8 @@ function parseSkillSheet(rows: Record[], mainSkillSet: Set {
+ const XLSX = await getXLSX();
const workbook = XLSX.read(new Uint8Array(data), { type: "array" });
const employeeSheet = workbook.Sheets["Employee Information"];
diff --git a/apps/web/src/lib/trpc/provider.tsx b/apps/web/src/lib/trpc/provider.tsx
index ec2ff8c..7c4b340 100644
--- a/apps/web/src/lib/trpc/provider.tsx
+++ b/apps/web/src/lib/trpc/provider.tsx
@@ -27,7 +27,9 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
new QueryClient({
defaultOptions: {
queries: {
- staleTime: 60 * 1000, // 60 seconds — reduces refetches on navigation
+ staleTime: 60_000, // 60 seconds — reduces refetches on navigation
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
retry: 1,
},
},
|