Use sport icons in league views (#394)

This commit is contained in:
Chris Parsons 2026-05-07 23:06:49 -07:00 committed by GitHub
parent 20685bae6b
commit 777315541b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 132 additions and 25 deletions

View file

@ -1,6 +1,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Calendar, CheckCircle2, ChevronRight, Clock, Layers, ListOrdered, SquareStack, Trophy } from "lucide-react"; import { Calendar, CheckCircle2, Clock, Layers } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { SportIcon as SportSeasonIcon } from "~/components/SportIcon";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { Card, CardContent, CardHeader } from "~/components/ui/card";
@ -21,6 +22,7 @@ export interface DraftedParticipantSummary {
export interface SportSeasonSummaryItem { export interface SportSeasonSummaryItem {
id: string; id: string;
sportName: string; sportName: string;
sportIconUrl?: string | null;
seasonName: string; seasonName: string;
status: "upcoming" | "active" | "completed"; status: "upcoming" | "active" | "completed";
scoringPattern?: string | null; scoringPattern?: string | null;
@ -52,25 +54,6 @@ function sortedByStatus(items: SportSeasonSummaryItem[]): SportSeasonSummaryItem
}); });
} }
const PATTERN_ICON: Record<string, { icon: typeof ChevronRight; title: string }> = {
playoff_bracket: { icon: ChevronRight, title: "Bracket" },
season_standings: { icon: ListOrdered, title: "Season Standings" },
qualifying_points: { icon: SquareStack, title: "Qualifying Points" },
};
function SportIcon({ pattern }: { pattern?: string | null }) {
const entry = pattern ? PATTERN_ICON[pattern] : null;
const Icon = entry?.icon ?? Trophy;
return (
<div
className="h-9 w-9 shrink-0 flex items-center justify-center bg-black"
title={entry?.title}
>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
/** Format the next upcoming event into a short string. */ /** Format the next upcoming event into a short string. */
function formatNextEvent(event: UpcomingParticipantEvent): string { function formatNextEvent(event: UpcomingParticipantEvent): string {
const base = event.matchLabel const base = event.matchLabel
@ -175,8 +158,15 @@ function SportRow({
: "hover:bg-white/[0.06]" : "hover:bg-white/[0.06]"
}`} }`}
> >
{/* Left: scoring pattern icon in black box */} {/* Left: sport icon */}
<SportIcon pattern={item.scoringPattern} /> <div className="flex h-9 w-9 shrink-0 items-center justify-center">
<SportSeasonIcon
sportName={item.sportName}
iconUrl={item.sportIconUrl}
size="md"
decorative
/>
</div>
{/* Right: name + participants + next event */} {/* Right: name + participants + next event */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View file

@ -1,7 +1,6 @@
import { ClipboardList } from "lucide-react"; import { ClipboardList } from "lucide-react";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { SportIcon } from "~/components/league/SportIcon"; import { SportIcon } from "~/components/league/SportIcon";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection"; import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
@ -63,13 +62,15 @@ export function SportsSection({
!canEditSports && "cursor-not-allowed opacity-65" !canEditSports && "cursor-not-allowed opacity-65"
)} )}
> >
<Checkbox <input
id={`sport-${ss.id}`} id={`sport-${ss.id}`}
type="checkbox"
name="sportsSeasons" name="sportsSeasons"
value={ss.id} value={ss.id}
checked={selected} checked={selected}
onCheckedChange={() => onSportToggle(ss.id)} onChange={() => onSportToggle(ss.id)}
disabled={!canEditSports} disabled={!canEditSports}
className="sr-only"
/> />
<SportIcon sport={ss.sport} /> <SportIcon sport={ss.sport} />
<span className="min-w-0 flex-1 overflow-hidden"> <span className="min-w-0 flex-1 overflow-hidden">

View file

@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState, type FormEvent } from "react";
import { describe, expect, it, vi } from "vitest";
import { SportsSection } from "../SportsSection";
type SportsSeason = {
id: string;
name: string;
year: number;
scoringType: string;
sport: { id: string; name: string; slug: string; type: string; iconUrl: string | null };
};
const displaySportsSeasons: SportsSeason[] = [
{
id: "nba-2025",
name: "2025 Season",
year: 2025,
scoringType: "standings",
sport: { id: "sport-1", name: "NBA", slug: "nba", type: "team", iconUrl: "/sports-icons/nba.svg" },
},
{
id: "mlb-2025",
name: "2025 Season",
year: 2025,
scoringType: "playoffs",
sport: { id: "sport-2", name: "MLB", slug: "mlb", type: "team", iconUrl: "/sports-icons/mlb.svg" },
},
];
function SportsSectionHarness({
initialSelected = ["nba-2025"],
canEditSports = true,
onSubmit,
}: {
initialSelected?: string[];
canEditSports?: boolean;
onSubmit?: (selectedSports: string[]) => void;
}) {
const [selectedSports, setSelectedSports] = useState(new Set(initialSelected));
const handleSportToggle = (id: string) => {
setSelectedSports((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleClearAll = () => {
setSelectedSports(new Set());
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
onSubmit?.(formData.getAll("sportsSeasons").map(String));
};
return (
<form onSubmit={handleSubmit}>
<SportsSection
active
canEditSports={canEditSports}
selectedSports={selectedSports}
displaySportsSeasons={displaySportsSeasons}
onSportToggle={handleSportToggle}
onClearAll={handleClearAll}
/>
<button type="submit">Save</button>
</form>
);
}
describe("SportsSection", () => {
it("keeps native checkbox semantics while using the row as the visible control", async () => {
const user = userEvent.setup();
render(<SportsSectionHarness />);
const mlbCheckbox = screen.getByRole("checkbox", { name: /mlb - 2025 season \(2025\)/i });
expect(mlbCheckbox).not.toBeChecked();
await user.click(screen.getByText("MLB - 2025 Season (2025)"));
expect(mlbCheckbox).toBeChecked();
});
it("submits the currently selected sports seasons after toggling", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<SportsSectionHarness onSubmit={handleSubmit} />);
await user.click(screen.getByText("MLB - 2025 Season (2025)"));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(handleSubmit).toHaveBeenCalledWith(["nba-2025", "mlb-2025"]);
});
it("submits an empty sports selection after clearing all", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<SportsSectionHarness onSubmit={handleSubmit} />);
await user.click(screen.getByRole("button", { name: /clear all/i }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(handleSubmit).toHaveBeenCalledWith([]);
});
});

View file

@ -25,6 +25,7 @@ export interface SeasonWithSportsSeasons extends Season {
name: string; name: string;
type: string; type: string;
slug: string; slug: string;
iconUrl: string | null;
}; };
}; };
}>; }>;

View file

@ -232,6 +232,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
sportsSeasons={sortedSportsSeasons.map((ss) => ({ sportsSeasons={sortedSportsSeasons.map((ss) => ({
id: ss.id, id: ss.id,
sportName: ss.sport.name, sportName: ss.sport.name,
sportIconUrl: ss.sport.iconUrl,
seasonName: ss.name, seasonName: ss.name,
status: ss.status, status: ss.status,
scoringPattern: ss.scoringPattern, scoringPattern: ss.scoringPattern,