TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { z } from 'zod';2import categoriesJson from '../../../data/taxonomy/categories.json' with { type: 'json' };3import conditionsJson from '../../../data/taxonomy/conditions.json' with { type: 'json' };4import gradersJson from '../../../data/taxonomy/graders.json' with { type: 'json' };5import indicesJson from '../../../data/taxonomy/indices.json' with { type: 'json' };67/**8 * Taxonomy loader. The JSON files under data/taxonomy are the seed; the database `categories`9 * table is the runtime source of truth (it may contain approved proposals). This module offers10 * pure helpers usable from connectors, workers and the web layer without a DB round-trip.11 */1213const NodeSchema = z.object({14 slug: z.string(),15 parent: z.string().optional(),16 name: z.string(),17 short: z.string().optional(),18 phase: z.number().int().min(1).max(3),19 index: z.string().nullable().optional(),20 conditionScale: z.string().optional(),21 graders: z.array(z.string()).optional(),22 compliance: z.array(z.string()).optional(),23 description: z.string().optional(),24 icon: z.string().optional(),25});26export type TaxonomyNode = z.infer<typeof NodeSchema>;2728export interface CategoryNode extends TaxonomyNode {29 level: number;30 familySlug: string;31 children: string[];32 sortOrder: number;33}3435const parsedNodes = z.array(NodeSchema).parse(categoriesJson.nodes);3637const bySlug = new Map<string, CategoryNode>();38for (const [i, n] of parsedNodes.entries()) {39 bySlug.set(n.slug, { ...n, level: 0, familySlug: n.slug, children: [], sortOrder: i });40}41for (const n of bySlug.values()) {42 if (n.parent) {43 const p = bySlug.get(n.parent);44 if (!p) throw new Error(`taxonomy: parent ${n.parent} of ${n.slug} not found`);45 p.children.push(n.slug);46 }47}48function resolve(n: CategoryNode): void {49 if (!n.parent) return;50 const p = bySlug.get(n.parent)!;51 resolve(p);52 n.level = p.level + 1;53 n.familySlug = p.familySlug;54 n.index = n.index ?? p.index ?? null;55 n.conditionScale = n.conditionScale ?? p.conditionScale;56 n.graders = n.graders ?? p.graders;57}58for (const n of bySlug.values()) resolve(n);5960export const CATEGORIES: readonly CategoryNode[] = [...bySlug.values()];61export const FAMILIES: readonly CategoryNode[] = CATEGORIES.filter((c) => c.level === 0);6263export function getCategory(slug: string): CategoryNode | undefined {64 return bySlug.get(slug);65}66export function requireCategory(slug: string): CategoryNode {67 const c = bySlug.get(slug);68 if (!c) throw new Error(`Unknown category slug: ${slug}`);69 return c;70}71export function categoryFamily(slug: string): string {72 return bySlug.get(slug)?.familySlug ?? slug;73}74export function categoryPath(slug: string): CategoryNode[] {75 const out: CategoryNode[] = [];76 let cur = bySlug.get(slug);77 while (cur) {78 out.unshift(cur);79 cur = cur.parent ? bySlug.get(cur.parent) : undefined;80 }81 return out;82}83export function descendants(slug: string): string[] {84 const out: string[] = [];85 const stack = [slug];86 while (stack.length) {87 const s = stack.pop()!;88 const n = bySlug.get(s);89 if (!n) continue;90 for (const c of n.children) {91 out.push(c);92 stack.push(c);93 }94 }95 return out;96}97export function phaseCategories(phase: 1 | 2 | 3): CategoryNode[] {98 return CATEGORIES.filter((c) => c.phase <= phase);99}100101// ---------- Condition scales ----------102export interface ConditionScale {103 values: string[];104 completeness: string[];105 aliases: Record<string, string | null>;106}107export const CONDITION_SCALES: Record<string, ConditionScale> = conditionsJson.scales as Record<string, ConditionScale>;108109export function conditionScaleFor(categorySlug: string): ConditionScale {110 const scale = bySlug.get(categorySlug)?.conditionScale ?? 'general';111 return CONDITION_SCALES[scale] ?? CONDITION_SCALES.general!;112}113114/** Map a raw condition string to the category's normalised slug; null when unknown (never guess). */115export function normalizeCondition(categorySlug: string, raw: string | null | undefined): string | null {116 if (!raw) return null;117 const scale = conditionScaleFor(categorySlug);118 const key = raw.trim().toLowerCase().replace(/\s+/g, ' ');119 if (key in scale.aliases) return scale.aliases[key] ?? null;120 const asSlug = key.replace(/[\s-]+/g, '_');121 if (scale.values.includes(asSlug)) return asSlug;122 // Try contained alias (e.g. "Condition: Near Mint or Better")123 for (const [alias, slug] of Object.entries(scale.aliases)) {124 if (alias.length >= 4 && key.includes(alias)) return slug;125 }126 return null;127}128129export function conditionRank(categorySlug: string, condition: string | null): number | null {130 if (!condition) return null;131 const i = conditionScaleFor(categorySlug).values.indexOf(condition);132 return i >= 0 ? i : null;133}134135// ---------- Graders ----------136export interface GraderDef {137 slug: string;138 name: string;139 categories: string[];140 scale: { type: 'numeric' | 'sheldon' | 'descriptive'; values: string[]; top: string };141 populationUrl?: string;142 verifyUrl?: string;143 aliases: string[];144}145export const GRADERS: readonly GraderDef[] = gradersJson.graders as GraderDef[];146const graderByAlias = new Map<string, GraderDef>();147for (const g of GRADERS) {148 graderByAlias.set(g.slug, g);149 for (const a of g.aliases) graderByAlias.set(a.toLowerCase(), g);150}151export function getGrader(slugOrAlias: string | null | undefined): GraderDef | undefined {152 if (!slugOrAlias) return undefined;153 return graderByAlias.get(slugOrAlias.trim().toLowerCase());154}155156// Grader token, optional "/DNA" (PSA/DNA autograph slabs), optional descriptive word, then either a157// numeric grade ("10", "9.5", "(8.5)") or an authentication-only marker ("Authentic", "AUTH", "A").158const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b(?:\s*\/?\s*DNA\b)?[\s:\-\[(]*(?:GEM\s*MT|GEM\s*MINT|PRISTINE|BLACK\s*LABEL|NM[-\/]?MT\+?|EX[-\/]?MT\+?|VG[-\/]?EX\+?|MINT\+?|NM\+?|EX\+?|VG\+?|FN\+?|GD\+?|MT\+?|PR|FR)?[\s:\-\[(]*(?:(\d{1,3}(?:\.\d)?)(?![\d.])|\b(AUTHENTIC|AUTH|CERTIFIED\s+AUTHENTIC)\b)?/gi;159/** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */160const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']);161/** Authentication-only slabs (no numeric grade). Kept apart from raw and from graded variants. */162export const AUTHENTIC_GRADE = 'authentic';163164/**165 * Extract grader + grade from a free-text title. Returns nulls when absent.166 * A grader token WITHOUT a readable grade yields `{ grader, grade: null }` — callers must treat that as167 * "graded, grade unknown" (never as raw): mixing such records into the raw variant polluted RIVs.168 */169export function parseGradeFromTitle(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {170 const matches = [...title.matchAll(GRADE_RE)];171 // Prefer the first match that carries a numeric grade; ambiguous tokens without a grade are ignored.172 const m = matches.find((x) => x[2]) ?? matches.find((x) => x[3]) ?? matches.find((x) => !AMBIGUOUS_GRADERS.has(x[1]!.toLowerCase()));173 if (!m) {174 if (/\b(raw|ungraded)\b/i.test(title)) return { grader: 'raw', grade: null, qualifier: null };175 return { grader: null, grade: null, qualifier: null };176 }177 const grader = getGrader(m[1]!)?.slug ?? m[1]!.toLowerCase();178 let grade = m[2] ?? (m[3] ? AUTHENTIC_GRADE : null);179 const g = getGrader(grader);180 if (grade && grade !== AUTHENTIC_GRADE && g && g.scale.values.length && !g.scale.values.includes(grade)) {181 // e.g. "PSA 10" where 10 is fine; "PSA 2021" is a year → reject182 if (Number(grade) > Number(g.scale.top)) grade = null;183 }184 let qualifier: string | null = null;185 if (/black\s*label/i.test(title)) qualifier = 'Black Label';186 else if (/pristine/i.test(title) && grader === 'bgs') qualifier = 'Pristine';187 else if (/signature\s*series/i.test(title)) qualifier = 'Signature Series';188 else if (/\b(OC|MC|ST|PD|MK)\b/.test(title) && grader === 'psa') qualifier = title.match(/\b(OC|MC|ST|PD|MK)\b/)![1]!;189 return { grader, grade, qualifier };190}191192/** Variant key used by asset_variants (deterministic). */193export function variantKey(input: { grader?: string | null; grade?: string | null; qualifier?: string | null; condition?: string | null; completeness?: string | null; size?: string | null }): { key: string; label: string } {194 const parts: string[] = [];195 const labelParts: string[] = [];196 if (input.grader && input.grader !== 'raw' && input.grade) {197 parts.push(input.grader, input.grade);198 labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} ${input.grade === AUTHENTIC_GRADE ? 'Authentic' : input.grade}`);199 if (input.qualifier) {200 parts.push(input.qualifier.toLowerCase().replace(/\s+/g, '_'));201 labelParts.push(input.qualifier);202 }203 } else if (input.grader && input.grader !== 'raw') {204 // Slabbed by a known grader but the grade could not be read: its own variant, never "raw".205 parts.push(input.grader, 'unknown');206 labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} · grade unknown`);207 } else {208 parts.push('raw');209 if (input.completeness) {210 parts.push(input.completeness);211 labelParts.push(humanize(input.completeness));212 } else if (input.condition) {213 parts.push(input.condition);214 labelParts.push(humanize(input.condition));215 } else {216 labelParts.push('Raw / ungraded');217 }218 }219 if (input.size) {220 parts.push(`size_${input.size.toLowerCase().replace(/\s+/g, '')}`);221 labelParts.push(`Size ${input.size}`);222 }223 return { key: parts.join('|'), label: labelParts.join(' · ') };224}225226export function humanize(slug: string): string {227 return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bCib\b/, 'CIB');228}229230// ---------- Indices ----------231export interface IndexDef {232 ticker: string;233 name: string;234 flagship?: boolean;235 description?: string;236 families: string[];237 color: string;238 minConstituents: number;239}240export const INDEX_BASE_DATE: string = indicesJson.baseDate;241export const INDEX_BASE_VALUE: number = indicesJson.baseValue;242export const INDICES: readonly IndexDef[] = indicesJson.indices as IndexDef[];243export function indexForCategory(categorySlug: string): IndexDef | undefined {244 const ticker = bySlug.get(categorySlug)?.index;245 return ticker ? INDICES.find((i) => i.ticker === ticker) : undefined;246}247