From e98d5ff750c9e115aebb8d05e516275517785cb3 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:49:20 -0600
Subject: [PATCH 01/27] feat: add timezone column to users table
Add nullable varchar(50) timezone column to store user's preferred
timezone as IANA timezone strings (e.g., "America/New_York").
Updates v_users view to include the new timezone column.
Co-Authored-By: Claude Opus 4.5
---
migrations/1770342476414_add-user-timezone.ts | 112 ++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 migrations/1770342476414_add-user-timezone.ts
diff --git a/migrations/1770342476414_add-user-timezone.ts b/migrations/1770342476414_add-user-timezone.ts
new file mode 100644
index 0000000..035e4e7
--- /dev/null
+++ b/migrations/1770342476414_add-user-timezone.ts
@@ -0,0 +1,112 @@
+import type { Kysely } from "kysely";
+import { sql } from "kysely";
+
+/**
+ * Add timezone column to users table.
+ *
+ * This stores the user's preferred timezone as an IANA timezone string
+ * (e.g., "America/New_York"). Users without a preference will get UTC
+ * as the default in application code.
+ */
+export async function up(db: Kysely): Promise {
+ // Drop views that depend on v_users first
+ await db.schema.dropView("v_suggested_props").execute();
+ await db.schema.dropView("v_users").execute();
+
+ // Add timezone column to users table (nullable - defaults to UTC in app code)
+ await db.schema
+ .alterTable("users")
+ .addColumn("timezone", "varchar(50)")
+ .execute();
+
+ // Recreate v_users with timezone
+ await db.schema
+ .createView("v_users")
+ .as(
+ db.selectFrom("users").select([
+ "users.id",
+ "users.name",
+ "users.email",
+ "users.is_admin",
+ "users.deactivated_at",
+ "users.created_at",
+ "users.updated_at",
+ "users.idp_user_id",
+ "users.username",
+ "users.picture_url",
+ "users.timezone",
+ ]),
+ )
+ .execute();
+
+ // Set security options on v_users
+ await sql`ALTER VIEW v_users SET (security_barrier = true, security_invoker = true)`.execute(
+ db,
+ );
+
+ // Recreate v_suggested_props
+ await db.schema
+ .createView("v_suggested_props")
+ .as(
+ db
+ .selectFrom("suggested_props")
+ .innerJoin("v_users", "suggested_props.suggester_user_id", "v_users.id")
+ .select([
+ "suggested_props.id",
+ "prop as prop_text",
+ "suggester_user_id as user_id",
+ "name as user_name",
+ "email as user_email",
+ ]),
+ )
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ // Drop views that depend on v_users first
+ await db.schema.dropView("v_suggested_props").execute();
+ await db.schema.dropView("v_users").execute();
+
+ // Remove timezone column from users table
+ await db.schema.alterTable("users").dropColumn("timezone").execute();
+
+ // Recreate v_users without timezone
+ await db.schema
+ .createView("v_users")
+ .as(
+ db.selectFrom("users").select([
+ "users.id",
+ "users.name",
+ "users.email",
+ "users.is_admin",
+ "users.deactivated_at",
+ "users.created_at",
+ "users.updated_at",
+ "users.idp_user_id",
+ "users.username",
+ "users.picture_url",
+ ]),
+ )
+ .execute();
+
+ await sql`ALTER VIEW v_users SET (security_barrier = true, security_invoker = true)`.execute(
+ db,
+ );
+
+ // Recreate v_suggested_props
+ await db.schema
+ .createView("v_suggested_props")
+ .as(
+ db
+ .selectFrom("suggested_props")
+ .innerJoin("v_users", "suggested_props.suggester_user_id", "v_users.id")
+ .select([
+ "suggested_props.id",
+ "prop as prop_text",
+ "suggester_user_id as user_id",
+ "name as user_name",
+ "email as user_email",
+ ]),
+ )
+ .execute();
+}
From 041e57153eed578d2148b5f591a99d035b053a05 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:51:27 -0600
Subject: [PATCH 02/27] feat: add timezone type to users table interface
Co-Authored-By: Claude Opus 4.5
---
types/db_types.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/types/db_types.ts b/types/db_types.ts
index 4153120..879c3e5 100644
--- a/types/db_types.ts
+++ b/types/db_types.ts
@@ -29,6 +29,7 @@ export interface UsersTable {
idp_user_id: string | null; // UUID from IDP
username: string | null; // Username from IDP, updated on each login
picture_url: string | null; // Avatar URL from IDP, updated on each login
+ timezone: string | null; // IANA timezone string e.g. "America/New_York"
updated_at: Generated;
created_at: Generated;
}
@@ -208,6 +209,7 @@ export interface VUsersView {
idp_user_id: string | null; // UUID from IDP
username: string | null; // Username from IDP
picture_url: string | null; // Avatar URL from IDP
+ timezone: string | null; // IANA timezone string e.g. "America/New_York"
created_at: Date;
updated_at: Date;
}
From dcafeba108713544b3f90d027517a032b7647abc Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:52:16 -0600
Subject: [PATCH 03/27] feat: allow timezone field in user updates
Co-Authored-By: Claude Opus 4.5
---
lib/db_actions/users.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/db_actions/users.ts b/lib/db_actions/users.ts
index 4b1b580..3ec3043 100644
--- a/lib/db_actions/users.ts
+++ b/lib/db_actions/users.ts
@@ -276,9 +276,9 @@ export async function updateUser({
);
}
- // Users can only change a couple of fields: name and email.
+ // Users can only change a couple of fields: name, email, and timezone.
// If they try to change anything else, return an error.
- const allowedFields = ["name", "email"];
+ const allowedFields = ["name", "email", "timezone"];
const invalidFields = Object.keys(user).filter(
(key) => !allowedFields.includes(key),
);
From b970940551808654c63387034f0ff78c1f270a85 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:52:50 -0600
Subject: [PATCH 04/27] feat: add timezone constants and types
---
lib/timezones.ts | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 lib/timezones.ts
diff --git a/lib/timezones.ts b/lib/timezones.ts
new file mode 100644
index 0000000..33a58e8
--- /dev/null
+++ b/lib/timezones.ts
@@ -0,0 +1,24 @@
+// Common timezones for the dropdown
+// Using IANA timezone database names
+export const TIMEZONES = [
+ { value: "UTC", label: "UTC (Coordinated Universal Time)" },
+ { value: "America/New_York", label: "Eastern Time (US & Canada)" },
+ { value: "America/Chicago", label: "Central Time (US & Canada)" },
+ { value: "America/Denver", label: "Mountain Time (US & Canada)" },
+ { value: "America/Los_Angeles", label: "Pacific Time (US & Canada)" },
+ { value: "America/Anchorage", label: "Alaska" },
+ { value: "Pacific/Honolulu", label: "Hawaii" },
+ { value: "Europe/London", label: "London" },
+ { value: "Europe/Paris", label: "Paris, Berlin, Rome" },
+ { value: "Europe/Moscow", label: "Moscow" },
+ { value: "Asia/Dubai", label: "Dubai" },
+ { value: "Asia/Kolkata", label: "India" },
+ { value: "Asia/Shanghai", label: "Beijing, Shanghai" },
+ { value: "Asia/Tokyo", label: "Tokyo" },
+ { value: "Australia/Sydney", label: "Sydney" },
+ { value: "Pacific/Auckland", label: "Auckland" },
+] as const;
+
+export type Timezone = (typeof TIMEZONES)[number]["value"];
+
+export const DEFAULT_TIMEZONE = "UTC";
From f9c2565afae419d910afe1e3eaf5b1a46a74f6cc Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:54:31 -0600
Subject: [PATCH 05/27] feat: add timezone parameter to date formatting
functions
Update formatDate and formatDateTime to accept an optional timezone
parameter that defaults to UTC. The formatDateTime function now
dynamically displays the timezone abbreviation instead of hardcoding UTC.
Co-Authored-By: Claude Opus 4.5
---
lib/time-utils.test.ts | 37 +++++++++++++++++++++++++++++++++++++
lib/time-utils.ts | 28 ++++++++++++++++++++++------
2 files changed, 59 insertions(+), 6 deletions(-)
create mode 100644 lib/time-utils.test.ts
diff --git a/lib/time-utils.test.ts b/lib/time-utils.test.ts
new file mode 100644
index 0000000..1bf2cac
--- /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 e4dcb9c..3f86384 100644
--- a/lib/time-utils.ts
+++ b/lib/time-utils.ts
@@ -1,13 +1,18 @@
+import { DEFAULT_TIMEZONE } from "./timezones";
+
/**
* 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}`;
}
From 84f6bb89912d79b17735b498be52e79303b1694d Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:55:57 -0600
Subject: [PATCH 06/27] feat: add timezone settings component
Add TimezoneSettings React component that allows users to select
their preferred timezone from a dropdown. Uses React Hook Form
with Zod validation and integrates with the existing updateUser
server action.
Co-Authored-By: Claude Opus 4.5
---
app/account/timezone-settings.tsx | 119 ++++++++++++++++++++++++++++++
1 file changed, 119 insertions(+)
create mode 100644 app/account/timezone-settings.tsx
diff --git a/app/account/timezone-settings.tsx b/app/account/timezone-settings.tsx
new file mode 100644
index 0000000..135be23
--- /dev/null
+++ b/app/account/timezone-settings.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { updateUser } from "@/lib/db_actions/users";
+import { useCurrentUser } from "@/hooks/useCurrentUser";
+import { TIMEZONES, DEFAULT_TIMEZONE } from "@/lib/timezones";
+
+const formSchema = z.object({
+ timezone: z.string().min(1, "Please select a timezone"),
+});
+
+type FormValues = z.infer;
+
+export function TimezoneSettings() {
+ const { user, mutate } = useCurrentUser();
+ const { toast } = useToast();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ timezone: user?.timezone ?? DEFAULT_TIMEZONE,
+ },
+ });
+
+ async function onSubmit(values: FormValues) {
+ if (!user) return;
+
+ setIsSubmitting(true);
+ try {
+ const result = await updateUser({
+ id: user.id,
+ user: { timezone: values.timezone },
+ });
+
+ if (result.success) {
+ toast({
+ title: "Timezone updated",
+ description: "Your timezone preference has been saved.",
+ });
+ mutate(); // Refresh user data
+ } else {
+ toast({
+ title: "Error",
+ description: result.error,
+ variant: "destructive",
+ });
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ if (!user) return null;
+
+ return (
+
+
Timezone
+
+
+
+ );
+}
From 69ab02704e508ecc87d970d428a15d440f8eaf7b Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:56:44 -0600
Subject: [PATCH 07/27] feat: add timezone settings to account page
---
app/account/account-details.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/account/account-details.tsx b/app/account/account-details.tsx
index cf65c29..9be2b4d 100644
--- a/app/account/account-details.tsx
+++ b/app/account/account-details.tsx
@@ -4,13 +4,17 @@ import { Button } from "@/components/ui/button";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { ExternalLink, User2 } from "lucide-react";
import Image from "next/image";
+import { TimezoneSettings } from "./timezone-settings";
export function AccountDetails({ idpBaseUrl }: { idpBaseUrl?: string }) {
const { user } = useCurrentUser();
return (
{user && (
-
+ <>
+
+
+ >
)}
);
From 69c08f36300a5c1442dc9928cba6da98d92e6a5b Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:57:36 -0600
Subject: [PATCH 08/27] feat: add useUserTimezone hook
Co-Authored-By: Claude Opus 4.5
---
hooks/useUserTimezone.ts | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 hooks/useUserTimezone.ts
diff --git a/hooks/useUserTimezone.ts b/hooks/useUserTimezone.ts
new file mode 100644
index 0000000..a8c91ce
--- /dev/null
+++ b/hooks/useUserTimezone.ts
@@ -0,0 +1,9 @@
+"use client";
+
+import { useCurrentUser } from "./useCurrentUser";
+import { DEFAULT_TIMEZONE } from "@/lib/timezones";
+
+export function useUserTimezone(): string {
+ const { user } = useCurrentUser();
+ return user?.timezone ?? DEFAULT_TIMEZONE;
+}
From d0b1922d251dc6bc41efbc3196c88f04f34a0c37 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:59:01 -0600
Subject: [PATCH 09/27] feat: use user timezone in upcoming deadlines component
Update the UpcomingDeadlines component to format deadline dates
using the user's preferred timezone from their profile settings.
Co-Authored-By: Claude Opus 4.5
---
.../upcoming-deadlines.tsx | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/components/competition-dashboard/upcoming-deadlines.tsx b/components/competition-dashboard/upcoming-deadlines.tsx
index f59166a..6fc48c4 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 { useUserTimezone } from "@/hooks/useUserTimezone";
+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 = useUserTimezone();
+
if (deadlines.length === 0) {
return (
@@ -173,6 +174,7 @@ export function UpcomingDeadlines({
key={prop.propId}
prop={prop}
competitionId={competitionId}
+ timezone={timezone}
/>
))}
From e5a8ef15d5ec5cd1877cf3559db2b450f065eb67 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 19:59:43 -0600
Subject: [PATCH 10/27] feat: use user timezone in competition prop view
Update the CompetitionPropView component to format forecast and
resolution due dates using the user's preferred timezone.
Co-Authored-By: Claude Opus 4.5
---
.../props/[propId]/competition-prop-view.tsx | 18 +++++++-----------
1 file changed, 7 insertions(+), 11 deletions(-)
diff --git a/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx b/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx
index 8442057..ba0338a 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 { useUserTimezone } from "@/hooks/useUserTimezone";
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 = useUserTimezone();
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)}
)}
From add728e3097de0baea2aa0dba7a77145b74b91c0 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:00:17 -0600
Subject: [PATCH 11/27] feat: use user timezone in resolved prop card
Convert ResolvedPropCard to a client component and update
resolution date display to use the user's preferred timezone.
Co-Authored-By: Claude Opus 4.5
---
components/landing/resolved-prop-card.tsx | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/components/landing/resolved-prop-card.tsx b/components/landing/resolved-prop-card.tsx
index 8fc66b6..4c3e3e2 100644
--- a/components/landing/resolved-prop-card.tsx
+++ b/components/landing/resolved-prop-card.tsx
@@ -1,6 +1,10 @@
+"use client";
+
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle, XCircle } from "lucide-react";
import Link from "next/link";
+import { useUserTimezone } from "@/hooks/useUserTimezone";
+import { formatDate } from "@/lib/time-utils";
interface ResolvedPropCardProps {
propId: number;
@@ -11,10 +15,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,
@@ -23,6 +23,7 @@ export default function ResolvedPropCard({
resolution,
resolutionDate,
}: ResolvedPropCardProps) {
+ const timezone = useUserTimezone();
return (
@@ -57,7 +58,7 @@ export default function ResolvedPropCard({
)}
- {formatShortDate(resolutionDate)}
+ {formatDate(resolutionDate, timezone)}
From c15c511d183c92f5e05023bbc9815b03c20810c2 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:00:48 -0600
Subject: [PATCH 12/27] feat: use user timezone in user detail card
Replace date-fns format() calls with timezone-aware formatDate()
and formatDateTime() functions from time-utils.
Co-Authored-By: Claude Opus 4.5
---
app/admin/users/[userId]/user-detail-card.tsx | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/app/admin/users/[userId]/user-detail-card.tsx b/app/admin/users/[userId]/user-detail-card.tsx
index 4e45a86..fbc5dfe 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 { useUserTimezone } from "@/hooks/useUserTimezone";
+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 = useUserTimezone();
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)}
)}
From 0e0303a2e1b76ec4292ccac949b893ad06b18034 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:01:35 -0600
Subject: [PATCH 13/27] feat: use user timezone in competitions page
Use the authenticated user's timezone preference for displaying
competition dates in the server-rendered competitions list page.
Co-Authored-By: Claude Opus 4.5
---
app/competitions/page.tsx | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/app/competitions/page.tsx b/app/competitions/page.tsx
index aad4044..c835254 100644
--- a/app/competitions/page.tsx
+++ b/app/competitions/page.tsx
@@ -13,10 +13,12 @@ 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 { DEFAULT_TIMEZONE } from "@/lib/timezones";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
export default async function CompetitionsPage() {
const user = (await getUserFromCookies())!;
+ const timezone = user.timezone ?? DEFAULT_TIMEZONE;
const allCompetitionsResult = await getCompetitions();
if (!allCompetitionsResult.success) {
@@ -74,13 +76,13 @@ export default async function CompetitionsPage() {
Forecasts due:
{" "}
- {formatDate(competition.forecasts_close_date)}
+ {formatDate(competition.forecasts_close_date, timezone)}
)}
{competition.end_date && (
Ends:{" "}
- {formatDate(competition.end_date)}
+ {formatDate(competition.end_date, timezone)}
)}
>
@@ -133,13 +135,13 @@ export default async function CompetitionsPage() {
Forecasts due:
{" "}
- {formatDate(competition.forecasts_close_date)}
+ {formatDate(competition.forecasts_close_date, timezone)}
)}
{competition.end_date && (
Ends:{" "}
- {formatDate(competition.end_date)}
+ {formatDate(competition.end_date, timezone)}
)}
>
From da7086c4d370b4bc3e249cc43d2d0659c5fefb06 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:02:13 -0600
Subject: [PATCH 14/27] feat: use user timezone in competition row component
Update the admin competition row component to format competition
dates using the user's preferred timezone from their profile.
Co-Authored-By: Claude Opus 4.5
---
app/admin/competitions/competition-row.tsx | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/app/admin/competitions/competition-row.tsx b/app/admin/competitions/competition-row.tsx
index 5240896..3db43da 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 { useUserTimezone } from "@/hooks/useUserTimezone";
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 = useUserTimezone();
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)}
)}
)}
From 1fb3d1bd943a9d1bcf8e00366fe571efdd1f9056 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:07:29 -0600
Subject: [PATCH 15/27] feat: use user timezone in new-prop-form.tsx
Co-Authored-By: Claude Opus 4.5
---
app/competitions/[competitionId]/props/new/new-prop-form.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/app/competitions/[competitionId]/props/new/new-prop-form.tsx b/app/competitions/[competitionId]/props/new/new-prop-form.tsx
index ded153c..d025993 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 { useUserTimezone } from "@/hooks/useUserTimezone";
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 = useUserTimezone();
const [showPreview, setShowPreview] = useState(false);
const form = useForm({
@@ -346,7 +349,7 @@ export function NewPropForm({
{watchedForecastsDueDate && (
Forecasts due:{" "}
- {watchedForecastsDueDate.toLocaleDateString()}
+ {formatDate(watchedForecastsDueDate, timezone)}
)}
From b10e30f69058b56e9e69726ac84ebbfc01e43e3b Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:08:05 -0600
Subject: [PATCH 16/27] feat: use user timezone in competition-start-end.tsx
Co-Authored-By: Claude Opus 4.5
---
.../[competitionId]/competition-start-end.tsx | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/app/competitions/[competitionId]/competition-start-end.tsx b/app/competitions/[competitionId]/competition-start-end.tsx
index 136a9dd..f4923f4 100644
--- a/app/competitions/[competitionId]/competition-start-end.tsx
+++ b/app/competitions/[competitionId]/competition-start-end.tsx
@@ -3,6 +3,7 @@
import { Competition } from "@/types/db_types";
import { formatInTimeZone } from "date-fns-tz";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
+import { useUserTimezone } from "@/hooks/useUserTimezone";
const DATE_FORMAT = "MMM d, yyyy";
@@ -11,6 +12,7 @@ export default function CompetitionStartEnd({
}: {
competition: Competition;
}) {
+ const timezone = useUserTimezone();
const status = getCompetitionStatusFromObject(competition);
return (
@@ -22,7 +24,7 @@ export default function CompetitionStartEnd({
Forecasts open{" "}
{formatInTimeZone(
competition.forecasts_open_date,
- "UTC",
+ timezone,
DATE_FORMAT,
)}
@@ -30,7 +32,7 @@ export default function CompetitionStartEnd({
Forecasts close{" "}
{formatInTimeZone(
competition.forecasts_close_date,
- "UTC",
+ timezone,
DATE_FORMAT,
)}
@@ -44,7 +46,7 @@ export default function CompetitionStartEnd({
Forecasts opened{" "}
{formatInTimeZone(
competition.forecasts_open_date,
- "UTC",
+ timezone,
DATE_FORMAT,
)}
@@ -52,7 +54,7 @@ export default function CompetitionStartEnd({
Forecasts close{" "}
{formatInTimeZone(
competition.forecasts_close_date,
- "UTC",
+ timezone,
DATE_FORMAT,
)}
@@ -66,20 +68,20 @@ export default function CompetitionStartEnd({
Forecasts closed{" "}
{formatInTimeZone(
competition.forecasts_close_date,
- "UTC",
+ timezone,
DATE_FORMAT,
)}
Competition Ends{" "}
- {formatInTimeZone(competition.end_date, "UTC", DATE_FORMAT)}
+ {formatInTimeZone(competition.end_date, timezone, DATE_FORMAT)}
>
)}
{status === "ended" && competition.end_date && (
Ended{" "}
- {formatInTimeZone(competition.end_date, "UTC", DATE_FORMAT)}
+ {formatInTimeZone(competition.end_date, timezone, DATE_FORMAT)}
)}
{status === "private" && (
From fbda4f663b0281bdd78b43bf1be2383e131b6977 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:21:46 -0600
Subject: [PATCH 17/27] refactor: use browser timezone detection instead of
saved preference
Co-Authored-By: Claude (claude-opus-4-6)
---
hooks/useUserTimezone.ts | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/hooks/useUserTimezone.ts b/hooks/useUserTimezone.ts
index a8c91ce..5dcbbad 100644
--- a/hooks/useUserTimezone.ts
+++ b/hooks/useUserTimezone.ts
@@ -1,9 +1,12 @@
"use client";
-import { useCurrentUser } from "./useCurrentUser";
-import { DEFAULT_TIMEZONE } from "@/lib/timezones";
+const DEFAULT_TIMEZONE = "UTC";
export function useUserTimezone(): string {
- const { user } = useCurrentUser();
- return user?.timezone ?? DEFAULT_TIMEZONE;
+ if (typeof window === "undefined") return DEFAULT_TIMEZONE;
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch {
+ return DEFAULT_TIMEZONE;
+ }
}
From d577f90d10adb78f575de4d022de157d8578f199 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:21:52 -0600
Subject: [PATCH 18/27] refactor: inline DEFAULT_TIMEZONE in time-utils
Co-Authored-By: Claude (claude-opus-4-6)
---
lib/time-utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/time-utils.ts b/lib/time-utils.ts
index 3f86384..0852118 100644
--- a/lib/time-utils.ts
+++ b/lib/time-utils.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_TIMEZONE } from "./timezones";
+const DEFAULT_TIMEZONE = "UTC";
/**
* Format a date for display (date only)
From 26b9cac0589d09e1774a46b3fcf279364a78f419 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:22:52 -0600
Subject: [PATCH 19/27] refactor: remove timezones.ts constants file
Co-Authored-By: Claude (claude-opus-4-6)
---
app/competitions/page.tsx | 2 +-
lib/timezones.ts | 24 ------------------------
2 files changed, 1 insertion(+), 25 deletions(-)
delete mode 100644 lib/timezones.ts
diff --git a/app/competitions/page.tsx b/app/competitions/page.tsx
index c835254..3555c52 100644
--- a/app/competitions/page.tsx
+++ b/app/competitions/page.tsx
@@ -13,7 +13,7 @@ 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 { DEFAULT_TIMEZONE } from "@/lib/timezones";
+const DEFAULT_TIMEZONE = "UTC";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
export default async function CompetitionsPage() {
diff --git a/lib/timezones.ts b/lib/timezones.ts
deleted file mode 100644
index 33a58e8..0000000
--- a/lib/timezones.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// Common timezones for the dropdown
-// Using IANA timezone database names
-export const TIMEZONES = [
- { value: "UTC", label: "UTC (Coordinated Universal Time)" },
- { value: "America/New_York", label: "Eastern Time (US & Canada)" },
- { value: "America/Chicago", label: "Central Time (US & Canada)" },
- { value: "America/Denver", label: "Mountain Time (US & Canada)" },
- { value: "America/Los_Angeles", label: "Pacific Time (US & Canada)" },
- { value: "America/Anchorage", label: "Alaska" },
- { value: "Pacific/Honolulu", label: "Hawaii" },
- { value: "Europe/London", label: "London" },
- { value: "Europe/Paris", label: "Paris, Berlin, Rome" },
- { value: "Europe/Moscow", label: "Moscow" },
- { value: "Asia/Dubai", label: "Dubai" },
- { value: "Asia/Kolkata", label: "India" },
- { value: "Asia/Shanghai", label: "Beijing, Shanghai" },
- { value: "Asia/Tokyo", label: "Tokyo" },
- { value: "Australia/Sydney", label: "Sydney" },
- { value: "Pacific/Auckland", label: "Auckland" },
-] as const;
-
-export type Timezone = (typeof TIMEZONES)[number]["value"];
-
-export const DEFAULT_TIMEZONE = "UTC";
From f6dae1722ba7eb35a5cafcee5cf7b152b0f60938 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:23:01 -0600
Subject: [PATCH 20/27] refactor: remove timezone settings UI
Co-Authored-By: Claude (claude-opus-4-6)
---
app/account/account-details.tsx | 7 +-
app/account/timezone-settings.tsx | 119 ------------------------------
2 files changed, 1 insertion(+), 125 deletions(-)
delete mode 100644 app/account/timezone-settings.tsx
diff --git a/app/account/account-details.tsx b/app/account/account-details.tsx
index 9be2b4d..988266f 100644
--- a/app/account/account-details.tsx
+++ b/app/account/account-details.tsx
@@ -4,17 +4,12 @@ import { Button } from "@/components/ui/button";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { ExternalLink, User2 } from "lucide-react";
import Image from "next/image";
-import { TimezoneSettings } from "./timezone-settings";
-
export function AccountDetails({ idpBaseUrl }: { idpBaseUrl?: string }) {
const { user } = useCurrentUser();
return (
{user && (
- <>
-
-
- >
+
)}
);
diff --git a/app/account/timezone-settings.tsx b/app/account/timezone-settings.tsx
deleted file mode 100644
index 135be23..0000000
--- a/app/account/timezone-settings.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { z } from "zod";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Button } from "@/components/ui/button";
-import { useToast } from "@/hooks/use-toast";
-import { updateUser } from "@/lib/db_actions/users";
-import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { TIMEZONES, DEFAULT_TIMEZONE } from "@/lib/timezones";
-
-const formSchema = z.object({
- timezone: z.string().min(1, "Please select a timezone"),
-});
-
-type FormValues = z.infer;
-
-export function TimezoneSettings() {
- const { user, mutate } = useCurrentUser();
- const { toast } = useToast();
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const form = useForm({
- resolver: zodResolver(formSchema),
- defaultValues: {
- timezone: user?.timezone ?? DEFAULT_TIMEZONE,
- },
- });
-
- async function onSubmit(values: FormValues) {
- if (!user) return;
-
- setIsSubmitting(true);
- try {
- const result = await updateUser({
- id: user.id,
- user: { timezone: values.timezone },
- });
-
- if (result.success) {
- toast({
- title: "Timezone updated",
- description: "Your timezone preference has been saved.",
- });
- mutate(); // Refresh user data
- } else {
- toast({
- title: "Error",
- description: result.error,
- variant: "destructive",
- });
- }
- } finally {
- setIsSubmitting(false);
- }
- }
-
- if (!user) return null;
-
- return (
-
-
Timezone
-
-
-
- );
-}
From e07b43df1bb16eb958b482afd406a27353362788 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:23:20 -0600
Subject: [PATCH 21/27] refactor: remove timezone DB column and migration
Co-Authored-By: Claude (claude-opus-4-6)
---
lib/db_actions/users.ts | 4 +-
migrations/1770342476414_add-user-timezone.ts | 112 ------------------
types/db_types.ts | 2 -
3 files changed, 2 insertions(+), 116 deletions(-)
delete mode 100644 migrations/1770342476414_add-user-timezone.ts
diff --git a/lib/db_actions/users.ts b/lib/db_actions/users.ts
index 3ec3043..4b1b580 100644
--- a/lib/db_actions/users.ts
+++ b/lib/db_actions/users.ts
@@ -276,9 +276,9 @@ export async function updateUser({
);
}
- // Users can only change a couple of fields: name, email, and timezone.
+ // Users can only change a couple of fields: name and email.
// If they try to change anything else, return an error.
- const allowedFields = ["name", "email", "timezone"];
+ const allowedFields = ["name", "email"];
const invalidFields = Object.keys(user).filter(
(key) => !allowedFields.includes(key),
);
diff --git a/migrations/1770342476414_add-user-timezone.ts b/migrations/1770342476414_add-user-timezone.ts
deleted file mode 100644
index 035e4e7..0000000
--- a/migrations/1770342476414_add-user-timezone.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import type { Kysely } from "kysely";
-import { sql } from "kysely";
-
-/**
- * Add timezone column to users table.
- *
- * This stores the user's preferred timezone as an IANA timezone string
- * (e.g., "America/New_York"). Users without a preference will get UTC
- * as the default in application code.
- */
-export async function up(db: Kysely): Promise {
- // Drop views that depend on v_users first
- await db.schema.dropView("v_suggested_props").execute();
- await db.schema.dropView("v_users").execute();
-
- // Add timezone column to users table (nullable - defaults to UTC in app code)
- await db.schema
- .alterTable("users")
- .addColumn("timezone", "varchar(50)")
- .execute();
-
- // Recreate v_users with timezone
- await db.schema
- .createView("v_users")
- .as(
- db.selectFrom("users").select([
- "users.id",
- "users.name",
- "users.email",
- "users.is_admin",
- "users.deactivated_at",
- "users.created_at",
- "users.updated_at",
- "users.idp_user_id",
- "users.username",
- "users.picture_url",
- "users.timezone",
- ]),
- )
- .execute();
-
- // Set security options on v_users
- await sql`ALTER VIEW v_users SET (security_barrier = true, security_invoker = true)`.execute(
- db,
- );
-
- // Recreate v_suggested_props
- await db.schema
- .createView("v_suggested_props")
- .as(
- db
- .selectFrom("suggested_props")
- .innerJoin("v_users", "suggested_props.suggester_user_id", "v_users.id")
- .select([
- "suggested_props.id",
- "prop as prop_text",
- "suggester_user_id as user_id",
- "name as user_name",
- "email as user_email",
- ]),
- )
- .execute();
-}
-
-export async function down(db: Kysely): Promise {
- // Drop views that depend on v_users first
- await db.schema.dropView("v_suggested_props").execute();
- await db.schema.dropView("v_users").execute();
-
- // Remove timezone column from users table
- await db.schema.alterTable("users").dropColumn("timezone").execute();
-
- // Recreate v_users without timezone
- await db.schema
- .createView("v_users")
- .as(
- db.selectFrom("users").select([
- "users.id",
- "users.name",
- "users.email",
- "users.is_admin",
- "users.deactivated_at",
- "users.created_at",
- "users.updated_at",
- "users.idp_user_id",
- "users.username",
- "users.picture_url",
- ]),
- )
- .execute();
-
- await sql`ALTER VIEW v_users SET (security_barrier = true, security_invoker = true)`.execute(
- db,
- );
-
- // Recreate v_suggested_props
- await db.schema
- .createView("v_suggested_props")
- .as(
- db
- .selectFrom("suggested_props")
- .innerJoin("v_users", "suggested_props.suggester_user_id", "v_users.id")
- .select([
- "suggested_props.id",
- "prop as prop_text",
- "suggester_user_id as user_id",
- "name as user_name",
- "email as user_email",
- ]),
- )
- .execute();
-}
diff --git a/types/db_types.ts b/types/db_types.ts
index 879c3e5..4153120 100644
--- a/types/db_types.ts
+++ b/types/db_types.ts
@@ -29,7 +29,6 @@ export interface UsersTable {
idp_user_id: string | null; // UUID from IDP
username: string | null; // Username from IDP, updated on each login
picture_url: string | null; // Avatar URL from IDP, updated on each login
- timezone: string | null; // IANA timezone string e.g. "America/New_York"
updated_at: Generated;
created_at: Generated;
}
@@ -209,7 +208,6 @@ export interface VUsersView {
idp_user_id: string | null; // UUID from IDP
username: string | null; // Username from IDP
picture_url: string | null; // Avatar URL from IDP
- timezone: string | null; // IANA timezone string e.g. "America/New_York"
created_at: Date;
updated_at: Date;
}
From 22630c2bd9bed859e7c26481a394e580873d6f8f Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:23:46 -0600
Subject: [PATCH 22/27] fix: remove stale user.timezone reference in
competitions page
Co-Authored-By: Claude (claude-opus-4-6)
---
app/competitions/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/competitions/page.tsx b/app/competitions/page.tsx
index 3555c52..5951664 100644
--- a/app/competitions/page.tsx
+++ b/app/competitions/page.tsx
@@ -18,7 +18,7 @@ import { getCompetitionStatusFromObject } from "@/lib/competition-status";
export default async function CompetitionsPage() {
const user = (await getUserFromCookies())!;
- const timezone = user.timezone ?? DEFAULT_TIMEZONE;
+ const timezone = DEFAULT_TIMEZONE;
const allCompetitionsResult = await getCompetitions();
if (!allCompetitionsResult.success) {
From c49b36ba7ad872b28521582043676befbd51f7c9 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:25:30 -0600
Subject: [PATCH 23/27] fix: clean up server component timezone handling in
competitions page
Co-Authored-By: Claude (claude-opus-4-6)
---
app/competitions/page.tsx | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/app/competitions/page.tsx b/app/competitions/page.tsx
index 5951664..aad4044 100644
--- a/app/competitions/page.tsx
+++ b/app/competitions/page.tsx
@@ -13,12 +13,10 @@ 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";
-const DEFAULT_TIMEZONE = "UTC";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
export default async function CompetitionsPage() {
const user = (await getUserFromCookies())!;
- const timezone = DEFAULT_TIMEZONE;
const allCompetitionsResult = await getCompetitions();
if (!allCompetitionsResult.success) {
@@ -76,13 +74,13 @@ export default async function CompetitionsPage() {
Forecasts due:
{" "}
- {formatDate(competition.forecasts_close_date, timezone)}
+ {formatDate(competition.forecasts_close_date)}
)}
{competition.end_date && (
Ends:{" "}
- {formatDate(competition.end_date, timezone)}
+ {formatDate(competition.end_date)}
)}
>
@@ -135,13 +133,13 @@ export default async function CompetitionsPage() {
Forecasts due:
{" "}
- {formatDate(competition.forecasts_close_date, timezone)}
+ {formatDate(competition.forecasts_close_date)}
)}
{competition.end_date && (
Ends:{" "}
- {formatDate(competition.end_date, timezone)}
+ {formatDate(competition.end_date)}
)}
>
From e3a30c26e48955f7386500886ae6210f33dbef9d Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:29:25 -0600
Subject: [PATCH 24/27] refactor: use formatDate instead of formatInTimeZone in
competition-start-end
Co-Authored-By: Claude (claude-opus-4-6)
---
.../[competitionId]/competition-start-end.tsx | 38 ++++---------------
1 file changed, 8 insertions(+), 30 deletions(-)
diff --git a/app/competitions/[competitionId]/competition-start-end.tsx b/app/competitions/[competitionId]/competition-start-end.tsx
index f4923f4..db9ca87 100644
--- a/app/competitions/[competitionId]/competition-start-end.tsx
+++ b/app/competitions/[competitionId]/competition-start-end.tsx
@@ -1,11 +1,9 @@
"use client";
import { Competition } from "@/types/db_types";
-import { formatInTimeZone } from "date-fns-tz";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
import { useUserTimezone } from "@/hooks/useUserTimezone";
-
-const DATE_FORMAT = "MMM d, yyyy";
+import { formatDate } from "@/lib/time-utils";
export default function CompetitionStartEnd({
competition,
@@ -22,19 +20,11 @@ export default function CompetitionStartEnd({
<>
Forecasts open{" "}
- {formatInTimeZone(
- competition.forecasts_open_date,
- timezone,
- DATE_FORMAT,
- )}
+ {formatDate(competition.forecasts_open_date, timezone)}
Forecasts close{" "}
- {formatInTimeZone(
- competition.forecasts_close_date,
- timezone,
- DATE_FORMAT,
- )}
+ {formatDate(competition.forecasts_close_date, timezone)}
>
)}
@@ -44,19 +34,11 @@ export default function CompetitionStartEnd({
<>
Forecasts opened{" "}
- {formatInTimeZone(
- competition.forecasts_open_date,
- timezone,
- DATE_FORMAT,
- )}
+ {formatDate(competition.forecasts_open_date, timezone)}
Forecasts close{" "}
- {formatInTimeZone(
- competition.forecasts_close_date,
- timezone,
- DATE_FORMAT,
- )}
+ {formatDate(competition.forecasts_close_date, timezone)}
>
)}
@@ -66,22 +48,18 @@ export default function CompetitionStartEnd({
<>
Forecasts closed{" "}
- {formatInTimeZone(
- competition.forecasts_close_date,
- timezone,
- DATE_FORMAT,
- )}
+ {formatDate(competition.forecasts_close_date, timezone)}
Competition Ends{" "}
- {formatInTimeZone(competition.end_date, timezone, DATE_FORMAT)}
+ {formatDate(competition.end_date, timezone)}
>
)}
{status === "ended" && competition.end_date && (
Ended{" "}
- {formatInTimeZone(competition.end_date, timezone, DATE_FORMAT)}
+ {formatDate(competition.end_date, timezone)}
)}
{status === "private" && (
From 63e2a601f6a236637d25746d5c4e40beb58bdc92 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:31:26 -0600
Subject: [PATCH 25/27] feat: add LocalDate component for timezone-aware dates
in server components
Co-Authored-By: Claude (claude-opus-4-6)
---
app/competitions/page.tsx | 10 +++++-----
components/local-date.tsx | 14 ++++++++++++++
2 files changed, 19 insertions(+), 5 deletions(-)
create mode 100644 components/local-date.tsx
diff --git a/app/competitions/page.tsx b/app/competitions/page.tsx
index aad4044..c2bdbdf 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/local-date.tsx b/components/local-date.tsx
new file mode 100644
index 0000000..eb2c270
--- /dev/null
+++ b/components/local-date.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { formatDate, formatDateTime } from "@/lib/time-utils";
+import { useUserTimezone } from "@/hooks/useUserTimezone";
+
+interface LocalDateProps {
+ date: Date;
+ includeTime?: boolean;
+}
+
+export function LocalDate({ date, includeTime = false }: LocalDateProps) {
+ const timezone = useUserTimezone();
+ return <>{includeTime ? formatDateTime(date, timezone) : formatDate(date, timezone)}>;
+}
From cf95dfbfa07e82597c3e49685f93936664e49771 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:38:50 -0600
Subject: [PATCH 26/27] refactor: rename useUserTimezone to getBrowserTimezone;
revert resolved-prop-card to server component
- Renamed useUserTimezone to getBrowserTimezone since it's a plain function,
not a React hook
- Reverted resolved-prop-card.tsx to a server component by using
instead of the hook directly, reducing client bundle size
- Restored missing blank line in account-details.tsx
Co-Authored-By: Claude (claude-opus-4-6)
---
app/account/account-details.tsx | 1 +
app/admin/competitions/competition-row.tsx | 4 ++--
app/admin/users/[userId]/user-detail-card.tsx | 4 ++--
.../[competitionId]/competition-start-end.tsx | 4 ++--
.../props/[propId]/competition-prop-view.tsx | 4 ++--
.../[competitionId]/props/new/new-prop-form.tsx | 4 ++--
components/competition-dashboard/upcoming-deadlines.tsx | 4 ++--
components/landing/resolved-prop-card.tsx | 8 ++------
components/local-date.tsx | 4 ++--
hooks/{useUserTimezone.ts => getBrowserTimezone.ts} | 2 +-
10 files changed, 18 insertions(+), 21 deletions(-)
rename hooks/{useUserTimezone.ts => getBrowserTimezone.ts} (82%)
diff --git a/app/account/account-details.tsx b/app/account/account-details.tsx
index 988266f..cf65c29 100644
--- a/app/account/account-details.tsx
+++ b/app/account/account-details.tsx
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { ExternalLink, User2 } from "lucide-react";
import Image from "next/image";
+
export function AccountDetails({ idpBaseUrl }: { idpBaseUrl?: string }) {
const { user } = useCurrentUser();
return (
diff --git a/app/admin/competitions/competition-row.tsx b/app/admin/competitions/competition-row.tsx
index 3db43da..36f57e3 100644
--- a/app/admin/competitions/competition-row.tsx
+++ b/app/admin/competitions/competition-row.tsx
@@ -19,7 +19,7 @@ import {
import { CreateEditCompetitionForm } from "@/components/forms/create-edit-competition-form";
import { useState } from "react";
import { formatDate, formatDateTime } from "@/lib/time-utils";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
+import { getBrowserTimezone } from "@/hooks/getBrowserTimezone";
import { CompetitionStatusBadge } from "./competition-status-badge";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
@@ -33,7 +33,7 @@ export default function CompetitionRow({
nResolvedProps: number;
}) {
const [open, setOpen] = useState(false);
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
const status = getCompetitionStatusFromObject(competition);
diff --git a/app/admin/users/[userId]/user-detail-card.tsx b/app/admin/users/[userId]/user-detail-card.tsx
index fbc5dfe..70e282f 100644
--- a/app/admin/users/[userId]/user-detail-card.tsx
+++ b/app/admin/users/[userId]/user-detail-card.tsx
@@ -15,7 +15,7 @@ import {
} from "@/components/ui/dialog";
import { Copy, Shield, User, UserCheck, UserX } from "lucide-react";
import { setUserActive } from "@/lib/db_actions/users";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
+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";
@@ -33,7 +33,7 @@ export default function UserDetailCard({ user }: UserDetailCardProps) {
const [isImpersonateDialogOpen, setIsImpersonateDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
const isActive = user.deactivated_at === null;
const canImpersonate = !user.is_admin && isActive;
diff --git a/app/competitions/[competitionId]/competition-start-end.tsx b/app/competitions/[competitionId]/competition-start-end.tsx
index db9ca87..dce95f1 100644
--- a/app/competitions/[competitionId]/competition-start-end.tsx
+++ b/app/competitions/[competitionId]/competition-start-end.tsx
@@ -2,7 +2,7 @@
import { Competition } from "@/types/db_types";
import { getCompetitionStatusFromObject } from "@/lib/competition-status";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
+import { getBrowserTimezone } from "@/hooks/getBrowserTimezone";
import { formatDate } from "@/lib/time-utils";
export default function CompetitionStartEnd({
@@ -10,7 +10,7 @@ export default function CompetitionStartEnd({
}: {
competition: Competition;
}) {
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
const status = getCompetitionStatusFromObject(competition);
return (
diff --git a/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx b/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx
index ba0338a..9351191 100644
--- a/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx
+++ b/app/competitions/[competitionId]/props/[propId]/competition-prop-view.tsx
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MarkdownRenderer } from "@/components/markdown";
import { useCurrentUser } from "@/hooks/useCurrentUser";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
+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";
@@ -99,7 +99,7 @@ export function CompetitionPropView({
}: CompetitionPropViewProps) {
const router = useRouter();
const { user } = useCurrentUser();
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
const [localForecast, setLocalForecast] = useState(
prop.user_forecast,
);
diff --git a/app/competitions/[competitionId]/props/new/new-prop-form.tsx b/app/competitions/[competitionId]/props/new/new-prop-form.tsx
index d025993..88d1410 100644
--- a/app/competitions/[competitionId]/props/new/new-prop-form.tsx
+++ b/app/competitions/[competitionId]/props/new/new-prop-form.tsx
@@ -40,7 +40,7 @@ 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 { useUserTimezone } from "@/hooks/useUserTimezone";
+import { getBrowserTimezone } from "@/hooks/getBrowserTimezone";
import { createProp } from "@/lib/db_actions";
import { formatDate } from "@/lib/time-utils";
import type { Category } from "@/types/db_types";
@@ -102,7 +102,7 @@ export function NewPropForm({
userId,
}: NewPropFormProps) {
const router = useRouter();
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
const [showPreview, setShowPreview] = useState(false);
const form = useForm({
diff --git a/components/competition-dashboard/upcoming-deadlines.tsx b/components/competition-dashboard/upcoming-deadlines.tsx
index 6fc48c4..920b732 100644
--- a/components/competition-dashboard/upcoming-deadlines.tsx
+++ b/components/competition-dashboard/upcoming-deadlines.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import type { UpcomingDeadline } from "@/lib/db_actions/competition-stats";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
+import { getBrowserTimezone } from "@/hooks/getBrowserTimezone";
import { formatDate } from "@/lib/time-utils";
interface DeadlineDisplay {
@@ -139,7 +139,7 @@ export function UpcomingDeadlines({
competitionId,
onViewAll,
}: UpcomingDeadlinesProps) {
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
if (deadlines.length === 0) {
return (
diff --git a/components/landing/resolved-prop-card.tsx b/components/landing/resolved-prop-card.tsx
index 4c3e3e2..43b9913 100644
--- a/components/landing/resolved-prop-card.tsx
+++ b/components/landing/resolved-prop-card.tsx
@@ -1,10 +1,7 @@
-"use client";
-
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle, XCircle } from "lucide-react";
import Link from "next/link";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
-import { formatDate } from "@/lib/time-utils";
+import { LocalDate } from "@/components/local-date";
interface ResolvedPropCardProps {
propId: number;
@@ -23,7 +20,6 @@ export default function ResolvedPropCard({
resolution,
resolutionDate,
}: ResolvedPropCardProps) {
- const timezone = useUserTimezone();
return (
@@ -58,7 +54,7 @@ export default function ResolvedPropCard({
)}
- {formatDate(resolutionDate, timezone)}
+
diff --git a/components/local-date.tsx b/components/local-date.tsx
index eb2c270..24b3a0d 100644
--- a/components/local-date.tsx
+++ b/components/local-date.tsx
@@ -1,7 +1,7 @@
"use client";
import { formatDate, formatDateTime } from "@/lib/time-utils";
-import { useUserTimezone } from "@/hooks/useUserTimezone";
+import { getBrowserTimezone } from "@/hooks/getBrowserTimezone";
interface LocalDateProps {
date: Date;
@@ -9,6 +9,6 @@ interface LocalDateProps {
}
export function LocalDate({ date, includeTime = false }: LocalDateProps) {
- const timezone = useUserTimezone();
+ const timezone = getBrowserTimezone();
return <>{includeTime ? formatDateTime(date, timezone) : formatDate(date, timezone)}>;
}
diff --git a/hooks/useUserTimezone.ts b/hooks/getBrowserTimezone.ts
similarity index 82%
rename from hooks/useUserTimezone.ts
rename to hooks/getBrowserTimezone.ts
index 5dcbbad..f2e8a3e 100644
--- a/hooks/useUserTimezone.ts
+++ b/hooks/getBrowserTimezone.ts
@@ -2,7 +2,7 @@
const DEFAULT_TIMEZONE = "UTC";
-export function useUserTimezone(): string {
+export function getBrowserTimezone(): string {
if (typeof window === "undefined") return DEFAULT_TIMEZONE;
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
From c7815e8db002e556eddeffef09bb8e43e0c7c9b2 Mon Sep 17 00:00:00 2001
From: Ethan Swan
Date: Thu, 5 Feb 2026 20:41:11 -0600
Subject: [PATCH 27/27] fix: suppress hydration warning in LocalDate component
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Server renders UTC, client renders browser timezone — the mismatch is
intentional, so suppressHydrationWarning is the correct approach.
Co-Authored-By: Claude (claude-opus-4-6)
---
components/local-date.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/components/local-date.tsx b/components/local-date.tsx
index 24b3a0d..b6c6b60 100644
--- a/components/local-date.tsx
+++ b/components/local-date.tsx
@@ -10,5 +10,9 @@ interface LocalDateProps {
export function LocalDate({ date, includeTime = false }: LocalDateProps) {
const timezone = getBrowserTimezone();
- return <>{includeTime ? formatDateTime(date, timezone) : formatDate(date, timezone)}>;
+ return (
+
+ {includeTime ? formatDateTime(date, timezone) : formatDate(date, timezone)}
+
+ );
}