// ========================================================================== // 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 = { 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 = { 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 { const result: Record = {}; 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; }