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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-15 10:22:42 -07:00 committed by GitHub
parent 4fe76b6845
commit dc13d99f39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 3790 additions and 38 deletions

View file

@ -7,6 +7,7 @@ interface ScheduleEvent {
id: string; id: string;
name: string; name: string;
eventDate: string | null; eventDate: string | null;
eventStartsAt?: Date | string | null;
eventType: string; eventType: string;
isComplete: boolean; isComplete: boolean;
completedAt?: Date | string | null; completedAt?: Date | string | null;
@ -82,6 +83,11 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5"> <p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{formatEventDate(event.eventDate)} {formatEventDate(event.eventDate)}
{event.eventStartsAt && (
<span suppressHydrationWarning>
· {format(new Date(event.eventStartsAt), "h:mm a")}
</span>
)}
</p> </p>
</div> </div>
{event.eventType === "schedule_event" ? ( {event.eventType === "schedule_event" ? (

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { localDateTimeToUtcIso } from "../date-utils"; import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
describe("localDateTimeToUtcIso", () => { describe("localDateTimeToUtcIso", () => {
it("returns null for empty string", () => { 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);
});
});

View file

@ -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 { export function localDateTimeToUtcIso(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const date = new Date(value); const date = new Date(value);

View file

@ -52,12 +52,14 @@ function makeParticipant(id: string, name: string) {
function makeScoringEvent(overrides: Partial<{ function makeScoringEvent(overrides: Partial<{
id: string; name: string; eventDate: string | null; id: string; name: string; eventDate: string | null;
eventStartsAt: Date | null;
eventType: string; sportsSeasonId: string; isComplete: boolean; eventType: string; sportsSeasonId: string; isComplete: boolean;
}> = {}) { }> = {}) {
return { return {
id: overrides.id ?? "event-1", id: overrides.id ?? "event-1",
name: overrides.name ?? "Test Event", name: overrides.name ?? "Test Event",
eventDate: overrides.eventDate ?? "2025-04-10", eventDate: overrides.eventDate ?? "2025-04-10",
eventStartsAt: overrides.eventStartsAt ?? null,
eventType: overrides.eventType ?? "schedule_event", eventType: overrides.eventType ?? "schedule_event",
sportsSeasonId: overrides.sportsSeasonId ?? SPORTS_SEASON_ID, sportsSeasonId: overrides.sportsSeasonId ?? SPORTS_SEASON_ID,
isComplete: overrides.isComplete ?? false, isComplete: overrides.isComplete ?? false,
@ -399,6 +401,92 @@ describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
expect(result[0].earliestGameTime).toBeNull(); expect(result[0].earliestGameTime).toBeNull();
expect(result[0].matchLabel).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 () => { it("uses leftJoin so events without any games are still considered", async () => {
const chain = makeSelectChain([]); const chain = makeSelectChain([]);

View file

@ -20,6 +20,7 @@ export interface CreateScoringEventData {
sportsSeasonId: string; sportsSeasonId: string;
name: string; name: string;
eventDate?: Date; eventDate?: Date;
eventStartsAt?: Date;
eventType: EventType; eventType: EventType;
isQualifyingEvent?: boolean; isQualifyingEvent?: boolean;
// Template system fields (Phase 2.6) // Template system fields (Phase 2.6)
@ -29,7 +30,8 @@ export interface CreateScoringEventData {
export interface UpdateScoringEventData { export interface UpdateScoringEventData {
name?: string; name?: string;
eventDate?: Date; eventDate?: Date | null;
eventStartsAt?: Date | null;
isComplete?: boolean; isComplete?: boolean;
completedAt?: Date; completedAt?: Date;
bracketTemplateId?: string; bracketTemplateId?: string;
@ -51,6 +53,7 @@ export async function createScoringEvent(
sportsSeasonId: data.sportsSeasonId, sportsSeasonId: data.sportsSeasonId,
name: data.name, name: data.name,
eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined, eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined,
eventStartsAt: data.eventStartsAt,
eventType: data.eventType, eventType: data.eventType,
isQualifyingEvent: data.isQualifyingEvent || false, isQualifyingEvent: data.isQualifyingEvent || false,
bracketTemplateId: data.bracketTemplateId, bracketTemplateId: data.bracketTemplateId,
@ -94,7 +97,7 @@ export async function getScoringEventsForSportsSeason(
return await db.query.scoringEvents.findMany({ return await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), 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: { with: {
eventResults: { eventResults: {
with: { with: {
@ -155,8 +158,9 @@ export async function updateScoringEvent(
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
if (data.eventDate !== undefined) { 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.isComplete !== undefined) updateData.isComplete = data.isComplete;
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt; if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId; if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
@ -289,7 +293,7 @@ export async function getUpcomingScoringEvents(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false) 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, limit,
}); });
} }
@ -393,7 +397,7 @@ export async function getUpcomingEventsForDraftedParticipants(
id: e.id, id: e.id,
name: e.name, name: e.name,
eventDate: e.eventDate, eventDate: e.eventDate,
earliestGameTime: null, earliestGameTime: e.eventStartsAt ? e.eventStartsAt.toISOString() : null,
matchLabel: null, matchLabel: null,
eventType: e.eventType, eventType: e.eventType,
sportsSeasonId: e.sportsSeasonId, sportsSeasonId: e.sportsSeasonId,
@ -542,7 +546,7 @@ export async function getUpcomingEventsForDraftedParticipants(
*/ */
export async function bulkCreateScoringEvents( export async function bulkCreateScoringEvents(
sportsSeasonId: string, 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<typeof database> providedDb?: ReturnType<typeof database>
) { ) {
const db = providedDb || database(); const db = providedDb || database();
@ -553,6 +557,7 @@ export async function bulkCreateScoringEvents(
sportsSeasonId, sportsSeasonId,
name: e.name, name: e.name,
eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined, eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined,
eventStartsAt: e.eventStartsAt,
eventType: e.eventType, eventType: e.eventType,
isQualifyingEvent: e.isQualifyingEvent ?? false, isQualifyingEvent: e.isQualifyingEvent ?? false,
isComplete: false, isComplete: false,

View file

@ -148,16 +148,33 @@ export async function action({ request, params }: Route.ActionArgs) {
if (intent === "update-event") { if (intent === "update-event") {
const name = formData.get("name"); const name = formData.get("name");
const eventDate = formData.get("eventDate"); const eventStartsAtRaw = formData.get("eventStartsAt");
if (typeof name !== "string" || !name.trim()) { if (typeof name !== "string" || !name.trim()) {
return { error: "Event name is required" }; 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 { try {
await updateScoringEvent(params.eventId, { await updateScoringEvent(params.eventId, {
name: name.trim(), name: name.trim(),
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, eventDate,
eventStartsAt,
}); });
return { success: "Event updated" }; return { success: "Event updated" };
} catch (error) { } catch (error) {

View file

@ -30,7 +30,9 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } 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 { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.event?.name ?? "Event"}${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }]; 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 }; 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -52,13 +67,17 @@ function EditEventCard({ event }: { event: { name: string; eventDate?: string |
<Input id="name" name="name" defaultValue={event.name} required /> <Input id="name" name="name" defaultValue={event.name} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="eventDate">Date</Label> <Label htmlFor="eventStartsAtLocal">Event Date & Time (Optional)</Label>
<Input <Input
id="eventDate" id="eventStartsAtLocal"
name="eventDate" type="datetime-local"
type="date" value={displayValue}
defaultValue={event.eventDate ?? ""} onChange={(e) => {
setDisplayValue(e.target.value);
setEventStartsAtUtc(localDateTimeToUtcIso(e.target.value) ?? "");
}}
/> />
<input type="hidden" name="eventStartsAt" value={eventStartsAtUtc} />
</div> </div>
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />

View file

@ -79,7 +79,7 @@ export async function action({ request, params }: Route.ActionArgs) {
sportsSeason.scoringPattern === "qualifying_points" && sportsSeason.scoringPattern === "qualifying_points" &&
eventType === "major_tournament"; eventType === "major_tournament";
// Parse lines: "Name, YYYY-MM-DD" or "Name\tYYYY-MM-DD" or just "Name" // Parse lines: "Name, YYYY-MM-DD[, HH:MM]" or tab-separated, or just "Name"
const lines = bulkText const lines = bulkText
.split("\n") .split("\n")
.map((l) => l.trim()) .map((l) => l.trim())
@ -89,11 +89,16 @@ export async function action({ request, params }: Route.ActionArgs) {
const parts = line.split(/[,\t]/).map((p) => p.trim()); const parts = line.split(/[,\t]/).map((p) => p.trim());
const name = parts[0]; const name = parts[0];
const dateStr = parts[1]; const dateStr = parts[1];
const timeStr = parts[2];
const eventDate = const eventDate =
dateStr && /^\d{4}-\d{2}-\d{2}$/.test(dateStr) dateStr && /^\d{4}-\d{2}-\d{2}$/.test(dateStr)
? new Date(dateStr) ? new Date(dateStr)
: undefined; : undefined;
return { name, eventDate, eventType, isQualifyingEvent: isQualifyingDefault }; const eventStartsAt =
eventDate && dateStr && timeStr && /^\d{2}:\d{2}$/.test(timeStr)
? new Date(`${dateStr}T${timeStr}:00.000Z`)
: undefined;
return { name, eventDate, eventStartsAt, eventType, isQualifyingEvent: isQualifyingDefault };
}); });
const validEvents = parsedEvents.filter((e) => e.name); const validEvents = parsedEvents.filter((e) => e.name);
@ -128,7 +133,7 @@ export async function action({ request, params }: Route.ActionArgs) {
const name = formData.get("name"); const name = formData.get("name");
const eventType = formData.get("eventType"); const eventType = formData.get("eventType");
const eventDate = formData.get("eventDate"); const eventStartsAtRaw = formData.get("eventStartsAt");
// Validation // Validation
if (typeof name !== "string" || !name.trim()) { if (typeof name !== "string" || !name.trim()) {
@ -154,11 +159,21 @@ export async function action({ request, params }: Route.ActionArgs) {
sportsSeason.scoringPattern === "qualifying_points" && sportsSeason.scoringPattern === "qualifying_points" &&
eventType === "major_tournament"; eventType === "major_tournament";
// eventStartsAt is a UTC ISO string produced by localDateTimeToUtcIso on the client
const eventStartsAt =
typeof eventStartsAtRaw === "string" && eventStartsAtRaw
? new Date(eventStartsAtRaw)
: undefined;
// Derive eventDate from eventStartsAt (UTC date portion)
const eventDate = eventStartsAt ? new Date(eventStartsAt.toISOString().split("T")[0]) : undefined;
const eventData: CreateScoringEventData = { const eventData: CreateScoringEventData = {
sportsSeasonId: params.id, sportsSeasonId: params.id,
name: name.trim(), name: name.trim(),
eventType: eventType as "playoff_game" | "major_tournament" | "schedule_event", eventType: eventType as "playoff_game" | "major_tournament" | "schedule_event",
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, eventDate,
eventStartsAt,
isQualifyingEvent, isQualifyingEvent,
}; };

View file

@ -1,7 +1,9 @@
import { Form, Link } from "react-router"; import { Form, Link } from "react-router";
import { useState } from "react";
import type { Route } from "./+types/admin.sports-seasons.$id.events"; import type { Route } from "./+types/admin.sports-seasons.$id.events";
import { loader, action } from "./admin.sports-seasons.$id.events.server"; import { loader, action } from "./admin.sports-seasons.$id.events.server";
import { localDateTimeToUtcIso } from "~/lib/date-utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@ -49,6 +51,8 @@ export default function SportsSeasonEvents({
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { sportsSeason, events, qpStandings, scoringRules } = loaderData; const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
const defaultEventType = const defaultEventType =
sportsSeason.scoringPattern === "qualifying_points" sportsSeason.scoringPattern === "qualifying_points"
? "major_tournament" ? "major_tournament"
@ -174,8 +178,15 @@ export default function SportsSeasonEvents({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="eventDate">Event Date (Optional)</Label> <Label htmlFor="eventStartsAtLocal">Event Date & Time (Optional)</Label>
<Input id="eventDate" name="eventDate" type="date" /> <Input
id="eventStartsAtLocal"
type="datetime-local"
onChange={(e) =>
setCreateEventStartsAt(localDateTimeToUtcIso(e.target.value) ?? "")
}
/>
<input type="hidden" name="eventStartsAt" value={createEventStartsAt} />
</div> </div>
{actionData?.error && ( {actionData?.error && (
@ -202,9 +213,9 @@ export default function SportsSeasonEvents({
<CardDescription> <CardDescription>
Paste a schedule one event per line. Format:{" "} Paste a schedule one event per line. Format:{" "}
<code className="text-xs bg-muted px-1 rounded"> <code className="text-xs bg-muted px-1 rounded">
Event Name, YYYY-MM-DD Event Name, YYYY-MM-DD[, HH:MM]
</code>{" "} </code>{" "}
(date is optional). (date and time are optional; time is UTC).
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -229,7 +240,7 @@ export default function SportsSeasonEvents({
id="bulkEvents" id="bulkEvents"
name="bulkEvents" name="bulkEvents"
rows={6} rows={6}
placeholder={"Bahrain Grand Prix, 2025-03-02\nSaudi Arabian Grand Prix, 2025-03-09\nAustralian Grand Prix, 2025-03-23"} placeholder={"Bahrain Grand Prix, 2025-03-02, 15:00\nSaudi Arabian Grand Prix, 2025-03-09, 18:00\nAustralian Grand Prix, 2025-03-23"}
/> />
</div> </div>
<Button type="submit" variant="outline" className="w-full"> <Button type="submit" variant="outline" className="w-full">
@ -255,7 +266,7 @@ export default function SportsSeasonEvents({
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; isComplete: boolean }) => ( {events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
<Card key={event.id} className="hover:border-primary/50 transition-colors"> <Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@ -275,6 +286,11 @@ export default function SportsSeasonEvents({
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{format(parseISO(event.eventDate), "MMM d, yyyy")} {format(parseISO(event.eventDate), "MMM d, yyyy")}
{event.eventStartsAt && (
<span className="text-muted-foreground/70" suppressHydrationWarning>
· {format(new Date(event.eventStartsAt), "h:mm a")}
</span>
)}
</span> </span>
)} )}
</div> </div>

View file

@ -12,6 +12,7 @@ import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"; import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel"; import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel"; import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { toEventSortKey } from "~/lib/date-utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
@ -102,11 +103,7 @@ export async function loader(args: Route.LoaderArgs) {
const upcomingCalendarEvents = leaguesWithData const upcomingCalendarEvents = leaguesWithData
.flatMap((l) => l.calendarEvents) .flatMap((l) => l.calendarEvents)
.sort((a, b) => { .sort((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
const dateA = a.eventDate ?? (a.earliestGameTime ? a.earliestGameTime.split("T")[0] : "");
const dateB = b.eventDate ?? (b.earliestGameTime ? b.earliestGameTime.split("T")[0] : "");
return dateA.localeCompare(dateB);
});
return { return {
leagues: leaguesWithData, leagues: leaguesWithData,

View file

@ -1,5 +1,6 @@
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { addDays } from "date-fns"; import { addDays } from "date-fns";
import { toEventSortKey } from "~/lib/date-utils";
import { import {
findLeagueById, findLeagueById,
findTeamsBySeasonId, findTeamsBySeasonId,
@ -161,13 +162,7 @@ export async function loader(args: Route.LoaderArgs) {
sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`, sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`,
})) }))
) )
.sort((a, b) => { .sort((a, b) => toEventSortKey(a).localeCompare(toEventSortKey(b)));
// Prefer eventDate; fall back to the date portion of earliestGameTime for bracket
// events where the admin set times on individual games rather than the event record.
const dateA = a.eventDate ?? (a.earliestGameTime ? a.earliestGameTime.split("T")[0] : "");
const dateB = b.eventDate ?? (b.earliestGameTime ? b.earliestGameTime.split("T")[0] : "");
return dateA.localeCompare(dateB);
});
// Check if draft order is set // Check if draft order is set
const isDraftOrderSet = draftSlots.length > 0; const isDraftOrderSet = draftSlots.length > 0;

View file

@ -342,6 +342,7 @@ export const scoringEvents = pgTable("scoring_events", {
.references(() => sportsSeasons.id, { onDelete: "cascade" }), .references(() => sportsSeasons.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(), // "2024 Masters", "Super Bowl LIX", etc. name: varchar("name", { length: 255 }).notNull(), // "2024 Masters", "Super Bowl LIX", etc.
eventDate: date("event_date"), eventDate: date("event_date"),
eventStartsAt: timestamp("event_starts_at", { withTimezone: true }),
eventType: eventTypeEnum("event_type").notNull(), eventType: eventTypeEnum("event_type").notNull(),
// For playoff events // For playoff events
playoffRound: varchar("playoff_round", { length: 50 }), // "Quarterfinals", "Semifinals", "Finals" playoffRound: varchar("playoff_round", { length: 50 }), // "Quarterfinals", "Semifinals", "Finals"

View file

@ -0,0 +1 @@
ALTER TABLE "scoring_events" ADD COLUMN "event_starts_at" timestamp with time zone;

View file

@ -0,0 +1,6 @@
-- Backfill event_starts_at from event_date (midnight UTC) for events
-- that have a date but no explicit start time.
UPDATE "scoring_events"
SET "event_starts_at" = event_date::timestamp AT TIME ZONE 'UTC'
WHERE "event_date" IS NOT NULL
AND "event_starts_at" IS NULL;

File diff suppressed because it is too large Load diff

View file

@ -288,6 +288,20 @@
"when": 1773262080928, "when": 1773262080928,
"tag": "0040_fat_puma", "tag": "0040_fat_puma",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1773557500336,
"tag": "0041_great_magma",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1773600000000,
"tag": "0042_migrate_event_date_to_starts_at",
"breakpoints": true
} }
] ]
} }