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 { 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,
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}` error: null,
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,7 +125,7 @@ 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) {
@ -122,38 +134,14 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
} }
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,7 +237,9 @@ 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, 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> </p>
<Button <Button
@ -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,15 +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 EV/futures data</li>
<li>All participant results</li>
<li>All season templates</li> <li>All season templates</li>
</ul> </ul>
</AlertDialogDescription> </AlertDialogDescription>
@ -332,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>
@ -349,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,153 +1,170 @@
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(
participantExpectedValues?: Array<{ z.object({
sportSlug: string; sportSlug: z.string(),
seasonName: string; seasonName: z.string(),
seasonYear: number; seasonYear: z.number(),
participantName: string; name: z.string(),
probFirst: string; shortName: z.string().nullable(),
probSecond: string; externalId: z.string().nullable(),
probThird: string; expectedValue: z.union([z.number(), z.string()]),
probFourth: string; })
probFifth: string; ),
probSixth: string; // Optional for backward compatibility with v1.0 exports
probSeventh: string; participantExpectedValues: z
probEighth: string; .array(
expectedValue: string; z.object({
source: string | null; sportSlug: z.string(),
sourceOdds: number | null; seasonName: z.string(),
}>; seasonYear: z.number(),
seasonTemplates: Array<{ participantName: z.string(),
name: string; probFirst: z.string(),
year: number; probSecond: z.string(),
description: string | null; probThird: z.string(),
isActive: boolean; probFourth: z.string(),
sports: Array<{ probFifth: z.string(),
sportSlug: string; probSixth: z.string(),
seasonName: string; probSeventh: z.string(),
seasonYear: number; 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 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 { 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,
})), })),
participantExpectedValues: participantEVs.map((ev) => ({ participantExpectedValues: participantEVs.map((ev) => ({
sportSlug: ev.participant.sportsSeason.sport.slug, sportSlug: ev.participant.sportsSeason.sport.slug,
@ -165,13 +182,23 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
expectedValue: ev.expectedValue, expectedValue: ev.expectedValue,
source: ev.source, source: ev.source,
sourceOdds: ev.sourceOdds, sourceOdds: ev.sourceOdds,
calculatedAt: ev.calculatedAt.toISOString(),
})), })),
seasonTemplates: seasonTemplates.map((template) => ({ participantResults: participantResultsData.map((r) => ({
name: template.name, sportSlug: r.participant.sportsSeason.sport.slug,
year: template.year, seasonName: r.participant.sportsSeason.name,
description: template.description, seasonYear: r.participant.sportsSeason.year,
isActive: template.isActive, participantName: r.participant.name,
sports: template.seasonTemplateSports.map((ts) => ({ 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,
@ -180,275 +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,
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}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (!sportsSeasonId) { if (!sportsSeasonId) {
skipped++; skipped++;
continue; continue;
} }
const participant = await db.query.participants.findFirst({ const existing = await tx.query.participants.findFirst({
where: and( where: and(
eq(schema.participants.sportsSeasonId, sportsSeasonId), eq(schema.participants.sportsSeasonId, sportsSeasonId),
eq(schema.participants.name, ev.participantName) eq(schema.participants.name, participant.name)
), ),
}); });
if (!participant) { if (existing) {
skipped++; // Update all mutable fields (fixes: merge mode was silently skipping)
continue; await tx
}
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
.update(schema.participants) .update(schema.participants)
.set({ expectedValue: ev.expectedValue }) .set({
.where(eq(schema.participants.id, participant.id)); 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++; created++;
} }
} }
}
// Import season templates // ── Participant expected values ──────────────────────────────────────────
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 (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) { // Use map built during participant import — no extra query needed
if (mode === "merge") { const participantId = participantIdMap.get(
await db `${sportsSeasonId}:${ev.participantName}`
.update(schema.seasonTemplates) );
.set({ 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",