brackt/app/models/group-stage-match.ts
chrisp a9d4954242
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 1m28s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m17s
🚀 Deploy / 🔍 Lint (push) Successful in 49s
🚀 Deploy / 🐳 Build (push) Successful in 14m50s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s
Fix World Cup group stage display and upcoming events (#55)
## Summary

- **Group stage match list**: Removed MD 1/2/3 headings; matches now listed chronologically under date separators (e.g. "Jun 14"). Kick-off time appears below the team names instead of between them.
- **Sort order**: \`findMatchesByGroupIds\` / \`findMatchesByEventId\` now order by \`scheduledAt ASC NULLS LAST\` so unscheduled matches always trail scheduled ones.
- **Groups/Bracket toggle**: When both group stage and knockout bracket exist, a toggle appears (mirroring the NBA/AFL standings toggle). Sports with both regular-season standings and a group stage get all views available.
- **Upcoming events**: \`getUpcomingEventsForDraftedParticipants\` now also queries \`groupStageMatches\`, so World Cup group fixtures appear on the home page calendar sorted by kick-off time with a label like "Group A — France vs Germany".
- **\`isAllCompete\` fix**: \`UpcomingEventsCard\` and \`UpcomingCalendarPanel\` now treat \`group_stage_match\` as a bracket-style event, showing team badges instead of "N of your picks".

Fixes #53

## Test plan

- [ ] Navigate to a World Cup sports season page — groups should be listed chronologically under date headers with no MD labels; kick-off time should appear below each match row
- [ ] Once the knockout bracket has matches, confirm the Groups/Bracket toggle appears and both views render correctly
- [ ] On the home page, a user with drafted World Cup participants should see upcoming group fixtures in the calendar panel with individual team badges (not a participant count)
- [ ] NBA/AFL/NHL pages unaffected — their standings/playoffs/finished toggle still works normally
- [ ] \`npm run typecheck\` and \`npm run test:run\` pass

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #55
2026-05-27 05:43:08 +00:00

361 lines
10 KiB
TypeScript

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";
export type GroupStageMatch = typeof schema.groupStageMatches.$inferSelect;
export interface GroupStandingsRow {
participantId: string;
participantName: string;
played: number;
wins: number;
draws: number;
losses: number;
gf: number; // goals for
ga: number; // goals against
gd: number; // goal difference
points: number;
}
export interface UpcomingGroupMatch {
matchId: string;
groupId: string;
groupName: string;
sportsSeasonId: string;
matchday: number;
scheduledAt: string; // ISO string
isComplete: boolean;
participant1: { id: string; name: string };
participant2: { id: string; name: string };
participant1Score: number | null;
participant2Score: number | null;
}
interface MatchInput {
participant1Id: string;
participant2Id: string;
matchday: number;
scheduledAt?: Date | null;
}
/**
* Create the 6 round-robin matches for a tournament group.
* Matches are created without scores (to be filled in as the tournament progresses).
*/
export async function createGroupStageMatches(
groupId: string,
matches: MatchInput[]
): Promise<GroupStageMatch[]> {
const db = database();
return await db
.insert(schema.groupStageMatches)
.values(
matches.map((m) => ({
tournamentGroupId: groupId,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
matchday: m.matchday,
scheduledAt: m.scheduledAt ?? null,
}))
)
.returning();
}
/**
* Generate all 6 round-robin pairings for 4 participants in a group.
* Uses a fixed round-robin schedule (matchdays 1, 2, 3).
*
* Standard 4-team round-robin schedule:
* Matchday 1: 1v4, 2v3
* Matchday 2: 1v3, 4v2
* Matchday 3: 1v2, 3v4
*/
export function generateRoundRobinPairings(
participantIds: string[]
): MatchInput[] {
if (participantIds.length !== 4) {
throw new Error("Round-robin generation requires exactly 4 participants");
}
const [p1, p2, p3, p4] = participantIds;
return [
{ participant1Id: p1, participant2Id: p4, matchday: 1 },
{ participant1Id: p2, participant2Id: p3, matchday: 1 },
{ participant1Id: p1, participant2Id: p3, matchday: 2 },
{ participant1Id: p4, participant2Id: p2, matchday: 2 },
{ participant1Id: p1, participant2Id: p2, matchday: 3 },
{ participant1Id: p3, participant2Id: p4, matchday: 3 },
];
}
/**
* Record the result of a group stage match and mark it complete.
*/
export async function updateGroupStageMatchResult(
matchId: string,
participant1Score: number,
participant2Score: number
): Promise<GroupStageMatch> {
const db = database();
const [updated] = await db
.update(schema.groupStageMatches)
.set({
participant1Score,
participant2Score,
isComplete: true,
updatedAt: new Date(),
})
.where(eq(schema.groupStageMatches.id, matchId))
.returning();
return updated;
}
/**
* Update the scheduled time of a group stage match.
*/
export async function updateGroupStageMatchSchedule(
matchId: string,
scheduledAt: Date | null
): Promise<GroupStageMatch> {
const db = database();
const [updated] = await db
.update(schema.groupStageMatches)
.set({ scheduledAt, updatedAt: new Date() })
.where(eq(schema.groupStageMatches.id, matchId))
.returning();
return updated;
}
/**
* Fetch all matches for a group, with participant info.
*/
export async function findMatchesByGroupId(groupId: string) {
const db = database();
return await db.query.groupStageMatches.findMany({
where: eq(schema.groupStageMatches.tournamentGroupId, groupId),
orderBy: [
asc(schema.groupStageMatches.matchday),
asc(schema.groupStageMatches.createdAt),
],
with: {
participant1: true,
participant2: true,
},
});
}
/**
* Fetch all matches for multiple groups in a single query.
* Returns a Map from groupId → matches (ordered by scheduledAt, createdAt).
* Use this instead of calling findMatchesByGroupId N times.
*/
export async function findMatchesByGroupIds(
groupIds: string[]
): Promise<Map<string, Awaited<ReturnType<typeof findMatchesByGroupId>>>> {
if (groupIds.length === 0) return new Map();
const db = database();
const rows = await db.query.groupStageMatches.findMany({
where: inArray(schema.groupStageMatches.tournamentGroupId, groupIds),
orderBy: [
sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`,
asc(schema.groupStageMatches.createdAt),
],
with: {
participant1: true,
participant2: true,
},
});
const result = new Map<string, typeof rows>();
for (const groupId of groupIds) result.set(groupId, []);
for (const row of rows) {
result.get(row.tournamentGroupId)?.push(row);
}
return result;
}
/**
* Fetch all group stage matches for a scoring event (all groups combined).
*/
export async function findMatchesByEventId(eventId: string) {
const db = database();
const groups = await db.query.tournamentGroups.findMany({
where: eq(schema.tournamentGroups.scoringEventId, eventId),
with: {
matches: {
orderBy: [
sql`${schema.groupStageMatches.scheduledAt} ASC NULLS LAST`,
asc(schema.groupStageMatches.createdAt),
],
with: {
participant1: true,
participant2: true,
},
},
},
});
return groups;
}
/**
* Compute standings for a group from match results.
* Sorted by: points desc → goal difference desc → goals for desc → name asc.
*/
export function computeGroupStandings(
members: Array<{ participantId: string; participantName: string }>,
matches: Array<{
participant1Id: string;
participant2Id: string;
participant1Score: number | null;
participant2Score: number | null;
isComplete: boolean;
}>
): GroupStandingsRow[] {
const stats = new Map<string, GroupStandingsRow>();
for (const m of members) {
stats.set(m.participantId, {
participantId: m.participantId,
participantName: m.participantName,
played: 0,
wins: 0,
draws: 0,
losses: 0,
gf: 0,
ga: 0,
gd: 0,
points: 0,
});
}
for (const match of matches) {
if (!match.isComplete) continue;
if (match.participant1Score === null || match.participant2Score === null) {
logger.warn(
`[computeGroupStandings] Match between ${match.participant1Id} and ${match.participant2Id} is marked complete but has null scores — skipping`
);
continue;
}
const p1 = stats.get(match.participant1Id);
const p2 = stats.get(match.participant2Id);
if (!p1 || !p2) continue;
const s1 = match.participant1Score;
const s2 = match.participant2Score;
p1.played++;
p2.played++;
p1.gf += s1;
p1.ga += s2;
p2.gf += s2;
p2.ga += s1;
p1.gd = p1.gf - p1.ga;
p2.gd = p2.gf - p2.ga;
if (s1 > s2) {
p1.wins++;
p1.points += 3;
p2.losses++;
} else if (s2 > s1) {
p2.wins++;
p2.points += 3;
p1.losses++;
} else {
p1.draws++;
p2.draws++;
p1.points++;
p2.points++;
}
}
return [...stats.values()].toSorted((a, b) => {
if (b.points !== a.points) return b.points - a.points;
if (b.gd !== a.gd) return b.gd - a.gd;
if (b.gf !== a.gf) return b.gf - a.gf;
return a.participantName.localeCompare(b.participantName);
});
}
/**
* Get upcoming group stage matches (within a date window) for specific participants.
* Used for calendar/schedule display on league home and team pages.
*/
export async function getUpcomingGroupStageMatchesForParticipants(
sportsSeasonId: string,
participantIds: string[],
dateFrom: Date,
dateTo: Date
): Promise<UpcomingGroupMatch[]> {
if (participantIds.length === 0) return [];
const db = database();
const rows = await db
.select({
matchId: schema.groupStageMatches.id,
groupId: schema.tournamentGroups.id,
groupName: schema.tournamentGroups.groupName,
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
matchday: schema.groupStageMatches.matchday,
scheduledAt: schema.groupStageMatches.scheduledAt,
isComplete: schema.groupStageMatches.isComplete,
participant1Id: schema.groupStageMatches.participant1Id,
participant2Id: schema.groupStageMatches.participant2Id,
participant1Score: schema.groupStageMatches.participant1Score,
participant2Score: schema.groupStageMatches.participant2Score,
})
.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),
or(
inArray(schema.groupStageMatches.participant1Id, participantIds),
inArray(schema.groupStageMatches.participant2Id, participantIds)
),
and(
gte(schema.groupStageMatches.scheduledAt, dateFrom),
lte(schema.groupStageMatches.scheduledAt, dateTo)
)
)
)
.orderBy(asc(schema.groupStageMatches.scheduledAt));
if (rows.length === 0) return [];
// Load participant names for all involved participants
const allParticipantIds = [
...new Set(rows.flatMap((r) => [r.participant1Id, r.participant2Id])),
];
const participantRows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allParticipantIds),
});
const participantMap = new Map(participantRows.map((p) => [p.id, p]));
return rows
.filter((r) => r.scheduledAt !== null)
.map((r) => {
const p1 = participantMap.get(r.participant1Id);
const p2 = participantMap.get(r.participant2Id);
return {
matchId: r.matchId,
groupId: r.groupId,
groupName: r.groupName,
sportsSeasonId: r.sportsSeasonId,
matchday: r.matchday,
scheduledAt: r.scheduledAt?.toISOString() ?? "",
isComplete: r.isComplete,
participant1: { id: r.participant1Id, name: p1?.name ?? "Unknown" },
participant2: { id: r.participant2Id, name: p2?.name ?? "Unknown" },
participant1Score: r.participant1Score,
participant2Score: r.participant2Score,
};
});
}