feat: add draft order management with UI for manual and random ordering

This commit is contained in:
Chris Parsons 2025-10-15 08:58:35 -07:00
parent 8c5a611b4b
commit fdfce98d87
9 changed files with 1548 additions and 3 deletions

View file

@ -0,0 +1,88 @@
# Draft Order Implementation
## Overview
Implemented a complete draft slot system that allows commissioners to manage draft order for their fantasy leagues.
## What Was Implemented
### 1. Database Schema
- **New Table**: `draft_slots`
- `id`: UUID primary key
- `season_id`: Foreign key to seasons (cascade delete)
- `team_id`: Foreign key to teams (cascade delete)
- `draft_order`: Integer representing the draft position
- `created_at`, `updated_at`: Timestamps
### 2. Model Layer (`app/models/draft-slot.ts`)
- **CRUD Operations**:
- `createDraftSlot()` - Create a single draft slot
- `createManyDraftSlots()` - Create multiple draft slots
- `findDraftSlotsBySeasonId()` - Get all draft slots for a season (with team info)
- `findDraftSlotByTeamId()` - Find draft slot by team
- `updateDraftSlotOrder()` - Update a slot's order
- `deleteDraftSlotsBySeasonId()` - Delete all slots for a season
- `deleteDraftSlot()` - Delete a specific slot
- **Special Functions**:
- `setDraftOrder()` - Set the complete draft order for a season
- `randomizeDraftOrder()` - Randomize draft order using Fisher-Yates shuffle
### 3. League Settings Page (`app/routes/leagues/$leagueId.settings.tsx`)
- **New Draft Order Section**:
- Visual list showing current draft order with numbered badges
- Up/Down arrow buttons to manually reorder teams
- "Save Draft Order" button to persist changes
- "🎲 Randomize Draft Order" button for random order generation
- Only editable during `pre_draft` status
- Shows helpful note when draft order hasn't been set yet
- **Action Handlers**:
- `set-draft-order` - Validates and saves manual draft order
- `randomize-draft-order` - Generates and saves random order
### 4. League Page (`app/routes/leagues/$leagueId.tsx`)
- **Draft Order Display Box**:
- Shows in right column during `pre_draft` and `draft` statuses
- Displays numbered list of teams in draft order
- Shows team owner names when available
- Automatically hidden once season moves to `active` or `completed`
### 5. Database Migration
- Generated migration: `drizzle/0013_pink_the_order.sql`
- Successfully applied to database
## Key Features
### Commissioner Controls
- **Manual Ordering**: Commissioners can manually arrange teams using up/down buttons
- **Randomization**: One-click randomization for fair draft order
- **Validation**: Ensures all teams are included and valid
- **Status Protection**: Draft order can only be modified during `pre_draft` status
### User Experience
- **Visual Feedback**: Numbered badges clearly show draft positions
- **Responsive Design**: Works on mobile and desktop
- **Conditional Display**: Draft order only shown when relevant (pre_draft/draft states)
- **Owner Information**: Shows which user owns each team in the draft order
## Technical Details
### Data Flow
1. Commissioner sets/randomizes order on settings page
2. Action handler validates and calls model functions
3. Model deletes old slots and creates new ones with correct order
4. League page loader fetches draft slots for display
5. Draft order box conditionally renders based on season status
### Snake Draft Ready
The current implementation stores draft order as a simple integer sequence (1, 2, 3...). When implementing snake draft logic later, you can use this order to determine:
- Round 1: Order 1→N
- Round 2: Order N→1
- Round 3: Order 1→N
- etc.
## Future Enhancements
- Drag-and-drop reordering (currently uses up/down buttons)
- Draft order history/audit log
- Automatic draft order setting when all teams are filled
- Draft order lottery system with weighted probabilities

144
app/models/draft-slot.ts Normal file
View file

@ -0,0 +1,144 @@
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type DraftSlot = typeof schema.draftSlots.$inferSelect;
export type NewDraftSlot = typeof schema.draftSlots.$inferInsert;
export interface DraftSlotWithTeam extends DraftSlot {
team: {
id: string;
name: string;
ownerId: string | null;
};
}
/**
* Create a single draft slot
*/
export async function createDraftSlot(data: NewDraftSlot): Promise<DraftSlot> {
const db = database();
const [draftSlot] = await db
.insert(schema.draftSlots)
.values(data)
.returning();
return draftSlot;
}
/**
* Create multiple draft slots at once
*/
export async function createManyDraftSlots(
slots: NewDraftSlot[]
): Promise<DraftSlot[]> {
const db = database();
return await db.insert(schema.draftSlots).values(slots).returning();
}
/**
* Find all draft slots for a season, ordered by draft order
*/
export async function findDraftSlotsBySeasonId(
seasonId: string
): Promise<DraftSlotWithTeam[]> {
const db = database();
return await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: (draftSlots, { asc }) => [asc(draftSlots.draftOrder)],
with: {
team: {
columns: {
id: true,
name: true,
ownerId: true,
},
},
},
}) as DraftSlotWithTeam[];
}
/**
* Find a draft slot by team ID
*/
export async function findDraftSlotByTeamId(
teamId: string
): Promise<DraftSlot | undefined> {
const db = database();
return await db.query.draftSlots.findFirst({
where: eq(schema.draftSlots.teamId, teamId),
});
}
/**
* Update a draft slot's order
*/
export async function updateDraftSlotOrder(
id: string,
draftOrder: number
): Promise<DraftSlot> {
const db = database();
const [draftSlot] = await db
.update(schema.draftSlots)
.set({ draftOrder, updatedAt: new Date() })
.where(eq(schema.draftSlots.id, id))
.returning();
return draftSlot;
}
/**
* Delete all draft slots for a season
*/
export async function deleteDraftSlotsBySeasonId(
seasonId: string
): Promise<void> {
const db = database();
await db
.delete(schema.draftSlots)
.where(eq(schema.draftSlots.seasonId, seasonId));
}
/**
* Delete a specific draft slot
*/
export async function deleteDraftSlot(id: string): Promise<void> {
const db = database();
await db.delete(schema.draftSlots).where(eq(schema.draftSlots.id, id));
}
/**
* Set the draft order for all teams in a season
* This will delete existing slots and create new ones
*/
export async function setDraftOrder(
seasonId: string,
teamIds: string[]
): Promise<DraftSlot[]> {
// Delete existing draft slots for this season
await deleteDraftSlotsBySeasonId(seasonId);
// Create new draft slots with the specified order
const slots = teamIds.map((teamId, index) => ({
seasonId,
teamId,
draftOrder: index + 1,
}));
return await createManyDraftSlots(slots);
}
/**
* Randomize the draft order for a season
*/
export async function randomizeDraftOrder(
seasonId: string,
teamIds: string[]
): Promise<DraftSlot[]> {
// Shuffle the team IDs using Fisher-Yates algorithm
const shuffled = [...teamIds];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return await setDraftOrder(seasonId, shuffled);
}

View file

@ -11,3 +11,4 @@ export * from "./season-template";
export * from "./season-template-sport"; export * from "./season-template-sport";
export * from "./season-sport"; export * from "./season-sport";
export * from "./participant-result"; export * from "./participant-result";
export * from "./draft-slot";

View file

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Form, Link, redirect } from "react-router"; import { Form, Link, redirect, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league"; import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
@ -7,6 +7,7 @@ import { findCurrentSeasonWithSports } from "~/models/season";
import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team"; import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport"; import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findAllSportsSeasons } from "~/models/sports-season"; import { findAllSportsSeasons } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
import type { Route } from "./+types/$leagueId.settings"; import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -69,6 +70,7 @@ export async function loader(args: Route.LoaderArgs) {
const season = await findCurrentSeasonWithSports(leagueId); const season = await findCurrentSeasonWithSports(leagueId);
const teams = season ? await findTeamsBySeasonId(season.id) : []; const teams = season ? await findTeamsBySeasonId(season.id) : [];
const allSportsSeasons = await findAllSportsSeasons(); const allSportsSeasons = await findAllSportsSeasons();
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
// Count teams with owners // Count teams with owners
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length; const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
@ -80,6 +82,7 @@ export async function loader(args: Route.LoaderArgs) {
teamCount: teams.length, teamCount: teams.length,
teamsWithOwners, teamsWithOwners,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>, allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
draftSlots,
}; };
} }
@ -150,6 +153,43 @@ export async function action(args: Route.ActionArgs) {
return { success: true }; return { success: true };
} }
if (intent === "set-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = formData.getAll("teamOrder") as string[];
// Validate that all teams are included
if (teamIds.length !== teams.length) {
return { error: "All teams must be included in the draft order" };
}
// Validate that all team IDs are valid
const validTeamIds = new Set(teams.map(t => t.id));
for (const teamId of teamIds) {
if (!validTeamIds.has(teamId)) {
return { error: "Invalid team ID in draft order" };
}
}
await setDraftOrder(season.id, teamIds);
return { success: true, message: "Draft order updated successfully" };
}
if (intent === "randomize-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = teams.map(t => t.id);
await randomizeDraftOrder(season.id, teamIds);
return { success: true, message: "Draft order randomized successfully" };
}
if (intent === "delete") { if (intent === "delete") {
// Delete the league // Delete the league
await deleteLeague(leagueId); await deleteLeague(leagueId);
@ -230,14 +270,29 @@ export async function action(args: Route.ActionArgs) {
} }
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) { export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
const { league, season, teamCount, teamsWithOwners, allSportsSeasons } = loaderData; const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedSports, setSelectedSports] = useState<Set<string>>( const [selectedSports, setSelectedSports] = useState<Set<string>>(
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || []) new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
); );
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id)
);
// Update draft order when loader data changes (after randomization)
useEffect(() => {
const newOrder = draftSlots.length > 0
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id);
setDraftOrderTeams(newOrder);
}, [draftSlots, teams]);
const canEditSports = season && season.status === "pre_draft"; const canEditSports = season && season.status === "pre_draft";
const canEditTeamCount = season && season.status === "pre_draft"; const canEditTeamCount = season && season.status === "pre_draft";
const canEditDraftOrder = season && season.status === "pre_draft";
const minTeamCount = Math.max(6, teamsWithOwners); const minTeamCount = Math.max(6, teamsWithOwners);
const handleSportToggle = (sportId: string) => { const handleSportToggle = (sportId: string) => {
@ -255,6 +310,17 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
// The form submission will handle the actual deletion // The form submission will handle the actual deletion
}; };
const moveDraftSlot = (fromIndex: number, toIndex: number) => {
const newOrder = [...draftOrderTeams];
const [movedTeam] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedTeam);
setDraftOrderTeams(newOrder);
};
const getTeamById = (teamId: string) => {
return teams.find((t: any) => t.id === teamId);
};
return ( return (
<div className="container max-w-2xl mx-auto py-8 px-4"> <div className="container max-w-2xl mx-auto py-8 px-4">
<div className="mb-8"> <div className="mb-8">
@ -415,6 +481,112 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardContent> </CardContent>
</Card> </Card>
{/* Draft Order Management */}
<Card>
<CardHeader>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{canEditDraftOrder
? "Set the draft order for your league. Drag teams to reorder or randomize."
: "Draft order cannot be changed after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{draftSlots.length === 0 && canEditDraftOrder && (
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.
</p>
</div>
)}
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="set-draft-order" />
<div className="space-y-2">
<div className="border rounded-md divide-y">
{draftOrderTeams.map((teamId, index) => {
const team = getTeamById(teamId);
if (!team) return null;
return (
<div key={teamId} className="flex items-center gap-3 p-3">
<input type="hidden" name="teamOrder" value={teamId} />
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold text-sm">
{index + 1}
</div>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
</div>
{canEditDraftOrder && (
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.max(0, index - 1))}
disabled={index === 0}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.min(draftOrderTeams.length - 1, index + 1))}
disabled={index === draftOrderTeams.length - 1}
>
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
{canEditDraftOrder && (
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Save Draft Order
</Button>
</div>
)}
{actionData?.success && actionData?.message && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
</Form>
{canEditDraftOrder && (
<Form method="post">
<input type="hidden" name="intent" value="randomize-draft-order" />
<Button
type="submit"
variant="outline"
className="w-full"
disabled={navigation.state === "submitting"}
>
{navigation.state === "submitting" && navigation.formData?.get("intent") === "randomize-draft-order"
? "Randomizing..."
: "🎲 Randomize Draft Order"}
</Button>
</Form>
)}
</div>
</CardContent>
</Card>
{/* Danger Zone */} {/* Danger Zone */}
<Card className="border-destructive"> <Card className="border-destructive">
<CardHeader> <CardHeader>

View file

@ -10,6 +10,7 @@ import {
isUserLeagueMember, isUserLeagueMember,
isCommissioner, isCommissioner,
findUserByClerkId, findUserByClerkId,
findDraftSlotsBySeasonId,
} from "~/models"; } from "~/models";
import type { Route } from "./+types/$leagueId"; import type { Route } from "./+types/$leagueId";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -66,6 +67,11 @@ export async function loader(args: Route.LoaderArgs) {
// Fetch teams for current season // Fetch teams for current season
const teams = season ? await findTeamsBySeasonId(season.id) : []; const teams = season ? await findTeamsBySeasonId(season.id) : [];
// Fetch draft slots if season is in pre_draft or draft status
const draftSlots = season && (season.status === "pre_draft" || season.status === "draft")
? await findDraftSlotsBySeasonId(season.id)
: [];
// Fetch user data for team owners // Fetch user data for team owners
const ownerIds = teams const ownerIds = teams
.map((t) => t.ownerId) .map((t) => t.ownerId)
@ -114,6 +120,7 @@ export async function loader(args: Route.LoaderArgs) {
ownerMap: Object.fromEntries(ownerMap), ownerMap: Object.fromEntries(ownerMap),
commissionerMap: Object.fromEntries(commissionerMap), commissionerMap: Object.fromEntries(commissionerMap),
availableTeamCount, availableTeamCount,
draftSlots,
}; };
} }
@ -128,6 +135,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
ownerMap, ownerMap,
commissionerMap, commissionerMap,
availableTeamCount, availableTeamCount,
draftSlots,
} = loaderData; } = loaderData;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -280,6 +288,38 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{/* Right Column - 1/3 width on desktop */} {/* Right Column - 1/3 width on desktop */}
<div className="space-y-6"> <div className="space-y-6">
{/* Draft Order Display - Only show during pre_draft and draft */}
{season && (season.status === "pre_draft" || season.status === "draft") && draftSlots.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{season.status === "pre_draft" ? "Upcoming draft order" : "Current draft order"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{draftSlots.map((slot: any, index: number) => {
const ownerName = slot.team.ownerId ? ownerMap[slot.team.ownerId] : null;
return (
<div key={slot.id} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{slot.team.name}</p>
{ownerName && (
<p className="text-xs text-muted-foreground truncate">{ownerName}</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>League Info</CardTitle> <CardTitle>League Info</CardTitle>

View file

@ -85,6 +85,19 @@ export const teams = pgTable("teams", {
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
export const draftSlots = pgTable("draft_slots", {
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
draftOrder: integer("draft_order").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const commissioners = pgTable("commissioners", { export const commissioners = pgTable("commissioners", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
leagueId: uuid("league_id") leagueId: uuid("league_id")
@ -245,3 +258,25 @@ export const participantResultsRelations = relations(participantResults, ({ one
references: [sportsSeasons.id], references: [sportsSeasons.id],
}), }),
})); }));
export const teamsRelations = relations(teams, ({ one }) => ({
season: one(seasons, {
fields: [teams.seasonId],
references: [seasons.id],
}),
draftSlot: one(draftSlots, {
fields: [teams.id],
references: [draftSlots.teamId],
}),
}));
export const draftSlotsRelations = relations(draftSlots, ({ one }) => ({
season: one(seasons, {
fields: [draftSlots.seasonId],
references: [seasons.id],
}),
team: one(teams, {
fields: [draftSlots.teamId],
references: [teams.id],
}),
}));

View file

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "draft_slots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"draft_order" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_slots" ADD CONSTRAINT "draft_slots_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "draft_slots" ADD CONSTRAINT "draft_slots_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,13 @@
"when": 1760504208398, "when": 1760504208398,
"tag": "0012_yielding_phil_sheldon", "tag": "0012_yielding_phil_sheldon",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1760543654915,
"tag": "0013_pink_the_order",
"breakpoints": true
} }
] ]
} }