diff --git a/app/components/sport-season/GroupStageStandings.tsx b/app/components/sport-season/GroupStageStandings.tsx index b1e1dcc..4931914 100644 --- a/app/components/sport-season/GroupStageStandings.tsx +++ b/app/components/sport-season/GroupStageStandings.tsx @@ -63,14 +63,17 @@ function MatchResult({ match }: { match: GroupMatch }) { const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null; return ( -
- {p1} - - {kickoff - ? kickoff.toLocaleDateString("en-US", { month: "short", day: "numeric" }) - : "vs"} - - {p2} +
+
+ {p1} + vs + {p2} +
+ {kickoff && ( + + {kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} + + )}
); } @@ -159,24 +162,36 @@ export function GroupStageStandings({
{/* Match results */} - {group.matches.length > 0 && ( -
- {[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(); + 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 ( +
+ {[...byDay.entries()].map(([day, matches]) => (

- MD {day} + {day}

- {dayMatches.map((m) => ( + {matches.map((m) => ( ))}
- ); - })} -
- )} + ))} +
+ ); + })()} ))} diff --git a/app/components/sport-season/UpcomingCalendarPanel.tsx b/app/components/sport-season/UpcomingCalendarPanel.tsx index 71acd58..f881ffa 100644 --- a/app/components/sport-season/UpcomingCalendarPanel.tsx +++ b/app/components/sport-season/UpcomingCalendarPanel.tsx @@ -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; diff --git a/app/components/sport-season/UpcomingEventsCard.tsx b/app/components/sport-season/UpcomingEventsCard.tsx index e965944..fb93d79 100644 --- a/app/components/sport-season/UpcomingEventsCard.tsx +++ b/app/components/sport-season/UpcomingEventsCard.tsx @@ -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", diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index 609ea7a..80c1e7d 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -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. diff --git a/app/models/group-stage-match.ts b/app/models/group-stage-match.ts index 62e7e16..fa46faa 100644 --- a/app/models/group-stage-match.ts +++ b/app/models/group-stage-match.ts @@ -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: { diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 67c4078..162ddc8 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -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 { diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index 053d64b..119c31c 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -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(() => { + 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") => ( - {showToggle && ( + {showAnyToggle && (
{TOGGLE_VIEWS.map(({ value, label }) => (
- {showToggle ? ( + {showAnyToggle ? (
+ {view === "groups" && ( + + )} {view === "standings" && standingsDisplay} {view === "playoffs" && bracketDisplay("bracket")} {view === "finished" && bracketDisplay("rankings")}