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 ? ( - + {project.name} ) : ( 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 ? (
- {`Cover {/* 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, }, },