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: "Rules", to: "/rules" },
|
||||
{ label: "How to Play", to: "/how-to-play" },
|
||||
{ label: "Sports", to: "/sports" },
|
||||
{ label: "Support", to: "/support" },
|
||||
{ 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="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
||||
<NavLink to="/sports" className={navLinkClass}>Sports</NavLink>
|
||||
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
@ -159,6 +160,13 @@ export function Navbar({ isAdmin, currentUser }: NavbarProps) {
|
|||
>
|
||||
How To Play
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/sports"
|
||||
className={mobileNavLinkClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Sports
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/rules"
|
||||
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 * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -17,6 +17,16 @@ export type SportType = "team" | "individual";
|
|||
export type SportWithActiveSeasons = Sport & {
|
||||
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> {
|
||||
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[]> {
|
||||
const db = database();
|
||||
return await db.query.sports.findMany({
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export default [
|
|||
route("user-profile", "routes/user-profile-redirect.tsx"),
|
||||
route("how-to-play", "routes/how-to-play.tsx"),
|
||||
route("rules", "routes/rules.tsx"),
|
||||
route("sports", "routes/sports.tsx"),
|
||||
route("support", "routes/support.tsx"),
|
||||
route("upcoming-events", "routes/upcoming-events.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 { findSportById, isSportIconUrlUsed, updateSport } from "~/models/sport";
|
||||
|
||||
function makeRequest(iconUrl: string) {
|
||||
function makeRequest(iconUrl: string, options: { excludeFromHomepage?: boolean } = {}) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.set("name", "NFL");
|
||||
formData.set("type", "team");
|
||||
|
|
@ -29,6 +29,9 @@ function makeRequest(iconUrl: string) {
|
|||
formData.set("description", "Football");
|
||||
formData.set("simulatorType", "playoff_bracket");
|
||||
formData.set("iconUrl", iconUrl);
|
||||
if (options.excludeFromHomepage) {
|
||||
formData.set("excludeFromHomepage", "on");
|
||||
}
|
||||
|
||||
return new Request("http://test/admin/sports/sport-1", {
|
||||
method: "POST",
|
||||
|
|
@ -48,12 +51,29 @@ beforeEach(() => {
|
|||
description: null,
|
||||
iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/old.svg",
|
||||
simulatorType: null,
|
||||
excludeFromHomepage: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as never);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
|
||||
vi.mocked(updateSport).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ import { action } from "../admin.sports.new";
|
|||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||
import { createSport, isSportIconUrlUsed } from "~/models/sport";
|
||||
|
||||
function makeRequest(iconUrl: string) {
|
||||
function makeRequest(iconUrl: string, options: { excludeFromHomepage?: boolean } = {}) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.set("name", "NFL");
|
||||
formData.set("type", "team");
|
||||
formData.set("slug", "nfl");
|
||||
formData.set("description", "Football");
|
||||
formData.set("iconUrl", iconUrl);
|
||||
if (options.excludeFromHomepage) {
|
||||
formData.set("excludeFromHomepage", "on");
|
||||
}
|
||||
|
||||
return new Request("http://test/admin/sports/new", {
|
||||
method: "POST",
|
||||
|
|
@ -38,6 +41,18 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
const response = await action({
|
||||
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 simulatorType = formData.get("simulatorType");
|
||||
const iconUrlValue = formData.get("iconUrl");
|
||||
const excludeFromHomepage = formData.get("excludeFromHomepage") === "on";
|
||||
|
||||
// Validation
|
||||
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,
|
||||
iconUrl,
|
||||
simulatorType: parsedSimulatorType,
|
||||
excludeFromHomepage,
|
||||
});
|
||||
|
||||
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
|
||||
|
|
@ -226,6 +228,26 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
|||
/>
|
||||
</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 && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{actionData.error}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
const slug = formData.get("slug");
|
||||
const description = formData.get("description");
|
||||
const iconUrlValue = formData.get("iconUrl");
|
||||
const excludeFromHomepage = formData.get("excludeFromHomepage") === "on";
|
||||
|
||||
// Validation
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
|
|
@ -71,6 +72,7 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
slug: slug.trim(),
|
||||
description: typeof description === "string" ? description.trim() : null,
|
||||
iconUrl,
|
||||
excludeFromHomepage,
|
||||
});
|
||||
|
||||
return redirect("/admin/sports");
|
||||
|
|
@ -160,6 +162,25 @@ export default function NewSport({ loaderData, actionData }: Route.ComponentProp
|
|||
/>
|
||||
</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 && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{actionData.error}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) {
|
|||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Public Page</TableHead>
|
||||
<TableHead>Current Seasons</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -101,6 +102,11 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) {
|
|||
<TableCell className="text-muted-foreground">
|
||||
{sport.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={sport.excludeFromHomepage ? "secondary" : "default"}>
|
||||
{sport.excludeFromHomepage ? "Hidden" : "Shown"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sport.activeSeasons.length > 0 ? (
|
||||
<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(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullable(),
|
||||
excludeFromHomepage: z.boolean().optional(),
|
||||
})
|
||||
),
|
||||
sportsSeasons: z.array(
|
||||
|
|
@ -147,6 +148,7 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
|
|||
type: s.type,
|
||||
slug: s.slug,
|
||||
description: s.description,
|
||||
excludeFromHomepage: s.excludeFromHomepage,
|
||||
})),
|
||||
sportsSeasons: sportsSeasons.map((s) => ({
|
||||
sportSlug: s.sport.slug,
|
||||
|
|
@ -266,6 +268,7 @@ export async function importSportsDataFromJSON(
|
|||
name: sport.name,
|
||||
type: sport.type as "team" | "individual",
|
||||
description: sport.description,
|
||||
excludeFromHomepage: sport.excludeFromHomepage ?? existing.excludeFromHomepage,
|
||||
})
|
||||
.where(eq(schema.sports.slug, sport.slug));
|
||||
sportIdMap.set(sport.slug, existing.id);
|
||||
|
|
@ -279,6 +282,7 @@ export async function importSportsDataFromJSON(
|
|||
type: sport.type as "team" | "individual",
|
||||
slug: sport.slug,
|
||||
description: sport.description,
|
||||
excludeFromHomepage: sport.excludeFromHomepage ?? false,
|
||||
})
|
||||
.returning();
|
||||
sportIdMap.set(sport.slug, created_.id);
|
||||
|
|
|
|||
|
|
@ -359,6 +359,7 @@ export const sports = pgTable("sports", {
|
|||
description: text("description"),
|
||||
iconUrl: varchar("icon_url", { length: 512 }), // Cloudinary URL or legacy filename in /public/sports-icons/
|
||||
simulatorType: simulatorTypeEnum("simulator_type"),
|
||||
excludeFromHomepage: boolean("exclude_from_homepage").notNull().default(false),
|
||||
createdAt: timestamp("created_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,
|
||||
"tag": "0109_fair_peter_quill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 110,
|
||||
"version": "7",
|
||||
"when": 1779256349467,
|
||||
"tag": "0110_thankful_nova",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue