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 { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateAveragedPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
/**
* Core scoring calculation engine
@ -227,7 +228,11 @@ export async function finalizeQualifyingPoints(
/**
* 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(
sportsSeasonId: string,
@ -235,14 +240,89 @@ export async function processSeasonStandings(
): Promise<void> {
const db = providedDb || database();
// TODO: Implement in Phase 3
// 1. Get final season standings from participant_season_results
// 2. Assign final placements (1-8) based on standings
// 3. Update participantResults with final placements
// 4. Trigger recalculation for all affected leagues
// Get all season results for this sports season, sorted by standings
const seasonResults = await getSeasonResults(sportsSeasonId, db);
// Group participants by position to handle ties
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(
`[ScoringCalculator] processSeasonStandings not yet implemented: ${sportsSeasonId}`
`[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
);
}

View file

@ -14,6 +14,11 @@ import {
type UpdateEventResultData,
} from "~/models/event-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) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -32,6 +37,12 @@ export async function loader({ params }: Route.LoaderArgs) {
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);
}
return {
sportsSeason: sportsSeason as typeof sportsSeason & {
sport: { id: string; name: string; type: string; slug: string };
@ -40,6 +51,7 @@ export async function loader({ params }: Route.LoaderArgs) {
participants,
results,
participantResults,
seasonResults,
};
}
@ -49,7 +61,19 @@ export async function action({ request, params }: Route.ActionArgs) {
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!" };
}
return { success: "Event marked as completed" };
} catch (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" };
}

View file

@ -19,7 +19,7 @@ import {
SelectValue,
} from "~/components/ui/select";
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 {
Table,
TableBody,
@ -28,6 +28,7 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { useState } from "react";
export { loader, action };
@ -35,7 +36,8 @@ export default function EventResults({
loaderData,
actionData,
}: 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
const participantResultsMap = new Map(
@ -50,6 +52,13 @@ export default function EventResults({
// Sort results by placement
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) => {
switch (type) {
case "playoff_game":
@ -116,8 +125,105 @@ export default function EventResults({
</div>
)}
{/* Add Result Form */}
{!event.isComplete && (
{/* Season Standings Table for final_standings events */}
{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>
<CardHeader>
<CardTitle>Add Result</CardTitle>
@ -193,7 +299,7 @@ export default function EventResults({
results
</CardDescription>
</div>
{!event.isComplete && results.length > 0 && (
{!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && (
<Form method="post">
<input type="hidden" name="intent" value="complete" />
<Button type="submit" variant="outline" size="sm">

View file

@ -286,7 +286,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</CardHeader>
<CardContent>
<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>
</CardContent>
</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)
- **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
- **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
- **Implementation**: Managed via "Final Standings" event type in events system
#### 4. Qualifying Points (Golf, Tennis)
- **Structure**: Two-phase scoring system
@ -750,6 +755,11 @@ async function updateAllExpectedValues(seasonId: uuid) {
- Mark event complete
- For playoffs: Enter bracket matchups and winners
- 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
- View current qualifying points standings
@ -813,12 +823,12 @@ async function updateAllExpectedValues(seasonId: uuid) {
- Show defaults, allow customization
- Preview point distribution
// app/components/scoring/EventResultEntry.tsx
- Admin form to enter event results
- Different UI for each scoring pattern
- Playoff bracket input
- Tournament placement input
- Season standings input
// app/routes/admin.sports-seasons.$id.events.$eventId.tsx
- Admin page to enter event results (implemented inline, not separate component)
- Different UI for each event type:
- playoff_game: Link to bracket management
- major_tournament: Participant placement input
- final_standings: Championship points table with auto-calculated positions
// app/components/scoring/PlayoffBracket.tsx
- Visual bracket display
@ -1153,12 +1163,22 @@ scoring_events {
### Phase 3: Other Scoring Patterns
**Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis)
- [ ] **3.1** Season standings (F1)
- [ ] UI for entering current season points
- [ ] Store running totals in `participant_season_results`
- [ ] Display current standings on sport season page
- [ ] Final standings entry (end of season)
- [ ] Convert final standings to fantasy placements
- [x] **3.1** Season standings (F1) ✅ *Completed*
- [x] Create `participant_season_results` model functions (app/models/participant-season-result.ts)
- [x] Implement `processSeasonStandings` function in scoring-calculator
- [x] UI for entering championship points via "Final Standings" event type
- [x] Auto-calculate positions from points (highest points = 1st place)
- [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)
- [ ] 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)