From dc13d99f3919b7cbafcb3903f77a4567aa6011ad Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:22:42 -0700 Subject: [PATCH] Add event_starts_at timestamp to scoring events for sub-day ordering (#146) - New `event_starts_at timestamptz` column on `scoring_events`; backfilled from `event_date` (midnight UTC) via migration for all existing rows - Admin create/edit forms consolidated from separate Date + Time inputs into a single `datetime-local` field; server derives `eventDate` from the UTC date portion of `eventStartsAt` so calendar-window queries continue to work - Edit form pre-fill computed client-side via useEffect to avoid server-timezone mismatch for non-UTC admins - Sort fix: replaced `eventDate ?? earliestGameTime.split("T")[0]` with `toEventSortKey` helper that prefers `earliestGameTime ?? eventDate` so bracket events with only per-game timestamps sort in the correct calendar position - DB sort queries updated to use `eventStartsAt` as a tiebreaker after `eventDate` - F1/IndyCar/golf events now populate `earliestGameTime` from `eventStartsAt`, giving time display in `UpcomingCalendarPanel` - Bulk import gains optional `HH:MM` (UTC) third column - New unit tests for `toEventSortKey` and `eventStartsAt` model behavior Co-authored-by: Claude Sonnet 4.6 --- app/components/sport-season/EventSchedule.tsx | 6 + app/lib/__tests__/date-utils.test.ts | 27 +- app/lib/date-utils.ts | 19 + .../__tests__/upcoming-calendar.test.ts | 88 + app/models/scoring-event.ts | 17 +- ...orts-seasons.$id.events.$eventId.server.ts | 21 +- ...min.sports-seasons.$id.events.$eventId.tsx | 33 +- .../admin.sports-seasons.$id.events.server.ts | 23 +- .../admin.sports-seasons.$id.events.tsx | 28 +- app/routes/home.tsx | 7 +- app/routes/leagues/$leagueId.server.ts | 9 +- database/schema.ts | 1 + drizzle/0041_great_magma.sql | 1 + .../0042_migrate_event_date_to_starts_at.sql | 6 + drizzle/meta/0041_snapshot.json | 3528 +++++++++++++++++ drizzle/meta/_journal.json | 14 + 16 files changed, 3790 insertions(+), 38 deletions(-) create mode 100644 drizzle/0041_great_magma.sql create mode 100644 drizzle/0042_migrate_event_date_to_starts_at.sql create mode 100644 drizzle/meta/0041_snapshot.json diff --git a/app/components/sport-season/EventSchedule.tsx b/app/components/sport-season/EventSchedule.tsx index 387a72c..de1b430 100644 --- a/app/components/sport-season/EventSchedule.tsx +++ b/app/components/sport-season/EventSchedule.tsx @@ -7,6 +7,7 @@ interface ScheduleEvent { id: string; name: string; eventDate: string | null; + eventStartsAt?: Date | string | null; eventType: string; isComplete: boolean; completedAt?: Date | string | null; @@ -82,6 +83,11 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro

{formatEventDate(event.eventDate)} + {event.eventStartsAt && ( + + · {format(new Date(event.eventStartsAt), "h:mm a")} + + )}

{event.eventType === "schedule_event" ? ( diff --git a/app/lib/__tests__/date-utils.test.ts b/app/lib/__tests__/date-utils.test.ts index 8b3133d..8a41652 100644 --- a/app/lib/__tests__/date-utils.test.ts +++ b/app/lib/__tests__/date-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { localDateTimeToUtcIso } from "../date-utils"; +import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils"; describe("localDateTimeToUtcIso", () => { it("returns null for empty string", () => { @@ -42,3 +42,28 @@ describe("localDateTimeToUtcIso", () => { }); }); + +describe("toEventSortKey", () => { + it("prefers earliestGameTime over eventDate", () => { + const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" }); + expect(key).toBe("2026-03-17T10:45:00.000Z"); + }); + + it("falls back to eventDate when earliestGameTime is null", () => { + const key = toEventSortKey({ eventDate: "2026-05-25", earliestGameTime: null }); + expect(key).toBe("2026-05-25"); + }); + + it("returns sentinel when both are null", () => { + const key = toEventSortKey({ eventDate: null, earliestGameTime: null }); + expect(key).toBe("9999-12-31"); + }); + + it("sorts ISO timestamp after same-day date-only string (mixed comparison)", () => { + // "2026-03-15" < "2026-03-15T..." lexicographically, so a date-only event + // on the same calendar day as a timed event correctly sorts before it + const dateOnly = toEventSortKey({ eventDate: "2026-03-15", earliestGameTime: null }); + const withTime = toEventSortKey({ eventDate: "2026-03-15", earliestGameTime: "2026-03-15T14:00:00.000Z" }); + expect(dateOnly < withTime).toBe(true); + }); +}); diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts index 6bc88ef..48b9267 100644 --- a/app/lib/date-utils.ts +++ b/app/lib/date-utils.ts @@ -31,6 +31,25 @@ export function formatEventDate(dateStr: string | null | undefined): string | nu } } +/** + * Produces a stable sort key for upcoming calendar events. + * + * Bracket events carry a full ISO game timestamp in `earliestGameTime`; all-compete + * events (F1, IndyCar, golf) use the ISO timestamp in `earliestGameTime` when a start + * time has been set, or fall back to the date-only `eventDate` string. Events with + * neither sort to the far future so they appear last. + * + * The mixed comparison of ISO timestamps and "YYYY-MM-DD" strings works correctly via + * lexicographic order because both share the same date prefix; a date-only string always + * sorts before a same-day timestamp (shorter string < longer string with "T" suffix). + */ +export function toEventSortKey(e: { + eventDate: string | null; + earliestGameTime: string | null; +}): string { + return e.earliestGameTime ?? e.eventDate ?? "9999-12-31"; +} + export function localDateTimeToUtcIso(value: string | null | undefined): string | null { if (!value) return null; const date = new Date(value); diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index 30efb2c..e55a330 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -52,12 +52,14 @@ function makeParticipant(id: string, name: string) { function makeScoringEvent(overrides: Partial<{ id: string; name: string; eventDate: string | null; + eventStartsAt: Date | null; eventType: string; sportsSeasonId: string; isComplete: boolean; }> = {}) { return { id: overrides.id ?? "event-1", name: overrides.name ?? "Test Event", eventDate: overrides.eventDate ?? "2025-04-10", + eventStartsAt: overrides.eventStartsAt ?? null, eventType: overrides.eventType ?? "schedule_event", sportsSeasonId: overrides.sportsSeasonId ?? SPORTS_SEASON_ID, isComplete: overrides.isComplete ?? false, @@ -399,6 +401,92 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => { expect(result[0].earliestGameTime).toBeNull(); expect(result[0].matchLabel).toBeNull(); }); +}); + +// ── eventStartsAt — all-compete sports ──────────────────────────────────── +describe("getUpcomingEventsForDraftedParticipants — eventStartsAt for all-compete sports", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.selectDistinct.mockReturnValue({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue([]), + }); + }); + + it("populates earliestGameTime from eventStartsAt when set", async () => { + const raceStart = new Date("2025-04-06T14:00:00.000Z"); + const participants = [makeParticipant("p1", "Verstappen")]; + mockDb.query.scoringEvents.findMany.mockResolvedValue([ + makeScoringEvent({ id: "e1", name: "Australian GP", eventStartsAt: raceStart }), + ]); + + const result = await getUpcomingEventsForDraftedParticipants( + SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO + ); + + expect(result[0].earliestGameTime).toBe(raceStart.toISOString()); + }); + + it("leaves earliestGameTime null when eventStartsAt is not set", async () => { + const participants = [makeParticipant("p1", "Verstappen")]; + mockDb.query.scoringEvents.findMany.mockResolvedValue([ + makeScoringEvent({ id: "e1", name: "Australian GP", eventStartsAt: null }), + ]); + + const result = await getUpcomingEventsForDraftedParticipants( + SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO + ); + + expect(result[0].earliestGameTime).toBeNull(); + }); + + it("works for qualifying_points pattern with eventStartsAt", async () => { + const majorStart = new Date("2025-04-10T12:00:00.000Z"); + const participants = [makeParticipant("g1", "Scheffler")]; + mockDb.query.scoringEvents.findMany.mockResolvedValue([ + makeScoringEvent({ id: "e1", name: "The Masters", eventStartsAt: majorStart }), + ]); + + const result = await getUpcomingEventsForDraftedParticipants( + SPORTS_SEASON_ID, "qualifying_points", participants, DATE_FROM, DATE_TO + ); + + expect(result[0].earliestGameTime).toBe(majorStart.toISOString()); + }); + + it("earliestGameTime from eventStartsAt is used as sort key over eventDate", async () => { + // Event on Apr 6, game starts at 14:00 UTC — both should be reflected in earliestGameTime + const raceStart = new Date("2025-04-06T14:00:00.000Z"); + const participants = [makeParticipant("p1", "Hamilton")]; + mockDb.query.scoringEvents.findMany.mockResolvedValue([ + makeScoringEvent({ id: "e1", name: "Bahrain GP", eventDate: "2025-04-06", eventStartsAt: raceStart }), + ]); + + const result = await getUpcomingEventsForDraftedParticipants( + SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO + ); + + // earliestGameTime carries the full timestamp so the sort key has time precision + expect(result[0].earliestGameTime).toBe("2025-04-06T14:00:00.000Z"); + expect(result[0].eventDate).toBe("2025-04-06"); + }); +}); + +// ── Bracket: misc ────────────────────────────────────────────────────────── +describe("getUpcomingEventsForDraftedParticipants — bracket misc", () => { + function makeSelectChain(rows: unknown[]) { + return { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue(rows), + }; + } + + beforeEach(() => vi.clearAllMocks()); it("uses leftJoin so events without any games are still considered", async () => { const chain = makeSelectChain([]); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index b666cb2..b2f16e6 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -20,6 +20,7 @@ export interface CreateScoringEventData { sportsSeasonId: string; name: string; eventDate?: Date; + eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; // Template system fields (Phase 2.6) @@ -29,7 +30,8 @@ export interface CreateScoringEventData { export interface UpdateScoringEventData { name?: string; - eventDate?: Date; + eventDate?: Date | null; + eventStartsAt?: Date | null; isComplete?: boolean; completedAt?: Date; bracketTemplateId?: string; @@ -51,6 +53,7 @@ export async function createScoringEvent( sportsSeasonId: data.sportsSeasonId, name: data.name, eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined, + eventStartsAt: data.eventStartsAt, eventType: data.eventType, isQualifyingEvent: data.isQualifyingEvent || false, bracketTemplateId: data.bracketTemplateId, @@ -94,7 +97,7 @@ export async function getScoringEventsForSportsSeason( return await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), - orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)], + orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.eventStartsAt), desc(schema.scoringEvents.createdAt)], with: { eventResults: { with: { @@ -155,8 +158,9 @@ export async function updateScoringEvent( if (data.name !== undefined) updateData.name = data.name; if (data.eventDate !== undefined) { - updateData.eventDate = data.eventDate.toISOString().split('T')[0]; + updateData.eventDate = data.eventDate ? data.eventDate.toISOString().split('T')[0] : null; } + if (data.eventStartsAt !== undefined) updateData.eventStartsAt = data.eventStartsAt; if (data.isComplete !== undefined) updateData.isComplete = data.isComplete; if (data.completedAt !== undefined) updateData.completedAt = data.completedAt; if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId; @@ -289,7 +293,7 @@ export async function getUpcomingScoringEvents( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.isComplete, false) ), - orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.createdAt)], + orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.eventStartsAt), asc(schema.scoringEvents.createdAt)], limit, }); } @@ -393,7 +397,7 @@ export async function getUpcomingEventsForDraftedParticipants( id: e.id, name: e.name, eventDate: e.eventDate, - earliestGameTime: null, + earliestGameTime: e.eventStartsAt ? e.eventStartsAt.toISOString() : null, matchLabel: null, eventType: e.eventType, sportsSeasonId: e.sportsSeasonId, @@ -542,7 +546,7 @@ export async function getUpcomingEventsForDraftedParticipants( */ export async function bulkCreateScoringEvents( sportsSeasonId: string, - events: Array<{ name: string; eventDate?: Date; eventType: EventType; isQualifyingEvent?: boolean }>, + events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean }>, providedDb?: ReturnType ) { const db = providedDb || database(); @@ -553,6 +557,7 @@ export async function bulkCreateScoringEvents( sportsSeasonId, name: e.name, eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined, + eventStartsAt: e.eventStartsAt, eventType: e.eventType, isQualifyingEvent: e.isQualifyingEvent ?? false, isComplete: false, diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index 5779c63..7468fa1 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -148,16 +148,33 @@ export async function action({ request, params }: Route.ActionArgs) { if (intent === "update-event") { const name = formData.get("name"); - const eventDate = formData.get("eventDate"); + const eventStartsAtRaw = formData.get("eventStartsAt"); if (typeof name !== "string" || !name.trim()) { return { error: "Event name is required" }; } + // eventStartsAt is a UTC ISO string from the client; empty string means "clear it" + const eventStartsAt: Date | null | undefined = + typeof eventStartsAtRaw === "string" + ? eventStartsAtRaw + ? new Date(eventStartsAtRaw) + : null + : undefined; + + // Derive eventDate from eventStartsAt; null when cleared + const eventDate: Date | null | undefined = + eventStartsAt !== undefined + ? eventStartsAt + ? new Date(eventStartsAt.toISOString().split("T")[0]) + : null + : undefined; + try { await updateScoringEvent(params.eventId, { name: name.trim(), - eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, + eventDate, + eventStartsAt, }); return { success: "Event updated" }; } catch (error) { diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index 75b6a93..02f60d8 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -30,7 +30,9 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { format } from "date-fns"; +import { localDateTimeToUtcIso } from "~/lib/date-utils"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `${data?.event?.name ?? "Event"} — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; @@ -38,7 +40,20 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export { loader, action }; -function EditEventCard({ event }: { event: { name: string; eventDate?: string | null } }) { +function EditEventCard({ event }: { event: { name: string; eventDate?: string | null; eventStartsAt?: Date | string | null } }) { + // Keep the UTC ISO value for the hidden field (safe for SSR) + const [eventStartsAtUtc, setEventStartsAtUtc] = useState( + event.eventStartsAt ? new Date(event.eventStartsAt).toISOString() : "" + ); + // Compute the display value client-side only — format() uses the runtime timezone, + // so running it on the server would pre-fill the wrong time for non-UTC admins. + const [displayValue, setDisplayValue] = useState(""); + useEffect(() => { + if (event.eventStartsAt) { + setDisplayValue(format(new Date(event.eventStartsAt), "yyyy-MM-dd'T'HH:mm")); + } + }, []); + return ( @@ -52,13 +67,17 @@ function EditEventCard({ event }: { event: { name: string; eventDate?: string |
- + { + setDisplayValue(e.target.value); + setEventStartsAtUtc(localDateTimeToUtcIso(e.target.value) ?? ""); + }} /> +