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:
393
frontend/src/utils/dateUtils.ts
Normal file
393
frontend/src/utils/dateUtils.ts
Normal 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})`;
|
||||
};
|
||||
Reference in New Issue
Block a user