claude/practical-newton-4v041g #98
3 changed files with 122 additions and 21 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
|
import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
|
||||||
|
|
||||||
describe("localDateTimeToUtcIso", () => {
|
describe("localDateTimeToUtcIso", () => {
|
||||||
it("returns null for empty string", () => {
|
it("returns null for empty string", () => {
|
||||||
|
|
@ -118,6 +118,46 @@ describe("event date display — UTC midnight rollover bug", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("utcIsoToLocalDateTime", () => {
|
||||||
|
it("returns empty string for empty string", () => {
|
||||||
|
expect(utcIsoToLocalDateTime("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for null", () => {
|
||||||
|
expect(utcIsoToLocalDateTime(null)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for undefined", () => {
|
||||||
|
expect(utcIsoToLocalDateTime(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for an invalid date string", () => {
|
||||||
|
expect(utcIsoToLocalDateTime("not-a-date")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a datetime-local formatted string", () => {
|
||||||
|
expect(utcIsoToLocalDateTime("2026-06-17T19:00:00.000Z")).toMatch(
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a Date instance", () => {
|
||||||
|
const result = utcIsoToLocalDateTime(new Date("2026-06-17T19:00:00.000Z"));
|
||||||
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips with localDateTimeToUtcIso to the same instant", () => {
|
||||||
|
// utcIsoToLocalDateTime renders a UTC instant in local time; feeding that
|
||||||
|
// back through localDateTimeToUtcIso (which interprets local time) must
|
||||||
|
// yield the original instant, regardless of the runtime timezone.
|
||||||
|
const utc = "2026-06-17T19:00:00.000Z";
|
||||||
|
const local = utcIsoToLocalDateTime(utc);
|
||||||
|
const backToUtc = localDateTimeToUtcIso(local);
|
||||||
|
expect(backToUtc).not.toBeNull();
|
||||||
|
expect(new Date(backToUtc ?? "").getTime()).toBe(new Date(utc).getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("toEventSortKey", () => {
|
describe("toEventSortKey", () => {
|
||||||
it("prefers earliestGameTime over eventDate", () => {
|
it("prefers earliestGameTime over eventDate", () => {
|
||||||
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });
|
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,29 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string
|
||||||
if (isNaN(date.getTime())) return null;
|
if (isNaN(date.getTime())) return null;
|
||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a stored UTC value (ISO string or Date) to the "YYYY-MM-DDTHH:MM"
|
||||||
|
* string a `datetime-local` input expects, expressed in the **browser's local**
|
||||||
|
* timezone. This is the inverse of `localDateTimeToUtcIso`.
|
||||||
|
*
|
||||||
|
* Built from local date getters (not `toISOString`, which is UTC) so the input
|
||||||
|
* shows the viewer's wall-clock time. Because it depends on the runtime
|
||||||
|
* timezone, call it on the client (e.g. inside `useEffect`) to avoid SSR
|
||||||
|
* hydration mismatches.
|
||||||
|
*
|
||||||
|
* Returns "" for nullish or unparseable input so it can be assigned directly to
|
||||||
|
* an input value.
|
||||||
|
*/
|
||||||
|
export function utcIsoToLocalDateTime(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return "";
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = pad(date.getMonth() + 1);
|
||||||
|
const day = pad(date.getDate());
|
||||||
|
const hours = pad(date.getHours());
|
||||||
|
const minutes = pad(date.getMinutes());
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Form, Link } from "react-router";
|
import { Form, Link } from "react-router";
|
||||||
import { Fragment, useState, useEffect, useMemo } from "react";
|
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||||
|
|
||||||
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||||
|
|
@ -39,6 +39,58 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule editor for a single group-stage match.
|
||||||
|
*
|
||||||
|
* The admin enters a local wall-clock time; on submit it is converted to a UTC
|
||||||
|
* ISO string (in the hidden `scheduledAt` field) so the DB stores a true UTC
|
||||||
|
* instant. The stored UTC value is rendered back into the input in the
|
||||||
|
* browser's local timezone via a client-only effect (avoids SSR hydration
|
||||||
|
* mismatch, since the server runs in UTC).
|
||||||
|
*/
|
||||||
|
function GroupMatchScheduleForm({
|
||||||
|
match,
|
||||||
|
localTzAbbr,
|
||||||
|
}: {
|
||||||
|
match: { id: string; scheduledAt: string | Date | null | undefined };
|
||||||
|
localTzAbbr: string;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt);
|
||||||
|
}
|
||||||
|
}, [match.scheduledAt]);
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
method="post"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const local = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
|
||||||
|
const hidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
||||||
|
if (hidden) hidden.value = localDateTimeToUtcIso(local?.value) ?? "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||||
|
<input type="hidden" name="matchId" value={match.id} />
|
||||||
|
{/* Hidden field holds the UTC ISO string written by onSubmit */}
|
||||||
|
<input type="hidden" name="scheduledAt" defaultValue="" />
|
||||||
|
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="datetime-local"
|
||||||
|
name="scheduledAtLocal"
|
||||||
|
title={`Time zone: ${localTzAbbr}`}
|
||||||
|
className="h-6 text-xs px-1 py-0"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { loader, action };
|
export { loader, action };
|
||||||
|
|
||||||
export default function EventBracket({
|
export default function EventBracket({
|
||||||
|
|
@ -806,24 +858,7 @@ export default function EventBracket({
|
||||||
{groupMatches.map((match) => (
|
{groupMatches.map((match) => (
|
||||||
<div key={match.id} className="text-xs space-y-1">
|
<div key={match.id} className="text-xs space-y-1">
|
||||||
{/* Schedule row */}
|
{/* Schedule row */}
|
||||||
<Form method="post" className="flex items-center gap-1">
|
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
|
||||||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
|
||||||
<input type="hidden" name="matchId" value={match.id} />
|
|
||||||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
name="scheduledAt"
|
|
||||||
defaultValue={
|
|
||||||
match.scheduledAt
|
|
||||||
? new Date(match.scheduledAt).toISOString().slice(0, 16)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
className="h-6 text-xs px-1 py-0"
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
{/* Score row */}
|
{/* Score row */}
|
||||||
<Form method="post" className="flex items-center gap-1">
|
<Form method="post" className="flex items-center gap-1">
|
||||||
<input type="hidden" name="intent" value="update-group-match" />
|
<input type="hidden" name="intent" value="update-group-match" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue