Compare commits

...

7 commits

Author SHA1 Message Date
Chris Parsons
edae04057d Fix lint: move pad helper to module scope in date-utils
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m48s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 02:19:31 +00:00
Claude
746bbcf531 Migrate existing group-match times and localize day headers
Add a one-off data migration (scripts/fix-group-match-times-utc.mjs) that
shifts group_stage_matches.scheduled_at +7h, converting the pre-fix
local-wall-time-as-UTC (all entered in PDT) into true UTC. Scoped to
group-stage matches only; playoff games were already stored correctly.
Defaults to a dry-run preview and only writes with --apply.
Run: npx dotenv -- node scripts/fix-group-match-times-utc.mjs [--apply]

Fix GroupStageStandings day-header grouping/labels, which keyed and
formatted by UTC date. With times now stored as true UTC, an evening PDT
match rolled into the next UTC day and appeared under the wrong header.
Group and label by the viewer's local date after hydration, keeping the
UTC representation on SSR/first paint for deterministic markup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125nZohVD2Cpoq3Q9f4jeds
2026-06-18 02:19:31 +00:00
Claude
516b4e7131 Fix group-stage match times being stored as local-wall-time-as-UTC
The admin group-stage schedule form submitted the raw datetime-local
string, which the server parsed with new Date() in the server's timezone
(UTC), so an admin's local wall-clock time was stored as if it were UTC.
Downstream displays then correctly converted UTC->local, shifting times
by the admin's UTC offset (e.g. 7 PM PDT showed as 12 PM).

Mirror the existing playoff "add-game" form: convert local->UTC on the
client via localDateTimeToUtcIso into a hidden field on submit, and
render the stored UTC value back into the input in the browser's local
timezone via a client-only effect (avoids SSR hydration mismatch).

Adds utcIsoToLocalDateTime helper (inverse of localDateTimeToUtcIso)
with unit tests covering nullish/invalid input and a tz-independent
round-trip.

Note: matches saved before this fix must be re-saved once in admin to
correct their stored values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125nZohVD2Cpoq3Q9f4jeds
2026-06-18 02:19:31 +00:00
d9ea815f50 world cup times (#97)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m2s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 18s
🚀 Deploy / 🚀 Deploy (push) Successful in 8s
2026-06-18 00:36:26 +00:00
Claude
fa98d514f2
Code review fixes: earliestGameTime fallback, isNaN guard, and missing index
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m58s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m18s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- getUpcomingScoringEvents: fall back to eventStartsAt.toISOString() when no
  game-level scheduledAt exists, matching the same pattern already used in
  getUpcomingEventsForDraftedParticipants — prevents F1/golf events from
  showing a date with no time in SportSeasonCard and similar consumers
- EventSchedule: replace bare toLocaleTimeString call with an isNaN-guarded
  block so a malformed timestamp renders nothing instead of "Invalid Date"
- database/schema.ts: add index on tournament_groups(scoring_event_id) to
  support the inArray filter added in the previous commit
  (run npm run db:generate && npm run db:migrate to apply)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiVGo8gSBXe3WuRniVNKsd
2026-06-17 17:47:24 +00:00
Claude
9a04e25c20
Also fetch group stage match times for sport-season event schedule
The previous fix only queried playoffMatchGames.scheduledAt for bracket
events. FIFA World Cup group stage matches live in groupStageMatches
(linked via tournamentGroups), not playoffMatchGames. Now
getUpcomingScoringEvents fetches both tables in parallel and uses
the earliest scheduledAt from either source as earliestGameTime,
matching what UpcomingCalendarPanel already shows on the Upcoming Events page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiVGo8gSBXe3WuRniVNKsd
2026-06-17 16:57:40 +00:00
Claude
363413cd38
Fix league sport-season page to show correct game times for FIFA World Cup
getUpcomingScoringEvents already fetched per-game scheduledAt times from
playoffMatchGames for sorting, but discarded them from the return value.
EventSchedule was showing eventStartsAt (event-level) instead of the
accurate per-game times. Now getUpcomingScoringEvents includes earliestGameTime
in each returned event, and EventSchedule prefers earliestGameTime over
eventStartsAt — matching how UpcomingCalendarPanel works on the Upcoming Events page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiVGo8gSBXe3WuRniVNKsd
2026-06-17 16:47:17 +00:00
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,25 +193,35 @@ 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"
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", { : mounted
month: "short", ? new Date(key + "T12:00:00").toLocaleDateString("en-US", {
day: "numeric", month: "short",
timeZone: "UTC", day: "numeric",
}); })
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
return ( return (
<div key={key} className="space-y-0.5"> <div key={key} className="space-y-0.5">
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1"> <p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">

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