claude/practical-newton-4v041g (#98)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #98
This commit is contained in:
chrisp 2026-06-18 02:33:05 +00:00
parent d9ea815f50
commit d31c23d63b
5 changed files with 227 additions and 30 deletions

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { GroupStandingsRow } from "~/models/group-stage-match"; import type { GroupStandingsRow } from "~/models/group-stage-match";
import { utcIsoToLocalDateTime } from "~/lib/date-utils";
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
interface GroupMatch { interface GroupMatch {
@ -100,6 +101,12 @@ export function GroupStageStandings({
ownershipMap, ownershipMap,
showEmpty = false, showEmpty = false,
}: GroupStageStandingsProps) { }: GroupStageStandingsProps) {
// SSR (and first client paint) groups/labels by UTC date for deterministic markup;
// after hydration we switch to the viewer's local date so evening matches don't roll
// into the wrong day header.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const visibleGroups = showEmpty const visibleGroups = showEmpty
? groups ? groups
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0); : groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
@ -186,20 +193,30 @@ export function GroupStageStandings({
if (!b.scheduledAt) return -1; if (!b.scheduledAt) return -1;
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(); return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
}); });
// Use the UTC date portion as a stable key so server and client always // Key by UTC date on SSR/first paint (deterministic); after mount, key by
// produce the same Map structure regardless of the user's timezone. // the viewer's local date so the day headers match local wall-clock.
const byDay = new Map<string, GroupMatch[]>(); const byDay = new Map<string, GroupMatch[]>();
for (const m of sorted) { for (const m of sorted) {
const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD"; const key = m.scheduledAt
? (mounted
? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10)
: m.scheduledAt.slice(0, 10))
: "TBD";
if (!byDay.has(key)) byDay.set(key, []); if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key)?.push(m); byDay.get(key)?.push(m);
} }
return ( return (
<div className="px-4 pb-3 space-y-0.5 border-t pt-2"> <div className="px-4 pb-3 space-y-0.5 border-t pt-2">
{[...byDay.entries()].map(([key, matches]) => { {[...byDay.entries()].map(([key, matches]) => {
// Format from the UTC date string so label is identical on server and client. // Before mount the key is a UTC date (format in UTC); after mount it is
// a local date (format in local time) so the label matches the grouping.
const dayLabel = key === "TBD" const dayLabel = key === "TBD"
? "TBD" ? "TBD"
: mounted
? new Date(key + "T12:00:00").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", { : new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",

View file

@ -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" });

View file

@ -18,6 +18,8 @@
*/ */
import { format } from "date-fns"; import { format } from "date-fns";
const pad = (n: number) => String(n).padStart(2, "0");
/** /**
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9"). * Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
* Returns null when the value is missing or unparseable callers decide how to handle that case. * Returns null when the value is missing or unparseable callers decide how to handle that case.
@ -57,3 +59,28 @@ 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 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}`;
}

View file

@ -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" />

View file

@ -0,0 +1,78 @@
/**
* One-off data migration: fix group-stage match times stored as local-wall-time-as-UTC.
*
* Before the schedule-form fix, the admin's local wall-clock time (PDT, UTC-7) was
* stored as if it were UTC, so every `group_stage_matches.scheduled_at` is 7 hours
* behind the true UTC instant. This shifts them +7 hours.
*
* Scope: ONLY `group_stage_matches.scheduled_at` (where not null). Playoff games
* (`playoff_match_games.scheduled_at`) were saved correctly and are left untouched.
*
* NOT idempotent running with --apply twice double-shifts. Defaults to a dry-run;
* pass --apply to write.
*
* Usage (loads .env via dotenv-cli):
* npx dotenv -- node scripts/fix-group-match-times-utc.mjs # preview
* npx dotenv -- node scripts/fix-group-match-times-utc.mjs --apply # apply
*/
import postgres from "postgres";
const SHIFT = "7 hours"; // PDT (UTC-7) wall-clock stored as UTC -> true UTC
const apply = process.argv.includes("--apply");
const DATABASE_URL = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("ERROR: DATABASE_DIRECT_URL (or DATABASE_URL) is required");
process.exit(1);
}
const sql = postgres(DATABASE_URL, { max: 1, onnotice: () => {} });
try {
const [{ count }] = await sql`
SELECT COUNT(*)::int AS count
FROM group_stage_matches
WHERE scheduled_at IS NOT NULL
`;
console.log(`Group-stage matches with a scheduled time: ${count}`);
if (count === 0) {
console.log("Nothing to do.");
await sql.end();
process.exit(0);
}
const samples = await sql`
SELECT id,
scheduled_at AS before,
scheduled_at + ${sql.unsafe(`interval '${SHIFT}'`)} AS after
FROM group_stage_matches
WHERE scheduled_at IS NOT NULL
ORDER BY scheduled_at
LIMIT 10
`;
console.log(`\nSample before -> after (+${SHIFT}):`);
for (const r of samples) {
console.log(` ${r.id}: ${r.before.toISOString()} -> ${r.after.toISOString()}`);
}
if (!apply) {
console.log(`\nDRY RUN — no changes written. Re-run with --apply to shift ${count} row(s) +${SHIFT}.`);
await sql.end();
process.exit(0);
}
const result = await sql`
UPDATE group_stage_matches
SET scheduled_at = scheduled_at + ${sql.unsafe(`interval '${SHIFT}'`)}
WHERE scheduled_at IS NOT NULL
`;
console.log(`\nApplied: shifted ${result.count} row(s) +${SHIFT}.`);
await sql.end();
process.exit(0);
} catch (err) {
console.error("Migration failed:", err);
await sql.end().catch(() => {});
process.exit(1);
}