diff --git a/app/admin/competitions/competition-row.tsx b/app/admin/competitions/competition-row.tsx index 5240896e..36f57e3a 100644 --- a/app/admin/competitions/competition-row.tsx +++ b/app/admin/competitions/competition-row.tsx @@ -19,6 +19,7 @@ import { import { CreateEditCompetitionForm } from "@/components/forms/create-edit-competition-form"; import { useState } from "react"; import { formatDate, formatDateTime } from "@/lib/time-utils"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; import { CompetitionStatusBadge } from "./competition-status-badge"; import { getCompetitionStatusFromObject } from "@/lib/competition-status"; @@ -32,6 +33,7 @@ export default function CompetitionRow({ nResolvedProps: number; }) { const [open, setOpen] = useState(false); + const timezone = getBrowserTimezone(); const status = getCompetitionStatusFromObject(competition); @@ -84,22 +86,22 @@ export default function CompetitionRow({ competition.forecasts_open_date && ( <> Forecasts open{" "} - {formatDate(competition.forecasts_open_date)} + {formatDate(competition.forecasts_open_date, timezone)} )} {status === "forecasts-open" && competition.forecasts_close_date && ( <> Forecasts close{" "} - {formatDate(competition.forecasts_close_date)} + {formatDate(competition.forecasts_close_date, timezone)} )} {status === "forecasts-closed" && competition.end_date && ( - <>Ends {formatDate(competition.end_date)} + <>Ends {formatDate(competition.end_date, timezone)} )} {status === "ended" && competition.end_date && ( - <>Ended {formatDate(competition.end_date)} + <>Ended {formatDate(competition.end_date, timezone)} )} {status === "private" && ( <>Uses per-prop deadlines @@ -114,17 +116,17 @@ export default function CompetitionRow({ {competition.forecasts_open_date && (

Forecasts Open:{" "} - {formatDateTime(competition.forecasts_open_date)} + {formatDateTime(competition.forecasts_open_date, timezone)}

)} {competition.forecasts_close_date && (

Forecasts Close:{" "} - {formatDateTime(competition.forecasts_close_date)} + {formatDateTime(competition.forecasts_close_date, timezone)}

)} {competition.end_date && ( -

Ends: {formatDateTime(competition.end_date)}

+

Ends: {formatDateTime(competition.end_date, timezone)}

)} )} diff --git a/app/admin/users/[userId]/user-detail-card.tsx b/app/admin/users/[userId]/user-detail-card.tsx index 4e45a86b..70e282f7 100644 --- a/app/admin/users/[userId]/user-detail-card.tsx +++ b/app/admin/users/[userId]/user-detail-card.tsx @@ -14,8 +14,9 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Copy, Shield, User, UserCheck, UserX } from "lucide-react"; -import { format } from "date-fns"; import { setUserActive } from "@/lib/db_actions/users"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; +import { formatDate, formatDateTime } from "@/lib/time-utils"; import { startImpersonation } from "@/lib/auth/impersonation"; import { handleServerActionResult } from "@/lib/server-action-helpers"; import { toast } from "@/hooks/use-toast"; @@ -32,6 +33,7 @@ export default function UserDetailCard({ user }: UserDetailCardProps) { const [isImpersonateDialogOpen, setIsImpersonateDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); + const timezone = getBrowserTimezone(); const isActive = user.deactivated_at === null; const canImpersonate = !user.is_admin && isActive; @@ -191,7 +193,7 @@ export default function UserDetailCard({ user }: UserDetailCardProps) { Created - {format(new Date(user.created_at), "MMM d, yyyy")} + {formatDate(new Date(user.created_at), timezone)} @@ -200,7 +202,7 @@ export default function UserDetailCard({ user }: UserDetailCardProps) { Updated - {format(new Date(user.updated_at), "MMM d, yyyy")} + {formatDate(new Date(user.updated_at), timezone)} @@ -210,10 +212,7 @@ export default function UserDetailCard({ user }: UserDetailCardProps) { Deactivated - {format( - new Date(user.deactivated_at), - "MMM d, yyyy 'at' h:mm a", - )} + {formatDateTime(new Date(user.deactivated_at), timezone)} )} diff --git a/app/competitions/[competitionId]/competition-start-end.tsx b/app/competitions/[competitionId]/competition-start-end.tsx index 136a9dd1..dce95f1a 100644 --- a/app/competitions/[competitionId]/competition-start-end.tsx +++ b/app/competitions/[competitionId]/competition-start-end.tsx @@ -1,16 +1,16 @@ "use client"; import { Competition } from "@/types/db_types"; -import { formatInTimeZone } from "date-fns-tz"; import { getCompetitionStatusFromObject } from "@/lib/competition-status"; - -const DATE_FORMAT = "MMM d, yyyy"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; +import { formatDate } from "@/lib/time-utils"; export default function CompetitionStartEnd({ competition, }: { competition: Competition; }) { + const timezone = getBrowserTimezone(); const status = getCompetitionStatusFromObject(competition); return (
@@ -20,19 +20,11 @@ export default function CompetitionStartEnd({ <>

Forecasts open{" "} - {formatInTimeZone( - competition.forecasts_open_date, - "UTC", - DATE_FORMAT, - )} + {formatDate(competition.forecasts_open_date, timezone)}

Forecasts close{" "} - {formatInTimeZone( - competition.forecasts_close_date, - "UTC", - DATE_FORMAT, - )} + {formatDate(competition.forecasts_close_date, timezone)}

)} @@ -42,19 +34,11 @@ export default function CompetitionStartEnd({ <>

Forecasts opened{" "} - {formatInTimeZone( - competition.forecasts_open_date, - "UTC", - DATE_FORMAT, - )} + {formatDate(competition.forecasts_open_date, timezone)}

Forecasts close{" "} - {formatInTimeZone( - competition.forecasts_close_date, - "UTC", - DATE_FORMAT, - )} + {formatDate(competition.forecasts_close_date, timezone)}

)} @@ -64,22 +48,18 @@ export default function CompetitionStartEnd({ <>

Forecasts closed{" "} - {formatInTimeZone( - competition.forecasts_close_date, - "UTC", - DATE_FORMAT, - )} + {formatDate(competition.forecasts_close_date, timezone)}

Competition Ends{" "} - {formatInTimeZone(competition.end_date, "UTC", DATE_FORMAT)} + {formatDate(competition.end_date, timezone)}

)} {status === "ended" && competition.end_date && (

Ended{" "} - {formatInTimeZone(competition.end_date, "UTC", DATE_FORMAT)} + {formatDate(competition.end_date, timezone)}

)} {status === "private" && ( diff --git a/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx b/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx index 84420578..93511910 100644 --- a/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx +++ b/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx @@ -10,10 +10,12 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { MarkdownRenderer } from "@/components/markdown"; import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; import { createForecast, updateForecast } from "@/lib/db_actions"; import { useServerAction } from "@/hooks/use-server-action"; import { Spinner } from "@/components/ui/spinner"; import { PropEditDialog } from "@/components/dialogs/prop-edit-dialog"; +import { formatDateTime } from "@/lib/time-utils"; interface CompetitionPropViewProps { prop: PropWithUserForecast; @@ -68,17 +70,10 @@ const getProbColor = (prob: number | null) => { }; }; -function formatDate(date: Date | string | null): string { +function formatPropDate(date: Date | string | null, timezone: string): string { if (!date) return "No deadline"; const d = typeof date === "string" ? new Date(date) : date; - return d.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }); + return formatDateTime(d, timezone); } function getRelativeDeadline(date: Date | string | null): string | null { @@ -104,6 +99,7 @@ export function CompetitionPropView({ }: CompetitionPropViewProps) { const router = useRouter(); const { user } = useCurrentUser(); + const timezone = getBrowserTimezone(); const [localForecast, setLocalForecast] = useState( prop.user_forecast, ); @@ -235,7 +231,7 @@ export function CompetitionPropView({
- Forecasts due: {formatDate(prop.prop_forecasts_due_date)} + Forecasts due: {formatPropDate(prop.prop_forecasts_due_date, timezone)} {relativeDeadline && ( - Resolves: {formatDate(prop.prop_resolution_due_date)} + Resolves: {formatPropDate(prop.prop_resolution_due_date, timezone)}
)}
diff --git a/app/competitions/[competitionId]/props/new/new-prop-form.tsx b/app/competitions/[competitionId]/props/new/new-prop-form.tsx index ded153cb..88d1410b 100644 --- a/app/competitions/[competitionId]/props/new/new-prop-form.tsx +++ b/app/competitions/[competitionId]/props/new/new-prop-form.tsx @@ -40,7 +40,9 @@ import { Card, CardContent } from "@/components/ui/card"; import { DateTimePicker } from "@/components/ui/date-time-picker"; import { Spinner } from "@/components/ui/spinner"; import { useServerAction } from "@/hooks/use-server-action"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; import { createProp } from "@/lib/db_actions"; +import { formatDate } from "@/lib/time-utils"; import type { Category } from "@/types/db_types"; const formSchema = z @@ -100,6 +102,7 @@ export function NewPropForm({ userId, }: NewPropFormProps) { const router = useRouter(); + const timezone = getBrowserTimezone(); const [showPreview, setShowPreview] = useState(false); const form = useForm({ @@ -346,7 +349,7 @@ export function NewPropForm({ {watchedForecastsDueDate && (

Forecasts due:{" "} - {watchedForecastsDueDate.toLocaleDateString()} + {formatDate(watchedForecastsDueDate, timezone)}

)}
diff --git a/app/competitions/page.tsx b/app/competitions/page.tsx index aad40449..c2bdbdff 100644 --- a/app/competitions/page.tsx +++ b/app/competitions/page.tsx @@ -12,7 +12,7 @@ import { import { Button } from "@/components/ui/button"; import { Trophy, BarChart3, List } from "lucide-react"; import { CompetitionStatusBadge } from "@/app/admin/competitions/competition-status-badge"; -import { formatDate } from "@/lib/time-utils"; +import { LocalDate } from "@/components/local-date"; import { getCompetitionStatusFromObject } from "@/lib/competition-status"; export default async function CompetitionsPage() { @@ -74,13 +74,13 @@ export default async function CompetitionsPage() { Forecasts due: {" "} - {formatDate(competition.forecasts_close_date)} +

)} {competition.end_date && (

Ends:{" "} - {formatDate(competition.end_date)} +

)} @@ -133,13 +133,13 @@ export default async function CompetitionsPage() { Forecasts due: {" "} - {formatDate(competition.forecasts_close_date)} +

)} {competition.end_date && (

Ends:{" "} - {formatDate(competition.end_date)} +

)} diff --git a/components/competition-dashboard/upcoming-deadlines.tsx b/components/competition-dashboard/upcoming-deadlines.tsx index f59166a1..920b7323 100644 --- a/components/competition-dashboard/upcoming-deadlines.tsx +++ b/components/competition-dashboard/upcoming-deadlines.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { cn } from "@/lib/utils"; import type { UpcomingDeadline } from "@/lib/db_actions/competition-stats"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; +import { formatDate } from "@/lib/time-utils"; interface DeadlineDisplay { text: string; @@ -10,16 +12,12 @@ interface DeadlineDisplay { urgent: boolean; } -function formatDeadline(date: Date): DeadlineDisplay { +function formatDeadline(date: Date, timezone: string): DeadlineDisplay { const now = new Date(); const diffMs = date.getTime() - now.getTime(); const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - const formatted = date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, - }); + const formatted = formatDate(date, timezone); if (diffDays <= 0) { return { text: formatted, relative: "Overdue", urgent: true }; @@ -73,10 +71,11 @@ function getProbColor(prob: number | null): { bg: string; text: string } { interface UpcomingPropRowProps { prop: UpcomingDeadline; competitionId: number; + timezone: string; } -function UpcomingPropRow({ prop, competitionId }: UpcomingPropRowProps) { - const deadline = formatDeadline(prop.deadline); +function UpcomingPropRow({ prop, competitionId, timezone }: UpcomingPropRowProps) { + const deadline = formatDeadline(prop.deadline, timezone); const colors = getProbColor(prop.userForecast); const percent = prop.userForecast !== null ? Math.round(prop.userForecast * 100) : null; @@ -140,6 +139,8 @@ export function UpcomingDeadlines({ competitionId, onViewAll, }: UpcomingDeadlinesProps) { + const timezone = getBrowserTimezone(); + if (deadlines.length === 0) { return (
@@ -173,6 +174,7 @@ export function UpcomingDeadlines({ key={prop.propId} prop={prop} competitionId={competitionId} + timezone={timezone} /> ))}
diff --git a/components/landing/resolved-prop-card.tsx b/components/landing/resolved-prop-card.tsx index 8fc66b6d..43b9913f 100644 --- a/components/landing/resolved-prop-card.tsx +++ b/components/landing/resolved-prop-card.tsx @@ -1,6 +1,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { CheckCircle, XCircle } from "lucide-react"; import Link from "next/link"; +import { LocalDate } from "@/components/local-date"; interface ResolvedPropCardProps { propId: number; @@ -11,10 +12,6 @@ interface ResolvedPropCardProps { resolutionDate: Date; } -function formatShortDate(date: Date): string { - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - export default function ResolvedPropCard({ propId, propText, @@ -57,7 +54,7 @@ export default function ResolvedPropCard({ )} - {formatShortDate(resolutionDate)} + diff --git a/components/local-date.tsx b/components/local-date.tsx new file mode 100644 index 00000000..b6c6b60b --- /dev/null +++ b/components/local-date.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { formatDate, formatDateTime } from "@/lib/time-utils"; +import { getBrowserTimezone } from "@/hooks/getBrowserTimezone"; + +interface LocalDateProps { + date: Date; + includeTime?: boolean; +} + +export function LocalDate({ date, includeTime = false }: LocalDateProps) { + const timezone = getBrowserTimezone(); + return ( + + {includeTime ? formatDateTime(date, timezone) : formatDate(date, timezone)} + + ); +} diff --git a/hooks/getBrowserTimezone.ts b/hooks/getBrowserTimezone.ts new file mode 100644 index 00000000..f2e8a3eb --- /dev/null +++ b/hooks/getBrowserTimezone.ts @@ -0,0 +1,12 @@ +"use client"; + +const DEFAULT_TIMEZONE = "UTC"; + +export function getBrowserTimezone(): string { + if (typeof window === "undefined") return DEFAULT_TIMEZONE; + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return DEFAULT_TIMEZONE; + } +} diff --git a/lib/time-utils.test.ts b/lib/time-utils.test.ts new file mode 100644 index 00000000..1bf2cac9 --- /dev/null +++ b/lib/time-utils.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { formatDate, formatDateTime } from "./time-utils"; + +describe("formatDate", () => { + const testDate = new Date("2025-01-15T17:00:00Z"); + + it("should format date in UTC by default", () => { + const result = formatDate(testDate); + expect(result).toContain("Jan"); + expect(result).toContain("15"); + expect(result).toContain("2025"); + }); + + it("should format date in specified timezone", () => { + // 5pm UTC = 12pm Eastern (UTC-5) + const result = formatDate(testDate, "America/New_York"); + expect(result).toContain("Jan"); + expect(result).toContain("15"); + }); +}); + +describe("formatDateTime", () => { + const testDate = new Date("2025-01-15T17:00:00Z"); + + it("should format datetime in UTC by default with UTC label", () => { + const result = formatDateTime(testDate); + expect(result).toContain("17:00"); + expect(result).toContain("UTC"); + }); + + it("should format datetime in specified timezone with timezone label", () => { + const result = formatDateTime(testDate, "America/New_York"); + expect(result).toContain("12:00"); + // Check for timezone indicator (could be EST, EDT, or full name) + expect(result).toMatch(/EST|EDT|Eastern/); + }); +}); diff --git a/lib/time-utils.ts b/lib/time-utils.ts index e4dcb9c5..08521181 100644 --- a/lib/time-utils.ts +++ b/lib/time-utils.ts @@ -1,13 +1,18 @@ +const DEFAULT_TIMEZONE = "UTC"; + /** * Format a date for display (date only) * Uses browser locale for proper internationalization */ -export function formatDate(date: Date): string { +export function formatDate( + date: Date, + timezone: string = DEFAULT_TIMEZONE +): string { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", - timeZone: "UTC", + timeZone: timezone, }).format(date); } @@ -15,20 +20,31 @@ export function formatDate(date: Date): string { * Format a full datetime for tooltips * Uses browser locale for proper internationalization with 24-hour time */ -export function formatDateTime(date: Date): string { +export function formatDateTime( + date: Date, + timezone: string = DEFAULT_TIMEZONE +): string { const dateStr = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", - timeZone: "UTC", + timeZone: timezone, }).format(date); const timeStr = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hour12: false, - timeZone: "UTC", + timeZone: timezone, }).format(date); - return `${dateStr} at ${timeStr} UTC`; + // Get the timezone abbreviation (e.g., EST, PST, UTC) + const tzAbbr = new Intl.DateTimeFormat(undefined, { + timeZoneName: "short", + timeZone: timezone, + }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value ?? timezone; + + return `${dateStr} at ${timeStr} ${tzAbbr}`; }