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
This commit is contained in:
Claude 2026-02-21 04:03:45 +00:00
parent dd5072a2c2
commit e1b4f8a3aa
No known key found for this signature in database
6 changed files with 626 additions and 444 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, participant EV/futures data, 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,15 +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>
@ -332,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>
@ -349,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,153 +1,170 @@
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;
}>;
participantExpectedValues?: Array<{
sportSlug: string;
seasonName: string;
seasonYear: number;
participantName: string;
probFirst: string;
probSecond: string;
probThird: string;
probFourth: string;
probFifth: string;
probSixth: string;
probSeventh: string;
probEighth: string;
expectedValue: string;
source: string | null;
sourceOdds: number | null;
}>;
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 participant expected values with relations
const participantEVs = await db.query.participantExpectedValues.findMany({
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
},
},
});
// 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,
})),
participantExpectedValues: participantEVs.map((ev) => ({
sportSlug: ev.participant.sportsSeason.sport.slug,
@ -165,13 +182,23 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
expectedValue: ev.expectedValue,
source: ev.source,
sourceOdds: ev.sourceOdds,
calculatedAt: ev.calculatedAt.toISOString(),
})),
seasonTemplates: seasonTemplates.map((template) => ({
name: template.name,
year: template.year,
description: template.description,
isActive: template.isActive,
sports: template.seasonTemplateSports.map((ts) => ({
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,
@ -180,275 +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),
});
created++;
} else {
skipped++;
}
}
// Import participant expected values (optional field for backward compatibility)
if (importData.participantExpectedValues) {
for (const ev of importData.participantExpectedValues) {
const seasonKey = `${ev.sportSlug}:${ev.seasonName}:${ev.seasonYear}`;
for (const participant of importData.participants) {
const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (!sportsSeasonId) {
skipped++;
continue;
}
const participant = await db.query.participants.findFirst({
const existing = await tx.query.participants.findFirst({
where: and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
eq(schema.participants.name, ev.participantName)
eq(schema.participants.name, participant.name)
),
});
if (!participant) {
skipped++;
continue;
}
const existingEV = await db.query.participantExpectedValues.findFirst({
where: and(
eq(schema.participantExpectedValues.participantId, participant.id),
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,
};
if (existingEV) {
if (mode === "merge") {
await db
.update(schema.participantExpectedValues)
.set({ ...evValues, updatedAt: new Date() })
.where(eq(schema.participantExpectedValues.id, existingEV.id));
await db
.update(schema.participants)
.set({ expectedValue: ev.expectedValue })
.where(eq(schema.participants.id, participant.id));
updated++;
}
} else {
await db.insert(schema.participantExpectedValues).values({
participantId: participant.id,
sportsSeasonId,
...evValues,
});
await db
if (existing) {
// Update all mutable fields (fixes: merge mode was silently skipping)
await tx
.update(schema.participants)
.set({ expectedValue: ev.expectedValue })
.where(eq(schema.participants.id, participant.id));
.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++;
}
}
}
// 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)
),
});
// ── Participant expected values ──────────────────────────────────────────
let templateId: string;
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;
}
if (existing) {
if (mode === "merge") {
await db
.update(schema.seasonTemplates)
.set({
// 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",