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 { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema"; 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 type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities } 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 * 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -73,6 +73,12 @@ export async function updateParticipant(
return participant; 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> { export async function deleteParticipant(id: string): Promise<void> {
const db = database(); const db = database();
await db.delete(schema.participants).where(eq(schema.participants.id, id)); await db.delete(schema.participants).where(eq(schema.participants.id, id));

View file

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

View file

@ -1,128 +1,204 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { z } from "zod";
interface ExportData { // ─── Validation schema ────────────────────────────────────────────────────────
version: string; // Used both for runtime validation of imported files and as the canonical shape
exportedAt: string; // for the exported JSON (fixes: zod validation, calculatedAt, participantResults)
sports: Array<{
name: string; const ImportDataSchema = z.object({
type: string; version: z.string(),
slug: string; exportedAt: z.string(),
description: string | null; sports: z.array(
}>; z.object({
sportsSeasons: Array<{ name: z.string(),
sportSlug: string; type: z.string(),
name: string; slug: z.string(),
year: number; description: z.string().nullable(),
startDate: string | null; })
endDate: string | null; ),
status: string; sportsSeasons: z.array(
scoringType: string; z.object({
}>; sportSlug: z.string(),
participants: Array<{ name: z.string(),
sportSlug: string; year: z.number(),
seasonName: string; startDate: z.string().nullable(),
seasonYear: number; endDate: z.string().nullable(),
name: string; status: z.string(),
shortName: string | null; scoringType: z.string(),
externalId: string | null; })
expectedValue: number | string; ),
}>; participants: z.array(
seasonTemplates: Array<{ z.object({
name: string; sportSlug: z.string(),
year: number; seasonName: z.string(),
description: string | null; seasonYear: z.number(),
isActive: boolean; name: z.string(),
sports: Array<{ shortName: z.string().nullable(),
sportSlug: string; externalId: z.string().nullable(),
seasonName: string; expectedValue: z.union([z.number(), z.string()]),
seasonYear: number; })
}>; ),
}>; // 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> { export async function exportSportsDataToJSON(): Promise<ExportData> {
const db = database(); const db = database();
// Export sports const [
const sports = await db.query.sports.findMany({ sports,
orderBy: (sports, { asc }) => [asc(sports.name)], sportsSeasons,
}); participants,
participantEVs,
// Export sports seasons with sport relation participantResultsData,
const sportsSeasons = await db.query.sportsSeasons.findMany({ seasonTemplates,
with: { ] = await Promise.all([
sport: true, db.query.sports.findMany({
}, orderBy: (s, { asc }) => [asc(s.name)],
orderBy: (sportsSeasons, { desc, asc }) => [ }),
desc(sportsSeasons.year), db.query.sportsSeasons.findMany({
asc(sportsSeasons.name), with: { sport: true },
], orderBy: (s, { desc, asc }) => [desc(s.year), asc(s.name)],
}); }),
db.query.participants.findMany({
// Export participants with relations with: { sportsSeason: { with: { sport: true } } },
const participants = await db.query.participants.findMany({ orderBy: (p, { asc }) => [asc(p.name)],
with: { }),
sportsSeason: { db.query.participantExpectedValues.findMany({
with: { with: {
sport: true, 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: (t, { desc }) => [desc(t.year)],
orderBy: (participants, { asc }) => [asc(participants.name)], }),
}); ]);
// 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 { return {
version: "1.0", version: "1.1",
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
sports: sports.map((sport) => ({ sports: sports.map((s) => ({
name: sport.name, name: s.name,
type: sport.type, type: s.type,
slug: sport.slug, slug: s.slug,
description: sport.description, description: s.description,
})), })),
sportsSeasons: sportsSeasons.map((season) => ({ sportsSeasons: sportsSeasons.map((s) => ({
sportSlug: season.sport.slug, sportSlug: s.sport.slug,
name: season.name, name: s.name,
year: season.year, year: s.year,
startDate: season.startDate, startDate: s.startDate,
endDate: season.endDate, endDate: s.endDate,
status: season.status, status: s.status,
scoringType: season.scoringType, scoringType: s.scoringType,
})), })),
participants: participants.map((participant) => ({ participants: participants.map((p) => ({
sportSlug: participant.sportsSeason.sport.slug, sportSlug: p.sportsSeason.sport.slug,
seasonName: participant.sportsSeason.name, seasonName: p.sportsSeason.name,
seasonYear: participant.sportsSeason.year, seasonYear: p.sportsSeason.year,
name: participant.name, name: p.name,
shortName: participant.shortName, shortName: p.shortName,
externalId: participant.externalId, externalId: p.externalId,
expectedValue: participant.expectedValue, expectedValue: p.expectedValue,
})), })),
seasonTemplates: seasonTemplates.map((template) => ({ participantExpectedValues: participantEVs.map((ev) => ({
name: template.name, sportSlug: ev.participant.sportsSeason.sport.slug,
year: template.year, seasonName: ev.participant.sportsSeason.name,
description: template.description, seasonYear: ev.participant.sportsSeason.year,
isActive: template.isActive, participantName: ev.participant.name,
sports: template.seasonTemplateSports.map((ts) => ({ 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, sportSlug: ts.sportsSeason.sport.slug,
seasonName: ts.sportsSeason.name, seasonName: ts.sportsSeason.name,
seasonYear: ts.sportsSeason.year, seasonYear: ts.sportsSeason.year,
@ -131,204 +207,372 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
}; };
} }
// ─── Import ───────────────────────────────────────────────────────────────────
export async function importSportsDataFromJSON( export async function importSportsDataFromJSON(
jsonData: string, jsonData: string,
mode: "merge" | "replace" = "merge" mode: "merge" | "replace" = "merge"
): Promise<{ created: number; updated: number; skipped: number }> { ): Promise<{ created: number; updated: number; skipped: number }> {
const db = database(); const db = database();
const importData: ExportData = JSON.parse(jsonData);
let created = 0; // Validate before touching the database — gives a clear error on bad files
let updated = 0; let importData: ExportData;
let skipped = 0; try {
importData = ImportDataSchema.parse(JSON.parse(jsonData));
if (mode === "replace") { } catch (err) {
// Delete in reverse order of dependencies throw new Error(
await db.delete(schema.seasonTemplateSports); `Invalid import file: ${err instanceof Error ? err.message : "unknown validation error"}`
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);
} }
// Track created IDs for relationships // Wrap everything in a transaction so a mid-import failure rolls back cleanly
const sportIdMap = new Map<string, string>(); return await db.transaction(async (tx) => {
const sportsSeasonIdMap = new Map<string, string>(); let created = 0;
let updated = 0;
let skipped = 0;
// Import sports if (mode === "replace") {
for (const sport of importData.sports) { // Delete in reverse dependency order.
const existing = await db.query.sports.findFirst({ // participantExpectedValues and participantResults both carry
where: eq(schema.sports.slug, sport.slug), // 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) { // ID maps used to resolve relationships without further queries
if (mode === "merge") { const sportIdMap = new Map<string, string>();
await db const sportsSeasonIdMap = new Map<string, string>();
.update(schema.sports) // key: `${sportsSeasonId}:${participantName}` — built during participant
.set({ // 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, name: sport.name,
type: sport.type as "team" | "individual", type: sport.type as "team" | "individual",
slug: sport.slug,
description: sport.description, description: sport.description,
}) })
.where(eq(schema.sports.slug, sport.slug)); .returning();
sportIdMap.set(sport.slug, existing.id); sportIdMap.set(sport.slug, created_.id);
updated++; 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({ // ── Sports seasons ───────────────────────────────────────────────────────
where: and(
eq(schema.sportsSeasons.sportId, sportId),
eq(schema.sportsSeasons.name, season.name),
eq(schema.sportsSeasons.year, season.year)
),
});
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) { const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
if (mode === "merge") {
await db const existing = await tx.query.sportsSeasons.findFirst({
.update(schema.sportsSeasons) where: and(
.set({ 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, startDate: season.startDate,
endDate: season.endDate, endDate: season.endDate,
status: season.status as "upcoming" | "active" | "completed", 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)); .returning();
sportsSeasonIdMap.set(seasonKey, existing.id); sportsSeasonIdMap.set(seasonKey, created_.id);
updated++; 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({ // ── Participants ─────────────────────────────────────────────────────────
where: and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
eq(schema.participants.name, participant.name)
),
});
if (!existing) { for (const participant of importData.participants) {
await db.insert(schema.participants).values({ const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
sportsSeasonId, const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
name: participant.name, if (!sportsSeasonId) {
shortName: participant.shortName, skipped++;
externalId: participant.externalId, continue;
expectedValue: String(participant.expectedValue ?? 0), }
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 if (existing) {
for (const template of importData.seasonTemplates) { // Update all mutable fields (fixes: merge mode was silently skipping)
const existing = await db.query.seasonTemplates.findFirst({ await tx
where: and( .update(schema.participants)
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)
.set({ .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, description: template.description,
isActive: template.isActive, isActive: template.isActive,
}) })
.where(eq(schema.seasonTemplates.id, existing.id)); .returning();
templateId = existing.id; templateId = created_.id;
updated++; created++;
// Remove existing template sports to re-add them
await db
.delete(schema.seasonTemplateSports)
.where(eq(schema.seasonTemplateSports.templateId, templateId));
} else {
continue;
} }
} 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) {
for (const templateSport of template.sports) { const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`; const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); if (sportsSeasonId) {
await tx.insert(schema.seasonTemplateSports).values({
if (sportsSeasonId) { templateId,
await db.insert(schema.seasonTemplateSports).values({ sportsSeasonId,
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", "socket.io-client": "^4.8.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"svix": "^1.77.0", "svix": "^1.77.0",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.7.1", "@react-router/dev": "^7.7.1",
@ -2457,6 +2458,16 @@
"node": ">=18" "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": { "node_modules/@mswjs/interceptors": {
"version": "0.40.0", "version": "0.40.0",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
@ -12144,6 +12155,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -14090,23 +14111,22 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
}, },
"node_modules/zod-to-json-schema": { "node_modules/zod-to-json-schema": {
"version": "3.24.6", "version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"zod": "^3.24.1" "zod": "^3.25 || ^4"
} }
} }
} }

View file

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