diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index e3f4ada..38a8937 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -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 { + const db = database(); + const result = await db.select({ value: count() }).from(participantExpectedValues); + return result[0].value; +} + /** * Get participant EV for a specific sports season */ diff --git a/app/models/participant.ts b/app/models/participant.ts index b1a41c9..77efca7 100644 --- a/app/models/participant.ts +++ b/app/models/participant.ts @@ -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 { + const db = database(); + const result = await db.select({ value: count() }).from(schema.participants); + return result[0].value; +} + export async function deleteParticipant(id: string): Promise { const db = database(); await db.delete(schema.participants).where(eq(schema.participants.id, id)); diff --git a/app/routes/admin.data-sync.tsx b/app/routes/admin.data-sync.tsx index 890d7a4..8f9c20f 100644 --- a/app/routes/admin.data-sync.tsx +++ b/app/routes/admin.data-sync.tsx @@ -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(null); const [isExporting, setIsExporting] = useState(false); + const fetcher = useFetcher(); + 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

- {actionData?.success && ( + {fetcher.data?.success && (
- {actionData.message || "Operation completed successfully!"} + {fetcher.data.message || "Operation completed successfully!"}
)} - {actionData?.error && ( + {fetcher.data?.error && (
- {actionData.error} + {fetcher.data.error}
)} -
+
Sports @@ -192,11 +180,39 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp - Sports Seasons + + Sports Seasons + -
{stats.sportsSeasonsCount}
+
+ {stats.sportsSeasonsCount} +
+
+
+ + + + + Participants + + + + +
+ {stats.participantCount} +
+
+
+ + + + EV Records + + + +
{stats.evCount}
@@ -221,9 +237,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp

- 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.

- + @@ -305,14 +325,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp Are you absolutely sure? - This will delete all existing sports data and replace it - with the data from the file. This action cannot be undone. -

+ This will{" "} + delete all existing sports data and + replace it with the data from the file. This action + cannot be undone. +
+
This includes:
  • All sports
  • All sports seasons
  • All participants
  • +
  • All participant EV/futures data
  • +
  • All participant results
  • All season templates
@@ -331,11 +356,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp ) : ( )}
@@ -348,16 +373,17 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
- Export: Downloads all sports data as a JSON file that you can - save, version control, or transfer to another environment. + Export: Downloads all sports data as a JSON file + that you can save, version control, or transfer to another + environment.
- Import (Merge): Updates existing records and adds new ones. - Safe to use - won't delete anything. + Import (Merge): Updates existing records and adds + new ones. Safe to use — won't delete anything.
- Import (Replace): Deletes all existing sports data and recreates - it from the file. Use this for a clean slate. + Import (Replace): Deletes all existing sports + data and recreates it from the file. Use this for a clean slate.
Typical workflow: diff --git a/app/utils/sports-data-sync.server.ts b/app/utils/sports-data-sync.server.ts index 0aa99c5..dc64eea 100644 --- a/app/utils/sports-data-sync.server.ts +++ b/app/utils/sports-data-sync.server.ts @@ -1,128 +1,204 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; +import { z } from "zod"; -interface ExportData { - version: string; - exportedAt: string; - sports: Array<{ - name: string; - type: string; - slug: string; - description: string | null; - }>; - sportsSeasons: Array<{ - sportSlug: string; - name: string; - year: number; - startDate: string | null; - endDate: string | null; - status: string; - scoringType: string; - }>; - participants: Array<{ - sportSlug: string; - seasonName: string; - seasonYear: number; - name: string; - shortName: string | null; - externalId: string | null; - expectedValue: number | string; - }>; - seasonTemplates: Array<{ - name: string; - year: number; - description: string | null; - isActive: boolean; - sports: Array<{ - sportSlug: string; - seasonName: string; - seasonYear: number; - }>; - }>; -} +// ─── Validation schema ──────────────────────────────────────────────────────── +// Used both for runtime validation of imported files and as the canonical shape +// for the exported JSON (fixes: zod validation, calculatedAt, participantResults) + +const ImportDataSchema = z.object({ + version: z.string(), + exportedAt: z.string(), + sports: z.array( + z.object({ + name: z.string(), + type: z.string(), + slug: z.string(), + description: z.string().nullable(), + }) + ), + sportsSeasons: z.array( + z.object({ + sportSlug: z.string(), + name: z.string(), + year: z.number(), + startDate: z.string().nullable(), + endDate: z.string().nullable(), + status: z.string(), + scoringType: z.string(), + }) + ), + participants: z.array( + z.object({ + sportSlug: z.string(), + seasonName: z.string(), + seasonYear: z.number(), + name: z.string(), + shortName: z.string().nullable(), + externalId: z.string().nullable(), + expectedValue: z.union([z.number(), z.string()]), + }) + ), + // Optional for backward compatibility with v1.0 exports + participantExpectedValues: z + .array( + z.object({ + sportSlug: z.string(), + seasonName: z.string(), + seasonYear: z.number(), + participantName: z.string(), + probFirst: z.string(), + probSecond: z.string(), + probThird: z.string(), + probFourth: z.string(), + probFifth: z.string(), + probSixth: z.string(), + probSeventh: z.string(), + probEighth: z.string(), + expectedValue: z.string(), + source: z.string().nullable(), + sourceOdds: z.number().nullable(), + calculatedAt: z.string().optional(), // optional for backward compat + }) + ) + .optional(), + participantResults: z + .array( + z.object({ + sportSlug: z.string(), + seasonName: z.string(), + seasonYear: z.number(), + participantName: z.string(), + finalPosition: z.number().nullable(), + qualifyingPoints: z.string().nullable(), + notes: z.string().nullable(), + }) + ) + .optional(), + seasonTemplates: z.array( + z.object({ + name: z.string(), + year: z.number(), + description: z.string().nullable(), + isActive: z.boolean(), + sports: z.array( + z.object({ + sportSlug: z.string(), + seasonName: z.string(), + seasonYear: z.number(), + }) + ), + }) + ), +}); + +type ExportData = z.infer; + +// ─── Export ─────────────────────────────────────────────────────────────────── export async function exportSportsDataToJSON(): Promise { const db = database(); - // Export sports - const sports = await db.query.sports.findMany({ - orderBy: (sports, { asc }) => [asc(sports.name)], - }); - - // Export sports seasons with sport relation - const sportsSeasons = await db.query.sportsSeasons.findMany({ - with: { - sport: true, - }, - orderBy: (sportsSeasons, { desc, asc }) => [ - desc(sportsSeasons.year), - asc(sportsSeasons.name), - ], - }); - - // Export participants with relations - const participants = await db.query.participants.findMany({ - with: { - sportsSeason: { - with: { - sport: true, + const [ + sports, + sportsSeasons, + participants, + participantEVs, + participantResultsData, + seasonTemplates, + ] = await Promise.all([ + db.query.sports.findMany({ + orderBy: (s, { asc }) => [asc(s.name)], + }), + db.query.sportsSeasons.findMany({ + with: { sport: true }, + orderBy: (s, { desc, asc }) => [desc(s.year), asc(s.name)], + }), + db.query.participants.findMany({ + with: { sportsSeason: { with: { sport: true } } }, + orderBy: (p, { asc }) => [asc(p.name)], + }), + db.query.participantExpectedValues.findMany({ + with: { + participant: { with: { sportsSeason: { with: { sport: true } } } }, + }, + }), + db.query.participantResults.findMany({ + with: { + participant: { with: { sportsSeason: { with: { sport: true } } } }, + }, + }), + db.query.seasonTemplates.findMany({ + with: { + seasonTemplateSports: { + with: { sportsSeason: { with: { sport: true } } }, }, }, - }, - orderBy: (participants, { asc }) => [asc(participants.name)], - }); + orderBy: (t, { desc }) => [desc(t.year)], + }), + ]); - // Export season templates with sports - const seasonTemplates = await db.query.seasonTemplates.findMany({ - with: { - seasonTemplateSports: { - with: { - sportsSeason: { - with: { - sport: true, - }, - }, - }, - }, - }, - orderBy: (templates, { desc }) => [desc(templates.year)], - }); - - // Build export data structure return { - version: "1.0", + version: "1.1", exportedAt: new Date().toISOString(), - sports: sports.map((sport) => ({ - name: sport.name, - type: sport.type, - slug: sport.slug, - description: sport.description, + sports: sports.map((s) => ({ + name: s.name, + type: s.type, + slug: s.slug, + description: s.description, })), - sportsSeasons: sportsSeasons.map((season) => ({ - sportSlug: season.sport.slug, - name: season.name, - year: season.year, - startDate: season.startDate, - endDate: season.endDate, - status: season.status, - scoringType: season.scoringType, + sportsSeasons: sportsSeasons.map((s) => ({ + sportSlug: s.sport.slug, + name: s.name, + year: s.year, + startDate: s.startDate, + endDate: s.endDate, + status: s.status, + scoringType: s.scoringType, })), - participants: participants.map((participant) => ({ - sportSlug: participant.sportsSeason.sport.slug, - seasonName: participant.sportsSeason.name, - seasonYear: participant.sportsSeason.year, - name: participant.name, - shortName: participant.shortName, - externalId: participant.externalId, - expectedValue: participant.expectedValue, + participants: participants.map((p) => ({ + sportSlug: p.sportsSeason.sport.slug, + seasonName: p.sportsSeason.name, + seasonYear: p.sportsSeason.year, + name: p.name, + shortName: p.shortName, + externalId: p.externalId, + expectedValue: p.expectedValue, })), - seasonTemplates: seasonTemplates.map((template) => ({ - name: template.name, - year: template.year, - description: template.description, - isActive: template.isActive, - sports: template.seasonTemplateSports.map((ts) => ({ + participantExpectedValues: participantEVs.map((ev) => ({ + sportSlug: ev.participant.sportsSeason.sport.slug, + seasonName: ev.participant.sportsSeason.name, + seasonYear: ev.participant.sportsSeason.year, + participantName: ev.participant.name, + probFirst: ev.probFirst, + probSecond: ev.probSecond, + probThird: ev.probThird, + probFourth: ev.probFourth, + probFifth: ev.probFifth, + probSixth: ev.probSixth, + probSeventh: ev.probSeventh, + probEighth: ev.probEighth, + expectedValue: ev.expectedValue, + source: ev.source, + sourceOdds: ev.sourceOdds, + calculatedAt: ev.calculatedAt.toISOString(), + })), + participantResults: participantResultsData.map((r) => ({ + sportSlug: r.participant.sportsSeason.sport.slug, + seasonName: r.participant.sportsSeason.name, + seasonYear: r.participant.sportsSeason.year, + participantName: r.participant.name, + finalPosition: r.finalPosition, + qualifyingPoints: r.qualifyingPoints, + notes: r.notes, + })), + seasonTemplates: seasonTemplates.map((t) => ({ + name: t.name, + year: t.year, + description: t.description, + isActive: t.isActive, + sports: t.seasonTemplateSports.map((ts) => ({ sportSlug: ts.sportsSeason.sport.slug, seasonName: ts.sportsSeason.name, seasonYear: ts.sportsSeason.year, @@ -131,204 +207,372 @@ export async function exportSportsDataToJSON(): Promise { }; } +// ─── 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(); - const sportsSeasonIdMap = new Map(); + // 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(); + const sportsSeasonIdMap = new Map(); + // key: `${sportsSeasonId}:${participantName}` — built during participant + // import so EV/result sections avoid a per-record lookup (fixes N+1) + const participantIdMap = new Map(); + + // ── Sports ────────────────────────────────────────────────────────────── + + for (const sport of importData.sports) { + const existing = await tx.query.sports.findFirst({ + where: eq(schema.sports.slug, sport.slug), + }); + + if (existing) { + if (mode === "merge") { + await tx + .update(schema.sports) + .set({ + name: sport.name, + type: sport.type as "team" | "individual", + description: sport.description, + }) + .where(eq(schema.sports.slug, sport.slug)); + sportIdMap.set(sport.slug, existing.id); + updated++; + } + } else { + const [created_] = await tx + .insert(schema.sports) + .values({ name: sport.name, type: sport.type as "team" | "individual", + slug: sport.slug, description: sport.description, }) - .where(eq(schema.sports.slug, sport.slug)); - sportIdMap.set(sport.slug, existing.id); - updated++; + .returning(); + sportIdMap.set(sport.slug, created_.id); + created++; } - } else { - const [createdSport] = await db - .insert(schema.sports) - .values({ - name: sport.name, - type: sport.type as "team" | "individual", - slug: sport.slug, - description: sport.description, - }) - .returning(); - sportIdMap.set(sport.slug, createdSport.id); - created++; - } - } - - // Import sports seasons - for (const season of importData.sportsSeasons) { - const sportId = sportIdMap.get(season.sportSlug); - if (!sportId) { - skipped++; - continue; } - const existing = await db.query.sportsSeasons.findFirst({ - where: and( - eq(schema.sportsSeasons.sportId, sportId), - eq(schema.sportsSeasons.name, season.name), - eq(schema.sportsSeasons.year, season.year) - ), - }); + // ── Sports seasons ─────────────────────────────────────────────────────── - const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`; + for (const season of importData.sportsSeasons) { + const sportId = sportIdMap.get(season.sportSlug); + if (!sportId) { + skipped++; + continue; + } - if (existing) { - if (mode === "merge") { - await db - .update(schema.sportsSeasons) - .set({ + const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`; + + const existing = await tx.query.sportsSeasons.findFirst({ + where: and( + eq(schema.sportsSeasons.sportId, sportId), + eq(schema.sportsSeasons.name, season.name), + eq(schema.sportsSeasons.year, season.year) + ), + }); + + if (existing) { + if (mode === "merge") { + await tx + .update(schema.sportsSeasons) + .set({ + startDate: season.startDate, + endDate: season.endDate, + status: season.status as "upcoming" | "active" | "completed", + scoringType: season.scoringType as + | "playoffs" + | "regular_season" + | "majors", + }) + .where(eq(schema.sportsSeasons.id, existing.id)); + sportsSeasonIdMap.set(seasonKey, existing.id); + updated++; + } + } else { + const [created_] = await tx + .insert(schema.sportsSeasons) + .values({ + sportId, + name: season.name, + year: season.year, startDate: season.startDate, endDate: season.endDate, status: season.status as "upcoming" | "active" | "completed", - scoringType: season.scoringType as "playoffs" | "regular_season" | "majors", + scoringType: season.scoringType as + | "playoffs" + | "regular_season" + | "majors", }) - .where(eq(schema.sportsSeasons.id, existing.id)); - sportsSeasonIdMap.set(seasonKey, existing.id); - updated++; + .returning(); + sportsSeasonIdMap.set(seasonKey, created_.id); + created++; } - } else { - const [createdSeason] = await db - .insert(schema.sportsSeasons) - .values({ - sportId, - name: season.name, - year: season.year, - startDate: season.startDate, - endDate: season.endDate, - status: season.status as "upcoming" | "active" | "completed", - scoringType: season.scoringType as "playoffs" | "regular_season" | "majors", - }) - .returning(); - sportsSeasonIdMap.set(seasonKey, createdSeason.id); - created++; - } - } - - // Import participants - for (const participant of importData.participants) { - const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`; - const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); - - if (!sportsSeasonId) { - skipped++; - continue; } - const existing = await db.query.participants.findFirst({ - where: and( - eq(schema.participants.sportsSeasonId, sportsSeasonId), - eq(schema.participants.name, participant.name) - ), - }); + // ── Participants ───────────────────────────────────────────────────────── - if (!existing) { - await db.insert(schema.participants).values({ - sportsSeasonId, - name: participant.name, - shortName: participant.shortName, - externalId: participant.externalId, - expectedValue: String(participant.expectedValue ?? 0), + for (const participant of importData.participants) { + const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`; + const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); + if (!sportsSeasonId) { + skipped++; + continue; + } + + const existing = await tx.query.participants.findFirst({ + where: and( + eq(schema.participants.sportsSeasonId, sportsSeasonId), + eq(schema.participants.name, participant.name) + ), }); - created++; - } else { - skipped++; - } - } - // Import season templates - for (const template of importData.seasonTemplates) { - const existing = await db.query.seasonTemplates.findFirst({ - where: and( - eq(schema.seasonTemplates.name, template.name), - eq(schema.seasonTemplates.year, template.year) - ), - }); - - let templateId: string; - - if (existing) { - if (mode === "merge") { - await db - .update(schema.seasonTemplates) + if (existing) { + // Update all mutable fields (fixes: merge mode was silently skipping) + await tx + .update(schema.participants) .set({ + shortName: participant.shortName, + externalId: participant.externalId, + expectedValue: String(participant.expectedValue ?? 0), + }) + .where(eq(schema.participants.id, existing.id)); + participantIdMap.set(`${sportsSeasonId}:${participant.name}`, existing.id); + updated++; + } else { + const [created_] = await tx + .insert(schema.participants) + .values({ + sportsSeasonId, + name: participant.name, + shortName: participant.shortName, + externalId: participant.externalId, + expectedValue: String(participant.expectedValue ?? 0), + }) + .returning(); + participantIdMap.set(`${sportsSeasonId}:${participant.name}`, created_.id); + created++; + } + } + + // ── Participant expected values ────────────────────────────────────────── + + if (importData.participantExpectedValues) { + for (const ev of importData.participantExpectedValues) { + const seasonKey = `${ev.sportSlug}:${ev.seasonName}:${ev.seasonYear}`; + const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); + if (!sportsSeasonId) { + skipped++; + continue; + } + + // Use map built during participant import — no extra query needed + const participantId = participantIdMap.get( + `${sportsSeasonId}:${ev.participantName}` + ); + if (!participantId) { + skipped++; + continue; + } + + const existingEV = await tx.query.participantExpectedValues.findFirst({ + where: and( + eq(schema.participantExpectedValues.participantId, participantId), + eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId) + ), + }); + + const evValues = { + probFirst: ev.probFirst, + probSecond: ev.probSecond, + probThird: ev.probThird, + probFourth: ev.probFourth, + probFifth: ev.probFifth, + probSixth: ev.probSixth, + probSeventh: ev.probSeventh, + probEighth: ev.probEighth, + expectedValue: ev.expectedValue, + source: ev.source as + | "manual" + | "futures_odds" + | "elo_simulation" + | "performance_model" + | null, + sourceOdds: ev.sourceOdds, + // Restore original timestamp when present (fixes: calculatedAt lost) + ...(ev.calculatedAt + ? { calculatedAt: new Date(ev.calculatedAt) } + : {}), + }; + + if (existingEV) { + if (mode === "merge") { + await tx + .update(schema.participantExpectedValues) + .set({ ...evValues, updatedAt: new Date() }) + .where(eq(schema.participantExpectedValues.id, existingEV.id)); + await tx + .update(schema.participants) + .set({ expectedValue: ev.expectedValue }) + .where(eq(schema.participants.id, participantId)); + updated++; + } + } else { + await tx.insert(schema.participantExpectedValues).values({ + participantId, + sportsSeasonId, + ...evValues, + }); + await tx + .update(schema.participants) + .set({ expectedValue: ev.expectedValue }) + .where(eq(schema.participants.id, participantId)); + created++; + } + } + } + + // ── Participant results ────────────────────────────────────────────────── + + if (importData.participantResults) { + for (const result of importData.participantResults) { + const seasonKey = `${result.sportSlug}:${result.seasonName}:${result.seasonYear}`; + const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); + if (!sportsSeasonId) { + skipped++; + continue; + } + + const participantId = participantIdMap.get( + `${sportsSeasonId}:${result.participantName}` + ); + if (!participantId) { + skipped++; + continue; + } + + const existingResult = await tx.query.participantResults.findFirst({ + where: and( + eq(schema.participantResults.participantId, participantId), + eq(schema.participantResults.sportsSeasonId, sportsSeasonId) + ), + }); + + const resultValues = { + finalPosition: result.finalPosition, + qualifyingPoints: result.qualifyingPoints, + notes: result.notes, + }; + + if (existingResult) { + if (mode === "merge") { + await tx + .update(schema.participantResults) + .set({ ...resultValues, updatedAt: new Date() }) + .where(eq(schema.participantResults.id, existingResult.id)); + updated++; + } + } else { + await tx.insert(schema.participantResults).values({ + participantId, + sportsSeasonId, + ...resultValues, + }); + created++; + } + } + } + + // ── Season templates ───────────────────────────────────────────────────── + + for (const template of importData.seasonTemplates) { + const existing = await tx.query.seasonTemplates.findFirst({ + where: and( + eq(schema.seasonTemplates.name, template.name), + eq(schema.seasonTemplates.year, template.year) + ), + }); + + let templateId: string; + + if (existing) { + if (mode === "merge") { + await tx + .update(schema.seasonTemplates) + .set({ + description: template.description, + isActive: template.isActive, + }) + .where(eq(schema.seasonTemplates.id, existing.id)); + templateId = existing.id; + updated++; + + // Remove existing template sports to re-add them cleanly + await tx + .delete(schema.seasonTemplateSports) + .where(eq(schema.seasonTemplateSports.templateId, templateId)); + } else { + continue; + } + } else { + const [created_] = await tx + .insert(schema.seasonTemplates) + .values({ + name: template.name, + year: template.year, description: template.description, isActive: template.isActive, }) - .where(eq(schema.seasonTemplates.id, existing.id)); - templateId = existing.id; - updated++; - - // Remove existing template sports to re-add them - await db - .delete(schema.seasonTemplateSports) - .where(eq(schema.seasonTemplateSports.templateId, templateId)); - } else { - continue; + .returning(); + templateId = created_.id; + created++; } - } else { - const [createdTemplate] = await db - .insert(schema.seasonTemplates) - .values({ - name: template.name, - year: template.year, - description: template.description, - isActive: template.isActive, - }) - .returning(); - templateId = createdTemplate.id; - created++; - } - // Add template sports - for (const templateSport of template.sports) { - const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`; - const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); - - if (sportsSeasonId) { - await db.insert(schema.seasonTemplateSports).values({ - templateId, - sportsSeasonId, - }); + for (const templateSport of template.sports) { + const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`; + const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); + if (sportsSeasonId) { + await tx.insert(schema.seasonTemplateSports).values({ + templateId, + sportsSeasonId, + }); + } } } - } - return { created, updated, skipped }; + return { created, updated, skipped }; + }); } diff --git a/package-lock.json b/package-lock.json index 8c07495..98d89ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index d1306dd..dc28c0c 100644 --- a/package.json +++ b/package.json @@ -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",