Compare commits

..

No commits in common. "e6f3c396ae4c622f345c6ede7822d5f509e598bd" and "a02f6035758e48f4193f8f5bd121d04449a4e6d0" have entirely different histories.

7 changed files with 40 additions and 171 deletions

View file

@ -63,17 +63,14 @@ function MatchResult({ match }: { match: GroupMatch }) {
const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null; const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null;
return ( return (
<div className="flex flex-col gap-0"> <div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="truncate max-w-[80px]">{p1}</span>
<span className="truncate max-w-[80px]">{p1}</span> <span className="shrink-0 text-muted-foreground/60">
<span className="shrink-0 text-muted-foreground/60">vs</span> {kickoff
<span className="truncate max-w-[80px]">{p2}</span> ? kickoff.toLocaleDateString("en-US", { month: "short", day: "numeric" })
</div> : "vs"}
{kickoff && ( </span>
<span className="text-[10px] text-muted-foreground/50"> <span className="truncate max-w-[80px] text-right">{p2}</span>
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
</span>
)}
</div> </div>
); );
} }
@ -162,36 +159,24 @@ export function GroupStageStandings({
</div> </div>
{/* Match results */} {/* Match results */}
{group.matches.length > 0 && (() => { {group.matches.length > 0 && (
const sorted = group.matches.toSorted((a, b) => { <div className="px-4 pb-3 space-y-0.5 border-t pt-2">
if (!a.scheduledAt && !b.scheduledAt) return 0; {[1, 2, 3].map((day) => {
if (!a.scheduledAt) return 1; const dayMatches = group.matches.filter((m) => m.matchday === day);
if (!b.scheduledAt) return -1; if (dayMatches.length === 0) return null;
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(); return (
});
const byDay = new Map<string, GroupMatch[]>();
for (const m of sorted) {
const key = m.scheduledAt
? new Date(m.scheduledAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })
: "TBD";
if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key)?.push(m);
}
return (
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
{[...byDay.entries()].map(([day, matches]) => (
<div key={day} className="space-y-0.5"> <div key={day} className="space-y-0.5">
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1"> <p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
{day} MD {day}
</p> </p>
{matches.map((m) => ( {dayMatches.map((m) => (
<MatchResult key={m.id} match={m} /> <MatchResult key={m.id} match={m} />
))} ))}
</div> </div>
))} );
</div> })}
); </div>
})()} )}
</div> </div>
))} ))}
</div> </div>

View file

@ -61,7 +61,7 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague
? format(gameDate, "MMM d") ? format(gameDate, "MMM d")
: formatEventDate(event.eventDate); : formatEventDate(event.eventDate);
const participantCount = event.relevantParticipants.length; const participantCount = event.relevantParticipants.length;
const isAllCompete = event.eventType !== "playoff_game" && event.eventType !== "group_stage_match"; const isAllCompete = event.eventType !== "playoff_game";
const displayName = event.matchLabel const displayName = event.matchLabel
? `${event.name}${event.matchLabel}` ? `${event.name}${event.matchLabel}`
: event.name; : event.name;

View file

@ -45,7 +45,7 @@ function groupEvents(events: CalendarPanelEvent[]): GroupedEvent[] {
for (const event of events) { for (const event of events) {
const existing = map.get(event.id); const existing = map.get(event.id);
const isAllCompete = event.eventType !== "playoff_game" && event.eventType !== "group_stage_match"; const isAllCompete = event.eventType !== "playoff_game";
const leagueEntry: LeagueParticipants = { const leagueEntry: LeagueParticipants = {
leagueId: event.leagueId ?? "unknown", leagueId: event.leagueId ?? "unknown",

View file

@ -13,10 +13,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const mockDb = { const mockDb = {
query: { query: {
scoringEvents: { findMany: vi.fn() }, scoringEvents: { findMany: vi.fn() },
seasonParticipants: { findMany: vi.fn() },
}, },
selectDistinct: vi.fn(), selectDistinct: vi.fn(),
select: vi.fn(),
}; };
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
@ -24,24 +22,11 @@ vi.mock("~/database/context", () => ({
})); }));
vi.mock("~/database/schema", () => ({ vi.mock("~/database/schema", () => ({
scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type", name: "se.name" }, scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" },
playoffMatches: { id: "pm.id", scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number", isComplete: "pm.is_complete" }, playoffMatches: { id: "pm.id", scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number", isComplete: "pm.is_complete" },
playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" }, playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" },
groupStageMatches: { id: "gsm.id", tournamentGroupId: "gsm.tournament_group_id", participant1Id: "gsm.participant1_id", participant2Id: "gsm.participant2_id", scheduledAt: "gsm.scheduled_at", isComplete: "gsm.is_complete" },
tournamentGroups: { id: "tg.id", groupName: "tg.group_name", scoringEventId: "tg.scoring_event_id" },
seasonParticipants: { id: "sp.id" },
})); }));
// Chain helper for the group stage db.select() query (resolves at orderBy).
function makeGroupStageSelectChain(rows: unknown[]) {
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue(rows),
};
}
vi.mock("drizzle-orm", () => ({ vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }), eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }), and: (...args: unknown[]) => ({ type: "and", args }),
@ -85,11 +70,8 @@ function makeScoringEvent(overrides: Partial<{
describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => { describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// selectDistinct won't be called for all-compete; default for the "unknown // selectDistinct won't be called for all-compete
// pattern" test which falls through to the bracket path. mockDb.selectDistinct.mockReturnValue({ from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) });
mockDb.selectDistinct.mockReturnValue({ from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) });
mockDb.select.mockReturnValue(makeGroupStageSelectChain([]));
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
}); });
it("returns [] when draftedParticipants is empty", async () => { it("returns [] when draftedParticipants is empty", async () => {
@ -187,9 +169,6 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
// Default for the second selectDistinct call (max-game-number lookup). // Default for the second selectDistinct call (max-game-number lookup).
// Individual tests override the first call with mockReturnValueOnce. // Individual tests override the first call with mockReturnValueOnce.
mockDb.selectDistinct.mockReturnValue(makeMaxGameChain([])); mockDb.selectDistinct.mockReturnValue(makeMaxGameChain([]));
// Default: no group stage matches (db.select used by the group stage query).
mockDb.select.mockReturnValue(makeGroupStageSelectChain([]));
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
}); });
it("returns [] when draftedParticipants is empty", async () => { it("returns [] when draftedParticipants is empty", async () => {
@ -645,12 +624,7 @@ describe("getUpcomingEventsForDraftedParticipants — bracket misc", () => {
}; };
} }
beforeEach(() => { beforeEach(() => vi.clearAllMocks());
vi.clearAllMocks();
// Default: no group stage matches.
mockDb.select.mockReturnValue(makeGroupStageSelectChain([]));
mockDb.query.seasonParticipants.findMany.mockResolvedValue([]);
});
it("excludes scored (isComplete) matches by passing the filter to the DB query", async () => { it("excludes scored (isComplete) matches by passing the filter to the DB query", async () => {
// The DB mock returns rows only for matches the WHERE clause matches. // The DB mock returns rows only for matches the WHERE clause matches.

View file

@ -1,4 +1,4 @@
import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm"; import { and, asc, eq, gte, inArray, lte, or } 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";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -146,7 +146,7 @@ export async function findMatchesByGroupId(groupId: string) {
/** /**
* Fetch all matches for multiple groups in a single query. * Fetch all matches for multiple groups in a single query.
* Returns a Map from groupId matches (ordered by scheduledAt, createdAt). * Returns a Map from groupId matches (ordered by matchday, createdAt).
* Use this instead of calling findMatchesByGroupId N times. * Use this instead of calling findMatchesByGroupId N times.
*/ */
export async function findMatchesByGroupIds( export async function findMatchesByGroupIds(
@ -157,7 +157,7 @@ export async function findMatchesByGroupIds(
const rows = await db.query.groupStageMatches.findMany({ const rows = await db.query.groupStageMatches.findMany({
where: inArray(schema.groupStageMatches.tournamentGroupId, groupIds), where: inArray(schema.groupStageMatches.tournamentGroupId, groupIds),
orderBy: [ orderBy: [
sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`, asc(schema.groupStageMatches.matchday),
asc(schema.groupStageMatches.createdAt), asc(schema.groupStageMatches.createdAt),
], ],
with: { with: {
@ -183,7 +183,7 @@ export async function findMatchesByEventId(eventId: string) {
with: { with: {
matches: { matches: {
orderBy: [ orderBy: [
sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`, asc(schema.groupStageMatches.matchday),
asc(schema.groupStageMatches.createdAt), asc(schema.groupStageMatches.createdAt),
], ],
with: { with: {

View file

@ -588,79 +588,7 @@ export async function getUpcomingEventsForDraftedParticipants(
} }
} }
const bracketResults = Array.from(entryMap.values()); return Array.from(entryMap.values());
// Also include group stage matches for the drafted participants.
// Group stage matches live in groupStageMatches, not playoffMatches, so
// the bracket query above misses them entirely.
const groupStageRows = await db
.select({
matchId: schema.groupStageMatches.id,
groupName: schema.tournamentGroups.groupName,
scoringEventName: schema.scoringEvents.name,
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
scheduledAt: schema.groupStageMatches.scheduledAt,
participant1Id: schema.groupStageMatches.participant1Id,
participant2Id: schema.groupStageMatches.participant2Id,
})
.from(schema.groupStageMatches)
.innerJoin(
schema.tournamentGroups,
eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id)
)
.innerJoin(
schema.scoringEvents,
eq(schema.tournamentGroups.scoringEventId, schema.scoringEvents.id)
)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.groupStageMatches.isComplete, false),
or(
inArray(schema.groupStageMatches.participant1Id, draftedIds),
inArray(schema.groupStageMatches.participant2Id, draftedIds)
),
isNotNull(schema.groupStageMatches.scheduledAt),
gte(schema.groupStageMatches.scheduledAt, dateFromTimestamp),
lte(schema.groupStageMatches.scheduledAt, dateToTimestamp)
)
)
.orderBy(asc(schema.groupStageMatches.scheduledAt));
if (groupStageRows.length > 0) {
const allGroupParticipantIds = [
...new Set(groupStageRows.flatMap((r) => [r.participant1Id, r.participant2Id])),
];
const groupParticipantRows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allGroupParticipantIds),
});
const groupParticipantMap = new Map(groupParticipantRows.map((p) => [p.id, p]));
for (const row of groupStageRows) {
if (!row.scheduledAt) continue;
const p1 = groupParticipantMap.get(row.participant1Id);
const p2 = groupParticipantMap.get(row.participant2Id);
const relevantParticipants: Array<{ id: string; name: string }> = [];
if (draftedIds.includes(row.participant1Id) && p1) {
relevantParticipants.push({ id: row.participant1Id, name: p1.name });
}
if (draftedIds.includes(row.participant2Id) && p2) {
relevantParticipants.push({ id: row.participant2Id, name: p2.name });
}
bracketResults.push({
id: `group|${row.matchId}`,
name: row.scoringEventName,
eventDate: null,
earliestGameTime: row.scheduledAt.toISOString(),
matchLabel: `Group ${row.groupName}${p1?.name ?? "?"} vs ${p2?.name ?? "?"}`,
eventType: "group_stage_match",
sportsSeasonId: row.sportsSeasonId,
relevantParticipants,
});
}
}
return bracketResults;
} }
export interface DashboardScoringEvent { export interface DashboardScoringEvent {

View file

@ -17,7 +17,7 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export { loader }; export { loader };
type BracketView = "standings" | "playoffs" | "finished" | "groups"; type BracketView = "standings" | "playoffs" | "finished";
export default function SportSeasonDetail({ export default function SportSeasonDetail({
loaderData, loaderData,
@ -66,35 +66,20 @@ export default function SportSeasonDetail({
]) ])
); );
// Show the 3-way toggle for bracket sports with regular season standings (NBA/AFL/etc.) // Show the 3-way toggle only for bracket sports that also have regular season standings
const showToggle = hasStandings && scoringPattern === "playoff_bracket"; const showToggle = hasStandings && scoringPattern === "playoff_bracket";
// Show Groups/Bracket toggle for tournaments with a group stage AND a knockout bracket.
// Independent of showToggle — a sport can have both standings and group stage.
const hasGroupStage = groupStandings.length > 0;
const showGroupStageToggle = hasGroupStage && hasBracket;
const showAnyToggle = showToggle || showGroupStageToggle;
const [view, setView] = useState<BracketView>(() => { const [view, setView] = useState<BracketView>(() => {
if (showGroupStageToggle) {
return sportsSeason.status === "completed" ? "finished" : "playoffs";
}
if (!hasBracket) return "standings"; if (!hasBracket) return "standings";
if (sportsSeason.status === "completed") return "finished"; if (sportsSeason.status === "completed") return "finished";
return "playoffs"; return "playoffs";
}); });
const TOGGLE_VIEWS: { value: BracketView; label: string }[] = showGroupStageToggle const TOGGLE_VIEWS: { value: BracketView; label: string }[] = [
? [ { value: "standings", label: "Standings" },
{ value: "groups", label: "Groups" }, { value: "playoffs", label: "Playoffs" },
...(showToggle ? [{ value: "standings" as BracketView, label: "Standings" }] : []), { value: "finished", label: "Finished" },
{ value: "playoffs", label: "Bracket" }, ];
...(sportsSeason.status === "completed" ? [{ value: "finished" as BracketView, label: "Final" }] : []),
]
: [
{ value: "standings", label: "Standings" },
{ value: "playoffs", label: "Playoffs" },
{ value: "finished", label: "Finished" },
];
const bracketDisplay = (bracketMode: "bracket" | "rankings") => ( const bracketDisplay = (bracketMode: "bracket" | "rankings") => (
<SportSeasonDisplay <SportSeasonDisplay
@ -158,7 +143,7 @@ export default function SportSeasonDetail({
</p> </p>
</div> </div>
{showAnyToggle && ( {showToggle && (
<div className="flex gap-1 rounded-lg bg-muted p-1 sm:shrink-0"> <div className="flex gap-1 rounded-lg bg-muted p-1 sm:shrink-0">
{TOGGLE_VIEWS.map(({ value, label }) => ( {TOGGLE_VIEWS.map(({ value, label }) => (
<button <button
@ -180,11 +165,8 @@ export default function SportSeasonDetail({
</div> </div>
</div> </div>
{showAnyToggle ? ( {showToggle ? (
<div> <div>
{view === "groups" && (
<GroupStageStandings groups={groupStandings} ownershipMap={ownershipMap} showEmpty={true} />
)}
{view === "standings" && standingsDisplay} {view === "standings" && standingsDisplay}
{view === "playoffs" && bracketDisplay("bracket")} {view === "playoffs" && bracketDisplay("bracket")}
{view === "finished" && bracketDisplay("rankings")} {view === "finished" && bracketDisplay("rankings")}