Add public sports overview page (#451)

This commit is contained in:
Chris Parsons 2026-05-19 23:02:58 -07:00 committed by GitHub
parent 0ba2b391f8
commit 461847c414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 6868 additions and 3 deletions

View file

@ -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" },
]; ];

View file

@ -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}

View 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"]);
});
});

View file

@ -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({

View file

@ -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"),

View file

@ -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({

View file

@ -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"),

View 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");
});
});

View file

@ -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}

View file

@ -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}

View file

@ -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
View 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&apos;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&apos;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>
);
}

View 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,
}));
});
});

View file

@ -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);

View file

@ -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(),
}); });

View file

@ -0,0 +1 @@
ALTER TABLE "sports" ADD COLUMN "exclude_from_homepage" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }