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

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
import { formatLocalDate } from '../utils/dateUtils';
interface ScheduledTask {
id: string;
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
setScheduleMode('onetime');
if (task.run_at) {
const date = new Date(task.run_at);
setRunAtDate(date.toISOString().split('T')[0]);
setRunAtDate(formatLocalDate(date));
setRunAtTime(date.toTimeString().slice(0, 5));
}
} else if (task.schedule_type === 'INTERVAL') {

View File

@@ -41,6 +41,7 @@ import {
Resource,
TimeBlockListItem,
} from '../../types';
import { formatLocalDate } from '../../utils/dateUtils';
// Preset block types
const PRESETS = [
@@ -418,8 +419,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
if (recurrenceType === 'NONE') {
if (selectedDates.length > 0) {
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
data.start_date = sorted[0].toISOString().split('T')[0];
data.end_date = sorted[sorted.length - 1].toISOString().split('T')[0];
data.start_date = formatLocalDate(sorted[0]);
data.end_date = formatLocalDate(sorted[sorted.length - 1]);
}
} else if (recurrenceType === 'WEEKLY') {
data.recurrence_pattern = { days_of_week: daysOfWeek };

View File

@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
import { BlockedDate, TimeBlockListItem } from '../../types';
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
import { formatLocalDate } from '../../utils/dateUtils';
interface YearlyBlockCalendarProps {
resourceId?: string;
@@ -134,7 +135,7 @@ const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
return <div key={`empty-${i}`} className="aspect-square" />;
}
const dateKey = day.toISOString().split('T')[0];
const dateKey = formatLocalDate(day);
const blocks = blockedDateMap.get(dateKey) || [];
const hasBlocks = blocks.length > 0;
const isToday = new Date().toDateString() === day.toDateString();

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">

View File

@@ -0,0 +1,393 @@
/**
* Date/Time Utility Functions
*
* TIMEZONE ARCHITECTURE:
* - Database: All times stored in UTC
* - API Communication: Always UTC (both directions)
* - Frontend Display: Convert based on business_timezone setting
* - If business_timezone is set: Display in that timezone
* - If business_timezone is blank/null: Display in user's local timezone
*
* See CLAUDE.md for full architecture documentation.
*/
// ============================================================================
// SENDING TO API - Convert to UTC
// ============================================================================
/**
* Convert a local Date to UTC ISO string for API requests.
* Use this when sending datetime values to the API.
*
* @example
* const payload = { start_time: toUTC(selectedDate) };
* // Returns: "2024-12-08T19:00:00.000Z"
*/
export const toUTC = (date: Date): string => {
return date.toISOString();
};
/**
* Convert a date and time (in display timezone) to UTC ISO string.
* Use when the user selects a time that should be interpreted in a specific timezone.
*
* @param date - The date portion
* @param time - Time string "HH:MM"
* @param timezone - IANA timezone the user is selecting in (business or local)
*
* @example
* // User in Eastern selects 2pm, but display mode is "business" (Mountain)
* // This means they selected 2pm Mountain time
* toUTCFromTimezone(date, "14:00", "America/Denver")
*/
export const toUTCFromTimezone = (
date: Date,
time: string,
timezone: string
): string => {
const [hours, minutes] = time.split(':').map(Number);
const dateStr = formatLocalDate(date);
// Create a date string that we'll parse in the target timezone
const dateTimeStr = `${dateStr}T${time}:00`;
// Use Intl to get the UTC offset for this timezone at this date/time
const targetDate = new Date(dateTimeStr);
const utcDate = convertTimezoneToUTC(targetDate, timezone);
return utcDate.toISOString();
};
/**
* Convert a Date object from a specific timezone to UTC.
* The input Date's time values are interpreted as being in the given timezone.
*/
export const convertTimezoneToUTC = (date: Date, timezone: string): Date => {
// Get the date/time components as they appear (treating as target timezone)
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
// Create a formatter for the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
// Find the UTC time that displays as our target time in the target timezone
// We do this by creating a UTC date and adjusting based on offset
const tempDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds));
// Get what this UTC time displays as in the target timezone
const parts = formatter.formatToParts(tempDate);
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
const displayedHour = getPart('hour');
const displayedMinute = getPart('minute');
const displayedDay = getPart('day');
// Calculate the offset in minutes
const displayedMinutes = displayedDay * 24 * 60 + displayedHour * 60 + displayedMinute;
const targetMinutes = day * 24 * 60 + hours * 60 + minutes;
const offsetMinutes = displayedMinutes - targetMinutes;
// Adjust the UTC time by the offset
return new Date(tempDate.getTime() - offsetMinutes * 60 * 1000);
};
// ============================================================================
// RECEIVING FROM API - Convert from UTC for Display
// ============================================================================
/**
* Convert a UTC datetime string from API to a Date object in the display timezone.
*
* @param utcString - ISO string from API (always UTC)
* @param businessTimezone - IANA timezone of the business (null = use local)
*/
export const fromUTC = (
utcString: string,
businessTimezone?: string | null
): Date => {
const utcDate = new Date(utcString);
const targetTimezone = getDisplayTimezone(businessTimezone);
return convertUTCToTimezone(utcDate, targetTimezone);
};
/**
* Convert a UTC Date to display in a specific timezone.
* Returns a Date object with values adjusted for the target timezone.
*/
export const convertUTCToTimezone = (utcDate: Date, timezone: string): Date => {
// Format the UTC date in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
// Create a new Date with the timezone-adjusted values
// Note: This Date object's internal UTC value won't match, but the displayed values will be correct
return new Date(
getPart('year'),
getPart('month') - 1,
getPart('day'),
getPart('hour'),
getPart('minute'),
getPart('second')
);
};
/**
* Get the timezone to use for display.
* If businessTimezone is set, use it. Otherwise use user's local timezone.
*/
export const getDisplayTimezone = (businessTimezone?: string | null): string => {
if (businessTimezone) {
return businessTimezone;
}
// No business timezone set - use browser's local timezone
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
/**
* Get the user's local timezone (browser timezone).
*/
export const getUserTimezone = (): string => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
// ============================================================================
// FORMATTING FOR DISPLAY
// ============================================================================
/**
* Format a UTC datetime string for display, respecting timezone settings.
*
* @param utcString - ISO string from API
* @param businessTimezone - IANA timezone of the business (null = use local)
* @param options - Intl.DateTimeFormat options for customizing output
*
* @example
* formatForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "Dec 8, 2024, 12:00 PM" (Mountain Time)
*
* formatForDisplay("2024-12-08T19:00:00Z", null)
* // Returns time in user's local timezone
*/
export const formatForDisplay = (
utcString: string,
businessTimezone?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
const defaultOptions: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...options,
};
return utcDate.toLocaleString('en-US', defaultOptions);
};
/**
* Format just the time portion for display.
*
* @example
* formatTimeForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "12:00 PM"
*/
export const formatTimeForDisplay = (
utcString: string,
businessTimezone?: string | null
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
};
/**
* Format just the date portion for display.
*
* @example
* formatDateForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "Dec 8, 2024"
*/
export const formatDateForDisplay = (
utcString: string,
businessTimezone?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
const defaultOptions: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
...options,
};
return utcDate.toLocaleDateString('en-US', defaultOptions);
};
/**
* Format a datetime for datetime-local input, in the display timezone.
* Returns: "YYYY-MM-DDTHH:MM"
*/
export const formatForDateTimeInput = (
utcString: string,
businessTimezone?: string | null
): string => {
const displayDate = fromUTC(utcString, businessTimezone);
return formatLocalDateTime(displayDate);
};
// ============================================================================
// DATE-ONLY HELPERS (for fields like time block start_date/end_date)
// ============================================================================
/**
* Format a Date object as YYYY-MM-DD string.
* Uses the Date's local values (not UTC).
*
* For date-only fields, use this when you have a Date object
* representing a calendar date selection.
*/
export const formatLocalDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Parse a YYYY-MM-DD string as a local date (at midnight local time).
*/
export const parseLocalDate = (dateString: string): Date => {
const [year, month, day] = dateString.split('-').map(Number);
return new Date(year, month - 1, day);
};
/**
* Format a Date as YYYY-MM-DDTHH:MM for datetime-local inputs.
*/
export const formatLocalDateTime = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
/**
* Get today's date as YYYY-MM-DD in a specific timezone.
* Useful for determining "today" in the business timezone.
*/
export const getTodayInTimezone = (timezone: string): string => {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return formatter.format(now); // Returns YYYY-MM-DD format
};
/**
* Check if a date string (YYYY-MM-DD) is today in the given timezone.
*/
export const isToday = (dateString: string, timezone: string): boolean => {
return dateString === getTodayInTimezone(timezone);
};
// ============================================================================
// UTILITY HELPERS
// ============================================================================
/**
* Check if two dates are the same day (ignoring time).
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};
/**
* Get the start of a day (midnight local time).
*/
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
/**
* Get the end of a day (23:59:59.999 local time).
*/
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
/**
* Get timezone abbreviation for display (e.g., "MST", "EST").
*/
export const getTimezoneAbbreviation = (
timezone: string,
date: Date = new Date()
): string => {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(date);
return parts.find(p => p.type === 'timeZoneName')?.value || timezone;
};
/**
* Format timezone for display (e.g., "Mountain Time (MST)").
*/
export const formatTimezoneDisplay = (timezone: string): string => {
const abbr = getTimezoneAbbreviation(timezone);
const cityName = timezone.split('/').pop()?.replace(/_/g, ' ') || timezone;
return `${cityName} (${abbr})`;
};