claude/practical-newton-4v041g #98
2 changed files with 104 additions and 9 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
78
scripts/fix-group-match-times-utc.mjs
Normal file
78
scripts/fix-group-match-times-utc.mjs
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue