2026-03-21 00:12:01 -07:00
|
|
|
|
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)
Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.
**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field
**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict
better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
|
|
|
|
import { auth } from "~/lib/auth.server";
|
|
|
|
|
|
import { isUserAdmin } from "~/models/user";
|
2025-10-12 21:54:49 -07:00
|
|
|
|
import type { Route } from "./+types/admin.sports-seasons.$id";
|
2026-03-10 12:10:52 -07:00
|
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
|
import { logger } from "~/lib/logger";
|
2026-03-21 09:44:05 -07:00
|
|
|
|
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewSportsSeason } from "~/models/sports-season";
|
2026-03-17 14:34:09 -07:00
|
|
|
|
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
2026-03-18 22:15:28 -07:00
|
|
|
|
import { createDailySnapshot } from "~/models/standings";
|
2026-03-09 15:34:31 -07:00
|
|
|
|
import { database } from "~/database/context";
|
2026-03-17 14:34:09 -07:00
|
|
|
|
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
2026-03-09 15:34:31 -07:00
|
|
|
|
import { eq, desc } from "drizzle-orm";
|
|
|
|
|
|
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
2026-03-21 00:12:01 -07:00
|
|
|
|
import { syncStandings } from "~/services/standings-sync/index";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getPendingStandingsMappings,
|
|
|
|
|
|
deletePendingStandingsMapping,
|
|
|
|
|
|
} from "~/models/pending-standings-mappings";
|
Canonical tournament layer: schema + backfill (1/2) (#365)
* refactor(schema): rename per-window tables to season_* prefix
Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: rename participant.ts model file to season-participant.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(models): update model layer to use renamed schema exports
Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant
Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts
Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route layer to use renamed schema exports
- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file
Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts
Error count reduced from 499 to 453 (46 errors fixed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(routes): update route files for schema rename
Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(services): update simulators and services for renamed schema
Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId
Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts
Typecheck errors reduced from 365 to 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: rename per-window tables to season_* prefix
* fix(tests): update mock query keys after participants table rename
Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.
Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(tests): update remaining mock paths and keys after schema rename
* fix(tests): final two mock stragglers after schema rename
- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: add post-phase1a baseline capture (temp, for diff verification)
* chore: capture pre-migration baselines
* chore: remove post-phase1a capture helper after verification
* schema: add canonical tournament & participant tables
Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(models): add canonical tournament, participant, result, surface-elo models
Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).
Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
createCanonicalParticipant, etc.) to avoid collision with existing
season-participant.ts exports
- All four models include comprehensive unit tests following the
audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints
Part of Phase 1b of canonical tournament layer migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* migration: create canonical tables, add nullable FKs
* scripts: add extractTournamentIdentity helper for backfill
Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.
* scripts: add backfill orchestrator for canonical layer
Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.
Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
(recorded in report.errors) rather than overwriting
* scripts: add backfill CLI with dry-run default
Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).
* fix(backfill-cli): wrap runBackfill in DatabaseContext.run
The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backfill-cli): exit 0 on success so pg pool doesn't block
The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00
|
|
|
|
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
2026-03-21 09:44:05 -07:00
|
|
|
|
import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
2025-10-12 21:54:49 -07:00
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
|
|
|
|
import { Input } from "~/components/ui/input";
|
|
|
|
|
|
import { Label } from "~/components/ui/label";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
CardContent,
|
|
|
|
|
|
CardDescription,
|
|
|
|
|
|
CardHeader,
|
|
|
|
|
|
CardTitle,
|
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "~/components/ui/select";
|
|
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog,
|
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
|
} from "~/components/ui/alert-dialog";
|
2026-03-07 21:59:29 -08:00
|
|
|
|
import { Badge } from "~/components/ui/badge";
|
2026-05-02 22:19:59 -07:00
|
|
|
|
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
2025-11-11 10:08:25 -08:00
|
|
|
|
import { useState } from "react";
|
2025-10-12 21:54:49 -07:00
|
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
|
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
|
|
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
|
|
|
|
|
|
|
|
|
|
if (!sportsSeason) {
|
|
|
|
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
|
// Get the most recent snapshot date (if any)
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const lastSnapshot = await db
|
|
|
|
|
|
.select({ snapshotDate: participantEvSnapshots.snapshotDate })
|
|
|
|
|
|
.from(participantEvSnapshots)
|
|
|
|
|
|
.where(eq(participantEvSnapshots.sportsSeasonId, params.id))
|
|
|
|
|
|
.orderBy(desc(participantEvSnapshots.snapshotDate))
|
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
|
|
const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
const simulatorInfo = sportsSeason.sport?.simulatorType
|
|
|
|
|
|
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
2026-03-21 00:12:01 -07:00
|
|
|
|
const lastStandingsSyncedAt =
|
|
|
|
|
|
sportsSeason.sport?.type === "team"
|
|
|
|
|
|
? await getLastSyncedAt(params.id)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
|
|
const pendingMappings =
|
|
|
|
|
|
sportsSeason.sport?.type === "team"
|
|
|
|
|
|
? await getPendingStandingsMappings(params.id)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
|
return {
|
|
|
|
|
|
sportsSeason,
|
|
|
|
|
|
participants,
|
|
|
|
|
|
lastSimulatedDate,
|
|
|
|
|
|
simulatorInfo,
|
2026-03-21 00:12:01 -07:00
|
|
|
|
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
|
|
|
|
|
pendingMappings,
|
2025-10-12 21:54:49 -07:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
|
|
|
|
const { request, params } = args;
|
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)
Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.
**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field
**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict
better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
|
|
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
|
|
|
|
const userId = session?.user.id ?? null;
|
|
|
|
|
|
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
2026-03-17 14:34:09 -07:00
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
|
throw new Response("Forbidden", { status: 403 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
|
const formData = await request.formData();
|
|
|
|
|
|
const intent = formData.get("intent");
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "delete") {
|
|
|
|
|
|
await deleteSportsSeason(params.id);
|
|
|
|
|
|
return redirect("/admin/sports-seasons");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
|
if (intent === "rescore") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const links = await db.query.seasonSports.findMany({
|
|
|
|
|
|
where: eq(seasonSports.sportsSeasonId, params.id),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (links.length === 0) {
|
|
|
|
|
|
return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." };
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db)));
|
2026-03-18 22:15:28 -07:00
|
|
|
|
await Promise.all(links.map((link) => createDailySnapshot(link.seasonId, db)));
|
2026-03-17 14:34:09 -07:00
|
|
|
|
return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` };
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error rescoring:", error);
|
2026-03-17 14:34:09 -07:00
|
|
|
|
return { error: "Failed to rescore. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 00:12:01 -07:00
|
|
|
|
if (intent === "sync-standings") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await syncStandings(params.id);
|
|
|
|
|
|
return { success: true, intent: "sync-standings", syncResult: result };
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error syncing standings:", error);
|
2026-03-21 00:12:01 -07:00
|
|
|
|
return {
|
|
|
|
|
|
syncError:
|
|
|
|
|
|
error instanceof Error ? error.message : "Failed to sync standings. Please try again.",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "resolve-mapping") {
|
|
|
|
|
|
const externalTeamId = formData.get("externalTeamId");
|
|
|
|
|
|
const participantId = formData.get("participantId");
|
|
|
|
|
|
const standingDataRaw = formData.get("standingData");
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof externalTeamId !== "string" ||
|
|
|
|
|
|
!externalTeamId.trim() ||
|
|
|
|
|
|
typeof participantId !== "string" ||
|
|
|
|
|
|
!participantId.trim() ||
|
|
|
|
|
|
typeof standingDataRaw !== "string"
|
|
|
|
|
|
) {
|
|
|
|
|
|
return { error: "Invalid resolve-mapping payload." };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
|
|
// Write externalId onto the participant for future ID-first matching
|
|
|
|
|
|
await updateParticipant(participantId, { externalId: externalTeamId });
|
|
|
|
|
|
|
|
|
|
|
|
// Upsert the standing record from the stored standingData
|
|
|
|
|
|
await upsertRegularSeasonStandings([
|
|
|
|
|
|
{
|
|
|
|
|
|
participantId,
|
|
|
|
|
|
sportsSeasonId: params.id,
|
|
|
|
|
|
wins: (standingData.wins as number) ?? 0,
|
|
|
|
|
|
losses: (standingData.losses as number) ?? 0,
|
|
|
|
|
|
otLosses: (standingData.otLosses as number | null) ?? null,
|
|
|
|
|
|
ties: (standingData.ties as number | null) ?? null,
|
|
|
|
|
|
winPct: (standingData.winPct as number) ?? 0,
|
|
|
|
|
|
gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
|
|
|
|
|
|
gamesBack: (standingData.gamesBack as number | null) ?? null,
|
|
|
|
|
|
conference: (standingData.conference as string | null) ?? null,
|
|
|
|
|
|
division: (standingData.division as string | null) ?? null,
|
|
|
|
|
|
conferenceRank: (standingData.conferenceRank as number | null) ?? null,
|
|
|
|
|
|
divisionRank: (standingData.divisionRank as number | null) ?? null,
|
|
|
|
|
|
leagueRank: (standingData.leagueRank as number) ?? 0,
|
|
|
|
|
|
streak: (standingData.streak as string | null) ?? null,
|
|
|
|
|
|
lastTen: (standingData.lastTen as string | null) ?? null,
|
|
|
|
|
|
homeRecord: (standingData.homeRecord as string | null) ?? null,
|
|
|
|
|
|
awayRecord: (standingData.awayRecord as string | null) ?? null,
|
|
|
|
|
|
externalTeamId,
|
|
|
|
|
|
syncedAt: new Date(),
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Remove from pending queue
|
|
|
|
|
|
await deletePendingStandingsMapping(params.id, externalTeamId);
|
|
|
|
|
|
|
|
|
|
|
|
return { success: true, intent: "resolve-mapping", resolvedTeam: String(standingData.teamName ?? "") };
|
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error resolving mapping:", error);
|
2026-03-21 00:12:01 -07:00
|
|
|
|
return { error: "Failed to resolve mapping. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 21:59:29 -08:00
|
|
|
|
if (intent === "finalize-standings") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await processSeasonStandings(params.id);
|
|
|
|
|
|
await updateSportsSeason(params.id, { status: "completed" });
|
2026-03-17 14:34:09 -07:00
|
|
|
|
return { success: true, intent: "finalize-standings", message: "Standings finalized and fantasy placements assigned!" };
|
2026-03-07 21:59:29 -08:00
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error finalizing standings:", error);
|
2026-03-07 21:59:29 -08:00
|
|
|
|
return { error: "Failed to finalize standings. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
|
// Update
|
|
|
|
|
|
const name = formData.get("name");
|
|
|
|
|
|
const year = formData.get("year");
|
|
|
|
|
|
const startDate = formData.get("startDate");
|
|
|
|
|
|
const endDate = formData.get("endDate");
|
|
|
|
|
|
const status = formData.get("status");
|
|
|
|
|
|
const scoringType = formData.get("scoringType");
|
2025-11-11 10:08:25 -08:00
|
|
|
|
const scoringPattern = formData.get("scoringPattern");
|
|
|
|
|
|
const totalMajors = formData.get("totalMajors");
|
2026-04-05 19:09:52 -07:00
|
|
|
|
const draftOn = formData.get("draftOn");
|
|
|
|
|
|
const draftOff = formData.get("draftOff");
|
2025-10-12 21:54:49 -07:00
|
|
|
|
|
|
|
|
|
|
// Validation
|
|
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
|
|
|
|
return { error: "Season name is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof year !== "string") {
|
|
|
|
|
|
return { error: "Year is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const yearNum = parseInt(year, 10);
|
|
|
|
|
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
|
|
|
|
|
return { error: "Year must be between 2000 and 2100" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status !== "upcoming" && status !== "active" && status !== "completed") {
|
|
|
|
|
|
return { error: "Invalid status" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (scoringType !== "playoffs" && scoringType !== "regular_season" && scoringType !== "majors") {
|
|
|
|
|
|
return { error: "Invalid scoring type" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 21:59:29 -08:00
|
|
|
|
const validScoringPatterns = ["playoff_bracket", "season_standings", "qualifying_points"];
|
2025-11-11 10:08:25 -08:00
|
|
|
|
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
|
|
|
|
|
return { error: "Invalid scoring pattern" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 19:09:52 -07:00
|
|
|
|
if (typeof draftOn !== "string" || !draftOn) {
|
|
|
|
|
|
return { error: "Draft open date is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof draftOff !== "string" || !draftOff) {
|
|
|
|
|
|
return { error: "Draft close date is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (draftOff < draftOn) {
|
|
|
|
|
|
return { error: "Draft close date must be on or after draft open date" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
|
try {
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const updateData: Partial<NewSportsSeason> = {
|
2025-10-12 21:54:49 -07:00
|
|
|
|
name: name.trim(),
|
|
|
|
|
|
year: yearNum,
|
|
|
|
|
|
startDate: typeof startDate === "string" && startDate ? startDate : null,
|
|
|
|
|
|
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
|
|
|
|
|
status,
|
|
|
|
|
|
scoringType,
|
2026-04-05 19:09:52 -07:00
|
|
|
|
draftOn,
|
|
|
|
|
|
draftOff,
|
2025-11-11 10:08:25 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (scoringPattern && typeof scoringPattern === "string") {
|
2026-03-21 09:44:05 -07:00
|
|
|
|
updateData.scoringPattern = scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points";
|
2025-11-11 10:08:25 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (totalMajors && typeof totalMajors === "string") {
|
|
|
|
|
|
const totalMajorsNum = parseInt(totalMajors, 10);
|
|
|
|
|
|
if (!isNaN(totalMajorsNum) && totalMajorsNum > 0) {
|
|
|
|
|
|
updateData.totalMajors = totalMajorsNum;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updateSportsSeason(params.id, updateData);
|
2025-10-12 21:54:49 -07:00
|
|
|
|
|
2026-03-07 21:59:29 -08:00
|
|
|
|
return { success: true, message: "Sports season updated successfully!" };
|
2025-10-12 21:54:49 -07:00
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
|
logger.error("Error updating sports season:", error);
|
2025-10-12 21:54:49 -07:00
|
|
|
|
return { error: "Failed to update sports season. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
2026-05-02 22:19:59 -07:00
|
|
|
|
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
2025-10-12 21:54:49 -07:00
|
|
|
|
const navigate = useNavigate();
|
2026-03-21 00:12:01 -07:00
|
|
|
|
const navigation = useNavigation();
|
|
|
|
|
|
const isSyncingStandings =
|
|
|
|
|
|
navigation.state === "submitting" &&
|
|
|
|
|
|
(navigation.formData?.get("intent") as string) === "sync-standings";
|
2025-11-11 10:08:25 -08:00
|
|
|
|
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
2025-10-12 21:54:49 -07:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-8">
|
|
|
|
|
|
<div className="max-w-2xl">
|
2026-04-12 01:03:37 -04:00
|
|
|
|
<div className="mb-6 flex items-start justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-3xl font-bold">Edit Sports Season</h1>
|
|
|
|
|
|
<p className="text-muted-foreground mt-1">
|
|
|
|
|
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button variant="outline" asChild>
|
|
|
|
|
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}/clone`}>
|
|
|
|
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Clone Season
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</Button>
|
2025-10-12 21:54:49 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Sports Season Details</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Update the information for this sports season
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<Form method="post" className="space-y-6">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="name">Season Name</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="name"
|
|
|
|
|
|
name="name"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
defaultValue={sportsSeason.name}
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="year">Year</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="year"
|
|
|
|
|
|
name="year"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="2000"
|
|
|
|
|
|
max="2100"
|
|
|
|
|
|
defaultValue={sportsSeason.year}
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="startDate">Start Date (Optional)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="startDate"
|
|
|
|
|
|
name="startDate"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
defaultValue={sportsSeason.startDate || ""}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="endDate">End Date (Optional)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="endDate"
|
|
|
|
|
|
name="endDate"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
defaultValue={sportsSeason.endDate || ""}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="status">Status</Label>
|
|
|
|
|
|
<Select name="status" defaultValue={sportsSeason.status} required>
|
|
|
|
|
|
<SelectTrigger id="status">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="upcoming">Upcoming</SelectItem>
|
|
|
|
|
|
<SelectItem value="active">Active</SelectItem>
|
|
|
|
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="scoringType">Scoring Type</Label>
|
|
|
|
|
|
<Select name="scoringType" defaultValue={sportsSeason.scoringType} required>
|
|
|
|
|
|
<SelectTrigger id="scoringType">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="playoffs">Playoffs</SelectItem>
|
|
|
|
|
|
<SelectItem value="regular_season">Regular Season</SelectItem>
|
|
|
|
|
|
<SelectItem value="majors">Majors</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
|
|
|
|
|
</p>
|
2025-10-12 21:54:49 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
|
|
|
|
|
<Select name="scoringPattern" value={scoringPattern} onValueChange={setScoringPattern}>
|
|
|
|
|
|
<SelectTrigger id="scoringPattern">
|
|
|
|
|
|
<SelectValue placeholder="Select scoring pattern (optional)" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-03-07 21:59:29 -08:00
|
|
|
|
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
2025-11-11 10:08:25 -08:00
|
|
|
|
<SelectItem value="season_standings">Season Standings</SelectItem>
|
|
|
|
|
|
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{scoringPattern === "qualifying_points" && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="totalMajors">Total Majors</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="totalMajors"
|
|
|
|
|
|
name="totalMajors"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
max="10"
|
|
|
|
|
|
defaultValue={sportsSeason.totalMajors || 4}
|
|
|
|
|
|
placeholder="e.g., 4 (for Golf)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-05 19:09:52 -07:00
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="draftOn">Draft Open Date</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="draftOn"
|
|
|
|
|
|
name="draftOn"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
defaultValue={sportsSeason.draftOn}
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="draftOff">Draft Close Date</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="draftOff"
|
|
|
|
|
|
name="draftOff"
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
defaultValue={sportsSeason.draftOff}
|
|
|
|
|
|
required
|
2026-03-18 22:40:19 -07:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-05 19:09:52 -07:00
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
This season appears in league creation and pre-draft settings only between these two dates (inclusive).
|
|
|
|
|
|
</p>
|
2026-03-18 22:40:19 -07:00
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
|
{actionData?.error && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
{actionData.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{actionData?.success && (
|
2026-02-20 19:26:11 -08:00
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
2026-03-07 21:59:29 -08:00
|
|
|
|
{actionData.message}
|
2025-10-12 21:54:49 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-4">
|
|
|
|
|
|
<Button type="submit" className="flex-1">
|
|
|
|
|
|
Save Changes
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" variant="outline" asChild>
|
|
|
|
|
|
<Link to="/admin/sports-seasons">Cancel</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>Participants</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{participants.length} {participants.length === 1 ? "participant" : "participants"}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/participants`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Users className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Manage Participants
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{participants.length === 0 ? (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
No participants added yet. Add teams or players to this season.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{participants.slice(0, 5).map((participant) => (
|
|
|
|
|
|
<div key={participant.id} className="text-sm">
|
|
|
|
|
|
{participant.name}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{participants.length > 5 && (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
And {participants.length - 5} more...
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>Expected Values</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
{simulatorInfo
|
|
|
|
|
|
? simulatorInfo.name
|
|
|
|
|
|
: "Manage probability distributions and projected points"}
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{sportsSeason.simulationStatus === "failed" && (
|
|
|
|
|
|
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
|
|
|
|
|
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
|
|
|
|
Last run failed
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
2025-11-17 22:19:46 -08:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Calculator className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Futures Odds
|
|
|
|
|
|
</Button>
|
2026-03-23 08:24:28 -07:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Calculator className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Elo Ratings
|
|
|
|
|
|
</Button>
|
Add tennis Grand Slam simulator with surface Elo ratings, fixes #116 (#216)
Implements a Monte Carlo simulator for men's/women's tennis seasons scored
on the qualifying_points pattern. Simulates all 4 Grand Slam majors
(Australian Open, French Open, Wimbledon, US Open) using surface-specific
Elo ratings and ATP/WTA world rankings for seeding.
New table: participant_surface_elos — one row per (participant, season)
storing worldRanking, eloHard, eloClay, eloGrass.
Key design decisions:
- Seeding uses ATP/WTA world ranking (not Elo), matching real draw procedure
- Top 32 seeded with standard slot placement (1→0, 2→64, 3-4→quarters, etc.)
- QP per round with tie-splitting pre-applied: W=20, F=14, SF=9, QF=4, R16=1.5
- Completed majors read actual qualifyingPointsAwarded from eventResults
- 10,000 Monte Carlo simulations; column sums naturally 1.0 (no normalization)
Admin UI at /admin/sports-seasons/:id/surface-elo:
- 5-column grid (Player | Rank | Hard | Clay | Grass)
- Bulk import: "Name, ranking, hardElo, clayElo, grassElo" one per line
- Fuzzy name matching (bigram Dice coefficient) with "Did you mean?" suggestions
- Inline participant creation for unmatched names via useFetcher
- Saves Elos and auto-runs simulation on submit
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:59:35 -07:00
|
|
|
|
{sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/surface-elo`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Calculator className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Surface Elo
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
{sportsSeason.sport?.simulatorType === "golf_qualifying_points" && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/golf-skills`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Calculator className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Golf Skills
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-03-09 15:34:31 -07:00
|
|
|
|
{simulatorInfo && (
|
|
|
|
|
|
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={sportsSeason.simulationStatus === "running"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sportsSeason.simulationStatus === "running" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
|
Running...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Run Simulation
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-03-09 15:34:31 -07:00
|
|
|
|
{simulatorInfo
|
|
|
|
|
|
? <>
|
|
|
|
|
|
{simulatorInfo.description}.{" "}
|
|
|
|
|
|
{lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."}
|
|
|
|
|
|
{" "}Import futures odds first, then run the simulation to update EVs and save a snapshot.
|
|
|
|
|
|
</>
|
|
|
|
|
|
: "Import futures odds to set probability distributions."}
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</p>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>Scoring Events</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
2026-03-07 21:59:29 -08:00
|
|
|
|
Manage games, tournaments, and schedule entries
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/events`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trophy className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Manage Events
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-03-07 21:59:29 -08:00
|
|
|
|
Create playoff brackets, major tournaments, or import a race/game schedule.
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
</p>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-03-21 00:12:01 -07:00
|
|
|
|
{sportsSeason.sport?.type === "team" && (
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>Regular Season Standings</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Sync current W/L standings from the official API, or edit manually.
|
|
|
|
|
|
{lastStandingsSyncedAt && (
|
|
|
|
|
|
<span className="ml-1 text-muted-foreground">
|
|
|
|
|
|
Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/regular-standings`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Edit Manually
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="sync-standings" />
|
|
|
|
|
|
<Button type="submit" size="sm" disabled={isSyncingStandings}>
|
|
|
|
|
|
{isSyncingStandings ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
|
Syncing...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Sync Standings
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
{(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? (
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
Synced {actionData.syncResult.synced} team{actionData.syncResult.synced !== 1 ? "s" : ""} successfully.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{actionData.syncResult.unmatched.length > 0 && (
|
|
|
|
|
|
<div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
<p className="font-medium mb-1">
|
|
|
|
|
|
{actionData.syncResult.unmatched.length} team{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched to participants:
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
|
|
|
|
{actionData.syncResult.unmatched.map((u: { teamName: string; externalTeamId: string }) => (
|
|
|
|
|
|
<li key={u.externalTeamId}>{u.teamName}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p className="mt-2 text-xs">
|
|
|
|
|
|
Use the "Unmatched Teams" card below to assign these to participants. Future syncs will use the saved ID.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{actionData?.syncError && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
{actionData.syncError}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && (
|
|
|
|
|
|
<Card className="border-amber-500/30">
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
|
|
|
|
Unmatched Teams ({pendingMappings.length})
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
These teams from the last sync could not be automatically matched to a participant.
|
|
|
|
|
|
Assign each one to the correct participant to resolve.
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
|
{actionData?.success && actionData.intent === "resolve-mapping" && (
|
|
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
Resolved: {actionData.resolvedTeam}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{pendingMappings.map((mapping) => (
|
|
|
|
|
|
<Form key={mapping.externalTeamId} method="post" className="flex items-center gap-3">
|
|
|
|
|
|
<input type="hidden" name="intent" value="resolve-mapping" />
|
|
|
|
|
|
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="hidden"
|
|
|
|
|
|
name="standingData"
|
|
|
|
|
|
value={JSON.stringify(mapping.standingData)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<p className="text-sm font-medium truncate">{mapping.teamName}</p>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">ID: {mapping.externalTeamId}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Select name="participantId" required>
|
|
|
|
|
|
<SelectTrigger className="w-56">
|
|
|
|
|
|
<SelectValue placeholder="Select participant…" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{participants.map((p) => (
|
|
|
|
|
|
<SelectItem key={p.id} value={p.id}>
|
|
|
|
|
|
{p.name}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button type="submit" size="sm">
|
|
|
|
|
|
Resolve
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-07 21:59:29 -08:00
|
|
|
|
{sportsSeason.scoringPattern === "season_standings" && (
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle>Championship Standings</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Update participant positions and points as the season progresses
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/standings`)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Update Standings
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{sportsSeason.scoringPattern === "season_standings" && (
|
|
|
|
|
|
<Card className={sportsSeason.status === "completed" ? "border-emerald-500/30" : "border-amber-500/30"}>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
Finalize Standings
|
|
|
|
|
|
{sportsSeason.status === "completed" && (
|
|
|
|
|
|
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
|
|
|
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
|
|
|
|
Finalized
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{sportsSeason.status === "completed"
|
|
|
|
|
|
? "Season standings have been finalized and fantasy placements assigned."
|
|
|
|
|
|
: "When the championship is over, finalize standings to assign fantasy placements (1st–8th) to participants based on their final positions."}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
{sportsSeason.status !== "completed" && (
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{actionData?.error && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-4">
|
|
|
|
|
|
{actionData.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-17 14:34:09 -07:00
|
|
|
|
{actionData?.success && actionData.intent === "finalize-standings" && (
|
2026-03-07 21:59:29 -08:00
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
|
|
|
|
|
{actionData.message}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<AlertDialog>
|
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
|
<Button variant="outline" className="border-amber-500/50 text-amber-600 hover:bg-amber-500/10 dark:text-amber-400">
|
|
|
|
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Finalize Standings
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Finalize championship standings?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This will read the current participant standings and assign fantasy placements (1st through 8th place). The season will be marked as completed. This action can be re-run to correct results if needed.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="finalize-standings" />
|
|
|
|
|
|
<AlertDialogAction type="submit">
|
|
|
|
|
|
Finalize Standings
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Fantasy Standings</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Force a recalculation of all fantasy standings linked to this sports season. Use this after fixing scoring bugs or data corrections.
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{actionData?.success && actionData.intent === "rescore" && (
|
|
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
|
|
|
|
|
{actionData.message}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="rescore" />
|
|
|
|
|
|
<Button type="submit" variant="outline" className="border-blue-500/50 text-blue-600 hover:bg-blue-500/10 dark:text-blue-400">
|
|
|
|
|
|
Force Re-score
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-12 21:54:49 -07:00
|
|
|
|
<Card className="border-destructive">
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Permanently delete this sports season
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<AlertDialog>
|
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
|
<Button variant="destructive">
|
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
|
Delete Sports Season
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This will permanently delete the sports season "{sportsSeason.name}" and all
|
|
|
|
|
|
associated participants and results. This action cannot be undone.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="delete" />
|
|
|
|
|
|
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
|
|
|
|
Delete
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|