brackt/app/routes/admin.data-sync.tsx
Claude dd5072a2c2
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
2026-02-21 03:45:00 +00:00

377 lines
13 KiB
TypeScript

import { useState } from "react";
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 { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Download, Upload, AlertTriangle, Database } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
export async function loader() {
const [sports, sportsSeasons, templates] = await Promise.all([
findAllSports(),
findAllSportsSeasons(),
findAllSeasonTemplates(),
]);
return {
stats: {
sportsCount: sports.length,
sportsSeasonsCount: sportsSeasons.length,
templatesCount: templates.length,
},
};
}
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" };
}
try {
// Import the data (we'll create a server-side function for this)
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}`
};
} catch (error) {
console.error("Import error:", error);
return { error: error instanceof Error ? error.message : "Import failed" };
}
}
return { error: "Invalid intent" };
}
export default function DataSync({ loaderData, actionData }: Route.ComponentProps) {
const { stats } = loaderData;
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isExporting, setIsExporting] = useState(false);
const 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 url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `sports-data-export-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Export failed:", error);
alert("Export failed. Please try again.");
} finally {
setIsExporting(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
alert("Please select a file first");
return;
}
const reader = new FileReader();
reader.onload = async (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();
};
reader.readAsText(selectedFile);
};
return (
<div className="p-8">
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Data Sync</h1>
<p className="text-muted-foreground mt-1">
Import and export sports data between environments
</p>
</div>
{actionData?.success && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-6">
{actionData.message || "Operation completed successfully!"}
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
{actionData.error}
</div>
)}
<div className="grid gap-6 md:grid-cols-3 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sports</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sports Seasons</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Templates</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.templatesCount}</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Export Data</CardTitle>
<CardDescription>
Download all sports data as a JSON file
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This will export all sports, sports seasons, participants, participant EV/futures data, and season templates.
</p>
<Button
onClick={handleExport}
disabled={isExporting}
className="w-full"
>
<Download className="mr-2 h-4 w-4" />
{isExporting ? "Exporting..." : "Export Data"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Import Data</CardTitle>
<CardDescription>
Upload a JSON file to import sports data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file">Select File</Label>
<input
id="file"
type="file"
accept=".json"
onChange={handleFileChange}
className="block w-full text-sm text-muted-foreground
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-primary file:text-primary-foreground
hover:file:bg-primary/90"
/>
</div>
<div className="space-y-2">
<Label>Import Mode</Label>
<div className="flex gap-2">
<Button
type="button"
variant={importMode === "merge" ? "default" : "outline"}
size="sm"
onClick={() => setImportMode("merge")}
>
Merge
</Button>
<Button
type="button"
variant={importMode === "replace" ? "default" : "outline"}
size="sm"
onClick={() => setImportMode("replace")}
>
Replace
</Button>
</div>
<p className="text-xs text-muted-foreground">
{importMode === "merge"
? "Updates existing records and adds new ones (safe)"
: "Deletes all existing data and recreates from file (destructive)"}
</p>
</div>
{importMode === "replace" ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={!selectedFile}
variant="destructive"
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
Import Data (Replace)
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will <strong>delete all existing sports data</strong> and replace it
with the data from the file. This action cannot be undone.
<br /><br />
This includes:
<ul className="list-disc list-inside mt-2">
<li>All sports</li>
<li>All sports seasons</li>
<li>All participants</li>
<li>All participant EV/futures data</li>
<li>All season templates</li>
</ul>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleImport}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, Replace All Data
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
onClick={handleImport}
disabled={!selectedFile}
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
Import Data (Merge)
</Button>
)}
</CardContent>
</Card>
</div>
<Card className="mt-6 border-electric/30 bg-electric/10">
<CardHeader>
<CardTitle className="text-electric">How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm text-foreground/80 space-y-3">
<div>
<strong>Export:</strong> Downloads all sports data as a JSON file that you can
save, version control, or transfer to another environment.
</div>
<div>
<strong>Import (Merge):</strong> Updates existing records and adds new ones.
Safe to use - won't delete anything.
</div>
<div>
<strong>Import (Replace):</strong> Deletes all existing sports data and recreates
it from the file. Use this for a clean slate.
</div>
<div className="pt-2 border-t border-electric/30">
<strong>Typical workflow:</strong>
<ol className="list-decimal list-inside mt-1 space-y-1">
<li>Export data from development</li>
<li>Download the JSON file</li>
<li>Upload it to production</li>
<li>Import using merge mode</li>
</ol>
</div>
</CardContent>
</Card>
</div>
</div>
);
}