From 32ae32ab6af8fd7d06dca73165e4ab70323c3034 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 01:57:53 +0000 Subject: [PATCH] Fix getEventsForDates to include bracket events by game scheduledAt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation only matched on scoringEvents.eventDate, which is often null for bracket events where individual game times are set on playoffMatchGames.scheduledAt instead. Switched to a two-step query mirroring getUpcomingEventsForDraftedParticipants: 1. selectDistinct event IDs via left joins through playoffMatches → playoffMatchGames, matching where eventDate IN dates OR any game's scheduledAt falls within the date range's timestamp bounds 2. findMany on the matched IDs with sportsSeason + sport relations Updated tests to cover the two-step flow, leftJoin usage, and the scheduledAt timestamp bounds appearing in the where clause. https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5 --- .../__tests__/scoring-event-dashboard.test.ts | 177 +++++++++++++----- app/models/scoring-event.ts | 56 +++++- 2 files changed, 174 insertions(+), 59 deletions(-) diff --git a/app/models/__tests__/scoring-event-dashboard.test.ts b/app/models/__tests__/scoring-event-dashboard.test.ts index 1cd6818..7097b29 100644 --- a/app/models/__tests__/scoring-event-dashboard.test.ts +++ b/app/models/__tests__/scoring-event-dashboard.test.ts @@ -3,12 +3,31 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -// ── DB mock ───────────────────────────────────────────────────────────────── +// ── DB mock ────────────────────────────────────────────────────────────────── +// selectDistinct returns a fluent chain; findMany is used in step 2. const mockFindMany = vi.fn(); +const mockSelectChainBase = { + from: vi.fn(), + leftJoin: vi.fn(), + where: vi.fn(), +}; + +function makeSelectChain(rows: { id: string }[]) { + const chain = { + from: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(rows), + }; + return chain; +} + +const mockSelectDistinct = vi.fn(); + const mockDb = { query: { scoringEvents: { findMany: mockFindMany }, }, + selectDistinct: mockSelectDistinct, }; vi.mock("~/database/context", () => ({ @@ -17,11 +36,20 @@ vi.mock("~/database/context", () => ({ vi.mock("~/database/schema", () => ({ scoringEvents: { + id: "se.id", eventDate: "se.event_date", eventType: "se.event_type", eventStartsAt: "se.event_starts_at", createdAt: "se.created_at", }, + playoffMatches: { + scoringEventId: "pm.scoring_event_id", + id: "pm.id", + }, + playoffMatchGames: { + playoffMatchId: "pmg.playoff_match_id", + scheduledAt: "pmg.scheduled_at", + }, })); vi.mock("drizzle-orm", () => ({ @@ -36,12 +64,11 @@ vi.mock("drizzle-orm", () => ({ isNotNull: (col: unknown) => ({ type: "isNotNull", col }), })); -// Must be imported after mocks are registered import { getEventsForDates } from "../scoring-event"; // ── Helpers ────────────────────────────────────────────────────────────────── -function makeScoringEventRow(overrides: Partial<{ +function makeEventRow(overrides: Partial<{ id: string; name: string; eventDate: string | null; @@ -68,33 +95,46 @@ function makeScoringEventRow(overrides: Partial<{ }; } -// ── Tests ──────────────────────────────────────────────────────────────────── +// ── Tests ───────────────────────────────────────────────────────────────────── describe("getEventsForDates", () => { beforeEach(() => { - mockFindMany.mockReset(); + vi.clearAllMocks(); + mockSelectDistinct.mockReturnValue(makeSelectChain([])); }); - it("returns an empty array when no dates are provided", async () => { + it("returns empty array and skips DB when no dates given", async () => { const result = await getEventsForDates([]); + expect(result).toEqual([]); + expect(mockSelectDistinct).not.toHaveBeenCalled(); + expect(mockFindMany).not.toHaveBeenCalled(); + }); + + it("returns empty array when selectDistinct finds no matching event IDs", async () => { + mockSelectDistinct.mockReturnValue(makeSelectChain([])); + + const result = await getEventsForDates(["2026-03-20"]); + expect(result).toEqual([]); expect(mockFindMany).not.toHaveBeenCalled(); }); it("maps database rows to DashboardScoringEvent shape", async () => { - const row = makeScoringEventRow({ - id: "e1", - name: "Conference Finals", - eventDate: "2026-03-20", - eventType: "playoff_game", - isComplete: false, - sportsSeasonId: "ss-1", - sportsSeason: { - name: "2026 NBA Playoffs", - sport: { name: "NBA", slug: "nba" }, - }, - }); - mockFindMany.mockResolvedValueOnce([row]); + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ + id: "e1", + name: "Conference Finals", + eventDate: "2026-03-20", + eventType: "playoff_game", + isComplete: false, + sportsSeasonId: "ss-1", + sportsSeason: { + name: "2026 NBA Playoffs", + sport: { name: "NBA", slug: "nba" }, + }, + }), + ]); const result = await getEventsForDates(["2026-03-20"]); @@ -113,9 +153,13 @@ describe("getEventsForDates", () => { }); it("returns events for multiple dates", async () => { - const todayRow = makeScoringEventRow({ id: "e1", eventDate: "2026-03-20" }); - const tomorrowRow = makeScoringEventRow({ id: "e2", eventDate: "2026-03-21" }); - mockFindMany.mockResolvedValueOnce([todayRow, tomorrowRow]); + mockSelectDistinct.mockReturnValue( + makeSelectChain([{ id: "e1" }, { id: "e2" }]) + ); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ id: "e1", eventDate: "2026-03-20" }), + makeEventRow({ id: "e2", eventDate: "2026-03-21" }), + ]); const result = await getEventsForDates(["2026-03-20", "2026-03-21"]); @@ -124,12 +168,14 @@ describe("getEventsForDates", () => { }); it("includes completed events with isComplete: true", async () => { - const completedRow = makeScoringEventRow({ - id: "e-done", - isComplete: true, - completedAt: new Date("2026-03-20T18:00:00Z"), - }); - mockFindMany.mockResolvedValueOnce([completedRow]); + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e-done" }])); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ + id: "e-done", + isComplete: true, + completedAt: new Date("2026-03-20T18:00:00Z"), + }), + ]); const result = await getEventsForDates(["2026-03-20"]); @@ -139,46 +185,75 @@ describe("getEventsForDates", () => { it("includes eventStartsAt when present", async () => { const starts = new Date("2026-03-20T19:30:00Z"); - const row = makeScoringEventRow({ id: "e1", eventStartsAt: starts }); - mockFindMany.mockResolvedValueOnce([row]); + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ id: "e1", eventStartsAt: starts }), + ]); const result = await getEventsForDates(["2026-03-20"]); expect(result[0].eventStartsAt).toEqual(starts); }); - it("passes both dates as an inArray condition to the query", async () => { - mockFindMany.mockResolvedValueOnce([]); + it("uses two DB calls: selectDistinct then findMany", async () => { + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1" })]); - await getEventsForDates(["2026-03-20", "2026-03-21"]); + await getEventsForDates(["2026-03-20"]); - const callArgs = mockFindMany.mock.calls[0][0]; - // The where clause should contain an inArray condition with both dates - const whereStr = JSON.stringify(callArgs.where); - expect(whereStr).toContain("2026-03-20"); - expect(whereStr).toContain("2026-03-21"); + expect(mockSelectDistinct).toHaveBeenCalledOnce(); + expect(mockFindMany).toHaveBeenCalledOnce(); }); - it("excludes schedule_event types via inArray condition", async () => { + it("passes event IDs from step 1 into findMany in step 2", async () => { + mockSelectDistinct.mockReturnValue( + makeSelectChain([{ id: "abc" }, { id: "def" }]) + ); mockFindMany.mockResolvedValueOnce([]); await getEventsForDates(["2026-03-20"]); - const callArgs = mockFindMany.mock.calls[0][0]; - const whereStr = JSON.stringify(callArgs.where); - // The allowed event types should be present - expect(whereStr).toContain("playoff_game"); - expect(whereStr).toContain("major_tournament"); - expect(whereStr).toContain("final_standings"); - // schedule_event should not appear - expect(whereStr).not.toContain("schedule_event"); + const findManyArgs = mockFindMany.mock.calls[0][0]; + const whereStr = JSON.stringify(findManyArgs.where); + expect(whereStr).toContain("abc"); + expect(whereStr).toContain("def"); }); - it("returns empty array when DB returns no rows", async () => { - mockFindMany.mockResolvedValueOnce([]); + it("uses leftJoin so bracket events without playoffMatchGames still qualify via eventDate", async () => { + const chain = makeSelectChain([]); + mockSelectDistinct.mockReturnValue(chain); - const result = await getEventsForDates(["2026-03-20"]); + await getEventsForDates(["2026-03-20"]); - expect(result).toEqual([]); + expect(chain.leftJoin).toHaveBeenCalled(); + }); + + it("includes both eventDate and scheduledAt timestamp bounds in the where clause", async () => { + const chain = makeSelectChain([]); + mockSelectDistinct.mockReturnValue(chain); + + await getEventsForDates(["2026-03-20", "2026-03-21"]); + + // The where clause is the first (and only) arg to chain.where + const whereArg = JSON.stringify((chain.where as ReturnType).mock.calls[0][0]); + // eventDate inArray check + expect(whereArg).toContain("2026-03-20"); + expect(whereArg).toContain("2026-03-21"); + // timestamp bounds for scheduledAt + expect(whereArg).toContain("T00:00:00.000Z"); + expect(whereArg).toContain("T23:59:59.999Z"); + }); + + it("excludes schedule_event type via the where clause", async () => { + const chain = makeSelectChain([]); + mockSelectDistinct.mockReturnValue(chain); + + await getEventsForDates(["2026-03-20"]); + + const whereArg = JSON.stringify((chain.where as ReturnType).mock.calls[0][0]); + expect(whereArg).toContain("playoff_game"); + expect(whereArg).toContain("major_tournament"); + expect(whereArg).toContain("final_standings"); + expect(whereArg).not.toContain("schedule_event"); }); }); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index f024489..8ca14cb 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -562,6 +562,11 @@ export interface DashboardScoringEvent { * Get all scoring events (excluding schedule_event type) for the given dates, * joined with their sports season and sport info. Used for the admin dashboard * "Games to Score" widget. + * + * An event is included if EITHER its own eventDate falls on one of the given + * dates, OR any of its playoffMatchGames has a scheduledAt timestamp on one of + * those dates. This ensures bracket events where the admin set dates on + * individual games (rather than the event itself) are still surfaced. */ export async function getEventsForDates( dates: string[], @@ -571,15 +576,50 @@ export async function getEventsForDates( const db = providedDb || database(); + // Build timestamp bounds covering the full span of the requested dates so we + // can range-check the playoffMatchGames.scheduledAt timestamp column. + const sortedDates = [...dates].sort(); + const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z"); + const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z"); + + // Step 1: collect distinct event IDs where the event date OR any game's + // scheduledAt falls within the requested dates. + const rows = await db + .selectDistinct({ id: schema.scoringEvents.id }) + .from(schema.scoringEvents) + .leftJoin( + schema.playoffMatches, + eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id) + ) + .leftJoin( + schema.playoffMatchGames, + eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id) + ) + .where( + and( + inArray(schema.scoringEvents.eventType, [ + "playoff_game", + "major_tournament", + "final_standings", + ]), + or( + inArray(schema.scoringEvents.eventDate, dates), + and( + isNotNull(schema.playoffMatchGames.scheduledAt), + gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp), + lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp) + ) + ) + ) + ); + + if (rows.length === 0) return []; + + const eventIds = rows.map((r) => r.id); + + // Step 2: fetch the matched events with sport season + sport info. const events = await db.query.scoringEvents.findMany({ - where: and( - inArray(schema.scoringEvents.eventDate, dates), - inArray(schema.scoringEvents.eventType, [ - "playoff_game", - "major_tournament", - "final_standings", - ]) - ), + where: inArray(schema.scoringEvents.id, eventIds), orderBy: [ asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.eventStartsAt),