Files
skill-tree-portfolio/src/lib/utils/stats.ts
T
TehRiehlDealandClaude Opus 5 f83a317520 content: audit phase 4 — lead with the work that differentiates
Brand/content phase. The engineering was never the weak part of this site;
the copy was. Decisions on title, bio angle, download figures and node copy
were the owner's — this implements them.

Identity:
- jobTitle was 'Software Development - Lead Associate', an internal HR grade
  that means nothing to a recruiter or a search engine. Now 'Lead Software
  Engineer' — a faithful translation, not a promotion.
- aboutMe opened with "Intermediate Software Engineer", grading himself down
  in his own headline, and named the most generic possible stack
  (React/Node/SQL/ShadCN/Docker). It now leads with the work almost nobody
  else has done: WebRTC telehealth, real-time gesture recognition that made
  chat usable for speech-impaired patients, and air-gapped deployment
  tooling. Those were previously buried 3-4 clicks deep in leaf nodes and
  appeared nowhere on the surface.
- Title/description/OG/Twitter copy realigned to match. JSON-LD needed no
  change — it already derives from the origin node.

Node copy:
- The five job nodes had placeholder descriptions ('Junior developer at the
  start of my career.', 'Where I am now.') that are the first thing a tooltip
  shows. Each is now a one-line hook naming the most substantial thing from
  that role.
- Normalised highlight punctuation: only exp-job5's bullets ended in periods.

Proof of work:
- `downloads` existed on ProjectNode and was used by zero nodes despite two
  published PyPI packages. Populated with verified lifetime figures from
  pypistats.org (tvdbAPI 2,277, tmdbAPI 784, checked 2026-08-09) and dated in
  a comment. Lifetime, deliberately, not the recent rate — 27 and 12 in the
  last month is not a number worth advertising.
- Nothing rendered the field. NodeModal now shows it beside the status badge,
  and SeoContent renders it too so it's crawlable.

Data integrity:
- edu-degree's description said "Computer Science" while its own structured
  fields said Applied Science / IT-Software Development. Its label was the
  generic "University"; now Green River College.
- calculatePlayerLevel() hardcoded '2020-10-01', duplicating
  exp-job1.startDate — two sources for one fact, free to drift. It now
  derives the earliest start from the experience nodes, via an explicit
  "Month YYYY" parser rather than handing a bare "October 2020" to
  `new Date` (not a format engines are required to accept).
- Added the first tests for calculatePlayerLevel, previously the only
  untested export in stats.ts. 14 -> 16 tests.
- Fixed the 'tool-vscod' id typo.

Corrections to the audit this phase acted on: the self-hosted repos at
git.tehriehldeal.com ARE publicly reachable (all three return 200
anonymously) — the earlier claim that recruiters couldn't reach them was
asserted without checking. All three live project sites are up too.

Verified in the production build: modal shows the new title and bio, the
downloads badge renders as "↓ 2,277 downloads", figures are crawlable in the
prerendered HTML, and no filler copy survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:42:45 -07:00

105 lines
3.3 KiB
TypeScript

// ==========================================================================
// STATS UTILS — pure aggregation for the RPG-style character panel.
// ==========================================================================
import { treeNodes, type SkillNode, type ExperienceNode } from '$lib/data';
/** Skill branch ids and their display labels (single source of truth). */
export const BRANCH_IDS = [
'concepts',
'tools-devops',
'frontend',
'backend-real-time',
'languages'
] as const;
export const BRANCH_LABELS: Record<string, string> = {
concepts: 'Concepts',
'tools-devops': 'Tools & DevOps',
frontend: 'Frontend',
'backend-real-time': 'Backend & Real-Time',
languages: 'Languages'
};
/** Proficiency sort order (best first). */
export const PROFICIENCY_ORDER: Record<string, number> = {
expert: 0,
advanced: 1,
intermediate: 2,
beginner: 3
};
/**
* Parse a "Month YYYY" string (the format used by every startDate in data.ts)
* into a timestamp. Built explicitly rather than handed straight to `new Date`:
* "October 2020" alone is not a format the spec requires engines to accept.
*/
function parseMonthYear(value: string): number | null {
const [month, year] = value.trim().split(/\s+/);
if (!month || !year) return null;
const parsed = new Date(`${month} 1, ${year}`).getTime();
return Number.isNaN(parsed) ? null : parsed;
}
/**
* Character "level" derived from years since the first job started.
*
* The start date is read from the experience nodes rather than hardcoded — it
* previously duplicated exp-job1.startDate, so the two could silently drift.
*/
export function calculatePlayerLevel(): number {
const starts = treeNodes
.filter((n): n is ExperienceNode => n.category === 'experience' && !!n.startDate)
.map((n) => parseMonthYear(n.startDate as string))
.filter((t): t is number => t !== null);
const earliestStart = new Date(starts.length ? Math.min(...starts) : Date.now());
const now = new Date();
const millisPerYear = 1000 * 60 * 60 * 24 * 365.25;
const years = (now.getTime() - earliestStart.getTime()) / millisPerYear;
return Math.floor(years);
}
/** Character "class" — the origin node's job title. */
export function getPlayerClass(): string {
const originNode = treeNodes.find((node) => node.category === 'origin');
return originNode?.jobTitle || 'Software Engineer';
}
/** Group skill nodes by branch, sorted by proficiency. */
export function aggregateSkills(): Record<string, { label: string; skills: SkillNode[] }> {
const result: Record<string, { label: string; skills: SkillNode[] }> = {};
BRANCH_IDS.forEach((branchId) => {
const skills = treeNodes.filter(
(node): node is SkillNode => node.parentId === branchId && node.category === 'skills'
);
skills.sort((a, b) => {
const aOrder = PROFICIENCY_ORDER[a.proficiency || 'beginner'];
const bOrder = PROFICIENCY_ORDER[b.proficiency || 'beginner'];
return aOrder - bOrder;
});
result[branchId] = { label: BRANCH_LABELS[branchId], skills };
});
return result;
}
/** Count all skill nodes by proficiency tier. */
export function countByProficiency(): {
expert: number;
advanced: number;
intermediate: number;
beginner: number;
} {
const counts = { expert: 0, advanced: 0, intermediate: 0, beginner: 0 };
treeNodes.forEach((node) => {
if (node.category === 'skills' && node.proficiency) {
counts[node.proficiency]++;
}
});
return counts;
}