Admins can now enable/disable a sport season from appearing in league creation and pre-draft league settings. Non-draftable seasons are hidden from selection but remain visible (and checked) if already linked to an existing pre-draft league. Leagues in draft status or beyond are unaffected. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a054247a55
commit
51a0a3ebba
11 changed files with 3708 additions and 6 deletions
66
app/models/__tests__/sports-season.test.ts
Normal file
66
app/models/__tests__/sports-season.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
|
const mockSeasons = [
|
||||||
|
{ id: "season-1", name: "2025 NBA Playoffs", isDraftable: true, year: 2025 },
|
||||||
|
{ id: "season-2", name: "2025 F1 Season", isDraftable: true, year: 2025 },
|
||||||
|
{ id: "season-3", name: "2024 Old Golf", isDraftable: false, year: 2024 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeMockDb(seasons: typeof mockSeasons) {
|
||||||
|
return {
|
||||||
|
query: {
|
||||||
|
sportsSeasons: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(seasons),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAllSportsSeasons", () => {
|
||||||
|
it("returns all sport seasons regardless of isDraftable", async () => {
|
||||||
|
vi.mocked(database).mockReturnValue(makeMockDb(mockSeasons) as never);
|
||||||
|
const result = await findAllSportsSeasons();
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findDraftableSportsSeasons", () => {
|
||||||
|
it("passes isDraftable=true filter to the query", async () => {
|
||||||
|
const draftableOnly = mockSeasons.filter((s) => s.isDraftable);
|
||||||
|
const mockDb = makeMockDb(draftableOnly);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||||
|
|
||||||
|
const result = await findDraftableSportsSeasons();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.every((s) => s.isDraftable)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no draftable seasons exist", async () => {
|
||||||
|
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
|
||||||
|
const result = await findDraftableSportsSeasons();
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls findMany with isDraftable where clause", async () => {
|
||||||
|
const mockDb = makeMockDb([]);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||||
|
|
||||||
|
await findDraftableSportsSeasons();
|
||||||
|
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.anything(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@ export interface SeasonWithSportsSeasons extends Season {
|
||||||
status: string;
|
status: string;
|
||||||
scoringType: string;
|
scoringType: string;
|
||||||
scoringPattern: string | null;
|
scoringPattern: string | null;
|
||||||
|
isDraftable: boolean;
|
||||||
sport: {
|
sport: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,17 @@ export async function findAllSportsSeasons(): Promise<SportsSeason[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findDraftableSportsSeasons(): Promise<SportsSeason[]> {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.sportsSeasons.findMany({
|
||||||
|
where: eq(schema.sportsSeasons.isDraftable, true),
|
||||||
|
orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)],
|
||||||
|
with: {
|
||||||
|
sport: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSportsSeason(
|
export async function updateSportsSeason(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<NewSportsSeason>
|
data: Partial<NewSportsSeason>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2 } from "lucide-react";
|
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
|
@ -133,6 +134,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const scoringType = formData.get("scoringType");
|
const scoringType = formData.get("scoringType");
|
||||||
const scoringPattern = formData.get("scoringPattern");
|
const scoringPattern = formData.get("scoringPattern");
|
||||||
const totalMajors = formData.get("totalMajors");
|
const totalMajors = formData.get("totalMajors");
|
||||||
|
const isDraftable = formData.get("isDraftable") === "true";
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
|
@ -169,6 +171,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
||||||
status,
|
status,
|
||||||
scoringType,
|
scoringType,
|
||||||
|
isDraftable,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (scoringPattern && typeof scoringPattern === "string") {
|
if (scoringPattern && typeof scoringPattern === "string") {
|
||||||
|
|
@ -328,6 +331,21 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isDraftable"
|
||||||
|
name="isDraftable"
|
||||||
|
defaultChecked={sportsSeason.isDraftable}
|
||||||
|
value="true"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isDraftable">Available for drafting</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
When unchecked, this season will not appear in league creation or pre-draft league settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{actionData?.error && (
|
{actionData?.error && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
{actionData.error}
|
{actionData.error}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps)
|
||||||
<TableHead>Sport</TableHead>
|
<TableHead>Sport</TableHead>
|
||||||
<TableHead>Year</TableHead>
|
<TableHead>Year</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Draftable</TableHead>
|
||||||
<TableHead>Scoring Type</TableHead>
|
<TableHead>Scoring Type</TableHead>
|
||||||
<TableHead>Participants</TableHead>
|
<TableHead>Participants</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
|
@ -112,6 +113,11 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps)
|
||||||
{season.status}
|
{season.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={season.isDraftable ? "default" : "secondary"}>
|
||||||
|
{season.isDraftable ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground capitalize">
|
<TableCell className="text-muted-foreground capitalize">
|
||||||
{season.scoringType.replace("_", " ")}
|
{season.scoringType.replace("_", " ")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
||||||
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
|
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
|
||||||
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
|
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
|
||||||
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
||||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
||||||
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -95,10 +95,19 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current season with sports
|
// Get current season with sports and draftable seasons in parallel
|
||||||
const season = await findCurrentSeasonWithSports(leagueId);
|
const [season, draftableSportsSeasons] = await Promise.all([
|
||||||
|
findCurrentSeasonWithSports(leagueId),
|
||||||
|
findDraftableSportsSeasons(),
|
||||||
|
]);
|
||||||
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
||||||
const allSportsSeasons = await findAllSportsSeasons();
|
// Merge draftable seasons with any already-linked non-draftable seasons so that
|
||||||
|
// previously-added seasons remain visible and checked in the UI even after being disabled.
|
||||||
|
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
|
||||||
|
const linkedButNotDraftable = (season?.seasonSports ?? [])
|
||||||
|
.map((s) => s.sportsSeason)
|
||||||
|
.filter((ss) => !ss.isDraftable && !draftableIds.has(ss.id));
|
||||||
|
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
|
||||||
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
||||||
|
|
||||||
// Count teams with owners
|
// Count teams with owners
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { createSeason } from "~/models/season";
|
||||||
import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
||||||
import { createManyTeams } from "~/models/team";
|
import { createManyTeams } from "~/models/team";
|
||||||
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
||||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
||||||
import { generateUniqueTeamNames } from "~/utils/team-names";
|
import { generateUniqueTeamNames } from "~/utils/team-names";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CalendarIcon, Check } from "lucide-react";
|
import { CalendarIcon, Check } from "lucide-react";
|
||||||
|
|
@ -47,7 +47,7 @@ export function meta(): Route.MetaDescriptors {
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
const templates = await findActiveSeasonTemplates();
|
const templates = await findActiveSeasonTemplates();
|
||||||
const allSportsSeasons = await findAllSportsSeasons();
|
const allSportsSeasons = await findDraftableSportsSeasons();
|
||||||
|
|
||||||
// Fetch templates with their sports seasons for mapping
|
// Fetch templates with their sports seasons for mapping
|
||||||
const templatesWithSports = await Promise.all(
|
const templatesWithSports = await Promise.all(
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,8 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
||||||
eloMaxRating: integer("elo_max_rating").default(1750),
|
eloMaxRating: integer("elo_max_rating").default(1750),
|
||||||
// Simulation status (prevents concurrent simulation runs)
|
// Simulation status (prevents concurrent simulation runs)
|
||||||
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
||||||
|
// Controls whether this sport season appears in league creation/settings
|
||||||
|
isDraftable: boolean("is_draftable").notNull().default(true),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
drizzle/0052_new_warbird.sql
Normal file
2
drizzle/0052_new_warbird.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "sports_seasons" ADD COLUMN "is_draftable" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "team_standings_snapshots_unique" ON "team_standings_snapshots" USING btree ("team_id","season_id","snapshot_date");
|
||||||
3580
drizzle/meta/0052_snapshot.json
Normal file
3580
drizzle/meta/0052_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -365,6 +365,13 @@
|
||||||
"when": 1773900000000,
|
"when": 1773900000000,
|
||||||
"tag": "0051_add_standings_snapshot_unique_index",
|
"tag": "0051_add_standings_snapshot_unique_index",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 52,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773897678653,
|
||||||
|
"tag": "0052_new_warbird",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue