Skip to content
287 changes: 287 additions & 0 deletions scripts/migrate-marathon-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
#!/usr/bin/env npx ts-node

/**
* Migration Script: marathon-data.json to new race architecture
*
* This script converts the existing marathon-data.json into the new
* scalable race data architecture with:
* - Individual TypeScript files per race (in courses/)
* - Separate JSON files for route data (in routes/)
* - Auto-registration with the race registry
*
* Usage:
* npx ts-node scripts/migrate-marathon-data.ts
*
* Or add to package.json scripts:
* "migrate-races": "ts-node scripts/migrate-marathon-data.ts"
*/

import * as fs from "fs";
import * as path from "path";

// ============================================================================
// Configuration
// ============================================================================

const SOURCE_FILE = path.join(
__dirname,
"../vite-project/src/data/marathon-data.json"
);

const OUTPUT_DIR = path.join(__dirname, "../vite-project/src/data/races");
const COURSES_DIR = path.join(OUTPUT_DIR, "courses");
const ROUTES_DIR = path.join(OUTPUT_DIR, "routes");

// ============================================================================
// Region/Tier Inference (copied from helpers.ts for standalone script)
// ============================================================================

type RaceRegion =
| "north-america"
| "europe"
| "asia-pacific"
| "south-america"
| "africa"
| "middle-east";

type RaceTier = "world-major" | "platinum" | "gold" | "silver" | "bronze";

const REGION_MAP: Record<string, RaceRegion> = {
usa: "north-america",
"united states": "north-america",
massachusetts: "north-america",
"new york": "north-america",
illinois: "north-america",
california: "north-america",
dc: "north-america",
uk: "europe",
england: "europe",
germany: "europe",
france: "europe",
spain: "europe",
netherlands: "europe",
norway: "europe",
japan: "asia-pacific",
australia: "asia-pacific",
};

const WORLD_MAJORS = [
"boston",
"new york",
"nyc",
"chicago",
"london",
"berlin",
"tokyo",
"sydney",
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sydney is listed in WORLD_MAJORS array but is not one of the six World Marathon Majors (Tokyo, Boston, London, Berlin, Chicago, and New York). This will cause incorrect tier classification in the migration script. Remove Sydney from this array.

Suggested change
"sydney",

Copilot uses AI. Check for mistakes.
];

const PLATINUM_RACES = [
"valencia",
"rotterdam",
"amsterdam",
"paris",
];

function inferRegion(country: string): RaceRegion {
const lower = country.toLowerCase();
for (const [key, region] of Object.entries(REGION_MAP)) {
if (lower.includes(key)) {
return region;
}
}
return "europe";
}

function inferTier(name: string, elevationGain: number): RaceTier {
const lower = name.toLowerCase();
if (WORLD_MAJORS.some((m) => lower.includes(m))) return "world-major";
if (PLATINUM_RACES.some((r) => lower.includes(r))) return "platinum";
if (elevationGain > 400) return "silver";
return "gold";
}
Comment on lines +96 to +102
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration script is missing the inferRaceType function that is needed to populate the raceType field. This function should be imported or copied from types.ts, similar to how inferRegion and inferTier are handled in this file.

Copilot uses AI. Check for mistakes.

// ============================================================================
// Template Generation
// ============================================================================

function toVariableName(id: string): string {
// Convert kebab-case to camelCase
return id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());

Check warning on line 110 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L110

Unsafe call of an `any` typed value.

Check warning on line 110 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L110

Unsafe member access .toUpperCase on an `any` value.
Comment on lines +109 to +110
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The toVariableName function has a comment saying "Convert kebab-case to camelCase" but the implementation doesn't handle all edge cases properly. For example, consecutive dashes would result in incorrect casing, and the regex replacement for numbers only handles the first character. Consider using a more robust implementation that handles multiple consecutive dashes and numbers in any position.

Suggested change
// Convert kebab-case to camelCase
return id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
// Convert kebab-case (with possible multiple dashes) to camelCase
return id
.split(/-+/)
.map((segment, index) => {
if (!segment) return "";
if (index === 0) {
return segment;
}
return segment.charAt(0).toUpperCase() + segment.slice(1);
})
.join("");

Copilot uses AI. Check for mistakes.
}

function toPascalCase(id: string): string {
const camel = toVariableName(id);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}

function generateCourseFile(id: string, data: any): string {
const varName = toVariableName(id);
const pascalName = toPascalCase(id);
const region = inferRegion(data.country);

Check warning on line 121 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L121

Unsafe argument of type `any` assigned to a parameter of type `string`.

Check warning on line 121 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L121

Unsafe member access .country on an `any` value.
const tier = inferTier(data.name, data.elevationGain);

Check warning on line 122 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L122

Unsafe member access .name on an `any` value.

const metadata = {
id,
name: data.name,
city: data.city,
country: data.country,

Check warning on line 128 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L128

Unsafe assignment of an `any` value.

Check warning on line 128 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L128

Unsafe member access .country on an `any` value.
region,
tier,
distance: data.distance,

Check warning on line 131 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L131

Unsafe member access .distance on an `any` value.
elevationGain: data.elevationGain,
elevationLoss: data.elevationLoss,

Check warning on line 133 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L133

Unsafe member access .elevationLoss on an `any` value.
startElevation: data.startElevation,
endElevation: data.endElevation,
slug: data.slug,
raceDate: data.raceDate,

Check warning on line 137 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L137

Unsafe assignment of an `any` value.

Check warning on line 137 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L137

Unsafe member access .raceDate on an `any` value.
website: data.website,

Check warning on line 138 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L138

Unsafe assignment of an `any` value.
description: data.description,

Check warning on line 139 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L139

Unsafe member access .description on an `any` value.
tips: data.tips || [],

Check warning on line 140 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L140

Unsafe member access .tips on an `any` value.
paceStrategy: data.paceStrategy || {
type: "even-pace",
summary: "Run at consistent effort throughout the race.",
segments: [],
},
fuelingNotes: data.fuelingNotes || "",

Check warning on line 146 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L146

Unsafe member access .fuelingNotes on an `any` value.
faq: data.faq || [],

Check warning on line 147 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L147

Unsafe assignment of an `any` value.

Check warning on line 147 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L147

Unsafe member access .faq on an `any` value.
keywords: generateKeywords(data),
lastUpdated: new Date().toISOString().split("T")[0],
};
Comment on lines +124 to +150
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration script's generateCourseFile function is missing the raceType field in the metadata object. This field is required by the RaceMetadata interface (line 132 of types.ts) but is not being populated during migration. This will cause TypeScript errors and runtime issues when the migrated files are used. Add raceType field using inferRaceType(data.distance) similar to how it's done in compat.ts line 63.

Copilot uses AI. Check for mistakes.

const metadataJson = JSON.stringify(metadata, null, 2)
.split("\n")
.map((line, i) => (i === 0 ? line : " " + line))
.join("\n");

return `/**
* ${data.name}
*
* ${data.description.split(".")[0]}.
*/

import type { RaceMetadata, RaceRouteData } from "../types";
import { registerRace } from "../registry";

// ============================================================================
// Metadata (Static - always available)
// ============================================================================

export const ${varName}Metadata: RaceMetadata = ${metadataJson};

// ============================================================================
// Route Loader (Lazy - loaded on demand)
// ============================================================================

/**
* Loads ${data.name} route data.

Check warning on line 177 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L177

Unsafe member access .name on an `any` value.
* This is called lazily when the route data is actually needed.
*/
export async function load${pascalName}Route(): Promise<RaceRouteData> {
// Dynamic import for code splitting
const routeData = await import("../routes/${id}-route.json");

return {
raceId: "${id}",
thumbnailPoints: routeData.thumbnailPoints,
${data.slug ? `firestoreDocId: "${data.slug}",` : ""}
};
}

// ============================================================================
// Register with global registry
// ============================================================================

registerRace(${varName}Metadata, load${pascalName}Route);

// ============================================================================
// Named exports for direct imports
// ============================================================================

export default ${varName}Metadata;
`;
}

function generateKeywords(data: any): string[] {
const keywords: string[] = [];

// Add name variations
keywords.push(data.name.toLowerCase());

Check warning on line 209 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L209

Unsafe argument of type `any` assigned to a parameter of type `string`.

Check warning on line 209 in scripts/migrate-marathon-data.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

scripts/migrate-marathon-data.ts#L209

Unsafe call of an `any` typed value.
keywords.push(data.city.toLowerCase() + " marathon");

// Add common terms
if (data.elevationGain < 100) {
keywords.push("flat marathon", "pr course", "fast course");
}
if (data.elevationGain > 300) {
keywords.push("hilly marathon", "challenging course");
}

Comment on lines +205 to +219
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generateKeywords function always appends "marathon" to the city name (line 210), but this assumes all races are marathons. The function should check the race type or distance to generate appropriate keywords for half-marathons, 10Ks, etc. This will become more important as the system scales to support multiple race types.

Suggested change
function generateKeywords(data: any): string[] {
const keywords: string[] = [];
// Add name variations
keywords.push(data.name.toLowerCase());
keywords.push(data.city.toLowerCase() + " marathon");
// Add common terms
if (data.elevationGain < 100) {
keywords.push("flat marathon", "pr course", "fast course");
}
if (data.elevationGain > 300) {
keywords.push("hilly marathon", "challenging course");
}
/**
* Infer a human-readable race descriptor (e.g., "marathon", "half marathon", "10k")
* from the available race data. Defaults to "marathon" to preserve existing
* behaviour for legacy marathon-only data.
*/
function getRaceDescriptor(data: any): string {
// Default assumption for legacy data
let descriptor = "marathon";
const distance = data?.distance;
const type = (data?.type || data?.raceType || "").toString().toLowerCase();
// Try to infer from numeric distance (assuming kilometers)
if (typeof distance === "number" && isFinite(distance)) {
const d = distance;
// Simple tolerance-based mapping
if (Math.abs(d - 42.195) < 1) {
descriptor = "marathon";
} else if (Math.abs(d - 21.0975) < 1) {
descriptor = "half marathon";
} else if (Math.abs(d - 10) < 0.5) {
descriptor = "10k";
} else if (Math.abs(d - 5) < 0.5) {
descriptor = "5k";
} else {
descriptor = "race";
}
} else {
// Try to infer from string distance or type/raceType fields
const distanceStr = (typeof distance === "string" ? distance : "").toLowerCase();
const source = `${distanceStr} ${type}`.trim();
if (source.includes("half") && source.includes("marathon")) {
descriptor = "half marathon";
} else if (source.includes("10k") || source.includes("10 k")) {
descriptor = "10k";
} else if (source.includes("5k") || source.includes("5 k")) {
descriptor = "5k";
} else if (source.includes("marathon")) {
descriptor = "marathon";
} else if (source.length > 0) {
descriptor = "race";
}
}
return descriptor;
}
function generateKeywords(data: any): string[] {
const keywords: string[] = [];
const raceDescriptor = getRaceDescriptor(data);
// Add name variations
if (data.name) {
keywords.push(data.name.toLowerCase());
}
if (data.city) {
keywords.push(`${data.city.toLowerCase()} ${raceDescriptor}`);
}
// Add common terms based on elevation, using race-specific descriptor
if (typeof data.elevationGain === "number") {
if (data.elevationGain < 100) {
keywords.push(`flat ${raceDescriptor}`, "pr course", "fast course");
}
if (data.elevationGain > 300) {
keywords.push(`hilly ${raceDescriptor}`, "challenging course");
}
}

Copilot uses AI. Check for mistakes.
return keywords;
}

function generateRouteFile(id: string, data: any): string {
const routeData = {
raceId: id,
thumbnailPoints: data.thumbnailPoints || [],
};

return JSON.stringify(routeData, null, 2);
}

// ============================================================================
// Main Migration Logic
// ============================================================================

function migrate() {
console.log("Starting marathon-data.json migration...\n");

// Read source file
const sourceData = JSON.parse(fs.readFileSync(SOURCE_FILE, "utf-8"));
const raceIds = Object.keys(sourceData);

console.log(`Found ${raceIds.length} races to migrate.\n`);

// Ensure output directories exist
fs.mkdirSync(COURSES_DIR, { recursive: true });
fs.mkdirSync(ROUTES_DIR, { recursive: true });

const imports: string[] = [];
const skipped: string[] = [];

for (const id of raceIds) {
const data = sourceData[id];
const courseFile = path.join(COURSES_DIR, `${id}.ts`);
const routeFile = path.join(ROUTES_DIR, `${id}-route.json`);

// Skip if course file already exists
if (fs.existsSync(courseFile)) {
console.log(` [SKIP] ${id} - course file already exists`);
skipped.push(id);
imports.push(`import "./courses/${id}";`);
continue;
}

// Generate course file
const courseContent = generateCourseFile(id, data);
fs.writeFileSync(courseFile, courseContent);
console.log(` [OK] Created ${id}.ts`);

// Generate route file
const routeContent = generateRouteFile(id, data);
fs.writeFileSync(routeFile, routeContent);
console.log(` [OK] Created ${id}-route.json`);

imports.push(`import "./courses/${id}";`);
}

console.log("\n--- Migration Summary ---");
console.log(`Migrated: ${raceIds.length - skipped.length} races`);
console.log(`Skipped: ${skipped.length} races (already exist)`);
console.log(`\nAdd these imports to src/data/races/index.ts:\n`);
console.log(imports.join("\n"));
console.log("\n--- Done ---");
}

// Run migration
migrate();
2 changes: 2 additions & 0 deletions vite-project/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import FuelSeoLanding from "./pages/FuelSeoLanding";
import ElevationGuidesSeoLanding from "./pages/ElevationGuidesSeoLanding";
import RaceSeoLanding from "./pages/RaceSeoLanding";
import RaceIndex from "./pages/RaceIndex";
import WorldRaceMapPage from "./pages/WorldRaceMapPage";
import Privacy from "./pages/Privacy";
import Terms from "./pages/Terms";
import About from "./pages/About";
Expand All @@ -49,6 +50,7 @@ function App() {
<Route path="/fuel" element={<FuelPlannerV2 />} />
<Route path="/fuel/:seoSlug" element={<FuelSeoLanding />} />
<Route path="/race" element={<RaceIndex />} />
<Route path="/race/map" element={<WorldRaceMapPage />} />
<Route path="/race/:raceSlug" element={<RaceSeoLanding />} />
<Route path="/elevation-finder" element={<ElevationPage />} />
<Route
Expand Down
Loading