From 7dddf2c9a7b8963a75bf000f03cae9d6e8c7ceda Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 29 Oct 2025 00:04:27 -0700 Subject: [PATCH] feat: Add scoring rules editor to league settings and creation pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created ScoringRulesEditor component with 8 placement point inputs - Added scoring-types.ts for shared client/server types - Integrated scoring rules into league settings page - Integrated scoring rules into league creation form - Added validation for point values (0-1000 range) - Disabled editing in settings after draft starts - Included helpful tips and preview display Phase 1.3 complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/components/scoring/ScoringRulesEditor.tsx | 96 +++++++++++++++++++ app/lib/scoring-types.ts | 25 +++++ app/models/scoring-rules.ts | 31 +----- app/routes/leagues/$leagueId.settings.tsx | 39 +++++++- app/routes/leagues/new.tsx | 24 +++++ plans/scoring-system.md | 13 ++- 6 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 app/components/scoring/ScoringRulesEditor.tsx create mode 100644 app/lib/scoring-types.ts diff --git a/app/components/scoring/ScoringRulesEditor.tsx b/app/components/scoring/ScoringRulesEditor.tsx new file mode 100644 index 0000000..6299288 --- /dev/null +++ b/app/components/scoring/ScoringRulesEditor.tsx @@ -0,0 +1,96 @@ +import { Label } from "~/components/ui/label"; +import { Input } from "~/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types"; + +interface ScoringRulesEditorProps { + scoringRules?: Partial; + disabled?: boolean; +} + +export function ScoringRulesEditor({ scoringRules, disabled = false }: ScoringRulesEditorProps) { + const rules = { + ...DEFAULT_SCORING_RULES, + ...scoringRules, + }; + + const placementLabels = [ + { key: "pointsFor1st", label: "1st Place", emoji: "🥇" }, + { key: "pointsFor2nd", label: "2nd Place", emoji: "🥈" }, + { key: "pointsFor3rd", label: "3rd Place", emoji: "🥉" }, + { key: "pointsFor4th", label: "4th Place", emoji: "4️⃣" }, + { key: "pointsFor5th", label: "5th Place", emoji: "5️⃣" }, + { key: "pointsFor6th", label: "6th Place", emoji: "6️⃣" }, + { key: "pointsFor7th", label: "7th Place", emoji: "7️⃣" }, + { key: "pointsFor8th", label: "8th Place", emoji: "8️⃣" }, + ] as const; + + return ( + + + Scoring Rules + + {disabled + ? "Scoring rules cannot be modified after the draft has started" + : "Set the fantasy points awarded for each final placement (1st through 8th)"} + + + +
+
+

+ How it works: At the end of each sport's season, participants will + be ranked 1st through 8th based on their performance. These point values will be + awarded to teams that drafted those participants. +

+
+ +
+ {placementLabels.map(({ key, label, emoji }) => ( +
+ + +
+ ))} +
+ +
+

+ 💡 Tip: The default scoring rewards winning heavily (100 pts for 1st) + but also values consistency (25 pts for 5th/6th). Customize these values to fit your + league's strategy preferences. +

+
+ +
+

+ Preview: 1st={rules.pointsFor1st} • 2nd={rules.pointsFor2nd} • 3rd={rules.pointsFor3rd} • + 4th={rules.pointsFor4th} • 5th={rules.pointsFor5th} • 6th={rules.pointsFor6th} • + 7th={rules.pointsFor7th} • 8th={rules.pointsFor8th} pts +

+
+
+
+
+ ); +} diff --git a/app/lib/scoring-types.ts b/app/lib/scoring-types.ts new file mode 100644 index 0000000..d23c7fb --- /dev/null +++ b/app/lib/scoring-types.ts @@ -0,0 +1,25 @@ +/** + * Shared scoring types that can be used on both client and server + */ + +export interface ScoringRules { + pointsFor1st: number; + pointsFor2nd: number; + pointsFor3rd: number; + pointsFor4th: number; + pointsFor5th: number; + pointsFor6th: number; + pointsFor7th: number; + pointsFor8th: number; +} + +export const DEFAULT_SCORING_RULES: ScoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, +}; diff --git a/app/models/scoring-rules.ts b/app/models/scoring-rules.ts index f4ea1ae..26fb39c 100644 --- a/app/models/scoring-rules.ts +++ b/app/models/scoring-rules.ts @@ -1,35 +1,10 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; +import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types"; -/** - * Scoring rules configuration for a season - * These define the points awarded for each placement (1st through 8th) - */ -export interface ScoringRules { - pointsFor1st: number; - pointsFor2nd: number; - pointsFor3rd: number; - pointsFor4th: number; - pointsFor5th: number; - pointsFor6th: number; - pointsFor7th: number; - pointsFor8th: number; -} - -/** - * Default scoring rules used when creating new seasons - */ -export const DEFAULT_SCORING_RULES: ScoringRules = { - pointsFor1st: 100, - pointsFor2nd: 70, - pointsFor3rd: 50, - pointsFor4th: 40, - pointsFor5th: 25, - pointsFor6th: 25, - pointsFor7th: 15, - pointsFor8th: 15, -}; +// Re-export for convenience +export { DEFAULT_SCORING_RULES, type ScoringRules }; /** * Get scoring rules for a season diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index a062e99..6227078 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -52,6 +52,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "~/components/ui/alert-dialog"; +import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; export async function loader(args: Route.LoaderArgs) { const { userId } = await getAuth(args); @@ -369,7 +370,7 @@ export async function action(args: Route.ActionArgs) { // Update season settings const seasonUpdates: any = {}; - + // Handle draft rounds if (typeof draftRounds === "string") { const draftRoundsNum = parseInt(draftRounds, 10); @@ -394,6 +395,27 @@ export async function action(args: Route.ActionArgs) { seasonUpdates.draftInitialTime = draftInitialTime; seasonUpdates.draftIncrementTime = draftIncrementTime; + // Handle scoring rules (only if in pre_draft status) + if (season.status === "pre_draft") { + const scoringFields = [ + "pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", + "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th" + ]; + + for (const field of scoringFields) { + const value = formData.get(field); + if (typeof value === "string") { + const points = parseInt(value, 10); + if (!isNaN(points)) { + if (points < 0 || points > 1000) { + return { error: `${field} must be between 0 and 1000 points` }; + } + seasonUpdates[field] = points; + } + } + } + } + // Update season if there are changes if (Object.keys(seasonUpdates).length > 0) { await updateSeason(season.id, seasonUpdates); @@ -743,6 +765,21 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone + {/* Scoring Rules */} + + {/* Sports Seasons */} diff --git a/app/routes/leagues/new.tsx b/app/routes/leagues/new.tsx index 3816e35..e3bb3e5 100644 --- a/app/routes/leagues/new.tsx +++ b/app/routes/leagues/new.tsx @@ -38,6 +38,7 @@ import { PopoverTrigger, } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; +import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; export async function loader() { const templates = await findActiveSeasonTemplates(); @@ -123,6 +124,26 @@ export async function action(args: Route.ActionArgs) { return { error: "Draft rounds must be between 1 and 50" }; } + // Extract and validate scoring rules + const scoringFields = [ + "pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", + "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th" + ]; + + const scoringRules: Record = {}; + for (const field of scoringFields) { + const value = formData.get(field); + if (typeof value === "string") { + const points = parseInt(value, 10); + if (!isNaN(points)) { + if (points < 0 || points > 1000) { + return { error: `${field} must be between 0 and 1000 points` }; + } + scoringRules[field] = points; + } + } + } + try { // Create league const league = await createLeague({ @@ -147,6 +168,7 @@ export async function action(args: Route.ActionArgs) { draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null, draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum, + ...scoringRules, }); // Set this as the current season for the league @@ -374,6 +396,8 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro

+ +

diff --git a/plans/scoring-system.md b/plans/scoring-system.md index 933dfb1..fafcf67 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -950,11 +950,14 @@ All clarification questions (Q1-Q21) have been answered and confirmed: - [x] Add draftPicks relations to schema - [x] All TypeScript compilation passes -- [ ] **1.3** Update season settings UI - - [ ] Add scoring rules editor component - - [ ] Add 8 input fields for placement points - - [ ] Update season create/edit forms - - [ ] Add validation for point values +- [x] **1.3** Update season settings UI ✅ *Completed* + - [x] Add scoring rules editor component (ScoringRulesEditor.tsx) + - [x] Add 8 input fields for placement points with emojis + - [x] Update league settings form to include scoring rules + - [x] Update league creation form to include scoring rules + - [x] Add validation for point values (0-1000 range) + - [x] Disable editing after draft starts (settings page only) + - [x] Show helpful tips and preview - [ ] **1.4** Basic admin result entry UI - [ ] Create admin route structure for sports seasons