brackt/app/lib/date-utils.ts
chrisp 08ae2bb88a
All checks were successful
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m38s
🚀 Deploy / 🧪 Test (push) Successful in 3m30s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
Fix SSR/hydration timezone flip in event date and time display (#95)
## Summary

- Adds `useHasMounted` hook (using `useSyncExternalStore` — no extra render cycle) to gate UTC timestamp formatting to client-only
- Fixes the visible time-flip in `EventSchedule` and `UpcomingCalendarPanel` where SSR rendered UTC time (e.g. "9:00 PM") and the client re-rendered with local time (e.g. "2:00 PM PDT") after hydration, with `suppressHydrationWarning` silently hiding the mismatch
- Fixes `formatEventDate` in `date-utils.ts` (and `EventSchedule`'s private copy) to parse `YYYY-MM-DD` strings as local midnight instead of UTC midnight, correcting an off-by-one day bug for UTC-negative users across all callers

## Test plan

- [ ] Navigate to a sports season page — confirm event times show in local timezone with no flash/flip on load
- [ ] Hard-reload the page — confirm no brief UTC time visible before settling on local time
- [ ] Check the home page `UpcomingCalendarPanel` — confirm same behavior
- [ ] Verify dates display correctly (e.g. no "Jun 17" showing for a "Jun 18" event near midnight UTC)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #95
2026-06-17 06:09:29 +00:00

59 lines
2.4 KiB
TypeScript

/**
* Converts a datetime-local input value (local time, no timezone info)
* to a UTC ISO string for server submission.
*
* `datetime-local` inputs use the format "YYYY-MM-DDTHH:MM" and do NOT accept
* ISO strings with a timezone designator (e.g. "2026-03-11T17:00:00.000Z").
* Setting an ISO UTC string directly on a datetime-local input causes the browser
* to silently clear the field value. Use this function with a separate hidden input
* to hold the converted UTC value before form submission.
*
* Returns null when value is empty, nullish, or not a valid date.
*
* NOTE: The returned ISO string (e.g. "2026-03-11T17:00:00.000Z") is intentionally
* incompatible with datetime-local inputs, which only accept "YYYY-MM-DDTHH:MM".
* Setting a Z-suffixed string on a datetime-local input causes browsers to silently
* clear the field. Always write the result to a separate hidden input, not back to
* the datetime-local input itself.
*/
import { format } from "date-fns";
/**
* 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.
*/
export function formatEventDate(dateStr: string | null | undefined): string | null {
if (!dateStr) return null;
try {
const [year, month, day] = dateStr.split("-").map(Number);
return format(new Date(year, month - 1, day), "MMM d");
} catch {
return null;
}
}
/**
* Produces a stable sort key for upcoming calendar events.
*
* Bracket events carry a full ISO game timestamp in `earliestGameTime`; all-compete
* events (auto racing, 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 {
if (!value) return null;
const date = new Date(value);
if (isNaN(date.getTime())) return null;
return date.toISOString();
}