Add public sports overview page (#451)
This commit is contained in:
parent
0ba2b391f8
commit
461847c414
18 changed files with 6868 additions and 3 deletions
|
|
@ -4,6 +4,7 @@ const FOOTER_LINKS = [
|
||||||
{ label: "Home", to: "/" },
|
{ label: "Home", to: "/" },
|
||||||
{ label: "Rules", to: "/rules" },
|
{ label: "Rules", to: "/rules" },
|
||||||
{ label: "How to Play", to: "/how-to-play" },
|
{ label: "How to Play", to: "/how-to-play" },
|
||||||
|
{ label: "Sports", to: "/sports" },
|
||||||
{ label: "Support", to: "/support" },
|
{ label: "Support", to: "/support" },
|
||||||
{ label: "Privacy Policy", to: "/privacy-policy" },
|
{ label: "Privacy Policy", to: "/privacy-policy" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ export function Navbar({ isAdmin, currentUser }: NavbarProps) {
|
||||||
: <NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
: <NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
||||||
}
|
}
|
||||||
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
||||||
|
<NavLink to="/sports" className={navLinkClass}>Sports</NavLink>
|
||||||
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,6 +160,13 @@ export function Navbar({ isAdmin, currentUser }: NavbarProps) {
|
||||||
>
|
>
|
||||||
How To Play
|
How To Play
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/sports"
|
||||||
|
className={mobileNavLinkClass}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Sports
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/rules"
|
to="/rules"
|
||||||
className={mobileNavLinkClass}
|
className={mobileNavLinkClass}
|
||||||
|
|
|
||||||
154
app/models/__tests__/sport.test.ts
Normal file
154
app/models/__tests__/sport.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/database/schema", () => ({
|
||||||
|
sports: {
|
||||||
|
name: "sports.name",
|
||||||
|
slug: "sports.slug",
|
||||||
|
simulatorType: "sports.simulator_type",
|
||||||
|
excludeFromHomepage: "sports.exclude_from_homepage",
|
||||||
|
},
|
||||||
|
sportsSeasons: {
|
||||||
|
fantasySeasonId: "sports_seasons.fantasy_season_id",
|
||||||
|
status: "sports_seasons.status",
|
||||||
|
scoringPattern: "sports_seasons.scoring_pattern",
|
||||||
|
scoringType: "sports_seasons.scoring_type",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||||
|
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||||
|
isNull: (col: unknown) => ({ type: "isNull", col }),
|
||||||
|
ne: (col: unknown, val: unknown) => ({ type: "ne", col, val }),
|
||||||
|
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { findPublicSportsWithCurrentSeasons } from "../sport";
|
||||||
|
|
||||||
|
function makeSport(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "sport-1",
|
||||||
|
name: "NFL",
|
||||||
|
type: "team",
|
||||||
|
slug: "nfl",
|
||||||
|
description: "Football playoffs.",
|
||||||
|
iconUrl: "nfl.svg",
|
||||||
|
simulatorType: "nfl_bracket",
|
||||||
|
excludeFromHomepage: false,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
sportsSeasons: [
|
||||||
|
{
|
||||||
|
id: "season-1",
|
||||||
|
name: "NFL 2026",
|
||||||
|
year: 2026,
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "playoff_bracket",
|
||||||
|
scoringType: "playoffs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockDb(sports: ReturnType<typeof makeSport>[]) {
|
||||||
|
return {
|
||||||
|
query: {
|
||||||
|
sports: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(sports),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findPublicSportsWithCurrentSeasons", () => {
|
||||||
|
it("returns non-Brackt sports with active or upcoming global seasons", async () => {
|
||||||
|
const mockDb = makeMockDb([
|
||||||
|
makeSport(),
|
||||||
|
makeSport({
|
||||||
|
id: "sport-2",
|
||||||
|
name: "Tennis",
|
||||||
|
type: "individual",
|
||||||
|
slug: "tennis",
|
||||||
|
simulatorType: "tennis_qualifying_points",
|
||||||
|
sportsSeasons: [
|
||||||
|
{
|
||||||
|
id: "season-2",
|
||||||
|
name: "Tennis 2026",
|
||||||
|
year: 2026,
|
||||||
|
status: "upcoming",
|
||||||
|
scoringPattern: "qualifying_points",
|
||||||
|
scoringType: "majors",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||||
|
|
||||||
|
const result = await findPublicSportsWithCurrentSeasons();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: "sport-1",
|
||||||
|
currentSeasons: [
|
||||||
|
{
|
||||||
|
id: "season-1",
|
||||||
|
name: "NFL 2026",
|
||||||
|
year: 2026,
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "playoff_bracket",
|
||||||
|
scoringType: "playoffs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mockDb.query.sports.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
with: expect.objectContaining({
|
||||||
|
sportsSeasons: expect.objectContaining({
|
||||||
|
where: expect.any(Function),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes sports with no current seasons after the query filter", async () => {
|
||||||
|
vi.mocked(database).mockReturnValue(makeMockDb([
|
||||||
|
makeSport({ id: "sport-1", sportsSeasons: [] }),
|
||||||
|
]) as never);
|
||||||
|
|
||||||
|
await expect(findPublicSportsWithCurrentSeasons()).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes Brackt by slug or simulator type", async () => {
|
||||||
|
vi.mocked(database).mockReturnValue(makeMockDb([
|
||||||
|
makeSport({ id: "brackt-slug", name: "Brackt", slug: "brackt" }),
|
||||||
|
makeSport({ id: "brackt-sim", name: "Meta", slug: "meta", simulatorType: "brackt" }),
|
||||||
|
makeSport({ id: "sport-2", name: "NHL", slug: "nhl", simulatorType: "nhl_bracket" }),
|
||||||
|
]) as never);
|
||||||
|
|
||||||
|
const result = await findPublicSportsWithCurrentSeasons();
|
||||||
|
|
||||||
|
expect(result.map((sport) => sport.id)).toEqual(["sport-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes sports marked hidden from the public page", async () => {
|
||||||
|
vi.mocked(database).mockReturnValue(makeMockDb([
|
||||||
|
makeSport({ id: "llws", name: "LLWS", slug: "llws", excludeFromHomepage: true }),
|
||||||
|
makeSport({ id: "sport-2", name: "NHL", slug: "nhl", simulatorType: "nhl_bracket" }),
|
||||||
|
]) as never);
|
||||||
|
|
||||||
|
const result = await findPublicSportsWithCurrentSeasons();
|
||||||
|
|
||||||
|
expect(result.map((sport) => sport.id)).toEqual(["sport-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, ne, sql } from "drizzle-orm";
|
import { and, eq, isNull, ne, sql } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -17,6 +17,16 @@ export type SportType = "team" | "individual";
|
||||||
export type SportWithActiveSeasons = Sport & {
|
export type SportWithActiveSeasons = Sport & {
|
||||||
activeSeasons: Array<{ id: string; name: string; year: number }>;
|
activeSeasons: Array<{ id: string; name: string; year: number }>;
|
||||||
};
|
};
|
||||||
|
export type PublicSportWithCurrentSeasons = Sport & {
|
||||||
|
currentSeasons: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
status: "active" | "upcoming";
|
||||||
|
scoringPattern: "playoff_bracket" | "season_standings" | "qualifying_points" | null;
|
||||||
|
scoringType: "playoffs" | "regular_season" | "majors";
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function createSport(data: NewSport): Promise<Sport> {
|
export async function createSport(data: NewSport): Promise<Sport> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
@ -85,6 +95,46 @@ export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveS
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findPublicSportsWithCurrentSeasons(): Promise<PublicSportWithCurrentSeasons[]> {
|
||||||
|
const db = database();
|
||||||
|
const sports = await db.query.sports.findMany({
|
||||||
|
where: eq(schema.sports.excludeFromHomepage, false),
|
||||||
|
orderBy: (s, { asc }) => [asc(s.name)],
|
||||||
|
with: {
|
||||||
|
sportsSeasons: {
|
||||||
|
where: (ss, { and: relationAnd, or }) =>
|
||||||
|
relationAnd(
|
||||||
|
isNull(ss.fantasySeasonId),
|
||||||
|
or(
|
||||||
|
eq(ss.status, "active"),
|
||||||
|
eq(ss.status, "upcoming")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
orderBy: (ss, { desc, asc }) => [desc(ss.year), asc(ss.name)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return sports
|
||||||
|
.filter((sport) =>
|
||||||
|
!sport.excludeFromHomepage &&
|
||||||
|
sport.slug !== "brackt" &&
|
||||||
|
sport.simulatorType !== "brackt"
|
||||||
|
)
|
||||||
|
.map((sport) => ({
|
||||||
|
...sport,
|
||||||
|
currentSeasons: sport.sportsSeasons.map((season) => ({
|
||||||
|
id: season.id,
|
||||||
|
name: season.name,
|
||||||
|
year: season.year,
|
||||||
|
status: season.status as "active" | "upcoming",
|
||||||
|
scoringPattern: season.scoringPattern,
|
||||||
|
scoringType: season.scoringType,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.filter((sport) => sport.currentSeasons.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function findSportsByType(type: SportType): Promise<Sport[]> {
|
export async function findSportsByType(type: SportType): Promise<Sport[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
return await db.query.sports.findMany({
|
return await db.query.sports.findMany({
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export default [
|
||||||
route("user-profile", "routes/user-profile-redirect.tsx"),
|
route("user-profile", "routes/user-profile-redirect.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
|
route("sports", "routes/sports.tsx"),
|
||||||
route("support", "routes/support.tsx"),
|
route("support", "routes/support.tsx"),
|
||||||
route("upcoming-events", "routes/upcoming-events.tsx"),
|
route("upcoming-events", "routes/upcoming-events.tsx"),
|
||||||
route("privacy-policy", "routes/privacy-policy.tsx"),
|
route("privacy-policy", "routes/privacy-policy.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { action } from "../admin.sports.$id";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||||
import { findSportById, isSportIconUrlUsed, updateSport } from "~/models/sport";
|
import { findSportById, isSportIconUrlUsed, updateSport } from "~/models/sport";
|
||||||
|
|
||||||
function makeRequest(iconUrl: string) {
|
function makeRequest(iconUrl: string, options: { excludeFromHomepage?: boolean } = {}) {
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.set("name", "NFL");
|
formData.set("name", "NFL");
|
||||||
formData.set("type", "team");
|
formData.set("type", "team");
|
||||||
|
|
@ -29,6 +29,9 @@ function makeRequest(iconUrl: string) {
|
||||||
formData.set("description", "Football");
|
formData.set("description", "Football");
|
||||||
formData.set("simulatorType", "playoff_bracket");
|
formData.set("simulatorType", "playoff_bracket");
|
||||||
formData.set("iconUrl", iconUrl);
|
formData.set("iconUrl", iconUrl);
|
||||||
|
if (options.excludeFromHomepage) {
|
||||||
|
formData.set("excludeFromHomepage", "on");
|
||||||
|
}
|
||||||
|
|
||||||
return new Request("http://test/admin/sports/sport-1", {
|
return new Request("http://test/admin/sports/sport-1", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -48,12 +51,29 @@ beforeEach(() => {
|
||||||
description: null,
|
description: null,
|
||||||
iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/old.svg",
|
iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/old.svg",
|
||||||
simulatorType: null,
|
simulatorType: null,
|
||||||
|
excludeFromHomepage: false,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("admin sport edit action", () => {
|
describe("admin sport edit action", () => {
|
||||||
|
it("stores the public page exclusion flag", async () => {
|
||||||
|
vi.mocked(updateSport).mockResolvedValue({
|
||||||
|
iconUrl: "nfl.svg",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await action({
|
||||||
|
request: makeRequest("nfl.svg", { excludeFromHomepage: true }),
|
||||||
|
params: { id: "sport-1" },
|
||||||
|
context: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(updateSport).toHaveBeenCalledWith("sport-1", expect.objectContaining({
|
||||||
|
excludeFromHomepage: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("stores the submitted icon URL and deletes a replaced Cloudinary icon", async () => {
|
it("stores the submitted icon URL and deletes a replaced Cloudinary icon", async () => {
|
||||||
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
|
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
|
||||||
vi.mocked(updateSport).mockResolvedValue({
|
vi.mocked(updateSport).mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,16 @@ import { action } from "../admin.sports.new";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||||
import { createSport, isSportIconUrlUsed } from "~/models/sport";
|
import { createSport, isSportIconUrlUsed } from "~/models/sport";
|
||||||
|
|
||||||
function makeRequest(iconUrl: string) {
|
function makeRequest(iconUrl: string, options: { excludeFromHomepage?: boolean } = {}) {
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.set("name", "NFL");
|
formData.set("name", "NFL");
|
||||||
formData.set("type", "team");
|
formData.set("type", "team");
|
||||||
formData.set("slug", "nfl");
|
formData.set("slug", "nfl");
|
||||||
formData.set("description", "Football");
|
formData.set("description", "Football");
|
||||||
formData.set("iconUrl", iconUrl);
|
formData.set("iconUrl", iconUrl);
|
||||||
|
if (options.excludeFromHomepage) {
|
||||||
|
formData.set("excludeFromHomepage", "on");
|
||||||
|
}
|
||||||
|
|
||||||
return new Request("http://test/admin/sports/new", {
|
return new Request("http://test/admin/sports/new", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -38,6 +41,18 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("admin sport create action", () => {
|
describe("admin sport create action", () => {
|
||||||
|
it("stores the public page exclusion flag", async () => {
|
||||||
|
await action({
|
||||||
|
request: makeRequest("nfl.svg", { excludeFromHomepage: true }),
|
||||||
|
params: {},
|
||||||
|
context: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(createSport).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
excludeFromHomepage: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects arbitrary icon URLs", async () => {
|
it("rejects arbitrary icon URLs", async () => {
|
||||||
const response = await action({
|
const response = await action({
|
||||||
request: makeRequest("https://example.com/icon.svg"),
|
request: makeRequest("https://example.com/icon.svg"),
|
||||||
|
|
|
||||||
113
app/routes/__tests__/sports.test.tsx
Normal file
113
app/routes/__tests__/sports.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { BrowserRouter } from "react-router";
|
||||||
|
|
||||||
|
vi.mock("~/models/sport", () => ({
|
||||||
|
findPublicSportsWithCurrentSeasons: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Sports, { loader } from "../sports";
|
||||||
|
import { findPublicSportsWithCurrentSeasons } from "~/models/sport";
|
||||||
|
import type { PublicSportWithCurrentSeasons } from "~/models/sport";
|
||||||
|
|
||||||
|
const sports: PublicSportWithCurrentSeasons[] = [
|
||||||
|
{
|
||||||
|
id: "nfl",
|
||||||
|
name: "NFL",
|
||||||
|
type: "team",
|
||||||
|
slug: "nfl",
|
||||||
|
description: "Draft teams in the NFL postseason.",
|
||||||
|
iconUrl: "nfl.svg",
|
||||||
|
simulatorType: "nfl_bracket",
|
||||||
|
excludeFromHomepage: false,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
currentSeasons: [
|
||||||
|
{
|
||||||
|
id: "nfl-2026",
|
||||||
|
name: "NFL 2026",
|
||||||
|
year: 2026,
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "playoff_bracket",
|
||||||
|
scoringType: "playoffs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tennis",
|
||||||
|
name: "Tennis",
|
||||||
|
type: "individual",
|
||||||
|
slug: "tennis",
|
||||||
|
description: null,
|
||||||
|
iconUrl: null,
|
||||||
|
simulatorType: "tennis_qualifying_points",
|
||||||
|
excludeFromHomepage: false,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
currentSeasons: [
|
||||||
|
{
|
||||||
|
id: "tennis-2026",
|
||||||
|
name: "Tennis Majors 2026",
|
||||||
|
year: 2026,
|
||||||
|
status: "upcoming",
|
||||||
|
scoringPattern: "qualifying_points",
|
||||||
|
scoringType: "majors",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f1",
|
||||||
|
name: "Formula 1",
|
||||||
|
type: "individual",
|
||||||
|
slug: "f1",
|
||||||
|
description: "Draft drivers for the full season.",
|
||||||
|
iconUrl: "f1.svg",
|
||||||
|
simulatorType: "f1_standings",
|
||||||
|
excludeFromHomepage: false,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
currentSeasons: [
|
||||||
|
{
|
||||||
|
id: "f1-2026",
|
||||||
|
name: "Formula 1 2026",
|
||||||
|
year: 2026,
|
||||||
|
status: "active",
|
||||||
|
scoringPattern: "season_standings",
|
||||||
|
scoringType: "regular_season",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderSports(loaderData = { sports }) {
|
||||||
|
return render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<Sports loaderData={loaderData} params={{}} matches={[] as never} />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/sports route", () => {
|
||||||
|
it("loads public sports from the model", async () => {
|
||||||
|
vi.mocked(findPublicSportsWithCurrentSeasons).mockResolvedValue(sports);
|
||||||
|
|
||||||
|
await expect(loader()).resolves.toEqual({ sports });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the sport count, groups, cards, and support CTA", () => {
|
||||||
|
renderSports();
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { name: "Sports" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Brackt currently supports 3 sports/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Playoffs" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Season-Long Standings" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Majors" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("NFL")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Draft teams in the NFL postseason.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText("NFL")).toHaveAttribute("src", "/sports-icons/nfl.svg");
|
||||||
|
expect(screen.getByText("Available for Brackt leagues.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/^1 active season$/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Tennis Majors 2026")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: /contact support/i })).toHaveAttribute("href", "/support");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -59,6 +59,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const description = formData.get("description");
|
const description = formData.get("description");
|
||||||
const simulatorType = formData.get("simulatorType");
|
const simulatorType = formData.get("simulatorType");
|
||||||
const iconUrlValue = formData.get("iconUrl");
|
const iconUrlValue = formData.get("iconUrl");
|
||||||
|
const excludeFromHomepage = formData.get("excludeFromHomepage") === "on";
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
|
@ -98,6 +99,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
description: typeof description === "string" ? description.trim() : null,
|
description: typeof description === "string" ? description.trim() : null,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
simulatorType: parsedSimulatorType,
|
simulatorType: parsedSimulatorType,
|
||||||
|
excludeFromHomepage,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
|
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
|
||||||
|
|
@ -226,6 +228,26 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border/70 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
id="excludeFromHomepage"
|
||||||
|
name="excludeFromHomepage"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={sport.excludeFromHomepage}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-input bg-background"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="excludeFromHomepage">
|
||||||
|
Exclude from public sports page
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Keep this sport available in admin and drafts, but hide it from the public Sports page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
const slug = formData.get("slug");
|
const slug = formData.get("slug");
|
||||||
const description = formData.get("description");
|
const description = formData.get("description");
|
||||||
const iconUrlValue = formData.get("iconUrl");
|
const iconUrlValue = formData.get("iconUrl");
|
||||||
|
const excludeFromHomepage = formData.get("excludeFromHomepage") === "on";
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
|
@ -71,6 +72,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
description: typeof description === "string" ? description.trim() : null,
|
description: typeof description === "string" ? description.trim() : null,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
|
excludeFromHomepage,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect("/admin/sports");
|
return redirect("/admin/sports");
|
||||||
|
|
@ -160,6 +162,25 @@ export default function NewSport({ loaderData, actionData }: Route.ComponentProp
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border/70 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
id="excludeFromHomepage"
|
||||||
|
name="excludeFromHomepage"
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-input bg-background"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="excludeFromHomepage">
|
||||||
|
Exclude from public sports page
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Keep this sport available in admin and drafts, but hide it from the public Sports page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) {
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Slug</TableHead>
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>Public Page</TableHead>
|
||||||
<TableHead>Current Seasons</TableHead>
|
<TableHead>Current Seasons</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -101,6 +102,11 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) {
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{sport.slug}
|
{sport.slug}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sport.excludeFromHomepage ? "secondary" : "default"}>
|
||||||
|
{sport.excludeFromHomepage ? "Hidden" : "Shown"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{sport.activeSeasons.length > 0 ? (
|
{sport.activeSeasons.length > 0 ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
|
||||||
188
app/routes/sports.tsx
Normal file
188
app/routes/sports.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { ArrowRight, Trophy } from "lucide-react";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/sports";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { SportIcon } from "~/components/SportIcon";
|
||||||
|
import {
|
||||||
|
findPublicSportsWithCurrentSeasons,
|
||||||
|
type PublicSportWithCurrentSeasons,
|
||||||
|
} from "~/models/sport";
|
||||||
|
|
||||||
|
export function meta(): Route.MetaDescriptors {
|
||||||
|
return [
|
||||||
|
{ title: "Sports - Brackt" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Explore the sports currently supported on Brackt.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const sports = await findPublicSportsWithCurrentSeasons();
|
||||||
|
return { sports };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(count: number, singular: string, plural = `${singular}s`) {
|
||||||
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SportGroupKey = "playoffs" | "standings" | "majors";
|
||||||
|
|
||||||
|
const SPORT_GROUPS: Array<{
|
||||||
|
key: SportGroupKey;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "playoffs",
|
||||||
|
title: "Playoffs",
|
||||||
|
description: "Bracket and postseason formats where advancement drives scoring.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "standings",
|
||||||
|
title: "Season-Long Standings",
|
||||||
|
description: "Full-season tables, races, and standings-based competitions.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "majors",
|
||||||
|
title: "Majors",
|
||||||
|
description: "Major tournaments and qualifying-points formats.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function seasonGroupKey(
|
||||||
|
season: PublicSportWithCurrentSeasons["currentSeasons"][number]
|
||||||
|
): SportGroupKey {
|
||||||
|
if (season.scoringPattern === "season_standings" || season.scoringType === "regular_season") {
|
||||||
|
return "standings";
|
||||||
|
}
|
||||||
|
if (season.scoringPattern === "qualifying_points" || season.scoringType === "majors") {
|
||||||
|
return "majors";
|
||||||
|
}
|
||||||
|
return "playoffs";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sportGroupKey(sport: PublicSportWithCurrentSeasons): SportGroupKey {
|
||||||
|
const keys = sport.currentSeasons.map(seasonGroupKey);
|
||||||
|
return SPORT_GROUPS.find((group) => keys.includes(group.key))?.key ?? "playoffs";
|
||||||
|
}
|
||||||
|
|
||||||
|
function SportCard({ sport }: { sport: PublicSportWithCurrentSeasons }) {
|
||||||
|
const fallbackDescription = "Available for Brackt leagues.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full gap-3 transition-colors hover:bg-card/80">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-border/70 bg-background/60">
|
||||||
|
<SportIcon
|
||||||
|
sportName={sport.name}
|
||||||
|
iconUrl={sport.iconUrl}
|
||||||
|
size="lg"
|
||||||
|
className="h-10 w-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="truncate text-xl">{sport.name}</CardTitle>
|
||||||
|
<p className="mt-1 text-sm capitalize text-muted-foreground">
|
||||||
|
{sport.type} sport · {pluralize(sport.currentSeasons.length, "current season")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
{sport.description || fallbackDescription}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SportSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
sports,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
sports: PublicSportWithCurrentSeasons[];
|
||||||
|
}) {
|
||||||
|
if (sports.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{description} {pluralize(sports.length, "sport")} available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<SportCard key={sport.id} sport={sport} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sports({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { sports } = loaderData;
|
||||||
|
const groupedSports = new Map<SportGroupKey, PublicSportWithCurrentSeasons[]>();
|
||||||
|
for (const group of SPORT_GROUPS) groupedSports.set(group.key, []);
|
||||||
|
for (const sport of sports) {
|
||||||
|
groupedSports.get(sportGroupKey(sport))?.push(sport);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-6xl px-4 py-10">
|
||||||
|
<div className="mb-10 max-w-3xl">
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl">Sports</h1>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-muted-foreground">
|
||||||
|
Brackt currently supports {pluralize(sports.length, "sport")} with active
|
||||||
|
or upcoming seasons, and we're adding more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sports.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-border/70 p-10 text-center">
|
||||||
|
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">No sports are listed yet</h2>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-muted-foreground">
|
||||||
|
Active and upcoming sports will appear here once they are available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-12">
|
||||||
|
{SPORT_GROUPS.map((group) => (
|
||||||
|
<SportSection
|
||||||
|
key={group.key}
|
||||||
|
title={group.title}
|
||||||
|
description={group.description}
|
||||||
|
sports={groupedSports.get(group.key) ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-12 rounded-md border border-border/70 bg-card/40 p-5 sm:flex sm:items-center sm:justify-between sm:gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">Want to see another sport on Brackt?</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Tell us what should be next and we'll take a look.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="mt-4 sm:mt-0">
|
||||||
|
<Link to="/support">
|
||||||
|
Contact support
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
app/utils/__tests__/sports-data-sync.test.ts
Normal file
96
app/utils/__tests__/sports-data-sync.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/database/schema", () => ({
|
||||||
|
seasonTemplateSports: "seasonTemplateSports",
|
||||||
|
seasonTemplates: "seasonTemplates",
|
||||||
|
seasonParticipants: "seasonParticipants",
|
||||||
|
seasonSports: "seasonSports",
|
||||||
|
sportsSeasons: {
|
||||||
|
sportId: "sports_seasons.sport_id",
|
||||||
|
name: "sports_seasons.name",
|
||||||
|
year: "sports_seasons.year",
|
||||||
|
},
|
||||||
|
sports: {
|
||||||
|
slug: "sports.slug",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||||
|
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { importSportsDataFromJSON } from "../sports-data-sync.server";
|
||||||
|
|
||||||
|
function makeImportData(overrides: Record<string, unknown> = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: "1.1",
|
||||||
|
exportedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
sports: [
|
||||||
|
{
|
||||||
|
name: "LLWS",
|
||||||
|
type: "team",
|
||||||
|
slug: "llws",
|
||||||
|
description: "Little League World Series",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sportsSeasons: [],
|
||||||
|
participants: [],
|
||||||
|
seasonTemplates: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTx(existingSport: Record<string, unknown>) {
|
||||||
|
const set = vi.fn().mockReturnThis();
|
||||||
|
const where = vi.fn().mockReturnThis();
|
||||||
|
return {
|
||||||
|
query: {
|
||||||
|
sports: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(existingSport),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: vi.fn(() => ({ set, where })),
|
||||||
|
insert: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
set,
|
||||||
|
where,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importSportsDataFromJSON", () => {
|
||||||
|
it("preserves an existing public page exclusion during legacy merge imports", async () => {
|
||||||
|
const tx = makeTx({ id: "sport-1", excludeFromHomepage: true });
|
||||||
|
vi.mocked(database).mockReturnValue({
|
||||||
|
transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await importSportsDataFromJSON(makeImportData(), "merge");
|
||||||
|
|
||||||
|
expect(tx.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
excludeFromHomepage: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an explicit public page exclusion value during merge imports", async () => {
|
||||||
|
const tx = makeTx({ id: "sport-1", excludeFromHomepage: true });
|
||||||
|
vi.mocked(database).mockReturnValue({
|
||||||
|
transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await importSportsDataFromJSON(makeImportData({ excludeFromHomepage: false }), "merge");
|
||||||
|
|
||||||
|
expect(tx.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
excludeFromHomepage: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,7 @@ const ImportDataSchema = z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
|
excludeFromHomepage: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
sportsSeasons: z.array(
|
sportsSeasons: z.array(
|
||||||
|
|
@ -147,6 +148,7 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
|
||||||
type: s.type,
|
type: s.type,
|
||||||
slug: s.slug,
|
slug: s.slug,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
|
excludeFromHomepage: s.excludeFromHomepage,
|
||||||
})),
|
})),
|
||||||
sportsSeasons: sportsSeasons.map((s) => ({
|
sportsSeasons: sportsSeasons.map((s) => ({
|
||||||
sportSlug: s.sport.slug,
|
sportSlug: s.sport.slug,
|
||||||
|
|
@ -266,6 +268,7 @@ export async function importSportsDataFromJSON(
|
||||||
name: sport.name,
|
name: sport.name,
|
||||||
type: sport.type as "team" | "individual",
|
type: sport.type as "team" | "individual",
|
||||||
description: sport.description,
|
description: sport.description,
|
||||||
|
excludeFromHomepage: sport.excludeFromHomepage ?? existing.excludeFromHomepage,
|
||||||
})
|
})
|
||||||
.where(eq(schema.sports.slug, sport.slug));
|
.where(eq(schema.sports.slug, sport.slug));
|
||||||
sportIdMap.set(sport.slug, existing.id);
|
sportIdMap.set(sport.slug, existing.id);
|
||||||
|
|
@ -279,6 +282,7 @@ export async function importSportsDataFromJSON(
|
||||||
type: sport.type as "team" | "individual",
|
type: sport.type as "team" | "individual",
|
||||||
slug: sport.slug,
|
slug: sport.slug,
|
||||||
description: sport.description,
|
description: sport.description,
|
||||||
|
excludeFromHomepage: sport.excludeFromHomepage ?? false,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
sportIdMap.set(sport.slug, created_.id);
|
sportIdMap.set(sport.slug, created_.id);
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@ export const sports = pgTable("sports", {
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
iconUrl: varchar("icon_url", { length: 512 }), // Cloudinary URL or legacy filename in /public/sports-icons/
|
iconUrl: varchar("icon_url", { length: 512 }), // Cloudinary URL or legacy filename in /public/sports-icons/
|
||||||
simulatorType: simulatorTypeEnum("simulator_type"),
|
simulatorType: simulatorTypeEnum("simulator_type"),
|
||||||
|
excludeFromHomepage: boolean("exclude_from_homepage").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1
drizzle/0110_thankful_nova.sql
Normal file
1
drizzle/0110_thankful_nova.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "sports" ADD COLUMN "exclude_from_homepage" boolean DEFAULT false NOT NULL;
|
||||||
6157
drizzle/meta/0110_snapshot.json
Normal file
6157
drizzle/meta/0110_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -771,6 +771,13 @@
|
||||||
"when": 1779211722613,
|
"when": 1779211722613,
|
||||||
"tag": "0109_fair_peter_quill",
|
"tag": "0109_fair_peter_quill",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 110,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779256349467,
|
||||||
|
"tag": "0110_thankful_nova",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue