claude/sports-templates-conversion-ILWAM #71

Merged
chrisp merged 3 commits from claude/sports-templates-conversion-ILWAM into main 2026-06-04 21:31:49 +00:00
15 changed files with 12555 additions and 165 deletions

View file

@ -1,6 +1,8 @@
import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { findDraftableSportsSeasonBySportId } from "~/models/sports-season";
import { logger } from "~/lib/logger";
export type SeasonSport = typeof schema.seasonSports.$inferSelect;
export type NewSeasonSport = typeof schema.seasonSports.$inferInsert;
@ -89,7 +91,6 @@ export async function applySportsFromTemplate(
): Promise<SeasonSport[]> {
const db = database();
// Get all sports from the template
const templateSports = await db.query.seasonTemplateSports.findMany({
where: eq(schema.seasonTemplateSports.templateId, templateId),
});
@ -98,12 +99,26 @@ export async function applySportsFromTemplate(
return [];
}
// Link them to the season
const resolved = await Promise.all(
templateSports.map(async (ts) => {
const season = await findDraftableSportsSeasonBySportId(ts.sportId);
if (!season) {
logger.warn(`No draftable season found for sport ${ts.sportId} in template ${templateId}`);
}
return season ?? null;
})
);
const sportsSeasonIds = resolved
.filter((s): s is NonNullable<typeof s> => s !== null)
.map((s) => s.id);
if (sportsSeasonIds.length === 0) {
return [];
}
return await linkMultipleSportsToSeason(
templateSports.map((ts) => ({
seasonId,
sportsSeasonId: ts.sportsSeasonId,
}))
sportsSeasonIds.map((sportsSeasonId) => ({ seasonId, sportsSeasonId }))
);
}

View file

@ -7,14 +7,14 @@ export type NewSeasonTemplateSport = typeof schema.seasonTemplateSports.$inferIn
export async function addSportToTemplate(
templateId: string,
sportsSeasonId: string
sportId: string
): Promise<SeasonTemplateSport> {
const db = database();
const [link] = await db
.insert(schema.seasonTemplateSports)
.values({
templateId,
sportsSeasonId,
sportId,
})
.returning();
return link;
@ -32,7 +32,7 @@ export async function addMultipleSportsToTemplate(
export async function removeSportFromTemplate(
templateId: string,
sportsSeasonId: string
sportId: string
): Promise<void> {
const db = database();
await db
@ -40,7 +40,7 @@ export async function removeSportFromTemplate(
.where(
and(
eq(schema.seasonTemplateSports.templateId, templateId),
eq(schema.seasonTemplateSports.sportsSeasonId, sportsSeasonId)
eq(schema.seasonTemplateSports.sportId, sportId)
)
);
}
@ -52,28 +52,23 @@ export async function findSeasonTemplateSportsByTemplateId(
return await db.query.seasonTemplateSports.findMany({
where: eq(schema.seasonTemplateSports.templateId, templateId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
sport: true,
},
});
}
export async function findSeasonTemplateSportsBySportsSeasonId(
sportsSeasonId: string
export async function findSeasonTemplateSportsBySportId(
sportId: string
): Promise<SeasonTemplateSport[]> {
const db = database();
return await db.query.seasonTemplateSports.findMany({
where: eq(schema.seasonTemplateSports.sportsSeasonId, sportsSeasonId),
where: eq(schema.seasonTemplateSports.sportId, sportId),
with: {
template: true,
},
});
}
export async function deleteSeasonTemplateSport(id: string): Promise<void> {
const db = database();
await db.delete(schema.seasonTemplateSports).where(eq(schema.seasonTemplateSports.id, id));

View file

@ -29,18 +29,14 @@ export async function findSeasonTemplateById(id: string): Promise<SeasonTemplate
});
}
export async function findSeasonTemplateWithSportsSeasons(id: string) {
export async function findSeasonTemplateWithSports(id: string) {
const db = database();
return await db.query.seasonTemplates.findFirst({
where: eq(schema.seasonTemplates.id, id),
with: {
seasonTemplateSports: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
sport: true,
},
},
},
@ -51,14 +47,6 @@ export async function findActiveSeasonTemplates(): Promise<SeasonTemplate[]> {
const db = database();
return await db.query.seasonTemplates.findMany({
where: eq(schema.seasonTemplates.isActive, true),
orderBy: (templates, { desc }) => [desc(templates.year)],
});
}
export async function findSeasonTemplatesByYear(year: number): Promise<SeasonTemplate[]> {
const db = database();
return await db.query.seasonTemplates.findMany({
where: eq(schema.seasonTemplates.year, year),
orderBy: (templates, { asc }) => [asc(templates.name)],
});
}
@ -66,7 +54,7 @@ export async function findSeasonTemplatesByYear(year: number): Promise<SeasonTem
export async function findAllSeasonTemplates(): Promise<SeasonTemplate[]> {
const db = database();
return await db.query.seasonTemplates.findMany({
orderBy: (templates, { desc }) => [desc(templates.year)],
orderBy: (templates, { asc }) => [asc(templates.name)],
});
}

View file

@ -151,6 +151,24 @@ export async function findDraftableSportsSeasons() {
}));
}
export async function findDraftableSportsSeasonBySportId(sportId: string) {
const db = database();
const today = sql`CURRENT_DATE`;
return await db.query.sportsSeasons.findFirst({
where: (ss, ops) =>
ops.and(
ops.eq(ss.sportId, sportId),
lte(ss.draftOn, today),
gte(ss.draftOff, today),
isNull(ss.fantasySeasonId)
),
orderBy: (ss, { desc }) => [desc(ss.year), desc(ss.draftOn)],
with: {
sport: true,
},
});
}
export async function updateSportsSeason(
id: string,
data: Partial<NewSportsSeason>

View file

@ -4,7 +4,7 @@ import type { Route } from "./+types/admin.templates.$id";
import { logger } from "~/lib/logger";
import {
findSeasonTemplateWithSportsSeasons,
findSeasonTemplateWithSports,
updateSeasonTemplate,
deleteSeasonTemplate,
setSeasonTemplateActive
@ -13,7 +13,7 @@ import {
addSportToTemplate,
removeSportFromTemplate
} from "~/models/season-template-sport";
import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSports } from "~/models/sport";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
@ -51,19 +51,15 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
}
export async function loader({ params }: Route.LoaderArgs) {
const template = await findSeasonTemplateWithSportsSeasons(params.id);
const template = await findSeasonTemplateWithSports(params.id);
if (!template) {
throw new Response("Template not found", { status: 404 });
}
const allSportsSeasons = await findAllSportsSeasons();
const allSports = await findAllSports();
// Type assertion since we know the sport relation is included
return {
template,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>
};
return { template, allSports };
}
export async function action({ request, params }: Route.ActionArgs) {
@ -82,45 +78,34 @@ export async function action({ request, params }: Route.ActionArgs) {
}
if (intent === "add-sport") {
const sportsSeasonId = formData.get("sportsSeasonId");
if (typeof sportsSeasonId === "string") {
await addSportToTemplate(params.id, sportsSeasonId);
const sportId = formData.get("sportId");
if (typeof sportId === "string") {
await addSportToTemplate(params.id, sportId);
}
return { success: true };
}
if (intent === "remove-sport") {
const sportsSeasonId = formData.get("sportsSeasonId");
if (typeof sportsSeasonId === "string") {
await removeSportFromTemplate(params.id, sportsSeasonId);
const sportId = formData.get("sportId");
if (typeof sportId === "string") {
await removeSportFromTemplate(params.id, sportId);
}
return { success: true };
}
// Update template
const name = formData.get("name");
const year = formData.get("year");
const description = formData.get("description");
if (typeof name !== "string" || !name.trim()) {
return { error: "Template name is required" };
}
if (typeof year !== "string") {
return { error: "Year is required" };
}
const yearNum = parseInt(year, 10);
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
return { error: "Year must be between 2000 and 2100" };
}
try {
await updateSeasonTemplate(params.id, {
name: name.trim(),
year: yearNum,
description: typeof description === "string" && description.trim() ? description.trim() : null,
});
@ -132,15 +117,15 @@ export async function action({ request, params }: Route.ActionArgs) {
}
export default function EditTemplate({ loaderData, actionData }: Route.ComponentProps) {
const { template, allSportsSeasons } = loaderData;
const { template, allSports } = loaderData;
const [selectedSportId, setSelectedSportId] = useState("");
const includedSeasonIds = new Set(
template.seasonTemplateSports.map((s) => s.sportsSeasonId)
const includedSportIds = new Set(
template.seasonTemplateSports.map((s) => s.sportId)
);
const availableSportsSeasons = allSportsSeasons.filter(
(s) => !includedSeasonIds.has(s.id)
const availableSports = allSports.filter(
(s) => !includedSportIds.has(s.id)
);
return (
@ -170,19 +155,6 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
/>
</div>
<div className="space-y-2">
<Label htmlFor="year">Year</Label>
<Input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={template.year}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
@ -236,9 +208,9 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardTitle>Sports</CardTitle>
<CardDescription>
Add sports seasons to this template
Add sports to this template. The active season for each sport will be used automatically when a league is created.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -249,17 +221,12 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
key={templateSport.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">
{templateSport.sportsSeason.sport.name} - {templateSport.sportsSeason.name}
</p>
<p className="text-sm text-muted-foreground">
{templateSport.sportsSeason.year} {templateSport.sportsSeason.scoringType.replace("_", " ")}
</p>
</div>
<p className="font-medium">
{templateSport.sport.name}
</p>
<Form method="post">
<input type="hidden" name="intent" value="remove-sport" />
<input type="hidden" name="sportsSeasonId" value={templateSport.sportsSeasonId} />
<input type="hidden" name="sportId" value={templateSport.sportId} />
<Button
type="submit"
variant="ghost"
@ -274,22 +241,22 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
</div>
)}
{availableSportsSeasons.length > 0 ? (
{availableSports.length > 0 ? (
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-sport" />
<Select
name="sportsSeasonId"
name="sportId"
value={selectedSportId}
onValueChange={setSelectedSportId}
required
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a sports season" />
<SelectValue placeholder="Select a sport" />
</SelectTrigger>
<SelectContent>
{availableSportsSeasons.map((season) => (
<SelectItem key={season.id} value={season.id}>
{season.sport.name} - {season.name} ({season.year})
{availableSports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
@ -300,7 +267,7 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
</Form>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
All available sports seasons have been added
All available sports have been added
</p>
)}
</CardContent>

View file

@ -22,27 +22,15 @@ export function meta(): Route.MetaDescriptors {
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get("name");
const year = formData.get("year");
const description = formData.get("description");
// Validation
if (typeof name !== "string" || !name.trim()) {
return { error: "Template name is required" };
}
if (typeof year !== "string") {
return { error: "Year is required" };
}
const yearNum = parseInt(year, 10);
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
return { error: "Year must be between 2000 and 2100" };
}
try {
const template = await createSeasonTemplate({
name: name.trim(),
year: yearNum,
description: typeof description === "string" && description.trim() ? description.trim() : null,
isActive: true,
});
@ -55,15 +43,13 @@ export async function action({ request }: Route.ActionArgs) {
}
export default function NewTemplate({ actionData }: Route.ComponentProps) {
const currentYear = new Date().getFullYear();
return (
<div className="p-8">
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Create Season Template</h1>
<p className="text-muted-foreground mt-1">
Bundle sports seasons together for league commissioners
Bundle sports together for league commissioners
</p>
</div>
@ -82,7 +68,7 @@ export default function NewTemplate({ actionData }: Route.ComponentProps) {
id="name"
name="name"
type="text"
placeholder="e.g., 2025 Full Season, 2025 Playoffs Only"
placeholder="e.g., Full Season, Playoffs Only"
required
/>
<p className="text-sm text-muted-foreground">
@ -90,25 +76,12 @@ export default function NewTemplate({ actionData }: Route.ComponentProps) {
</p>
</div>
<div className="space-y-2">
<Label htmlFor="year">Year</Label>
<Input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={currentYear}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
name="description"
placeholder="Describe what sports seasons are included in this template"
placeholder="Describe what sports are included in this template"
rows={3}
/>
</div>
@ -138,7 +111,7 @@ export default function NewTemplate({ actionData }: Route.ComponentProps) {
<CardContent className="text-sm text-foreground/80">
<p>After creating the template, you'll be able to:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Add sports seasons to the template</li>
<li>Add sports to the template</li>
<li>Mark which sports are required vs optional</li>
<li>Set the number of flex spots</li>
</ul>

View file

@ -77,7 +77,6 @@ export default function AdminTemplates({ loaderData }: Route.ComponentProps) {
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Year</TableHead>
<TableHead>Status</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Actions</TableHead>
@ -87,7 +86,6 @@ export default function AdminTemplates({ loaderData }: Route.ComponentProps) {
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.name}</TableCell>
<TableCell>{template.year}</TableCell>
<TableCell>
<Badge variant={template.isActive ? "default" : "secondary"}>
{template.isActive ? "Active" : "Inactive"}

View file

@ -10,7 +10,7 @@ import { createCommissioner } from "~/models/commissioner";
import { createSeason } from "~/models/season";
import { linkMultipleSportsToSeason } from "~/models/season-sport";
import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findActiveSeasonTemplates, findSeasonTemplateWithSports } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById, updateUser } from "~/models/user";
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
@ -57,7 +57,6 @@ type SportSeason = {
type Template = {
id: string;
name: string;
year: number;
description: string | null;
sportsSeasonIds: string[];
};
@ -104,14 +103,18 @@ export async function loader(args: Route.LoaderArgs) {
const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons();
const draftableSeasonBySportId = new Map(
allSportsSeasons.map((s) => [s.sportId, s])
);
const templatesWithSports = await Promise.all(
templates.map(async (template) => {
const t = await findSeasonTemplateWithSportsSeasons(template.id);
const t = await findSeasonTemplateWithSports(template.id);
return {
...template,
sportsSeasonIds: t?.seasonTemplateSports?.map(
(ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
) ?? [],
sportsSeasonIds: (t?.seasonTemplateSports ?? [])
.map((ts: { sportId: string }) => draftableSeasonBySportId.get(ts.sportId)?.id)
.filter((id): id is string => id !== null && id !== undefined),
};
})
);

View file

@ -80,14 +80,11 @@ const ImportDataSchema = z.object({
seasonTemplates: z.array(
z.object({
name: z.string(),
year: z.number(),
description: z.string().nullable(),
isActive: z.boolean(),
sports: z.array(
z.object({
sportSlug: z.string(),
seasonName: z.string(),
seasonYear: z.number(),
})
),
})
@ -133,10 +130,10 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
db.query.seasonTemplates.findMany({
with: {
seasonTemplateSports: {
with: { sportsSeason: { with: { sport: true } } },
with: { sport: true },
},
},
orderBy: (t, { desc }) => [desc(t.year)],
orderBy: (t, { asc }) => [asc(t.name)],
}),
]);
@ -197,13 +194,10 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
})),
seasonTemplates: seasonTemplates.map((t) => ({
name: t.name,
year: t.year,
description: t.description,
isActive: t.isActive,
sports: t.seasonTemplateSports.map((ts) => ({
sportSlug: ts.sportsSeason.sport.slug,
seasonName: ts.sportsSeason.name,
seasonYear: ts.sportsSeason.year,
sportSlug: ts.sport.slug,
})),
})),
};
@ -529,10 +523,7 @@ export async function importSportsDataFromJSON(
for (const template of importData.seasonTemplates) {
const existing = await tx.query.seasonTemplates.findFirst({
where: and(
eq(schema.seasonTemplates.name, template.name),
eq(schema.seasonTemplates.year, template.year)
),
where: eq(schema.seasonTemplates.name, template.name),
});
let templateId: string;
@ -561,7 +552,6 @@ export async function importSportsDataFromJSON(
.insert(schema.seasonTemplates)
.values({
name: template.name,
year: template.year,
description: template.description,
isActive: template.isActive,
})
@ -571,12 +561,11 @@ export async function importSportsDataFromJSON(
}
for (const templateSport of template.sports) {
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (sportsSeasonId) {
const sportId = sportIdMap.get(templateSport.sportSlug);
if (sportId) {
await tx.insert(schema.seasonTemplateSports).values({
templateId,
sportsSeasonId,
sportId,
});
}
}

View file

@ -190,11 +190,12 @@ export const seasonTemplates = pgTable("season_templates", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
year: integer("year").notNull(),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
}, (t) => ({
uniqueName: uniqueIndex("season_templates_name_unique").on(t.name),
}));
export const seasons = pgTable("seasons", {
id: uuid("id").primaryKey().defaultRandom(),
@ -567,11 +568,13 @@ export const seasonTemplateSports = pgTable("season_template_sports", {
templateId: uuid("template_id")
.notNull()
.references(() => seasonTemplates.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
sportId: uuid("sport_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
.references(() => sports.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
}, (t) => ({
uniqueTemplateSport: uniqueIndex("season_template_sports_template_sport_unique").on(t.templateId, t.sportId),
}));
export const seasonSports = pgTable("season_sports", {
id: uuid("id").primaryKey().defaultRandom(),
@ -974,6 +977,7 @@ export const sportsRelations = relations(sports, ({ many }) => ({
sportsSeasons: many(sportsSeasons),
tournaments: many(tournaments),
participants: many(participants),
seasonTemplateSports: many(seasonTemplateSports),
}));
export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) => ({
@ -988,7 +992,6 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
participants: many(seasonParticipants),
simulatorConfig: one(sportsSeasonSimulatorConfigs),
simulatorInputs: many(seasonParticipantSimulatorInputs),
seasonTemplateSports: many(seasonTemplateSports),
seasonSports: many(seasonSports),
participantResults: many(seasonParticipantResults),
regularSeasonStandings: many(regularSeasonStandings),
@ -1092,9 +1095,9 @@ export const seasonTemplateSportsRelations = relations(seasonTemplateSports, ({
fields: [seasonTemplateSports.templateId],
references: [seasonTemplates.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [seasonTemplateSports.sportsSeasonId],
references: [sportsSeasons.id],
sport: one(sports, {
fields: [seasonTemplateSports.sportId],
references: [sports.id],
}),
}));

View file

@ -0,0 +1,13 @@
TRUNCATE TABLE "season_template_sports";
--> statement-breakpoint
ALTER TABLE "season_template_sports" DROP CONSTRAINT "season_template_sports_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "season_template_sports" ADD COLUMN "sport_id" uuid NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_template_sports" ADD CONSTRAINT "season_template_sports_sport_id_sports_id_fk" FOREIGN KEY ("sport_id") REFERENCES "public"."sports"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "season_template_sports" DROP COLUMN IF EXISTS "sports_season_id";--> statement-breakpoint
ALTER TABLE "season_templates" DROP COLUMN IF EXISTS "year";

View file

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX IF NOT EXISTS "season_template_sports_template_sport_unique" ON "season_template_sports" USING btree ("template_id","sport_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "season_templates_name_unique" ON "season_templates" USING btree ("name");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -799,6 +799,20 @@
"when": 1779579877169,
"tag": "0113_bent_banshee",
"breakpoints": true
},
{
"idx": 114,
"version": "7",
"when": 1780595525976,
"tag": "0114_warm_swarm",
"breakpoints": true
},
{
"idx": 115,
"version": "7",
"when": 1780596702982,
"tag": "0115_spooky_spot",
"breakpoints": true
}
]
}