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")}