refactor: model TreeNode as a discriminated union
Phase 3 (behavior-preserving): - Replace the 45-optional-field TreeNode interface with a tagged union over category: OriginNode | ExperienceNode | ProjectNode | SkillNode | EducationNode, sharing a BaseNode. Category-specific fields now live only on their member. - Modal template narrows by category (category === 'experience' && ...) instead of bare field-presence checks; equivalent at runtime since each field is exclusive to its category (proven by the union compiling against the data). - stats utils narrow correctly: aggregateSkills filters to SkillNode[] via a type predicate; countByProficiency and getPlayerClass guard on category. svelte-check: 0 errors (every data node conforms). lint/test/build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6091c62654
commit
bf235680a4
4 files changed
+68
-51
No files matched your search
@@ -1240,7 +1240,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Experience-specific content -->
|
<!-- Experience-specific content -->
|
||||||
{#if selectedNode.company}
|
{#if selectedNode.category === 'experience' && selectedNode.company}
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<span class="company">{selectedNode.company}</span>
|
<span class="company">{selectedNode.company}</span>
|
||||||
{#if selectedNode.startDate}
|
{#if selectedNode.startDate}
|
||||||
@@ -1252,7 +1252,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Education-specific content -->
|
<!-- Education-specific content -->
|
||||||
{#if selectedNode.institution}
|
{#if selectedNode.category === 'education' && selectedNode.institution}
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<span class="company">{selectedNode.institution}</span>
|
<span class="company">{selectedNode.institution}</span>
|
||||||
{#if selectedNode.degree}
|
{#if selectedNode.degree}
|
||||||
@@ -1265,7 +1265,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Skill-specific content -->
|
<!-- Skill-specific content -->
|
||||||
{#if selectedNode.proficiency}
|
{#if selectedNode.category === 'skills' && selectedNode.proficiency}
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<span class="proficiency-badge {selectedNode.proficiency}"
|
<span class="proficiency-badge {selectedNode.proficiency}"
|
||||||
>{selectedNode.proficiency}</span
|
>{selectedNode.proficiency}</span
|
||||||
@@ -1281,7 +1281,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Highlights (for experience) -->
|
<!-- Highlights (for experience) -->
|
||||||
{#if selectedNode.highlights && selectedNode.highlights.length > 0}
|
{#if selectedNode.category === 'experience' && selectedNode.highlights && selectedNode.highlights.length > 0}
|
||||||
<ul class="highlights">
|
<ul class="highlights">
|
||||||
{#each selectedNode.highlights as highlight (highlight)}
|
{#each selectedNode.highlights as highlight (highlight)}
|
||||||
<li>{highlight}</li>
|
<li>{highlight}</li>
|
||||||
@@ -1290,7 +1290,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Tech stack (for projects) -->
|
<!-- Tech stack (for projects) -->
|
||||||
{#if selectedNode.techStack && selectedNode.techStack.length > 0}
|
{#if selectedNode.category === 'projects' && selectedNode.techStack && selectedNode.techStack.length > 0}
|
||||||
<div class="tech-stack">
|
<div class="tech-stack">
|
||||||
{#each selectedNode.techStack as tech (tech)}
|
{#each selectedNode.techStack as tech (tech)}
|
||||||
<span class="tech-tag">{tech}</span>
|
<span class="tech-tag">{tech}</span>
|
||||||
@@ -1299,14 +1299,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Project status badge -->
|
<!-- Project status badge -->
|
||||||
{#if selectedNode.status}
|
{#if selectedNode.category === 'projects' && selectedNode.status}
|
||||||
<div class="project-status">
|
<div class="project-status">
|
||||||
<span class="status-badge {selectedNode.status}">{selectedNode.status}</span>
|
<span class="status-badge {selectedNode.status}">{selectedNode.status}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Links (for projects) -->
|
<!-- Links (for projects) -->
|
||||||
{#if selectedNode.projectUrl || selectedNode.repoUrl || selectedNode.pypiUrl || selectedNode.npmUrl}
|
{#if selectedNode.category === 'projects' && (selectedNode.projectUrl || selectedNode.repoUrl || selectedNode.pypiUrl || selectedNode.npmUrl)}
|
||||||
<div class="links">
|
<div class="links">
|
||||||
{#if selectedNode.projectUrl}
|
{#if selectedNode.projectUrl}
|
||||||
<a
|
<a
|
||||||
|
|||||||
+53
-36
@@ -1,49 +1,20 @@
|
|||||||
export interface TreeNode {
|
// Fields shared by every node, regardless of category.
|
||||||
|
interface BaseNode {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
parentId: string | null; // null means it's the root or connects to center
|
parentId: string | null; // null means it's the root or connects to center
|
||||||
category: 'origin' | 'experience' | 'projects' | 'skills' | 'education';
|
|
||||||
size: 'x-large' | 'large' | 'medium' | 'small';
|
size: 'x-large' | 'large' | 'medium' | 'small';
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== EXPERIENCE-SPECIFIC FIELDS ==========
|
// Center node with profile/contact info.
|
||||||
company?: string;
|
export interface OriginNode extends BaseNode {
|
||||||
jobTitle?: string;
|
category: 'origin';
|
||||||
startDate?: string;
|
|
||||||
endDate?: string; // Use "Present" for current role
|
|
||||||
highlights?: string[]; // Key achievements or responsibilities
|
|
||||||
|
|
||||||
// ========== CONTRACT-SPECIFIC FIELDS ==========
|
|
||||||
contractName?: string;
|
|
||||||
client?: string;
|
|
||||||
contractStart?: string;
|
|
||||||
contractEnd?: string;
|
|
||||||
|
|
||||||
// ========== PROJECT-SPECIFIC FIELDS (extended) ==========
|
|
||||||
techStack?: string[];
|
|
||||||
projectUrl?: string;
|
|
||||||
repoUrl?: string;
|
|
||||||
npmUrl?: string; // For npm packages
|
|
||||||
pypiUrl?: string; // For PyPI packages
|
|
||||||
downloads?: string; // "1,000+ downloads"
|
|
||||||
stars?: number; // GitHub stars
|
|
||||||
status?: 'active' | 'maintained' | 'archived' | 'completed';
|
|
||||||
|
|
||||||
// ========== SKILL-SPECIFIC FIELDS ==========
|
|
||||||
proficiency?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
||||||
yearsOfExperience?: number;
|
|
||||||
|
|
||||||
// ========== EDUCATION-SPECIFIC FIELDS ==========
|
|
||||||
institution?: string;
|
|
||||||
degree?: string;
|
|
||||||
field?: string;
|
|
||||||
graduationYear?: string;
|
|
||||||
|
|
||||||
// ========== ORIGIN-SPECIFIC FIELDS ==========
|
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
|
jobTitle?: string;
|
||||||
aboutMe?: string;
|
aboutMe?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -53,6 +24,52 @@ export interface TreeNode {
|
|||||||
resumeUrl?: string;
|
resumeUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jobs and contract roles.
|
||||||
|
export interface ExperienceNode extends BaseNode {
|
||||||
|
category: 'experience';
|
||||||
|
company?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string; // Use "Present" for current role
|
||||||
|
highlights?: string[]; // Key achievements or responsibilities
|
||||||
|
contractName?: string;
|
||||||
|
client?: string;
|
||||||
|
contractStart?: string;
|
||||||
|
contractEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personal projects / packages.
|
||||||
|
export interface ProjectNode extends BaseNode {
|
||||||
|
category: 'projects';
|
||||||
|
techStack?: string[];
|
||||||
|
projectUrl?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
npmUrl?: string; // For npm packages
|
||||||
|
pypiUrl?: string; // For PyPI packages
|
||||||
|
downloads?: string; // "1,000+ downloads"
|
||||||
|
stars?: number; // GitHub stars
|
||||||
|
status?: 'active' | 'maintained' | 'archived' | 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technical skills with proficiency.
|
||||||
|
export interface SkillNode extends BaseNode {
|
||||||
|
category: 'skills';
|
||||||
|
proficiency?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||||
|
yearsOfExperience?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Academic background.
|
||||||
|
export interface EducationNode extends BaseNode {
|
||||||
|
category: 'education';
|
||||||
|
institution?: string;
|
||||||
|
degree?: string;
|
||||||
|
field?: string;
|
||||||
|
graduationYear?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discriminated union over `category` — narrow on it to access category fields.
|
||||||
|
export type TreeNode = OriginNode | ExperienceNode | ProjectNode | SkillNode | EducationNode;
|
||||||
|
|
||||||
// The center of our tree is at (0, 0)
|
// The center of our tree is at (0, 0)
|
||||||
// Positive X = right, Negative X = left
|
// Positive X = right, Negative X = left
|
||||||
// Positive Y = down, Negative Y = up
|
// Positive Y = down, Negative Y = up
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ describe('countByProficiency', () => {
|
|||||||
it('sums to the total number of nodes that have a proficiency', () => {
|
it('sums to the total number of nodes that have a proficiency', () => {
|
||||||
const counts = countByProficiency();
|
const counts = countByProficiency();
|
||||||
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
||||||
const expected = treeNodes.filter((n) => n.proficiency).length;
|
const expected = treeNodes.filter((n) => n.category === 'skills' && n.proficiency).length;
|
||||||
expect(total).toBe(expected);
|
expect(total).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPlayerClass', () => {
|
describe('getPlayerClass', () => {
|
||||||
it("returns the origin node's job title", () => {
|
it("returns the origin node's job title", () => {
|
||||||
const origin = treeNodes.find((n) => n.id === 'origin');
|
const origin = treeNodes.find((n) => n.category === 'origin');
|
||||||
expect(getPlayerClass()).toBe(origin?.jobTitle);
|
expect(getPlayerClass()).toBe(origin?.jobTitle);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// STATS UTILS — pure aggregation for the RPG-style character panel.
|
// STATS UTILS — pure aggregation for the RPG-style character panel.
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
import { treeNodes, type SkillNode } from '$lib/data';
|
||||||
|
|
||||||
/** Skill branch ids and their display labels (single source of truth). */
|
/** Skill branch ids and their display labels (single source of truth). */
|
||||||
export const BRANCH_IDS = [
|
export const BRANCH_IDS = [
|
||||||
@@ -40,17 +40,17 @@ export function calculatePlayerLevel(): number {
|
|||||||
|
|
||||||
/** Character "class" — the origin node's job title. */
|
/** Character "class" — the origin node's job title. */
|
||||||
export function getPlayerClass(): string {
|
export function getPlayerClass(): string {
|
||||||
const originNode = treeNodes.find((node) => node.id === 'origin');
|
const originNode = treeNodes.find((node) => node.category === 'origin');
|
||||||
return originNode?.jobTitle || 'Software Engineer';
|
return originNode?.jobTitle || 'Software Engineer';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group skill nodes by branch, sorted by proficiency. */
|
/** Group skill nodes by branch, sorted by proficiency. */
|
||||||
export function aggregateSkills(): Record<string, { label: string; skills: TreeNode[] }> {
|
export function aggregateSkills(): Record<string, { label: string; skills: SkillNode[] }> {
|
||||||
const result: Record<string, { label: string; skills: TreeNode[] }> = {};
|
const result: Record<string, { label: string; skills: SkillNode[] }> = {};
|
||||||
|
|
||||||
BRANCH_IDS.forEach((branchId) => {
|
BRANCH_IDS.forEach((branchId) => {
|
||||||
const skills = treeNodes.filter(
|
const skills = treeNodes.filter(
|
||||||
(node) => node.parentId === branchId && node.category === 'skills'
|
(node): node is SkillNode => node.parentId === branchId && node.category === 'skills'
|
||||||
);
|
);
|
||||||
|
|
||||||
skills.sort((a, b) => {
|
skills.sort((a, b) => {
|
||||||
@@ -74,7 +74,7 @@ export function countByProficiency(): {
|
|||||||
} {
|
} {
|
||||||
const counts = { expert: 0, advanced: 0, intermediate: 0, beginner: 0 };
|
const counts = { expert: 0, advanced: 0, intermediate: 0, beginner: 0 };
|
||||||
treeNodes.forEach((node) => {
|
treeNodes.forEach((node) => {
|
||||||
if (node.proficiency) {
|
if (node.category === 'skills' && node.proficiency) {
|
||||||
counts[node.proficiency]++;
|
counts[node.proficiency]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user