chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,515 @@
"use client";
import { useState, useId } from "react";
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"];
// SVG fill colors for the bar chart (work in both light and dark contexts)
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
// 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)));
}
function exportXlsx() {
if (!data) return;
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 animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-64" />
<div className="h-64 bg-gray-100 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>
<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>
<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>
);
}