From 9ae00a83859cdb0510700e3a3fc519f4b076812e Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:25:25 -0800 Subject: [PATCH] Add Zod validation and expand export/import for EV and results data (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- app/models/participant-expected-value.ts | 8 +- app/models/participant.ts | 8 +- app/routes/admin.data-sync.tsx | 172 ++--- app/utils/sports-data-sync.server.ts | 786 +++++++++++++++-------- package-lock.json | 38 +- package.json | 3 +- 6 files changed, 659 insertions(+), 356 deletions(-) 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",