Add Zod validation and expand export/import for EV and results data (#14)

* Include participant EV/futures data in data sync export/import

The export now includes the full participantExpectedValues records
(placement probabilities, source, sourceOdds) alongside participants.
On import, EV records are upserted in merge mode and created fresh in
replace mode (cascade handles cleanup). Backward-compatible with older
exports that lack the participantExpectedValues field.

https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B

* Address all data-sync code review findings

- Wrap importSportsDataFromJSON in a db.transaction() so any mid-import
  failure rolls back cleanly instead of leaving partial data
- Add zod validation of imported JSON before any DB writes, giving a
  clear error on malformed/incompatible files
- Fix N+1 queries: build participantIdMap during participant import so
  the EV and results sections resolve IDs from memory, not extra queries
- Fix merge mode silently skipping existing participants — now updates
  shortName, externalId, and expectedValue
- Add participantResults to export/import (was deleted in replace mode
  but never exported, so results were permanently lost)
- Restore calculatedAt timestamp on EV import instead of defaulting to
  now(); stored as ISO string in the export for portability
- Document that onDelete:cascade covers participantExpectedValues and
  participantResults when participants is deleted; remove the now-
  redundant explicit participantResults delete from replace mode
- Bump export version to 1.1; old v1.0 files still import cleanly since
  participantExpectedValues, participantResults, and calculatedAt are
  all optional in the zod schema
- Collapse all six DB fetches in exportSportsDataToJSON into one
  Promise.all for parallel execution
- Add countAllParticipants() and countAllParticipantEVs() model funcs
- Show Participants and EV Records counts on the dashboard stat cards
- Replace raw DOM form creation/submit in handleImport with useFetcher,
  giving proper loading state, no full-page reload, and type-safe data
- Remove dead export intent handler from the action function

https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-20 20:25:25 -08:00 committed by GitHub
parent 401efcd833
commit 9ae00a8385
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 659 additions and 356 deletions

View file

@ -7,7 +7,7 @@
import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { eq, and, count } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
@ -161,6 +161,12 @@ export async function upsertParticipantEVWithNormalization(
});
}
export async function countAllParticipantEVs(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(participantExpectedValues);
return result[0].value;
}
/**
* Get participant EV for a specific sports season
*/

View file

@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm";
import { eq, inArray, count } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -73,6 +73,12 @@ export async function updateParticipant(
return participant;
}
export async function countAllParticipants(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(schema.participants);
return result[0].value;
}
export async function deleteParticipant(id: string): Promise<void> {
const db = database();
await db.delete(schema.participants).where(eq(schema.participants.id, id));

View file

@ -1,8 +1,11 @@
import { useState } from "react";
import { useFetcher } from "react-router";
import type { Route } from "./+types/admin.data-sync";
import { findAllSports } from "~/models/sport";
import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSeasonTemplates } from "~/models/season-template";
import { countAllParticipants } from "~/models/participant";
import { countAllParticipantEVs } from "~/models/participant-expected-value";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import {
@ -26,17 +29,22 @@ import {
} from "~/components/ui/alert-dialog";
export async function loader() {
const [sports, sportsSeasons, templates] = await Promise.all([
findAllSports(),
findAllSportsSeasons(),
findAllSeasonTemplates(),
]);
const [sports, sportsSeasons, templates, participantCount, evCount] =
await Promise.all([
findAllSports(),
findAllSportsSeasons(),
findAllSeasonTemplates(),
countAllParticipants(),
countAllParticipantEVs(),
]);
return {
stats: {
sportsCount: sports.length,
sportsSeasonsCount: sportsSeasons.length,
templatesCount: templates.length,
participantCount,
evCount,
},
};
}
@ -45,51 +53,55 @@ export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "export") {
// Export will be handled client-side via download
return { success: true };
}
if (intent === "import") {
const mode = formData.get("mode") as "merge" | "replace";
const fileData = formData.get("fileData") as string;
if (!fileData) {
return { error: "No file data provided" };
return { success: false, error: "No file data provided" };
}
try {
// Import the data (we'll create a server-side function for this)
const { importSportsDataFromJSON } = await import("~/utils/sports-data-sync.server");
const { importSportsDataFromJSON } = await import(
"~/utils/sports-data-sync.server"
);
const result = await importSportsDataFromJSON(fileData, mode);
return {
success: true,
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`
return {
success: true,
error: null,
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`,
};
} catch (error) {
console.error("Import error:", error);
return { error: error instanceof Error ? error.message : "Import failed" };
return {
success: false,
error: error instanceof Error ? error.message : "Import failed",
message: null,
};
}
}
return { error: "Invalid intent" };
return { success: false, error: "Invalid intent", message: null };
}
export default function DataSync({ loaderData, actionData }: Route.ComponentProps) {
export default function DataSync({ loaderData }: Route.ComponentProps) {
const { stats } = loaderData;
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isExporting, setIsExporting] = useState(false);
const fetcher = useFetcher<typeof action>();
const isImporting = fetcher.state !== "idle";
const handleExport = async () => {
setIsExporting(true);
try {
const response = await fetch("/api/admin/export-sports-data");
const data = await response.json();
// Create download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@ -113,47 +125,23 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
}
};
const handleImport = async (e: React.FormEvent) => {
const handleImport = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
alert("Please select a file first");
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
reader.onload = (event) => {
const fileData = event.target?.result as string;
const formData = new FormData();
formData.append("intent", "import");
formData.append("mode", importMode);
formData.append("fileData", fileData);
// Submit via form
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
const intentInput = document.createElement("input");
intentInput.name = "intent";
intentInput.value = "import";
form.appendChild(intentInput);
const modeInput = document.createElement("input");
modeInput.name = "mode";
modeInput.value = importMode;
form.appendChild(modeInput);
const dataInput = document.createElement("input");
dataInput.name = "fileData";
dataInput.value = fileData;
form.appendChild(dataInput);
document.body.appendChild(form);
form.submit();
fetcher.submit(formData, { method: "POST" });
};
reader.readAsText(selectedFile);
};
@ -167,19 +155,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
</p>
</div>
{actionData?.success && (
{fetcher.data?.success && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-6">
{actionData.message || "Operation completed successfully!"}
{fetcher.data.message || "Operation completed successfully!"}
</div>
)}
{actionData?.error && (
{fetcher.data?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
{actionData.error}
{fetcher.data.error}
</div>
)}
<div className="grid gap-6 md:grid-cols-3 mb-6">
<div className="grid gap-6 md:grid-cols-5 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sports</CardTitle>
@ -192,11 +180,39 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sports Seasons</CardTitle>
<CardTitle className="text-sm font-medium">
Sports Seasons
</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
<div className="text-2xl font-bold">
{stats.sportsSeasonsCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Participants
</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.participantCount}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">EV Records</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.evCount}</div>
</CardContent>
</Card>
@ -221,9 +237,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This will export all sports, sports seasons, participants, and season templates.
This will export all sports, sports seasons, participants,
participant EV/futures data, participant results, and season
templates.
</p>
<Button
onClick={handleExport}
disabled={isExporting}
@ -290,12 +308,14 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={!selectedFile}
disabled={!selectedFile || isImporting}
variant="destructive"
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
Import Data (Replace)
{isImporting
? "Importing..."
: "Import Data (Replace)"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@ -305,14 +325,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will <strong>delete all existing sports data</strong> and replace it
with the data from the file. This action cannot be undone.
<br /><br />
This will{" "}
<strong>delete all existing sports data</strong> and
replace it with the data from the file. This action
cannot be undone.
<br />
<br />
This includes:
<ul className="list-disc list-inside mt-2">
<li>All sports</li>
<li>All sports seasons</li>
<li>All participants</li>
<li>All participant EV/futures data</li>
<li>All participant results</li>
<li>All season templates</li>
</ul>
</AlertDialogDescription>
@ -331,11 +356,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
) : (
<Button
onClick={handleImport}
disabled={!selectedFile}
disabled={!selectedFile || isImporting}
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
Import Data (Merge)
{isImporting ? "Importing..." : "Import Data (Merge)"}
</Button>
)}
</CardContent>
@ -348,16 +373,17 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
</CardHeader>
<CardContent className="text-sm text-foreground/80 space-y-3">
<div>
<strong>Export:</strong> Downloads all sports data as a JSON file that you can
save, version control, or transfer to another environment.
<strong>Export:</strong> Downloads all sports data as a JSON file
that you can save, version control, or transfer to another
environment.
</div>
<div>
<strong>Import (Merge):</strong> Updates existing records and adds new ones.
Safe to use - won't delete anything.
<strong>Import (Merge):</strong> Updates existing records and adds
new ones. Safe to use won&apos;t delete anything.
</div>
<div>
<strong>Import (Replace):</strong> Deletes all existing sports data and recreates
it from the file. Use this for a clean slate.
<strong>Import (Replace):</strong> Deletes all existing sports
data and recreates it from the file. Use this for a clean slate.
</div>
<div className="pt-2 border-t border-electric/30">
<strong>Typical workflow:</strong>

View file

@ -1,128 +1,204 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
interface ExportData {
version: string;
exportedAt: string;
sports: Array<{
name: string;
type: string;
slug: string;
description: string | null;
}>;
sportsSeasons: Array<{
sportSlug: string;
name: string;
year: number;
startDate: string | null;
endDate: string | null;
status: string;
scoringType: string;
}>;
participants: Array<{
sportSlug: string;
seasonName: string;
seasonYear: number;
name: string;
shortName: string | null;
externalId: string | null;
expectedValue: number | string;
}>;
seasonTemplates: Array<{
name: string;
year: number;
description: string | null;
isActive: boolean;
sports: Array<{
sportSlug: string;
seasonName: string;
seasonYear: number;
}>;
}>;
}
// ─── Validation schema ────────────────────────────────────────────────────────
// Used both for runtime validation of imported files and as the canonical shape
// for the exported JSON (fixes: zod validation, calculatedAt, participantResults)
const ImportDataSchema = z.object({
version: z.string(),
exportedAt: z.string(),
sports: z.array(
z.object({
name: z.string(),
type: z.string(),
slug: z.string(),
description: z.string().nullable(),
})
),
sportsSeasons: z.array(
z.object({
sportSlug: z.string(),
name: z.string(),
year: z.number(),
startDate: z.string().nullable(),
endDate: z.string().nullable(),
status: z.string(),
scoringType: z.string(),
})
),
participants: z.array(
z.object({
sportSlug: z.string(),
seasonName: z.string(),
seasonYear: z.number(),
name: z.string(),
shortName: z.string().nullable(),
externalId: z.string().nullable(),
expectedValue: z.union([z.number(), z.string()]),
})
),
// Optional for backward compatibility with v1.0 exports
participantExpectedValues: z
.array(
z.object({
sportSlug: z.string(),
seasonName: z.string(),
seasonYear: z.number(),
participantName: z.string(),
probFirst: z.string(),
probSecond: z.string(),
probThird: z.string(),
probFourth: z.string(),
probFifth: z.string(),
probSixth: z.string(),
probSeventh: z.string(),
probEighth: z.string(),
expectedValue: z.string(),
source: z.string().nullable(),
sourceOdds: z.number().nullable(),
calculatedAt: z.string().optional(), // optional for backward compat
})
)
.optional(),
participantResults: z
.array(
z.object({
sportSlug: z.string(),
seasonName: z.string(),
seasonYear: z.number(),
participantName: z.string(),
finalPosition: z.number().nullable(),
qualifyingPoints: z.string().nullable(),
notes: z.string().nullable(),
})
)
.optional(),
seasonTemplates: z.array(
z.object({
name: z.string(),
year: z.number(),
description: z.string().nullable(),
isActive: z.boolean(),
sports: z.array(
z.object({
sportSlug: z.string(),
seasonName: z.string(),
seasonYear: z.number(),
})
),
})
),
});
type ExportData = z.infer<typeof ImportDataSchema>;
// ─── Export ───────────────────────────────────────────────────────────────────
export async function exportSportsDataToJSON(): Promise<ExportData> {
const db = database();
// Export sports
const sports = await db.query.sports.findMany({
orderBy: (sports, { asc }) => [asc(sports.name)],
});
// Export sports seasons with sport relation
const sportsSeasons = await db.query.sportsSeasons.findMany({
with: {
sport: true,
},
orderBy: (sportsSeasons, { desc, asc }) => [
desc(sportsSeasons.year),
asc(sportsSeasons.name),
],
});
// Export participants with relations
const participants = await db.query.participants.findMany({
with: {
sportsSeason: {
with: {
sport: true,
const [
sports,
sportsSeasons,
participants,
participantEVs,
participantResultsData,
seasonTemplates,
] = await Promise.all([
db.query.sports.findMany({
orderBy: (s, { asc }) => [asc(s.name)],
}),
db.query.sportsSeasons.findMany({
with: { sport: true },
orderBy: (s, { desc, asc }) => [desc(s.year), asc(s.name)],
}),
db.query.participants.findMany({
with: { sportsSeason: { with: { sport: true } } },
orderBy: (p, { asc }) => [asc(p.name)],
}),
db.query.participantExpectedValues.findMany({
with: {
participant: { with: { sportsSeason: { with: { sport: true } } } },
},
}),
db.query.participantResults.findMany({
with: {
participant: { with: { sportsSeason: { with: { sport: true } } } },
},
}),
db.query.seasonTemplates.findMany({
with: {
seasonTemplateSports: {
with: { sportsSeason: { with: { sport: true } } },
},
},
},
orderBy: (participants, { asc }) => [asc(participants.name)],
});
orderBy: (t, { desc }) => [desc(t.year)],
}),
]);
// Export season templates with sports
const seasonTemplates = await db.query.seasonTemplates.findMany({
with: {
seasonTemplateSports: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
},
},
orderBy: (templates, { desc }) => [desc(templates.year)],
});
// Build export data structure
return {
version: "1.0",
version: "1.1",
exportedAt: new Date().toISOString(),
sports: sports.map((sport) => ({
name: sport.name,
type: sport.type,
slug: sport.slug,
description: sport.description,
sports: sports.map((s) => ({
name: s.name,
type: s.type,
slug: s.slug,
description: s.description,
})),
sportsSeasons: sportsSeasons.map((season) => ({
sportSlug: season.sport.slug,
name: season.name,
year: season.year,
startDate: season.startDate,
endDate: season.endDate,
status: season.status,
scoringType: season.scoringType,
sportsSeasons: sportsSeasons.map((s) => ({
sportSlug: s.sport.slug,
name: s.name,
year: s.year,
startDate: s.startDate,
endDate: s.endDate,
status: s.status,
scoringType: s.scoringType,
})),
participants: participants.map((participant) => ({
sportSlug: participant.sportsSeason.sport.slug,
seasonName: participant.sportsSeason.name,
seasonYear: participant.sportsSeason.year,
name: participant.name,
shortName: participant.shortName,
externalId: participant.externalId,
expectedValue: participant.expectedValue,
participants: participants.map((p) => ({
sportSlug: p.sportsSeason.sport.slug,
seasonName: p.sportsSeason.name,
seasonYear: p.sportsSeason.year,
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
expectedValue: p.expectedValue,
})),
seasonTemplates: seasonTemplates.map((template) => ({
name: template.name,
year: template.year,
description: template.description,
isActive: template.isActive,
sports: template.seasonTemplateSports.map((ts) => ({
participantExpectedValues: participantEVs.map((ev) => ({
sportSlug: ev.participant.sportsSeason.sport.slug,
seasonName: ev.participant.sportsSeason.name,
seasonYear: ev.participant.sportsSeason.year,
participantName: ev.participant.name,
probFirst: ev.probFirst,
probSecond: ev.probSecond,
probThird: ev.probThird,
probFourth: ev.probFourth,
probFifth: ev.probFifth,
probSixth: ev.probSixth,
probSeventh: ev.probSeventh,
probEighth: ev.probEighth,
expectedValue: ev.expectedValue,
source: ev.source,
sourceOdds: ev.sourceOdds,
calculatedAt: ev.calculatedAt.toISOString(),
})),
participantResults: participantResultsData.map((r) => ({
sportSlug: r.participant.sportsSeason.sport.slug,
seasonName: r.participant.sportsSeason.name,
seasonYear: r.participant.sportsSeason.year,
participantName: r.participant.name,
finalPosition: r.finalPosition,
qualifyingPoints: r.qualifyingPoints,
notes: r.notes,
})),
seasonTemplates: seasonTemplates.map((t) => ({
name: t.name,
year: t.year,
description: t.description,
isActive: t.isActive,
sports: t.seasonTemplateSports.map((ts) => ({
sportSlug: ts.sportsSeason.sport.slug,
seasonName: ts.sportsSeason.name,
seasonYear: ts.sportsSeason.year,
@ -131,204 +207,372 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
};
}
// ─── Import ───────────────────────────────────────────────────────────────────
export async function importSportsDataFromJSON(
jsonData: string,
mode: "merge" | "replace" = "merge"
): Promise<{ created: number; updated: number; skipped: number }> {
const db = database();
const importData: ExportData = JSON.parse(jsonData);
let created = 0;
let updated = 0;
let skipped = 0;
if (mode === "replace") {
// Delete in reverse order of dependencies
await db.delete(schema.seasonTemplateSports);
await db.delete(schema.seasonTemplates);
await db.delete(schema.participantResults);
await db.delete(schema.participants);
await db.delete(schema.seasonSports);
await db.delete(schema.sportsSeasons);
await db.delete(schema.sports);
// Validate before touching the database — gives a clear error on bad files
let importData: ExportData;
try {
importData = ImportDataSchema.parse(JSON.parse(jsonData));
} catch (err) {
throw new Error(
`Invalid import file: ${err instanceof Error ? err.message : "unknown validation error"}`
);
}
// Track created IDs for relationships
const sportIdMap = new Map<string, string>();
const sportsSeasonIdMap = new Map<string, string>();
// Wrap everything in a transaction so a mid-import failure rolls back cleanly
return await db.transaction(async (tx) => {
let created = 0;
let updated = 0;
let skipped = 0;
// Import sports
for (const sport of importData.sports) {
const existing = await db.query.sports.findFirst({
where: eq(schema.sports.slug, sport.slug),
});
if (mode === "replace") {
// Delete in reverse dependency order.
// participantExpectedValues and participantResults both carry
// onDelete: "cascade" on their participantId FK, so they are
// automatically removed when participants is deleted.
await tx.delete(schema.seasonTemplateSports);
await tx.delete(schema.seasonTemplates);
await tx.delete(schema.participants); // cascades to EVs + results
await tx.delete(schema.seasonSports);
await tx.delete(schema.sportsSeasons);
await tx.delete(schema.sports);
}
if (existing) {
if (mode === "merge") {
await db
.update(schema.sports)
.set({
// ID maps used to resolve relationships without further queries
const sportIdMap = new Map<string, string>();
const sportsSeasonIdMap = new Map<string, string>();
// key: `${sportsSeasonId}:${participantName}` — built during participant
// import so EV/result sections avoid a per-record lookup (fixes N+1)
const participantIdMap = new Map<string, string>();
// ── Sports ──────────────────────────────────────────────────────────────
for (const sport of importData.sports) {
const existing = await tx.query.sports.findFirst({
where: eq(schema.sports.slug, sport.slug),
});
if (existing) {
if (mode === "merge") {
await tx
.update(schema.sports)
.set({
name: sport.name,
type: sport.type as "team" | "individual",
description: sport.description,
})
.where(eq(schema.sports.slug, sport.slug));
sportIdMap.set(sport.slug, existing.id);
updated++;
}
} else {
const [created_] = await tx
.insert(schema.sports)
.values({
name: sport.name,
type: sport.type as "team" | "individual",
slug: sport.slug,
description: sport.description,
})
.where(eq(schema.sports.slug, sport.slug));
sportIdMap.set(sport.slug, existing.id);
updated++;
.returning();
sportIdMap.set(sport.slug, created_.id);
created++;
}
} else {
const [createdSport] = await db
.insert(schema.sports)
.values({
name: sport.name,
type: sport.type as "team" | "individual",
slug: sport.slug,
description: sport.description,
})
.returning();
sportIdMap.set(sport.slug, createdSport.id);
created++;
}
}
// Import sports seasons
for (const season of importData.sportsSeasons) {
const sportId = sportIdMap.get(season.sportSlug);
if (!sportId) {
skipped++;
continue;
}
const existing = await db.query.sportsSeasons.findFirst({
where: and(
eq(schema.sportsSeasons.sportId, sportId),
eq(schema.sportsSeasons.name, season.name),
eq(schema.sportsSeasons.year, season.year)
),
});
// ── Sports seasons ───────────────────────────────────────────────────────
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
for (const season of importData.sportsSeasons) {
const sportId = sportIdMap.get(season.sportSlug);
if (!sportId) {
skipped++;
continue;
}
if (existing) {
if (mode === "merge") {
await db
.update(schema.sportsSeasons)
.set({
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
const existing = await tx.query.sportsSeasons.findFirst({
where: and(
eq(schema.sportsSeasons.sportId, sportId),
eq(schema.sportsSeasons.name, season.name),
eq(schema.sportsSeasons.year, season.year)
),
});
if (existing) {
if (mode === "merge") {
await tx
.update(schema.sportsSeasons)
.set({
startDate: season.startDate,
endDate: season.endDate,
status: season.status as "upcoming" | "active" | "completed",
scoringType: season.scoringType as
| "playoffs"
| "regular_season"
| "majors",
})
.where(eq(schema.sportsSeasons.id, existing.id));
sportsSeasonIdMap.set(seasonKey, existing.id);
updated++;
}
} else {
const [created_] = await tx
.insert(schema.sportsSeasons)
.values({
sportId,
name: season.name,
year: season.year,
startDate: season.startDate,
endDate: season.endDate,
status: season.status as "upcoming" | "active" | "completed",
scoringType: season.scoringType as "playoffs" | "regular_season" | "majors",
scoringType: season.scoringType as
| "playoffs"
| "regular_season"
| "majors",
})
.where(eq(schema.sportsSeasons.id, existing.id));
sportsSeasonIdMap.set(seasonKey, existing.id);
updated++;
.returning();
sportsSeasonIdMap.set(seasonKey, created_.id);
created++;
}
} else {
const [createdSeason] = await db
.insert(schema.sportsSeasons)
.values({
sportId,
name: season.name,
year: season.year,
startDate: season.startDate,
endDate: season.endDate,
status: season.status as "upcoming" | "active" | "completed",
scoringType: season.scoringType as "playoffs" | "regular_season" | "majors",
})
.returning();
sportsSeasonIdMap.set(seasonKey, createdSeason.id);
created++;
}
}
// Import participants
for (const participant of importData.participants) {
const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (!sportsSeasonId) {
skipped++;
continue;
}
const existing = await db.query.participants.findFirst({
where: and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
eq(schema.participants.name, participant.name)
),
});
// ── Participants ─────────────────────────────────────────────────────────
if (!existing) {
await db.insert(schema.participants).values({
sportsSeasonId,
name: participant.name,
shortName: participant.shortName,
externalId: participant.externalId,
expectedValue: String(participant.expectedValue ?? 0),
for (const participant of importData.participants) {
const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (!sportsSeasonId) {
skipped++;
continue;
}
const existing = await tx.query.participants.findFirst({
where: and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
eq(schema.participants.name, participant.name)
),
});
created++;
} else {
skipped++;
}
}
// Import season templates
for (const template of importData.seasonTemplates) {
const existing = await db.query.seasonTemplates.findFirst({
where: and(
eq(schema.seasonTemplates.name, template.name),
eq(schema.seasonTemplates.year, template.year)
),
});
let templateId: string;
if (existing) {
if (mode === "merge") {
await db
.update(schema.seasonTemplates)
if (existing) {
// Update all mutable fields (fixes: merge mode was silently skipping)
await tx
.update(schema.participants)
.set({
shortName: participant.shortName,
externalId: participant.externalId,
expectedValue: String(participant.expectedValue ?? 0),
})
.where(eq(schema.participants.id, existing.id));
participantIdMap.set(`${sportsSeasonId}:${participant.name}`, existing.id);
updated++;
} else {
const [created_] = await tx
.insert(schema.participants)
.values({
sportsSeasonId,
name: participant.name,
shortName: participant.shortName,
externalId: participant.externalId,
expectedValue: String(participant.expectedValue ?? 0),
})
.returning();
participantIdMap.set(`${sportsSeasonId}:${participant.name}`, created_.id);
created++;
}
}
// ── Participant expected values ──────────────────────────────────────────
if (importData.participantExpectedValues) {
for (const ev of importData.participantExpectedValues) {
const seasonKey = `${ev.sportSlug}:${ev.seasonName}:${ev.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (!sportsSeasonId) {
skipped++;
continue;
}
// Use map built during participant import — no extra query needed
const participantId = participantIdMap.get(
`${sportsSeasonId}:${ev.participantName}`
);
if (!participantId) {
skipped++;
continue;
}
const existingEV = await tx.query.participantExpectedValues.findFirst({
where: and(
eq(schema.participantExpectedValues.participantId, participantId),
eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)
),
});
const evValues = {
probFirst: ev.probFirst,
probSecond: ev.probSecond,
probThird: ev.probThird,
probFourth: ev.probFourth,
probFifth: ev.probFifth,
probSixth: ev.probSixth,
probSeventh: ev.probSeventh,
probEighth: ev.probEighth,
expectedValue: ev.expectedValue,
source: ev.source as
| "manual"
| "futures_odds"
| "elo_simulation"
| "performance_model"
| null,
sourceOdds: ev.sourceOdds,
// Restore original timestamp when present (fixes: calculatedAt lost)
...(ev.calculatedAt
? { calculatedAt: new Date(ev.calculatedAt) }
: {}),
};
if (existingEV) {
if (mode === "merge") {
await tx
.update(schema.participantExpectedValues)
.set({ ...evValues, updatedAt: new Date() })
.where(eq(schema.participantExpectedValues.id, existingEV.id));
await tx
.update(schema.participants)
.set({ expectedValue: ev.expectedValue })
.where(eq(schema.participants.id, participantId));
updated++;
}
} else {
await tx.insert(schema.participantExpectedValues).values({
participantId,
sportsSeasonId,
...evValues,
});
await tx
.update(schema.participants)
.set({ expectedValue: ev.expectedValue })
.where(eq(schema.participants.id, participantId));
created++;
}
}
}
// ── Participant results ──────────────────────────────────────────────────
if (importData.participantResults) {
for (const result of importData.participantResults) {
const seasonKey = `${result.sportSlug}:${result.seasonName}:${result.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (!sportsSeasonId) {
skipped++;
continue;
}
const participantId = participantIdMap.get(
`${sportsSeasonId}:${result.participantName}`
);
if (!participantId) {
skipped++;
continue;
}
const existingResult = await tx.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
),
});
const resultValues = {
finalPosition: result.finalPosition,
qualifyingPoints: result.qualifyingPoints,
notes: result.notes,
};
if (existingResult) {
if (mode === "merge") {
await tx
.update(schema.participantResults)
.set({ ...resultValues, updatedAt: new Date() })
.where(eq(schema.participantResults.id, existingResult.id));
updated++;
}
} else {
await tx.insert(schema.participantResults).values({
participantId,
sportsSeasonId,
...resultValues,
});
created++;
}
}
}
// ── Season templates ─────────────────────────────────────────────────────
for (const template of importData.seasonTemplates) {
const existing = await tx.query.seasonTemplates.findFirst({
where: and(
eq(schema.seasonTemplates.name, template.name),
eq(schema.seasonTemplates.year, template.year)
),
});
let templateId: string;
if (existing) {
if (mode === "merge") {
await tx
.update(schema.seasonTemplates)
.set({
description: template.description,
isActive: template.isActive,
})
.where(eq(schema.seasonTemplates.id, existing.id));
templateId = existing.id;
updated++;
// Remove existing template sports to re-add them cleanly
await tx
.delete(schema.seasonTemplateSports)
.where(eq(schema.seasonTemplateSports.templateId, templateId));
} else {
continue;
}
} else {
const [created_] = await tx
.insert(schema.seasonTemplates)
.values({
name: template.name,
year: template.year,
description: template.description,
isActive: template.isActive,
})
.where(eq(schema.seasonTemplates.id, existing.id));
templateId = existing.id;
updated++;
// Remove existing template sports to re-add them
await db
.delete(schema.seasonTemplateSports)
.where(eq(schema.seasonTemplateSports.templateId, templateId));
} else {
continue;
.returning();
templateId = created_.id;
created++;
}
} else {
const [createdTemplate] = await db
.insert(schema.seasonTemplates)
.values({
name: template.name,
year: template.year,
description: template.description,
isActive: template.isActive,
})
.returning();
templateId = createdTemplate.id;
created++;
}
// Add template sports
for (const templateSport of template.sports) {
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (sportsSeasonId) {
await db.insert(schema.seasonTemplateSports).values({
templateId,
sportsSeasonId,
});
for (const templateSport of template.sports) {
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (sportsSeasonId) {
await tx.insert(schema.seasonTemplateSports).values({
templateId,
sportsSeasonId,
});
}
}
}
}
return { created, updated, skipped };
return { created, updated, skipped };
});
}

38
package-lock.json generated
View file

@ -48,7 +48,8 @@
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"svix": "^1.77.0",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@react-router/dev": "^7.7.1",
@ -2457,6 +2458,16 @@
"node": ">=18"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@mswjs/interceptors": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
@ -12144,6 +12155,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/shadcn/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -14090,23 +14111,22 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
"zod": "^3.25 || ^4"
}
}
}

View file

@ -63,7 +63,8 @@
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"svix": "^1.77.0",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@react-router/dev": "^7.7.1",