|
| 1 | +# Universal Timezone Utilities |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +The Timezone Utilities provide comprehensive timezone handling for global bot applications. Production-tested with GMT+7 (Bangkok timezone) in the Kogotochki bot, serving users across different timezones with accurate local time display. |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +Bots serving global users face timezone challenges: |
| 10 | + |
| 11 | +- Displaying times in user's local timezone |
| 12 | +- Scheduling notifications at appropriate local times |
| 13 | +- Handling business hours across timezones |
| 14 | +- Auction/event countdowns for different regions |
| 15 | + |
| 16 | +## Solution |
| 17 | + |
| 18 | +A universal timezone utility using date-fns v4 with @date-fns/tz: |
| 19 | + |
| 20 | +```typescript |
| 21 | +import { TimezoneFactory } from '@/lib/utils/timezone'; |
| 22 | + |
| 23 | +// Create timezone utils for user |
| 24 | +const user = await getUserFromDB(userId); |
| 25 | +const tz = TimezoneFactory.forUser(user); |
| 26 | + |
| 27 | +// Display time in user's timezone |
| 28 | +const eventTime = new Date('2025-08-06T14:00:00Z'); |
| 29 | +await ctx.reply(`Event starts at ${tz.formatBotDateTime(eventTime)} (${tz.getAbbreviation()})`); |
| 30 | +// Output: "Event starts at 21:00 06.08.2025 (GMT+7)" |
| 31 | +``` |
| 32 | + |
| 33 | +## Installation |
| 34 | + |
| 35 | +```bash |
| 36 | +npm install date-fns @date-fns/tz |
| 37 | +``` |
| 38 | + |
| 39 | +## Features |
| 40 | + |
| 41 | +### Basic Formatting |
| 42 | + |
| 43 | +```typescript |
| 44 | +const bangkok = TimezoneFactory.get('Asia/Bangkok'); |
| 45 | +const now = new Date(); |
| 46 | + |
| 47 | +bangkok.formatBotTime(now); // "14:30" |
| 48 | +bangkok.formatBotDate(now); // "06.08.2025" |
| 49 | +bangkok.formatBotDateTime(now); // "14:30 06.08.2025" |
| 50 | +bangkok.format(now, 'EEEE'); // "Wednesday" |
| 51 | +``` |
| 52 | + |
| 53 | +### Timezone Information |
| 54 | + |
| 55 | +```typescript |
| 56 | +const tz = TimezoneFactory.get('Asia/Bangkok'); |
| 57 | + |
| 58 | +tz.getOffset(); // 7 (hours from UTC) |
| 59 | +tz.getAbbreviation(); // "GMT+7" |
| 60 | +``` |
| 61 | + |
| 62 | +### Business Hours |
| 63 | + |
| 64 | +```typescript |
| 65 | +const supportTz = TimezoneFactory.get('Asia/Bangkok'); |
| 66 | + |
| 67 | +if (supportTz.isBusinessHours()) { |
| 68 | + await ctx.reply('✅ Support is online!'); |
| 69 | +} else { |
| 70 | + const nextOpen = supportTz.getNextOccurrence(9, 0); |
| 71 | + await ctx.reply(`Support opens at ${supportTz.formatBotDateTime(nextOpen)}`); |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +### Scheduling |
| 76 | + |
| 77 | +```typescript |
| 78 | +// Schedule notification for 9 AM local time |
| 79 | +const tz = TimezoneFactory.forUser(user); |
| 80 | +const notificationTime = tz.getNextOccurrence(9, 0); |
| 81 | + |
| 82 | +await scheduleNotification({ |
| 83 | + userId: user.id, |
| 84 | + sendAt: notificationTime, |
| 85 | + message: 'Good morning!', |
| 86 | +}); |
| 87 | +``` |
| 88 | + |
| 89 | +## Usage Patterns |
| 90 | + |
| 91 | +### Pattern 1: User Timezone Preference |
| 92 | + |
| 93 | +```typescript |
| 94 | +interface User { |
| 95 | + id: string; |
| 96 | + timezone?: string; // 'Asia/Bangkok' |
| 97 | + location?: string; // 'bangkok' |
| 98 | +} |
| 99 | + |
| 100 | +// Automatically select best timezone |
| 101 | +const tz = TimezoneFactory.forUser(user); |
| 102 | +// Uses timezone if set, otherwise maps location, fallback to UTC |
| 103 | +``` |
| 104 | + |
| 105 | +### Pattern 2: Auction End Times |
| 106 | + |
| 107 | +```typescript |
| 108 | +async function showAuctionEnd(ctx: Context) { |
| 109 | + const user = await getUser(ctx.from.id); |
| 110 | + const tz = TimezoneFactory.forUser(user); |
| 111 | + |
| 112 | + // Auction ends at 6 AM local time |
| 113 | + const auctionEnd = tz.getNextOccurrence(6, 0); |
| 114 | + |
| 115 | + await ctx.reply( |
| 116 | + `⏰ Auction ends:\n` + `${tz.formatBotDateTime(auctionEnd)} (${tz.getAbbreviation()})`, |
| 117 | + ); |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +### Pattern 3: Global Event Announcement |
| 122 | + |
| 123 | +```typescript |
| 124 | +const eventTime = new Date('2025-08-10T14:00:00Z'); |
| 125 | + |
| 126 | +const timezones = [ |
| 127 | + { name: 'Bangkok', tz: TimezoneFactory.get('Asia/Bangkok') }, |
| 128 | + { name: 'Moscow', tz: TimezoneFactory.get('Europe/Moscow') }, |
| 129 | + { name: 'New York', tz: TimezoneFactory.get('America/New_York') }, |
| 130 | +]; |
| 131 | + |
| 132 | +let message = '🎉 Event Starting Times:\n\n'; |
| 133 | +for (const { name, tz } of timezones) { |
| 134 | + message += `${name}: ${tz.formatBotDateTime(eventTime)} (${tz.getAbbreviation()})\n`; |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +### Pattern 4: Time-Based Greetings |
| 139 | + |
| 140 | +```typescript |
| 141 | +function getGreeting(tz: TimezoneUtils): string { |
| 142 | + const hour = parseInt(tz.format(new Date(), 'HH')); |
| 143 | + |
| 144 | + if (hour < 12) return '🌅 Good morning'; |
| 145 | + if (hour < 18) return '☀️ Good afternoon'; |
| 146 | + if (hour < 22) return '🌆 Good evening'; |
| 147 | + return '🌙 Good night'; |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +### Pattern 5: Payment History |
| 152 | + |
| 153 | +```typescript |
| 154 | +async function showPayments(ctx: Context) { |
| 155 | + const user = await getUser(ctx.from.id); |
| 156 | + const tz = TimezoneFactory.forUser(user); |
| 157 | + const payments = await getPaymentHistory(user.id); |
| 158 | + |
| 159 | + let message = `💳 Payment History (${tz.getAbbreviation()}):\n\n`; |
| 160 | + |
| 161 | + for (const payment of payments) { |
| 162 | + message += `${tz.formatBotDateTime(payment.timestamp)} - ${payment.amount} stars\n`; |
| 163 | + } |
| 164 | + |
| 165 | + await ctx.reply(message); |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +## Location Mapping |
| 170 | + |
| 171 | +Built-in mapping for common locations: |
| 172 | + |
| 173 | +```typescript |
| 174 | +const locationMap = { |
| 175 | + bangkok: 'Asia/Bangkok', |
| 176 | + thailand: 'Asia/Bangkok', |
| 177 | + phuket: 'Asia/Bangkok', |
| 178 | + moscow: 'Europe/Moscow', |
| 179 | + london: 'Europe/London', |
| 180 | + new_york: 'America/New_York', |
| 181 | + tokyo: 'Asia/Tokyo', |
| 182 | + dubai: 'Asia/Dubai', |
| 183 | + singapore: 'Asia/Singapore', |
| 184 | + sydney: 'Australia/Sydney', |
| 185 | + // ... more locations |
| 186 | +}; |
| 187 | +``` |
| 188 | + |
| 189 | +## Convenience Exports |
| 190 | + |
| 191 | +Pre-configured timezone utilities: |
| 192 | + |
| 193 | +```typescript |
| 194 | +import { UTC, Bangkok, Moscow, NewYork, London, Tokyo } from '@/lib/utils/timezone'; |
| 195 | + |
| 196 | +// Use directly |
| 197 | +Bangkok.formatBotDateTime(new Date()); // "14:30 06.08.2025" |
| 198 | +Moscow.getAbbreviation(); // "GMT+3" |
| 199 | +``` |
| 200 | + |
| 201 | +## Testing |
| 202 | + |
| 203 | +```typescript |
| 204 | +import { describe, it, expect } from 'vitest'; |
| 205 | +import { TimezoneFactory } from '@/lib/utils/timezone'; |
| 206 | + |
| 207 | +describe('Timezone handling', () => { |
| 208 | + it('should format times correctly', () => { |
| 209 | + const bangkok = TimezoneFactory.get('Asia/Bangkok'); |
| 210 | + const utcDate = new Date('2025-08-06T00:00:00Z'); |
| 211 | + |
| 212 | + // Bangkok is UTC+7 |
| 213 | + expect(bangkok.format(utcDate, 'HH:mm')).toBe('07:00'); |
| 214 | + }); |
| 215 | + |
| 216 | + it('should cache instances', () => { |
| 217 | + const tz1 = TimezoneFactory.get('Asia/Bangkok'); |
| 218 | + const tz2 = TimezoneFactory.get('Asia/Bangkok'); |
| 219 | + |
| 220 | + expect(tz1).toBe(tz2); // Same instance |
| 221 | + }); |
| 222 | +}); |
| 223 | +``` |
| 224 | + |
| 225 | +## Production Results |
| 226 | + |
| 227 | +From Kogotochki bot (Thailand market): |
| 228 | + |
| 229 | +### Before Timezone Support |
| 230 | + |
| 231 | +- Users confused about "6 AM" auction end (which 6 AM?) |
| 232 | +- Support requests at 3 AM Bangkok time |
| 233 | +- Notifications sent at inappropriate hours |
| 234 | + |
| 235 | +### After Timezone Support |
| 236 | + |
| 237 | +- Clear display: "Auction ends at 06:00 (GMT+7)" |
| 238 | +- Support hours properly indicated |
| 239 | +- Notifications sent at user's 9 AM local time |
| 240 | +- Payment history shows local timestamps |
| 241 | + |
| 242 | +## Best Practices |
| 243 | + |
| 244 | +### 1. Always Show Timezone |
| 245 | + |
| 246 | +```typescript |
| 247 | +// ❌ Ambiguous |
| 248 | +'Auction ends at 06:00'; |
| 249 | + |
| 250 | +// ✅ Clear |
| 251 | +'Auction ends at 06:00 (GMT+7)'; |
| 252 | +``` |
| 253 | + |
| 254 | +### 2. Store UTC, Display Local |
| 255 | + |
| 256 | +```typescript |
| 257 | +// Store in database as UTC |
| 258 | +const timestamp = new Date().toISOString(); |
| 259 | + |
| 260 | +// Display in user's timezone |
| 261 | +const tz = TimezoneFactory.forUser(user); |
| 262 | +const display = tz.formatBotDateTime(timestamp); |
| 263 | +``` |
| 264 | + |
| 265 | +### 3. Cache Timezone Utils |
| 266 | + |
| 267 | +```typescript |
| 268 | +// TimezoneFactory automatically caches instances |
| 269 | +const tz1 = TimezoneFactory.get('Asia/Bangkok'); |
| 270 | +const tz2 = TimezoneFactory.get('Asia/Bangkok'); |
| 271 | +// tz1 === tz2 (same instance) |
| 272 | +``` |
| 273 | + |
| 274 | +### 4. User Preference Over Detection |
| 275 | + |
| 276 | +```typescript |
| 277 | +const tz = TimezoneFactory.forUser({ |
| 278 | + timezone: user.timezone, // First priority |
| 279 | + location: user.location, // Second priority |
| 280 | +}); // Fallback: UTC |
| 281 | +``` |
| 282 | + |
| 283 | +### 5. Business Hours Check |
| 284 | + |
| 285 | +```typescript |
| 286 | +if (!supportTz.isBusinessHours()) { |
| 287 | + const nextOpen = supportTz.getNextOccurrence(9, 0); |
| 288 | + await ctx.reply(`Support opens at ${supportTz.formatBotDateTime(nextOpen)}`); |
| 289 | +} |
| 290 | +``` |
| 291 | + |
| 292 | +## Migration Guide |
| 293 | + |
| 294 | +### From Manual Formatting |
| 295 | + |
| 296 | +```typescript |
| 297 | +// Before |
| 298 | +const date = new Date(); |
| 299 | +const formatted = `${date.getHours()}:${date.getMinutes()} ${date.toDateString()}`; |
| 300 | + |
| 301 | +// After |
| 302 | +const tz = TimezoneFactory.forUser(user); |
| 303 | +const formatted = tz.formatBotDateTime(date); |
| 304 | +``` |
| 305 | + |
| 306 | +### From Moment.js |
| 307 | + |
| 308 | +```typescript |
| 309 | +// Before (moment-timezone) |
| 310 | +moment.tz(date, 'Asia/Bangkok').format('HH:mm DD.MM.YYYY'); |
| 311 | + |
| 312 | +// After (timezone utils) |
| 313 | +Bangkok.formatBotDateTime(date); |
| 314 | +``` |
| 315 | + |
| 316 | +## Performance |
| 317 | + |
| 318 | +- **Lightweight**: Uses native date-fns (smaller than moment.js) |
| 319 | +- **Cached**: Timezone instances are reused |
| 320 | +- **Fast**: < 1ms per format operation |
| 321 | +- **No API calls**: Works offline |
| 322 | + |
| 323 | +## Common Issues |
| 324 | + |
| 325 | +### Issue: Daylight Saving Time |
| 326 | + |
| 327 | +**Solution**: date-fns handles DST automatically |
| 328 | + |
| 329 | +### Issue: Invalid Timezone |
| 330 | + |
| 331 | +**Solution**: Validate and fallback to UTC |
| 332 | + |
| 333 | +```typescript |
| 334 | +try { |
| 335 | + const tz = new TimezoneUtils(userInput); |
| 336 | +} catch { |
| 337 | + const tz = new TimezoneUtils('UTC'); |
| 338 | +} |
| 339 | +``` |
| 340 | + |
| 341 | +### Issue: Relative Time |
| 342 | + |
| 343 | +**Solution**: Use date-fns formatDistance |
| 344 | + |
| 345 | +```typescript |
| 346 | +import { formatDistance } from 'date-fns'; |
| 347 | + |
| 348 | +const timeUntil = formatDistance(auctionEnd, new Date()); |
| 349 | +// "in 2 hours" |
| 350 | +``` |
| 351 | + |
| 352 | +## Summary |
| 353 | + |
| 354 | +The Timezone Utilities are essential for any bot serving users across different timezones. With automatic caching, location mapping, and convenient formatting methods, they make timezone handling simple and reliable. |
| 355 | + |
| 356 | +Key benefits: |
| 357 | + |
| 358 | +- **Clear time display** with timezone abbreviations |
| 359 | +- **Accurate scheduling** for notifications |
| 360 | +- **Business hours** handling |
| 361 | +- **User-friendly** formatting |
| 362 | +- **Production tested** with real users |
| 363 | + |
| 364 | +This utility has been running in production for 30+ days, handling auction times, payment history, and notifications for users primarily in GMT+7 (Thailand) timezone. |
0 commit comments