|
2 | 2 | * Timezone utilities for handling user timezone detection and date conversions |
3 | 3 | */ |
4 | 4 |
|
5 | | -export interface TimezoneInfo { |
| 5 | +import dayjs from 'dayjs'; |
| 6 | +import utc from 'dayjs/plugin/utc'; |
| 7 | +import timezone from 'dayjs/plugin/timezone'; |
| 8 | + |
| 9 | +dayjs.extend(utc); |
| 10 | +dayjs.extend(timezone); |
| 11 | + |
| 12 | +export type TimezoneInfo = { |
6 | 13 | timezone: string; |
7 | | - detected: boolean; |
8 | | - source: 'explicit' | 'header' | 'cloudflare' | 'accept-language' | 'default'; |
9 | | -} |
| 14 | + offset: number; |
| 15 | +}; |
10 | 16 |
|
11 | 17 | /** |
12 | | - * Detect user timezone from various sources |
| 18 | + * Validate timezone string |
13 | 19 | */ |
14 | | -export function detectUserTimezone(headers: Headers, explicitTimezone?: string): TimezoneInfo { |
15 | | - // Use explicit timezone if provided and valid |
16 | | - if (explicitTimezone) { |
17 | | - try { |
18 | | - Intl.DateTimeFormat(undefined, { timeZone: explicitTimezone }); |
19 | | - return { |
20 | | - timezone: explicitTimezone, |
21 | | - detected: true, |
22 | | - source: 'explicit' |
23 | | - }; |
24 | | - } catch { |
25 | | - // Invalid timezone, fall through to header detection |
26 | | - } |
| 20 | +export function isValidTimezone(tz: string): boolean { |
| 21 | + try { |
| 22 | + // dayjs.tz throws an error for invalid timezones |
| 23 | + dayjs().tz(tz); |
| 24 | + return true; |
| 25 | + } catch (e) { |
| 26 | + return false; |
27 | 27 | } |
| 28 | +} |
28 | 29 |
|
29 | | - // Try to get timezone from various headers |
30 | | - const timezoneHeader = headers.get('x-timezone') || |
31 | | - headers.get('timezone') || |
32 | | - headers.get('x-user-timezone'); |
33 | | - |
34 | | - if (timezoneHeader) { |
35 | | - try { |
36 | | - // Validate timezone |
37 | | - Intl.DateTimeFormat(undefined, { timeZone: timezoneHeader }); |
38 | | - return { |
39 | | - timezone: timezoneHeader, |
40 | | - detected: true, |
41 | | - source: 'header' |
42 | | - }; |
43 | | - } catch { |
44 | | - // Invalid timezone, fall through to other methods |
45 | | - } |
46 | | - } |
| 30 | +/** |
| 31 | + * Detect user timezone from various sources |
| 32 | + */ |
| 33 | +export function detectUserTimezone( |
| 34 | + headers: Headers, |
| 35 | + explicitTimezone?: string, |
| 36 | +): TimezoneInfo { |
| 37 | + const timezonesToCheck = [ |
| 38 | + explicitTimezone, |
| 39 | + headers.get('x-vercel-ip-timezone'), |
| 40 | + headers.get('cf-timezone'), |
| 41 | + ]; |
47 | 42 |
|
48 | | - // Try to infer from CloudFlare headers (if using CloudFlare) |
49 | | - const cfTimezone = headers.get('cf-timezone'); |
50 | | - if (cfTimezone) { |
51 | | - try { |
52 | | - Intl.DateTimeFormat(undefined, { timeZone: cfTimezone }); |
| 43 | + for (const tz of timezonesToCheck) { |
| 44 | + if (tz && isValidTimezone(tz)) { |
53 | 45 | return { |
54 | | - timezone: cfTimezone, |
55 | | - detected: true, |
56 | | - source: 'cloudflare' |
| 46 | + timezone: tz, |
| 47 | + offset: dayjs().tz(tz).utcOffset(), |
57 | 48 | }; |
58 | | - } catch { |
59 | | - // Invalid timezone |
60 | 49 | } |
61 | 50 | } |
62 | 51 |
|
63 | | - // Try to extract from Accept-Language header |
64 | | - const acceptLanguage = headers.get('accept-language'); |
65 | | - if (acceptLanguage) { |
66 | | - // Look for timezone info in accept-language (some browsers include it) |
67 | | - const timezoneMatch = acceptLanguage.match(/timezone=([^,;]+)/i); |
68 | | - if (timezoneMatch) { |
69 | | - try { |
70 | | - Intl.DateTimeFormat(undefined, { timeZone: timezoneMatch[1] }); |
71 | | - return { |
72 | | - timezone: timezoneMatch[1], |
73 | | - detected: true, |
74 | | - source: 'accept-language' |
75 | | - }; |
76 | | - } catch { |
77 | | - // Invalid timezone |
78 | | - } |
79 | | - } |
80 | | - } |
81 | | - |
82 | | - // Default to UTC if no timezone detected |
83 | 52 | return { |
84 | 53 | timezone: 'UTC', |
85 | | - detected: false, |
86 | | - source: 'default' |
| 54 | + offset: 0, |
87 | 55 | }; |
88 | 56 | } |
89 | 57 |
|
90 | 58 | /** |
91 | | - * Convert date to user's timezone |
| 59 | + * Convert date string to a string in the user's timezone |
92 | 60 | */ |
93 | 61 | export function convertToUserTimezone(date: string, timezone: string): string { |
94 | | - try { |
95 | | - const utcDate = new Date(`${date}T00:00:00Z`); |
96 | | - const userDate = new Date(utcDate.toLocaleString('en-US', { timeZone: timezone })); |
97 | | - return userDate.toISOString().split('T')[0]; |
98 | | - } catch { |
99 | | - // If timezone conversion fails, return original date |
| 62 | + if (!isValidTimezone(timezone)) { |
100 | 63 | return date; |
101 | 64 | } |
| 65 | + return dayjs(date).tz(timezone).format('YYYY-MM-DD'); |
102 | 66 | } |
103 | 67 |
|
104 | 68 | /** |
105 | | - * Adjust date range for user timezone to ensure we capture all relevant data |
| 69 | + * Adjust a date range to fully encompass the range in a given timezone, returning the new range in UTC. |
106 | 70 | */ |
107 | 71 | export function adjustDateRangeForTimezone( |
108 | | - startDate: string, |
109 | | - endDate: string, |
110 | | - timezone: string |
111 | | -): { startDate: string, endDate: string } { |
112 | | - if (timezone === 'UTC') { |
| 72 | + startDate: string, |
| 73 | + endDate: string, |
| 74 | + timezone: string, |
| 75 | +): { startDate: string; endDate: string } { |
| 76 | + if (!isValidTimezone(timezone) || timezone === 'UTC') { |
113 | 77 | return { startDate, endDate }; |
114 | 78 | } |
115 | 79 |
|
116 | | - try { |
117 | | - // Convert start date to user timezone (might need to go back a day) |
118 | | - const startUTC = new Date(`${startDate}T00:00:00Z`); |
119 | | - const startInUserTZ = new Date(startUTC.toLocaleString('en-US', { timeZone: timezone })); |
120 | | - |
121 | | - // Convert end date to user timezone (might need to go forward a day) |
122 | | - const endUTC = new Date(`${endDate}T23:59:59Z`); |
123 | | - const endInUserTZ = new Date(endUTC.toLocaleString('en-US', { timeZone: timezone })); |
124 | | - |
125 | | - // Adjust the range to ensure we capture all data for the user's timezone |
126 | | - const adjustedStart = new Date(startInUserTZ.getTime() - 24 * 60 * 60 * 1000); // Go back 1 day |
127 | | - const adjustedEnd = new Date(endInUserTZ.getTime() + 24 * 60 * 60 * 1000); // Go forward 1 day |
| 80 | + const start = dayjs.tz(startDate, timezone).startOf('day').utc().format('YYYY-MM-DD'); |
| 81 | + const end = dayjs.tz(endDate, timezone).endOf('day').utc().format('YYYY-MM-DD'); |
128 | 82 |
|
129 | | - return { |
130 | | - startDate: adjustedStart.toISOString().split('T')[0], |
131 | | - endDate: adjustedEnd.toISOString().split('T')[0] |
132 | | - }; |
133 | | - } catch { |
134 | | - // If timezone conversion fails, return original dates |
135 | | - return { startDate, endDate }; |
136 | | - } |
| 83 | + return { startDate: start, endDate: end }; |
137 | 84 | } |
138 | 85 |
|
139 | 86 | /** |
140 | | - * Validate timezone string |
141 | | - */ |
142 | | -export function isValidTimezone(timezone: string): boolean { |
143 | | - try { |
144 | | - Intl.DateTimeFormat(undefined, { timeZone: timezone }); |
145 | | - return true; |
146 | | - } catch { |
147 | | - return false; |
148 | | - } |
149 | | -} |
150 | | - |
151 | | -/** |
152 | | - * Get current date in user's timezone |
| 87 | + * Get the current date as a string in the given timezone. |
153 | 88 | */ |
154 | 89 | export function getCurrentDateInTimezone(timezone: string): string { |
155 | | - try { |
156 | | - const now = new Date(); |
157 | | - const userDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); |
158 | | - return userDate.toISOString().split('T')[0]; |
159 | | - } catch { |
160 | | - // If timezone conversion fails, return UTC date |
161 | | - return new Date().toISOString().split('T')[0]; |
| 90 | + if (!isValidTimezone(timezone)) { |
| 91 | + return dayjs().utc().format('YYYY-MM-DD'); |
162 | 92 | } |
| 93 | + return dayjs().tz(timezone).format('YYYY-MM-DD'); |
163 | 94 | } |
164 | 95 |
|
165 | 96 | /** |
166 | | - * Convert timestamp to user's timezone |
| 97 | + * Convert a UNIX timestamp to a Date object in the given timezone. |
167 | 98 | */ |
168 | | -export function convertTimestampToUserTimezone(timestamp: number, timezone: string): Date { |
169 | | - try { |
170 | | - const utcDate = new Date(timestamp); |
171 | | - return new Date(utcDate.toLocaleString('en-US', { timeZone: timezone })); |
172 | | - } catch { |
173 | | - // If timezone conversion fails, return original date |
| 99 | +export function convertTimestampToUserTimezone( |
| 100 | + timestamp: number, |
| 101 | + timezone: string, |
| 102 | +): Date { |
| 103 | + if (!isValidTimezone(timezone)) { |
174 | 104 | return new Date(timestamp); |
175 | 105 | } |
| 106 | + return dayjs(timestamp).tz(timezone).toDate(); |
176 | 107 | } |
0 commit comments