feat: Add timezone architecture for consistent date/time handling

- Create dateUtils.ts with helpers for UTC conversion and timezone display
- Add TimezoneSerializerMixin to include business_timezone in API responses
- Update GeneralSettings timezone dropdown with IANA identifiers
- Apply timezone mixin to Event, TimeBlock, and field mobile serializers
- Document timezone architecture in CLAUDE.md

All times stored in UTC, converted for display based on business timezone.
If business_timezone is null, uses user's local timezone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-07 19:39:36 -05:00
parent 897a336d0b
commit f4332153f4
10 changed files with 783 additions and 43 deletions

View File

@@ -14,6 +14,7 @@ import Portal from '../components/Portal';
import EventAutomations from '../components/EventAutomations';
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
import { formatLocalDate } from '../utils/dateUtils';
// Time settings
const START_HOUR = 0; // Midnight
@@ -87,8 +88,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
// Fetch blocked dates for the calendar overlay
const blockedDatesParams = useMemo(() => ({
start_date: dateRange.startDate.toISOString().split('T')[0],
end_date: dateRange.endDate.toISOString().split('T')[0],
start_date: formatLocalDate(dateRange.startDate),
end_date: formatLocalDate(dateRange.endDate),
include_business: true,
}), [dateRange]);
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);

View File

@@ -33,28 +33,134 @@ const GeneralSettings: React.FC = () => {
setFormState(prev => ({ ...prev, [name]: value }));
};
// Common timezones grouped by region
const commonTimezones = [
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
{ value: 'America/Anchorage', label: 'Alaska Time' },
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Central European Time' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Asia/Tokyo', label: 'Japan Time' },
{ value: 'Asia/Shanghai', label: 'China Time' },
{ value: 'Asia/Singapore', label: 'Singapore Time' },
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
{ value: 'UTC', label: 'UTC' },
// IANA timezones grouped by region
const timezoneGroups = [
{
label: 'United States',
timezones: [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'Pacific/Honolulu',
'America/Phoenix',
'America/Detroit',
'America/Indiana/Indianapolis',
'America/Boise',
],
},
{
label: 'Canada',
timezones: [
'America/Toronto',
'America/Vancouver',
'America/Edmonton',
'America/Winnipeg',
'America/Halifax',
'America/St_Johns',
],
},
{
label: 'Mexico & Central America',
timezones: [
'America/Mexico_City',
'America/Tijuana',
'America/Cancun',
'America/Guatemala',
'America/Costa_Rica',
'America/Panama',
],
},
{
label: 'South America',
timezones: [
'America/Sao_Paulo',
'America/Argentina/Buenos_Aires',
'America/Santiago',
'America/Bogota',
'America/Lima',
'America/Caracas',
],
},
{
label: 'Europe',
timezones: [
'Europe/London',
'Europe/Dublin',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'Europe/Rome',
'Europe/Amsterdam',
'Europe/Brussels',
'Europe/Vienna',
'Europe/Zurich',
'Europe/Stockholm',
'Europe/Oslo',
'Europe/Copenhagen',
'Europe/Helsinki',
'Europe/Athens',
'Europe/Bucharest',
'Europe/Warsaw',
'Europe/Prague',
'Europe/Moscow',
'Europe/Istanbul',
],
},
{
label: 'Asia',
timezones: [
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Taipei',
'Asia/Singapore',
'Asia/Kuala_Lumpur',
'Asia/Bangkok',
'Asia/Ho_Chi_Minh',
'Asia/Jakarta',
'Asia/Manila',
'Asia/Kolkata',
'Asia/Mumbai',
'Asia/Dubai',
'Asia/Riyadh',
'Asia/Jerusalem',
'Asia/Karachi',
'Asia/Dhaka',
],
},
{
label: 'Australia & Pacific',
timezones: [
'Australia/Sydney',
'Australia/Melbourne',
'Australia/Brisbane',
'Australia/Perth',
'Australia/Adelaide',
'Australia/Darwin',
'Pacific/Auckland',
'Pacific/Fiji',
'Pacific/Guam',
],
},
{
label: 'Africa',
timezones: [
'Africa/Johannesburg',
'Africa/Cairo',
'Africa/Lagos',
'Africa/Nairobi',
'Africa/Casablanca',
],
},
{
label: 'Other',
timezones: [
'UTC',
],
},
];
const handleSave = async () => {
@@ -146,10 +252,14 @@ const GeneralSettings: React.FC = () => {
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
>
{commonTimezones.map(tz => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
{timezoneGroups.map(group => (
<optgroup key={group.label} label={group.label}>
{group.timezones.map(tz => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</optgroup>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">