Files
CapaKraken/apps/web/src/components/analytics/SkillsAnalytics.tsx
T
Hartmut 5ffc0d92e4 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>
2026-03-19 01:23:33 +01:00

497 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { trpc } from "~/lib/trpc/client.js";
import * as XLSX from "xlsx";
const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"),
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
// Tailwind class sets per proficiency level (15), dark-mode aware
const PROFICIENCY_CLASSES = [
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
type SkillRule = { skill: string; minProficiency: number };
export function SkillsAnalytics() {
const datalistId = useId();
// ── Skill table filters ──────────────────────────────────────────────────
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [minCount, setMinCount] = useState<number>(1);
const [skillSearch, setSkillSearch] = useState<string>("");
// ── People Finder ────────────────────────────────────────────────────────
const [rules, setRules] = useState<SkillRule[]>([]);
const [operator, setOperator] = useState<"AND" | "OR">("AND");
const [peopleChapter, setPeopleChapter] = useState<string>("");
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
staleTime: 60_000,
});
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
const { data: peopleResults, isFetching: peopleFetching } =
trpc.resource.searchBySkills.useQuery(
{
rules: activeRules,
operator,
...(peopleChapter ? { chapter: peopleChapter } : {}),
},
{
enabled: activeRules.length > 0,
staleTime: 30_000,
},
);
function addRule() {
setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]);
}
function removeRule(idx: number) {
setRules((prev) => prev.filter((_, i) => i !== idx));
}
function updateRule(idx: number, patch: Partial<SkillRule>) {
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
async function exportXlsx() {
if (!data) return;
const XLSX = await import("xlsx");
const rows = data.aggregated.map((e) => ({
Skill: e.skill,
Category: e.category,
"# Resources": e.count,
"Avg Proficiency": e.avgProficiency,
Chapters: e.chapters.join(", "),
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Skills");
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
}
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
const filtered = (data?.aggregated ?? []).filter((e) => {
if (categoryFilter && e.category !== categoryFilter) return false;
if (e.count < minCount) return false;
if (skillSearch && !e.skill.toLowerCase().includes(skillSearch.toLowerCase())) return false;
return true;
});
const { sorted: sortedSkills, sortField: skillSortField, sortDir: skillSortDir, toggle: skillToggle } = useTableSort(filtered);
const top20 = filtered.slice(0, 20);
const gapSkills = (data?.aggregated ?? []).filter((e) => e.count < 3 && e.avgProficiency >= 3);
if (isLoading) {
return (
<div className="p-6 space-y-4">
<div className="h-8 shimmer-skeleton rounded w-64" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{error.message}
</div>
</div>
);
}
return (
<div className="p-6 pb-24 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Skills Analytics</h1>
<p className="text-sm text-gray-500 mt-1">
{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills
</p>
</div>
<button
type="button"
onClick={exportXlsx}
disabled={!data}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Export XLS
</button>
</div>
{/* ── People Finder ──────────────────────────────────────────────────── */}
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800">People Finder</h2>
<span className="text-xs text-gray-400">
Find resources that match skill criteria
</span>
</div>
{/* Rules */}
<datalist id={datalistId}>
{allSkillNames.map((s) => (
<option key={s} value={s} />
))}
</datalist>
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2 flex-wrap">
{/* AND / OR connector label */}
{idx > 0 && (
<button
type="button"
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 transition-colors shrink-0"
>
{operator}
</button>
)}
{idx === 0 && (
<span className="w-12 text-center text-xs font-medium text-gray-400 shrink-0">
knows
</span>
)}
{/* Skill input */}
<input
type="text"
list={datalistId}
placeholder="Skill name…"
value={rule.skill}
onChange={(e) => updateRule(idx, { skill: e.target.value })}
className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
{/* Min proficiency selector */}
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400 shrink-0">min.</span>
<div className="flex rounded-lg overflow-hidden border border-gray-200">
{[1, 2, 3, 4, 5].map((lvl) => (
<button
key={lvl}
type="button"
title={PROFICIENCY_LABELS[lvl]}
onClick={() => updateRule(idx, { minProficiency: lvl })}
className={`px-2 py-1 text-xs font-medium transition-colors ${
rule.minProficiency === lvl
? "bg-brand-600 text-white"
: "bg-white text-gray-500 hover:bg-gray-50"
}`}
>
{lvl}
</button>
))}
</div>
</div>
{/* Remove */}
<button
type="button"
onClick={() => removeRule(idx)}
className="text-gray-400 hover:text-red-500 transition-colors p-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Add rule + chapter filter row */}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={addRule}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 text-brand-600 hover:bg-brand-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add skill rule
</button>
{rules.length > 1 && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Match:</span>
<button
type="button"
onClick={() => setOperator("AND")}
className={`px-2.5 py-1 text-xs font-medium rounded-l-lg border transition-colors ${
operator === "AND"
? "bg-brand-600 border-brand-600 text-white"
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
All (AND)
</button>
<button
type="button"
onClick={() => setOperator("OR")}
className={`px-2.5 py-1 text-xs font-medium rounded-r-lg border -ml-px transition-colors ${
operator === "OR"
? "bg-brand-600 border-brand-600 text-white"
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
Any (OR)
</button>
</div>
)}
{(data?.allChapters ?? []).length > 0 && (
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-xs text-gray-500">Chapter:</span>
<select
value={peopleChapter}
onChange={(e) => setPeopleChapter(e.target.value)}
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All chapters</option>
{(data?.allChapters ?? []).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{/* Results */}
{activeRules.length > 0 && (
<div className="border-t border-gray-100 pt-4">
{peopleFetching ? (
<div className="text-sm text-gray-400 animate-pulse">Searching</div>
) : peopleResults && peopleResults.length === 0 ? (
<p className="text-sm text-gray-400 italic">No resources match these criteria.</p>
) : peopleResults && peopleResults.length > 0 ? (
<>
<p className="text-xs text-gray-500 mb-3">
{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found
</p>
<div className="space-y-2">
{peopleResults.map((person) => (
<div
key={person.id}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<a
href={`/resources/${person.id}`}
className="text-sm font-medium text-gray-900 hover:text-brand-600 transition-colors"
>
{person.displayName}
</a>
{person.chapter && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-100">
{person.chapter}
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{person.matchedSkills.map((s) => (
<span
key={s.skill}
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}
>
{s.skill}
<span className="font-semibold">{s.proficiency}</span>
</span>
))}
</div>
</div>
<a
href={`/resources/${person.id}`}
className="text-xs text-brand-600 hover:underline shrink-0 mt-0.5"
>
View
</a>
</div>
))}
</div>
</>
) : null}
</div>
)}
</div>
{/* ── Skill table filters ────────────────────────────────────────────── */}
<div className="flex flex-wrap gap-3 items-center">
{/* Fuzzy search */}
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
<input
type="text"
placeholder="Search skills…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.target.value)}
className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500 w-52"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">All Categories</option>
{(data?.categories ?? []).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-600">
Min. resources:
<input
type="number"
min={1}
max={50}
value={minCount}
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
className="w-16 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</label>
<span className="text-sm text-gray-400">{filtered.length} skills shown</span>
{(skillSearch || categoryFilter || minCount > 1) && (
<button
type="button"
onClick={() => { setSkillSearch(""); setCategoryFilter(""); setMinCount(1); }}
className="text-xs text-brand-600 hover:underline"
>
Clear filters
</button>
)}
</div>
{/* Top 20 Skills Bar Chart */}
{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>
<SkillDistributionChart data={top20} />
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light dark = low high)</p>
</div>
)}
{/* Skills Gap */}
{gapSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3">
Skills Gap
<span className="ml-2 text-xs font-normal text-gray-400">high proficiency, few practitioners (&lt;3)</span>
</h2>
<div className="flex flex-wrap gap-2">
{gapSkills.map((e) => (
<button
key={e.skill}
type="button"
onClick={() => {
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 3 }]);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
title="Add to People Finder"
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
>
{e.skill}
<span className="text-xs opacity-70">{e.count} person{e.count !== 1 ? "s" : ""}</span>
</button>
))}
</div>
<p className="text-xs text-gray-400 mt-2">Click a skill to add it to the People Finder above.</p>
</div>
)}
{/* Skills Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<SortableColumnHeader label="Skill" field="skill" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
<SortableColumnHeader label="Category" field="category" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
<SortableColumnHeader label="Resources" field="count" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chapters</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Find</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sortedSkills.map((e) => (
<tr key={e.skill} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-900">{e.skill}</td>
<td className="px-4 py-2.5 text-gray-500">{e.category}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{e.count}</td>
<td className="px-4 py-2.5 text-right">
<ProficiencyBadge value={e.avgProficiency} />
</td>
<td className="px-4 py-2.5 text-gray-400 text-xs">{e.chapters.join(", ") || "—"}</td>
<td className="px-4 py-2.5 text-center">
<button
type="button"
title="Add to People Finder"
onClick={() => {
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 1 }]);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
className="text-gray-400 hover:text-brand-600 transition-colors"
>
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</td>
</tr>
))}
{sortedSkills.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-10 text-gray-400 text-sm">
No skills found matching the filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}