brackt/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts

454 lines
15 KiB
TypeScript
Raw Normal View History

import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
import { logger } from "~/lib/logger";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId, createParticipant } from "~/models/season-participant";
import {
getScoringEventById,
completeScoringEvent,
updateScoringEvent,
} from "~/models/scoring-event";
import {
getEventResults,
createEventResult,
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
createEventResultsBulk,
updateEventResult,
deleteEventResult,
type CreateEventResultData,
type UpdateEventResultData,
} from "~/models/event-result";
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
import { filterNewResults, findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import {
upsertParticipantSeasonResult,
getSeasonResults,
} from "~/models/participant-season-result";
import { processSeasonStandings, processQualifyingEvent } from "~/models/scoring-calculator";
import { getQPStandings, getQPConfig } from "~/models/qualifying-points";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { upsertTournamentResult } from "~/models/tournament-result";
import { syncTournamentResults } from "~/services/sync-tournament-results";
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 event = await getScoringEventById(params.eventId);
if (!event) {
throw new Response("Event not found", { status: 404 });
}
const participants = await findParticipantsBySportsSeasonId(params.id);
const results = await getEventResults(params.eventId);
const participantResults = await findParticipantResultsBySportsSeasonId(params.id);
// For final_standings events, also get season results
let seasonResults = null;
if (event.eventType === "final_standings") {
seasonResults = await getSeasonResults(params.id);
}
// For qualifying events, get QP config
let qpConfig = null;
if (event.isQualifyingEvent) {
qpConfig = await getQPConfig(params.id);
}
// For qualifying sports seasons, get QP standings
let qpStandings = null;
if (sportsSeason.scoringPattern === "qualifying_points") {
qpStandings = await getQPStandings(params.id);
}
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
Add CS2 Major Qualifying Points simulator and stage management (#260) * Add CS2 Major qualifying points simulator Implements a full CS2 Major tournament simulator with: - 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3) + Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5) - Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season - Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank - Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3) - Stage assignments stored per-event so actual field composition drives simulation - Admin CS Elo form for entering team Elo + HLTV world rankings - Admin CS2 stage setup page for assigning teams to stages and tracking advancement - Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table - 24 unit tests covering all exported pure functions https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate Elo + ranking input into generic elo-ratings page The darts-elo and cs-elo pages were unreachable from the admin nav, which always links to the generic elo-ratings page. Extended elo-ratings to conditionally show world ranking fields for simulator types that need it (darts_bracket, cs2_major_qualifying_points), then deleted the redundant sport-specific pages. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate server postgres connections into one shared pool Four separate postgres() clients were open simultaneously (app, timer, snapshots, socket), each defaulting to 10 connections, exhausting the database's max_connections limit. Replaced with a single shared lazy- initialized client in server/db.ts using a Proxy to defer the DATABASE_URL check until first use (preserving test compatibility). Also bumps the CS2 Champions Stage stochastic test from 200 → 1000 iterations to eliminate flakiness. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix and() bug and add Swiss loop safety guard - cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements were using JS && instead of Drizzle and(), causing WHERE to filter only by participantId (not scoringEventId), which would update rows across all events instead of just the target event - cs-major-simulator.ts: add break guard in simulateSwiss while loop to prevent infinite loop if pairGroups returns no pairs https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix all remaining code review issues - cs2-major-stage.ts: use schema column reference for stageEliminated in markCs2StageEliminations instead of raw SQL string - cs-major-simulator.ts: simulateOneMajor now locks in known stage results when a stage is complete (8 recorded eliminations), only simulating the remaining stages during live events - admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points simulator types; expose simulatorType in server loader type cast - cs2-setup.tsx: replace document.getElementById DOM manipulation with React state (eliminatedChecked map) for checkbox show/hide logic; remove unused stageMap and unassignedParticipants variables https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix oxlint errors: non-null assertions, sort→toSorted, unused vars - cs-major-simulator.ts: replace 5 non-null assertions (!) with safe optional chaining / if-guards; replace 6 .sort() with .toSorted() - cs2-major-stage.ts: remove unused `inArray` import - cs2-setup.tsx: remove unused `assignedIds` variable https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix flaky Champions Stage stochastic test The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730). With the Champions Stage bracket math this gives team-0 a ~19.6% win rate — right at the 0.2 threshold, causing the test to fail ~63% of the time in CI despite 1000 iterations. Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate and raising the assertion threshold to 0.25 for a clear safety margin. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 13:40:05 -07:00
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
},
event,
participants,
results,
participantResults,
seasonResults,
qpConfig,
qpStandings,
};
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "mark-qualifying") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
// Update the event to mark it as a qualifying event
const db = database();
await db.update(schema.scoringEvents)
.set({ isQualifyingEvent: true })
.where(eq(schema.scoringEvents.id, params.eventId));
return { success: "Event marked as qualifying event!" };
} catch (error) {
logger.error("Error marking event as qualifying:", error);
return { error: "Failed to mark event as qualifying" };
}
}
if (intent === "process-qp") {
try {
await processQualifyingEvent(params.eventId);
return { success: "Qualifying points processed and awarded!" };
} catch (error) {
logger.error("Error processing qualifying points:", error);
return { error: error instanceof Error ? error.message : "Failed to process qualifying points" };
}
}
if (intent === "complete") {
try {
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
await completeScoringEvent(params.eventId);
// If this is a final_standings event, process season standings
if (event.eventType === "final_standings") {
await processSeasonStandings(params.id);
return { success: "Event completed and fantasy placements assigned to top 8!" };
}
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
// If this is a qualifying event, process qualifying points then fill 0-QP rows
if (event.isQualifyingEvent) {
await processQualifyingEvent(params.eventId);
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
// Write 0-QP rows for all season participants who have no result for this event.
// This marks them as "competed, earned nothing" so simulations skip this event.
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const existingResults = await getEventResults(params.eventId);
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId));
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
const missingIds = findParticipantsWithoutResults(
allParticipants.map((p) => p.id),
existingIds
);
if (missingIds.length > 0) {
await createEventResultsBulk(
missingIds.map((participantId) => ({
scoringEventId: params.eventId,
participantId,
qualifyingPointsAwarded: 0,
}))
);
}
return { success: "Event completed and qualifying points awarded!" };
}
return { success: "Event marked as completed" };
} catch (error) {
logger.error("Error completing event:", error);
return { error: "Failed to complete event" };
}
}
if (intent === "uncomplete") {
try {
await updateScoringEvent(params.eventId, { isComplete: false });
return { success: "Event marked as not updated" };
} catch (error) {
logger.error("Error uncompleting event:", error);
return { error: "Failed to update event" };
}
}
if (intent === "update-event") {
const name = formData.get("name");
const eventStartsAtRaw = formData.get("eventStartsAt");
if (typeof name !== "string" || !name.trim()) {
return { error: "Event name is required" };
}
// eventStartsAt is a UTC ISO string from the client; empty string means "clear it"
const eventStartsAt: Date | null | undefined =
typeof eventStartsAtRaw === "string"
? eventStartsAtRaw
? new Date(eventStartsAtRaw)
: null
: undefined;
// Derive eventDate from eventStartsAt; null when cleared
const eventDate: Date | null | undefined =
eventStartsAt !== undefined
? eventStartsAt
? new Date(eventStartsAt.toISOString().split("T")[0])
: null
: undefined;
try {
await updateScoringEvent(params.eventId, {
name: name.trim(),
eventDate,
eventStartsAt,
});
return { success: "Event updated" };
} catch (error) {
logger.error("Error updating event:", error);
return { error: "Failed to update event" };
}
}
if (intent === "add-result") {
const participantId = formData.get("participantId");
const placement = formData.get("placement");
if (typeof participantId !== "string" || !participantId) {
return { error: "Participant is required" };
}
if (typeof placement !== "string" || !placement) {
return { error: "Placement is required" };
}
const placementNum = parseInt(placement, 10);
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
return { error: "Placement must be between 1 and 100" };
}
const resultData: CreateEventResultData = {
scoringEventId: params.eventId,
participantId,
placement: placementNum,
};
try {
await createEventResult(resultData);
return { success: "Result added successfully" };
} catch (error) {
logger.error("Error adding result:", error);
return { error: "Failed to add result" };
}
}
if (intent === "update-result") {
const resultId = formData.get("resultId");
const placement = formData.get("placement");
if (typeof resultId !== "string" || !resultId) {
return { error: "Result ID is required" };
}
if (typeof placement !== "string" || !placement) {
return { error: "Placement is required" };
}
const placementNum = parseInt(placement, 10);
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
return { error: "Placement must be between 1 and 100" };
}
const updateData: UpdateEventResultData = {
placement: placementNum,
};
try {
await updateEventResult(resultId, updateData);
return { success: "Result updated successfully" };
} catch (error) {
logger.error("Error updating result:", error);
return { error: "Failed to update result" };
}
}
if (intent === "delete-result") {
const resultId = formData.get("resultId");
if (typeof resultId !== "string" || !resultId) {
return { error: "Result ID is required" };
}
try {
await deleteEventResult(resultId);
return { success: "Result deleted successfully" };
} catch (error) {
logger.error("Error deleting result:", error);
return { error: "Failed to delete result" };
}
}
if (intent === "update-standings") {
// Bulk update season standings for final_standings events
try {
// Parse all form fields and collect participants with points
const participantPoints: Array<{ participantId: string; points: number }> = [];
for (const [key, value] of formData.entries()) {
if (key.startsWith("points-")) {
const participantId = key.replace("points-", "");
const points = value ? parseFloat(value as string) : 0;
if (points > 0) {
participantPoints.push({ participantId, points });
}
}
}
// Sort by points descending to determine positions
participantPoints.sort((a, b) => b.points - a.points);
// Assign positions based on sorted order and update
for (let i = 0; i < participantPoints.length; i++) {
const { participantId, points } = participantPoints[i];
const position = i + 1; // Position is 1-based
await upsertParticipantSeasonResult(
{
participantId,
sportsSeasonId: params.id,
currentPoints: points,
currentPosition: position,
}
);
}
// Also update participants with 0 or no points (they don't have a position)
for (const [key, value] of formData.entries()) {
if (key.startsWith("points-")) {
const participantId = key.replace("points-", "");
const points = value ? parseFloat(value as string) : 0;
if (points === 0) {
await upsertParticipantSeasonResult(
{
participantId,
sportsSeasonId: params.id,
currentPoints: 0,
currentPosition: undefined,
}
);
}
}
}
return { success: `Updated standings for ${participantPoints.length} participants (positions auto-calculated from points)` };
} catch (error) {
logger.error("Error updating season standings:", error);
return { error: "Failed to update season standings" };
}
}
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
if (intent === "batch-add-results") {
const resultsJson = formData.get("results");
if (typeof resultsJson !== "string" || !resultsJson) {
return { error: "Results data is required" };
}
let incoming: { participantId: string; placement: number }[];
try {
incoming = JSON.parse(resultsJson);
} catch {
return { error: "Invalid results format" };
}
if (!Array.isArray(incoming) || incoming.length === 0) {
return { error: "No results provided" };
}
// Validate that all participant IDs belong to this sports season
const allSeasonParticipants = await findParticipantsBySportsSeasonId(params.id);
const validParticipantIds = new Set(allSeasonParticipants.map((p) => p.id));
const invalidEntries = incoming.filter((r) => !validParticipantIds.has(r.participantId));
if (invalidEntries.length > 0) {
return { error: "Some participant IDs do not belong to this sports season" };
}
try {
// If this scoring event is linked to a canonical tournament, write
// results to the canonical layer and fan out via syncTournamentResults.
// This keeps sibling windows (if any) in sync automatically.
const scoringEvent = await database().query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, params.eventId),
});
if (!scoringEvent) {
return { error: "Scoring event not found" };
}
// Qualifying events always have a canonical tournament link (check
// constraint). Write results canonically and fan out.
if (!scoringEvent.tournamentId) {
return {
error:
"This scoring event has no canonical tournament link — cannot save batch results. Contact a developer.",
};
}
const spRows = allSeasonParticipants.filter((sp) =>
incoming.some((r) => r.participantId === sp.id),
);
const missingCanonical = spRows.filter((sp) => !sp.participantId);
if (missingCanonical.length > 0) {
return {
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
};
}
for (const row of incoming) {
const sp = spRows.find((s) => s.id === row.participantId);
if (!sp?.participantId) continue;
await upsertTournamentResult({
tournamentId: scoringEvent.tournamentId,
participantId: sp.participantId,
placement: row.placement,
});
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
}
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
return {
success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
syncReport,
feat: batch qualifying results entry (#290) Fixes #289 * docs: add batch qualifying results entry design spec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add batch qualifying results entry implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: ignore .worktrees directory * feat: add qualifying results text parser with tests * fix: handle no-space after dot separator, tighten T-prefix to uppercase only * feat: add batch-add-results server intent with duplicate filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add create-participant intent for inline participant creation * fix: trim sportsSeasonId validation in create-participant intent * feat: fill 0-QP rows for unplaced participants on qualifying event finalize Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: use bulk insert for 0-QP rows on qualifying event finalize * feat: add BatchResultEntry component for paste-and-parse batch result import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: disable add participant button while create request is in flight * feat: render BatchResultEntry on qualifying event pages Add BatchResultEntry component import and render it on event pages when the event is a qualifying event, not a final_standings/playoff_game type, and not yet complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address final review issues (sportsSeasonId authority, participant validation, CRLF, unused prop) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:52:22 -04:00
};
} catch (error) {
logger.error("Error saving batch results:", error);
return { error: "Failed to save results" };
}
}
if (intent === "create-participant") {
const name = formData.get("name");
if (typeof name !== "string" || !name.trim()) {
return { error: "Participant name is required" };
}
try {
const participant = await createParticipant({
name: name.trim(),
sportsSeasonId: params.id,
});
return {
success: `Participant "${participant.name}" created.`,
newParticipant: { id: participant.id, name: participant.name },
};
} catch (error) {
logger.error("Error creating participant:", error);
if (error instanceof Error && error.message.includes("participants_sports_season_name_unique")) {
return { error: `A participant named "${name.trim()}" already exists in this sports season.` };
}
return { error: "Failed to create participant" };
}
}
return { error: "Invalid action" };
}