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>
105 lines
3.3 KiB
TypeScript
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;
|
|
}
|