From cce1115a4d5dafeff60b07fdd2a05c4f3b69a522 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 26 May 2026 22:26:03 -0700 Subject: [PATCH 1/2] Fix World Cup group stage display and upcoming events - Remove MD 1/2/3 grouping from GroupStageStandings; list matches chronologically under date separators (Jun 14, Jun 18, etc.) - Fix MatchResult layout: show kick-off time below team names instead of sandwiching the date between them - Sort group stage matches by scheduledAt ASC NULLS LAST in the model so unscheduled matches always trail scheduled ones - Add Groups/Bracket toggle to the sports season page when both a group stage and knockout bracket exist, mirroring the NBA/AFL standings toggle; sports with both standings and group stage now include all three views - Include group stage matches in getUpcomingEventsForDraftedParticipants so World Cup fixtures appear on the home page calendar - Fix isAllCompete check in UpcomingEventsCard and UpcomingCalendarPanel to treat group_stage_match as a bracket-style event (shows team badges, not "N of your picks") Co-Authored-By: Claude Sonnet 4.6 --- .../sport-season/GroupStageStandings.tsx | 55 +++++++++----- .../sport-season/UpcomingCalendarPanel.tsx | 2 +- .../sport-season/UpcomingEventsCard.tsx | 2 +- app/models/group-stage-match.ts | 8 +- app/models/scoring-event.ts | 74 ++++++++++++++++++- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 36 ++++++--- 6 files changed, 141 insertions(+), 36 deletions(-) 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/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")} -- 2.45.3 From e6f3c396ae4c622f345c6ede7822d5f509e598bd Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 26 May 2026 22:34:26 -0700 Subject: [PATCH 2/2] Fix test mocks for group stage upcoming events changes Add groupStageMatches, tournamentGroups, and seasonParticipants to the schema mock, add db.select mock for the group stage query, and wire default empty results into all bracket-path beforeEach hooks. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/upcoming-calendar.test.ts | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) 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. -- 2.45.3