"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: () =>
}, ); const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"]; // Tailwind class sets per proficiency level (1–5), 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 ( {value} {PROFICIENCY_LABELS[value] ?? ""} ); } type SkillRule = { skill: string; minProficiency: number }; export function SkillsAnalytics() { const datalistId = useId(); // ── Skill table filters ────────────────────────────────────────────────── const [categoryFilter, setCategoryFilter] = useState(""); const [minCount, setMinCount] = useState(1); const [skillSearch, setSkillSearch] = useState(""); // ── People Finder ──────────────────────────────────────────────────────── const [rules, setRules] = useState([]); const [operator, setOperator] = useState<"AND" | "OR">("AND"); const [peopleChapter, setPeopleChapter] = useState(""); 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) { 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 (
); } if (error) { return (
{error.message}
); } return (
{/* Header */}

Skills Analytics

{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills

{/* ── People Finder ──────────────────────────────────────────────────── */}

People Finder

Find resources that match skill criteria
{/* Rules */} {allSkillNames.map((s) => (
{rules.map((rule, idx) => (
{/* AND / OR connector label */} {idx > 0 && ( )} {idx === 0 && ( knows )} {/* Skill input */} 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 */}
min.
{[1, 2, 3, 4, 5].map((lvl) => ( ))}
{/* Remove */}
))}
{/* Add rule + chapter filter row */}
{rules.length > 1 && (
Match:
)} {(data?.allChapters ?? []).length > 0 && (
Chapter:
)}
{/* Results */} {activeRules.length > 0 && (
{peopleFetching ? (
Searching…
) : peopleResults && peopleResults.length === 0 ? (

No resources match these criteria.

) : peopleResults && peopleResults.length > 0 ? ( <>

{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found

{peopleResults.map((person) => (
{person.displayName} {person.chapter && ( {person.chapter} )}
{person.matchedSkills.map((s) => ( {s.skill} {s.proficiency} ))}
View →
))}
) : null}
)}
{/* ── Skill table filters ────────────────────────────────────────────── */}
{/* Fuzzy search */}
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" />
{filtered.length} skills shown {(skillSearch || categoryFilter || minCount > 1) && ( )}
{/* Top 20 Skills Bar Chart */} {top20.length > 0 && (

Top Skills by Resource Count

Bar color = average proficiency (light → dark = low → high)

)} {/* Skills Gap */} {gapSkills.length > 0 && (

Skills Gap high proficiency, few practitioners (<3)

{gapSkills.map((e) => ( ))}

Click a skill to add it to the People Finder above.

)} {/* Skills Table */}
{sortedSkills.map((e) => ( ))} {sortedSkills.length === 0 && ( )}
Chapters Find
{e.skill} {e.category} {e.count} {e.chapters.join(", ") || "—"}
No skills found matching the filters.
); }