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:
parent
4fe76b6845
commit
dc13d99f39
16 changed files with 3790 additions and 38 deletions
|
|
@ -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" ? (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
1
drizzle/0041_great_magma.sql
Normal file
1
drizzle/0041_great_magma.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "scoring_events" ADD COLUMN "event_starts_at" timestamp with time zone;
|
||||||
6
drizzle/0042_migrate_event_date_to_starts_at.sql
Normal file
6
drizzle/0042_migrate_event_date_to_starts_at.sql
Normal 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;
|
||||||
3528
drizzle/meta/0041_snapshot.json
Normal file
3528
drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue