feat: Implement season standings processing and participant result management for final standings events

This commit is contained in:
Chris Parsons 2025-11-08 22:35:07 -08:00
parent 5d0e6b999c
commit d926486934
7 changed files with 755 additions and 29 deletions

View file

@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
describe("Season Standings (F1 Pattern)", () => {
describe("Basic Placement Scoring", () => {
it("should calculate points for 1st place finisher", () => {
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(points).toBe(100);
});
it("should calculate points for 8th place finisher", () => {
const points = calculateFantasyPoints(8, DEFAULT_SCORING);
expect(points).toBe(15);
});
it("should return 0 points for finishers beyond 8th place", () => {
const points = calculateFantasyPoints(0, DEFAULT_SCORING);
expect(points).toBe(0);
});
});
describe("Tie Scenarios", () => {
it("should handle 2-way tie for 3rd place", () => {
// Two drivers tied for 3rd get average of 3rd and 4th place points
const points = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
// (50 + 40) / 2 = 45
expect(points).toBe(45);
});
it("should handle 3-way tie for 5th place", () => {
// Three drivers tied for 5th share 5th, 6th, and 7th place points
const points = calculateAveragedPoints([5, 6, 7], DEFAULT_SCORING);
// (25 + 25 + 15) / 3 = 21.67
expect(points).toBeCloseTo(21.67, 2);
});
it("should handle 4-way tie for 1st place", () => {
// Four drivers tied for 1st (unlikely but possible)
const points = calculateAveragedPoints([1, 2, 3, 4], DEFAULT_SCORING);
// (100 + 70 + 50 + 40) / 4 = 65
expect(points).toBe(65);
});
});
describe("Season Standings Simulation", () => {
it("should correctly score a complete F1-style season (no ties)", () => {
// Simulate final standings positions 1-10
const standings = [
{ driver: "Max Verstappen", position: 1, fantasyPoints: 100 },
{ driver: "Lewis Hamilton", position: 2, fantasyPoints: 70 },
{ driver: "Charles Leclerc", position: 3, fantasyPoints: 50 },
{ driver: "Lando Norris", position: 4, fantasyPoints: 40 },
{ driver: "Carlos Sainz", position: 5, fantasyPoints: 25 },
{ driver: "George Russell", position: 6, fantasyPoints: 25 },
{ driver: "Sergio Perez", position: 7, fantasyPoints: 15 },
{ driver: "Fernando Alonso", position: 8, fantasyPoints: 15 },
{ driver: "Oscar Piastri", position: 9, fantasyPoints: 0 }, // Beyond top 8
{ driver: "Pierre Gasly", position: 10, fantasyPoints: 0 }, // Beyond top 8
];
standings.forEach((entry) => {
const points = calculateFantasyPoints(entry.position, DEFAULT_SCORING);
expect(points).toBe(entry.fantasyPoints);
});
// Total points if one team drafted all top 8
const totalTop8 = 100 + 70 + 50 + 40 + 25 + 25 + 15 + 15;
expect(totalTop8).toBe(340);
});
it("should correctly score season with tied positions", () => {
// Simulate standings where 3rd, 4th, and 5th are tied
const standings = [
{ position: 1, expected: 100 },
{ position: 2, expected: 70 },
// Three tied for 3rd place - each gets placement 3
// When scoring, these will share 3rd, 4th, 5th
{ position: 3, base: 50 }, // Will be averaged: (50+40+25)/3
{ position: 3, base: 50 },
{ position: 3, base: 50 },
{ position: 6, expected: 25 },
{ position: 7, expected: 15 },
{ position: 8, expected: 15 },
];
// Verify first two positions
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
// Verify tied positions get averaged
const tiedPoints = calculateAveragedPoints([3, 4, 5], DEFAULT_SCORING);
expect(tiedPoints).toBeCloseTo(38.33, 2); // (50 + 40 + 25) / 3
// Verify remaining positions
expect(calculateFantasyPoints(6, DEFAULT_SCORING)).toBe(25);
expect(calculateFantasyPoints(7, DEFAULT_SCORING)).toBe(15);
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBe(15);
});
it("should handle ties that cross the 8th place boundary", () => {
// Simulate where 7th, 8th, and 9th are tied
// In practice, 7th and 8th would score, 9th would get 0
// First 7 and 8 get points
expect(calculateFantasyPoints(7, DEFAULT_SCORING)).toBe(15);
expect(calculateFantasyPoints(8, DEFAULT_SCORING)).toBe(15);
// If they share 7th and 8th place
const shared7and8 = calculateAveragedPoints([7, 8], DEFAULT_SCORING);
expect(shared7and8).toBe(15); // (15 + 15) / 2 = 15
// 9th gets 0
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
});
});
describe("Custom Scoring Rules", () => {
it("should work with custom F1 league scoring rules", () => {
const customScoring: ScoringRules = {
pointsFor1st: 150,
pointsFor2nd: 100,
pointsFor3rd: 75,
pointsFor4th: 60,
pointsFor5th: 50,
pointsFor6th: 40,
pointsFor7th: 30,
pointsFor8th: 20,
};
expect(calculateFantasyPoints(1, customScoring)).toBe(150);
expect(calculateFantasyPoints(8, customScoring)).toBe(20);
// Tied positions
const tied3rd = calculateAveragedPoints([3, 4], customScoring);
expect(tied3rd).toBe(67.5); // (75 + 60) / 2
});
});
describe("Edge Cases", () => {
it("should handle all 8 positions tied", () => {
// Extremely unlikely but mathematically possible
const allTied = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
expect(allTied).toBe(42.5);
});
it("should handle only top 4 finishing (others DNF/DQ)", () => {
// If only 4 participants finish the season
expect(calculateFantasyPoints(1, DEFAULT_SCORING)).toBe(100);
expect(calculateFantasyPoints(2, DEFAULT_SCORING)).toBe(70);
expect(calculateFantasyPoints(3, DEFAULT_SCORING)).toBe(50);
expect(calculateFantasyPoints(4, DEFAULT_SCORING)).toBe(40);
// Positions 5-8 would have no participants
// Any non-finishers get 0
expect(calculateFantasyPoints(0, DEFAULT_SCORING)).toBe(0);
});
it("should handle single participant season", () => {
// Only 1 participant completes the season
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(points).toBe(100);
});
});
});

View file

@ -0,0 +1,244 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray, desc } from "drizzle-orm";
export interface UpsertSeasonResultData {
participantId: string;
sportsSeasonId: string;
currentPoints?: number;
currentPosition?: number;
}
export interface UpdateSeasonResultData {
currentPoints?: number;
currentPosition?: number;
}
/**
* Upsert a participant's season result (for F1, etc.)
* Creates if doesn't exist, updates if it does
*/
export async function upsertParticipantSeasonResult(
data: UpsertSeasonResultData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Check if result exists
const existing = await db.query.participantSeasonResults.findFirst({
where: and(
eq(schema.participantSeasonResults.participantId, data.participantId),
eq(schema.participantSeasonResults.sportsSeasonId, data.sportsSeasonId)
),
});
if (existing) {
// Update existing
const [updated] = await db
.update(schema.participantSeasonResults)
.set({
currentPoints: data.currentPoints?.toString(),
currentPosition: data.currentPosition,
updatedAt: new Date(),
})
.where(eq(schema.participantSeasonResults.id, existing.id))
.returning();
return updated;
} else {
// Insert new
const [created] = await db
.insert(schema.participantSeasonResults)
.values({
participantId: data.participantId,
sportsSeasonId: data.sportsSeasonId,
currentPoints: data.currentPoints?.toString() || "0",
currentPosition: data.currentPosition,
})
.returning();
return created;
}
}
/**
* Bulk upsert season results for multiple participants
* Useful for entering standings for an entire F1 grid at once
*/
export async function upsertSeasonResultsBulk(
results: UpsertSeasonResultData[],
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const upserted = [];
for (const data of results) {
const result = await upsertParticipantSeasonResult(data, db);
upserted.push(result);
}
return upserted;
}
/**
* Get a participant's season result
*/
export async function getParticipantSeasonResult(
participantId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.participantSeasonResults.findFirst({
where: and(
eq(schema.participantSeasonResults.participantId, participantId),
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)
),
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
},
},
});
}
/**
* Get all season results for a sports season
* Returns participants ordered by current position (or points if position not set)
*/
export async function getSeasonResults(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const results = await db.query.participantSeasonResults.findMany({
where: eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId),
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
},
},
});
// Sort by position (nulls last), then by points descending
return results.sort((a, b) => {
if (a.currentPosition !== null && b.currentPosition !== null) {
return a.currentPosition - b.currentPosition;
}
if (a.currentPosition !== null) return -1;
if (b.currentPosition !== null) return 1;
// Both null positions, sort by points
const aPoints = parseFloat(a.currentPoints || "0");
const bPoints = parseFloat(b.currentPoints || "0");
return bPoints - aPoints;
});
}
/**
* Get season results for specific participants
*/
export async function getSeasonResultsForParticipants(
sportsSeasonId: string,
participantIds: string[],
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
if (participantIds.length === 0) return [];
return await db.query.participantSeasonResults.findMany({
where: and(
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId),
inArray(schema.participantSeasonResults.participantId, participantIds)
),
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
},
},
});
}
/**
* Update a participant's season result
*/
export async function updateParticipantSeasonResult(
participantId: string,
sportsSeasonId: string,
data: UpdateSeasonResultData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const [updated] = await db
.update(schema.participantSeasonResults)
.set({
currentPoints: data.currentPoints?.toString(),
currentPosition: data.currentPosition,
updatedAt: new Date(),
})
.where(
and(
eq(schema.participantSeasonResults.participantId, participantId),
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)
)
)
.returning();
return updated;
}
/**
* Delete all season results for a sports season
*/
export async function deleteSeasonResults(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
await db
.delete(schema.participantSeasonResults)
.where(eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId));
}
/**
* Check if a participant has a season result
*/
export async function hasParticipantSeasonResult(
participantId: string,
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<boolean> {
const db = providedDb || database();
const result = await db.query.participantSeasonResults.findFirst({
where: and(
eq(schema.participantSeasonResults.participantId, participantId),
eq(schema.participantSeasonResults.sportsSeasonId, sportsSeasonId)
),
});
return !!result;
}

View file

@ -2,6 +2,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules"; import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
/** /**
* Core scoring calculation engine * Core scoring calculation engine
@ -227,7 +228,11 @@ export async function finalizeQualifyingPoints(
/** /**
* Process season standings (F1) and assign final placements * Process season standings (F1) and assign final placements
* Implementation will be added in Phase 3 *
* Q7: For F1, we show current F1 points during the season, then assign fantasy points at the end
* Q8: Just final standings, not individual races
* Q14: Store current running total, manual update with API future
* Q13/Q19: Ties handled by admin entering same position, system auto-shares placements
*/ */
export async function processSeasonStandings( export async function processSeasonStandings(
sportsSeasonId: string, sportsSeasonId: string,
@ -235,14 +240,89 @@ export async function processSeasonStandings(
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
// TODO: Implement in Phase 3 // Get all season results for this sports season, sorted by standings
// 1. Get final season standings from participant_season_results const seasonResults = await getSeasonResults(sportsSeasonId, db);
// 2. Assign final placements (1-8) based on standings
// 3. Update participantResults with final placements // Group participants by position to handle ties
// 4. Trigger recalculation for all affected leagues const positionGroups: Record<number, string[]> = {};
for (const result of seasonResults) {
const position = result.currentPosition;
if (position === null) continue; // Skip participants without a position
if (!positionGroups[position]) {
positionGroups[position] = [];
}
positionGroups[position].push(result.participantId);
}
// Get sorted positions
const positions = Object.keys(positionGroups)
.map(Number)
.sort((a, b) => a - b);
// Assign fantasy placements (1-8) based on standings
let currentPlacement = 1;
for (const position of positions) {
const participantIds = positionGroups[position];
// Stop if we've gone beyond the top 8 fantasy placements
if (currentPlacement > 8) break;
// Calculate how many placements this group shares
const participantsInGroup = participantIds.length;
// If this group would exceed position 8, only some participants get points
const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;
// Determine if we should assign placements to this group
if (currentPlacement <= 8) {
// All participants tied at this position get the first placement in the range
// (e.g., if 4 people tie for 5th place, they all get placement 5)
// The scoring system will handle averaging the points for positions 5, 6, 7, 8
for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
const participantId = participantIds[i];
await upsertParticipantResult(
participantId,
sportsSeasonId,
currentPlacement,
db
);
}
// If there are more participants than scoring positions, assign 0 to the rest
for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
const participantId = participantIds[i];
await upsertParticipantResult(
participantId,
sportsSeasonId,
0, // Beyond top 8
db
);
}
} else {
// Position is beyond top 8, assign 0 points
for (const participantId of participantIds) {
await upsertParticipantResult(
participantId,
sportsSeasonId,
0,
db
);
}
}
// Move to next placement group
currentPlacement += participantsInGroup;
}
// Trigger recalculation for all affected leagues
await recalculateAffectedLeagues(sportsSeasonId, db);
console.log( console.log(
`[ScoringCalculator] processSeasonStandings not yet implemented: ${sportsSeasonId}` `[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
); );
} }

View file

@ -14,6 +14,11 @@ import {
type UpdateEventResultData, type UpdateEventResultData,
} from "~/models/event-result"; } from "~/models/event-result";
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import {
upsertParticipantSeasonResult,
getSeasonResults,
} from "~/models/participant-season-result";
import { processSeasonStandings } from "~/models/scoring-calculator";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -32,6 +37,12 @@ export async function loader({ params }: Route.LoaderArgs) {
const results = await getEventResults(params.eventId); const results = await getEventResults(params.eventId);
const participantResults = await findParticipantResultsBySportsSeasonId(params.id); 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);
}
return { return {
sportsSeason: sportsSeason as typeof sportsSeason & { sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string }; sport: { id: string; name: string; type: string; slug: string };
@ -40,6 +51,7 @@ export async function loader({ params }: Route.LoaderArgs) {
participants, participants,
results, results,
participantResults, participantResults,
seasonResults,
}; };
} }
@ -49,7 +61,19 @@ export async function action({ request, params }: Route.ActionArgs) {
if (intent === "complete") { if (intent === "complete") {
try { try {
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
await completeScoringEvent(params.eventId); 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!" };
}
return { success: "Event marked as completed" }; return { success: "Event marked as completed" };
} catch (error) { } catch (error) {
console.error("Error completing event:", error); console.error("Error completing event:", error);
@ -135,5 +159,66 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
} }
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) {
console.error("Error updating season standings:", error);
return { error: "Failed to update season standings" };
}
}
return { error: "Invalid action" }; return { error: "Invalid action" };
} }

View file

@ -19,7 +19,7 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets } from "lucide-react"; import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
import { import {
Table, Table,
TableBody, TableBody,
@ -28,6 +28,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } from "~/components/ui/table";
import { useState } from "react";
export { loader, action }; export { loader, action };
@ -35,7 +36,8 @@ export default function EventResults({
loaderData, loaderData,
actionData, actionData,
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { sportsSeason, event, participants, results, participantResults } = loaderData; const { sportsSeason, event, participants, results, participantResults, seasonResults } = loaderData;
const [hasChanges, setHasChanges] = useState(false);
// Create a map of participants with results for easy lookup // Create a map of participants with results for easy lookup
const participantResultsMap = new Map( const participantResultsMap = new Map(
@ -50,6 +52,13 @@ export default function EventResults({
// Sort results by placement // Sort results by placement
const sortedResults = [...results].sort((a, b) => (a.placement || 999) - (b.placement || 999)); const sortedResults = [...results].sort((a, b) => (a.placement || 999) - (b.placement || 999));
// Create a map of season results for easy lookup
const seasonResultsMap = seasonResults
? new Map(
seasonResults.map((r: { participantId: string }) => [r.participantId, r])
)
: new Map();
const getEventTypeLabel = (type: string) => { const getEventTypeLabel = (type: string) => {
switch (type) { switch (type) {
case "playoff_game": case "playoff_game":
@ -116,8 +125,105 @@ export default function EventResults({
</div> </div>
)} )}
{/* Add Result Form */} {/* Season Standings Table for final_standings events */}
{!event.isComplete && ( {event.eventType === "final_standings" && !event.isComplete && (
<>
<Card>
<CardHeader>
<CardTitle>Season Standings Tracker</CardTitle>
<CardDescription>
Enter championship points for each participant. Positions are automatically calculated based on points (highest = 1st).
When the season ends, mark this event as complete to assign fantasy points to the top 8.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="update-standings" />
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
<TableHead className="text-right">Championship Points</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{participants.map((participant: { id: string; name: string }) => {
const result = seasonResultsMap.get(participant.id);
const currentPoints = result?.currentPoints || "";
return (
<TableRow key={participant.id}>
<TableCell className="font-medium">
{participant.name}
</TableCell>
<TableCell className="text-right">
<Input
type="number"
name={`points-${participant.id}`}
defaultValue={currentPoints}
placeholder="e.g., 250"
step="0.01"
min="0"
className="w-32 ml-auto"
onChange={() => setHasChanges(true)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex justify-between items-center pt-4">
<p className="text-sm text-muted-foreground">
{hasChanges
? "You have unsaved changes"
: "Update standings after each race/event during the season"}
</p>
<Button type="submit">
<Save className="mr-2 h-4 w-4" />
Save Standings
</Button>
</div>
</Form>
</CardContent>
</Card>
{/* Complete Season Button */}
<Card className="border-orange-200 dark:border-orange-800">
<CardHeader>
<CardTitle className="text-orange-600 dark:text-orange-400">
<Trophy className="inline mr-2 h-5 w-5" />
Finalize Season
</CardTitle>
<CardDescription>
When the season is complete, mark this event as complete to:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Convert the top 8 participants (by position) to fantasy placements 1-8</li>
<li>Award fantasy points based on league scoring rules</li>
<li>Update all league standings</li>
</ul>
<p className="mt-2 font-semibold text-orange-600 dark:text-orange-400">
Make sure all standings are saved before completing
</p>
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="complete" />
<Button type="submit" variant="default" className="bg-orange-600 hover:bg-orange-700">
<CheckCircle2 className="mr-2 h-4 w-4" />
Mark Season Complete & Assign Fantasy Points
</Button>
</Form>
</CardContent>
</Card>
</>
)}
{/* Regular Result Entry for other event types */}
{event.eventType !== "final_standings" && !event.isComplete && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Add Result</CardTitle> <CardTitle>Add Result</CardTitle>
@ -193,7 +299,7 @@ export default function EventResults({
results results
</CardDescription> </CardDescription>
</div> </div>
{!event.isComplete && results.length > 0 && ( {!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && (
<Form method="post"> <Form method="post">
<input type="hidden" name="intent" value="complete" /> <input type="hidden" name="intent" value="complete" />
<Button type="submit" variant="outline" size="sm"> <Button type="submit" variant="outline" size="sm">

View file

@ -286,7 +286,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Create playoff games, tournaments, races, or standings events to track participant results and calculate fantasy points. Create playoff games, tournaments, races, or final standings events to track participant results and calculate fantasy points.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -33,10 +33,15 @@ Based on the answers, we need to support these distinct patterns:
- **Placement Logic**: All 8 placements are determined individually (7th, 8th, 5th, 6th, 3rd, 4th, 2nd, 1st) - **Placement Logic**: All 8 placements are determined individually (7th, 8th, 5th, 6th, 3rd, 4th, 2nd, 1st)
- **Points**: Apply league scoring rules directly (or average if needed) - **Points**: Apply league scoring rules directly (or average if needed)
#### 3. Season Standings (F1) #### 3. Season Standings (F1) ✅ *Implemented*
- **Structure**: Final season standings determine placements - **Structure**: Final season standings determine placements
- **Placement Logic**: Use the sport's official standings (1st through 8th) - **Placement Logic**:
- Championship points are entered throughout the season
- Positions automatically calculated from points (highest = 1st)
- Top 8 finishers by position get fantasy placements
- Ties handled automatically (same points = same position)
- **Points**: Apply league scoring rules to final positions - **Points**: Apply league scoring rules to final positions
- **Implementation**: Managed via "Final Standings" event type in events system
#### 4. Qualifying Points (Golf, Tennis) #### 4. Qualifying Points (Golf, Tennis)
- **Structure**: Two-phase scoring system - **Structure**: Two-phase scoring system
@ -750,6 +755,11 @@ async function updateAllExpectedValues(seasonId: uuid) {
- Mark event complete - Mark event complete
- For playoffs: Enter bracket matchups and winners - For playoffs: Enter bracket matchups and winners
- For majors: Enter participant placements - For majors: Enter participant placements
- For final_standings (F1, etc.):
- Enter championship points for all participants
- Positions auto-calculated from points (highest = 1st)
- Update standings throughout season
- Finalize button to convert top 8 to fantasy placements
/admin/sports-seasons/$sportsSeasonId/finalize-qp /admin/sports-seasons/$sportsSeasonId/finalize-qp
- View current qualifying points standings - View current qualifying points standings
@ -813,12 +823,12 @@ async function updateAllExpectedValues(seasonId: uuid) {
- Show defaults, allow customization - Show defaults, allow customization
- Preview point distribution - Preview point distribution
// app/components/scoring/EventResultEntry.tsx // app/routes/admin.sports-seasons.$id.events.$eventId.tsx
- Admin form to enter event results - Admin page to enter event results (implemented inline, not separate component)
- Different UI for each scoring pattern - Different UI for each event type:
- Playoff bracket input - playoff_game: Link to bracket management
- Tournament placement input - major_tournament: Participant placement input
- Season standings input - final_standings: Championship points table with auto-calculated positions
// app/components/scoring/PlayoffBracket.tsx // app/components/scoring/PlayoffBracket.tsx
- Visual bracket display - Visual bracket display
@ -1153,12 +1163,22 @@ scoring_events {
### Phase 3: Other Scoring Patterns ### Phase 3: Other Scoring Patterns
**Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis) **Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis)
- [ ] **3.1** Season standings (F1) - [x] **3.1** Season standings (F1) ✅ *Completed*
- [ ] UI for entering current season points - [x] Create `participant_season_results` model functions (app/models/participant-season-result.ts)
- [ ] Store running totals in `participant_season_results` - [x] Implement `processSeasonStandings` function in scoring-calculator
- [ ] Display current standings on sport season page - [x] UI for entering championship points via "Final Standings" event type
- [ ] Final standings entry (end of season) - [x] Auto-calculate positions from points (highest points = 1st place)
- [ ] Convert final standings to fantasy placements - [x] Store running totals in `participant_season_results` table
- [x] Bulk update standings throughout season
- [x] Finalize season button converts top 8 to fantasy placements
- [x] Handle ties automatically (Q13, Q19)
- [x] Comprehensive test suite (13 tests covering all scenarios)
- [x] All 281 tests passing ✅
**Implementation Note**: Season standings are managed through the events system using `eventType: "final_standings"`.
When viewing a final_standings event, admins see a standings tracker table where they enter championship points
after each race/event. Positions are automatically calculated from points. When the season ends, marking the event
as complete triggers `processSeasonStandings()` which assigns fantasy placements to the top 8 finishers.
- [ ] **3.2** Qualifying points (Golf/Tennis) - [ ] **3.2** Qualifying points (Golf/Tennis)
- [ ] QP value configuration per sports season (Q21) - [ ] QP value configuration per sports season (Q21)
@ -1290,6 +1310,21 @@ scoring_events {
--- ---
**STATUS: READY TO BEGIN IMPLEMENTATION** **IMPLEMENTATION STATUS**
All planning complete. Starting with Phase 1: Core Scoring Infrastructure. - ✅ **Phase 1**: Core Scoring Infrastructure - COMPLETE
- ✅ **Phase 2**: Playoff Scoring (Single Elimination) - COMPLETE
- Supports 4, 8, 16, 32 team brackets
- NCAA 68-team March Madness with First Four
- NFL 14-team playoffs with bye weeks
- Template-based system for all bracket types
- ✅ **Phase 3.1**: Season Standings (F1) - COMPLETE
- Event-based workflow for tracking championship points
- Auto-calculated positions from points
- Finalization converts top 8 to fantasy placements
- ⏳ **Phase 3.2-3.4**: Qualifying Points & Page Playoffs - TODO
- ⏳ **Phase 4**: Standings & Display - TODO
- ⏳ **Phase 5**: Expected Value - TODO
- ⏳ **Phase 6**: Polish & Optimization - TODO
**Current Focus**: Phase 3.2 - Qualifying Points (Golf/Tennis)