Fix World Cup group stage display and upcoming events #55

Merged
chrisp merged 2 commits from fix/world-cup-group-stage-display into main 2026-05-27 05:43:08 +00:00
7 changed files with 171 additions and 40 deletions

View file

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

View file

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

View file

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

View file

@ -13,8 +13,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const mockDb = {
query: {
scoringEvents: { findMany: vi.fn() },
seasonParticipants: { findMany: vi.fn() },
},
selectDistinct: vi.fn(),
select: vi.fn(),
};
vi.mock("~/database/context", () => ({
@ -22,11 +24,24 @@ vi.mock("~/database/context", () => ({
}));
vi.mock("~/database/schema", () => ({
scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" },
scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type", name: "se.name" },
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" },
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", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
@ -70,8 +85,11 @@ function makeScoringEvent(overrides: Partial<{
describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => {
beforeEach(() => {
vi.clearAllMocks();
// selectDistinct won't be called for all-compete
mockDb.selectDistinct.mockReturnValue({ from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) });
// selectDistinct won't be called for all-compete; default for the "unknown
// pattern" test which falls through to the bracket path.
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 () => {
@ -169,6 +187,9 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
// Default for the second selectDistinct call (max-game-number lookup).
// Individual tests override the first call with mockReturnValueOnce.
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 () => {
@ -624,7 +645,12 @@ describe("getUpcomingEventsForDraftedParticipants — bracket misc", () => {
};
}
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
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 () => {
// 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 } from "drizzle-orm";
import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
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.
* Returns a Map from groupId matches (ordered by matchday, createdAt).
* Returns a Map from groupId matches (ordered by scheduledAt, createdAt).
* Use this instead of calling findMatchesByGroupId N times.
*/
export async function findMatchesByGroupIds(
@ -157,7 +157,7 @@ export async function findMatchesByGroupIds(
const rows = await db.query.groupStageMatches.findMany({
where: inArray(schema.groupStageMatches.tournamentGroupId, groupIds),
orderBy: [
asc(schema.groupStageMatches.matchday),
sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`,
asc(schema.groupStageMatches.createdAt),
],
with: {
@ -183,7 +183,7 @@ export async function findMatchesByEventId(eventId: string) {
with: {
matches: {
orderBy: [
asc(schema.groupStageMatches.matchday),
sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`,
asc(schema.groupStageMatches.createdAt),
],
with: {

View file

@ -588,7 +588,79 @@ export async function getUpcomingEventsForDraftedParticipants(
}
}
return Array.from(entryMap.values());
const bracketResults = 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 {

View file

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