assets_js_timezone-utils.js

/**
 * KMP Timezone Utilities
 *
 * Client-side timezone handling for the KMP application. Provides utilities for
 * detecting user timezone, formatting dates/times, and converting between timezones
 * for datetime inputs and displays.
 *
 * See /docs/10.3.1-timezone-utils-api.md for complete API documentation and usage examples.
 *
 * @namespace KMP_Timezone
 */
const KMP_Timezone = {
    /**
     * Detect user's timezone from browser using Intl.DateTimeFormat
     * @returns {string} IANA timezone identifier (e.g., "America/Chicago")
     */
    detectTimezone() {
        try {
            return Intl.DateTimeFormat().resolvedOptions().timeZone;
        } catch (e) {
            console.warn('Could not detect timezone, defaulting to UTC', e);
            return 'UTC';
        }
    },

    /**
     * Get timezone from element data attribute or detect from browser
     * @param {HTMLElement} element - Element with optional data-timezone attribute
     * @returns {string} Timezone identifier
     */
    getTimezone(element) {
        if (element && element.dataset && element.dataset.timezone) {
            return element.dataset.timezone;
        }
        return this.detectTimezone();
    },

    /**
     * Convert UTC datetime to user's timezone for display
     * @param {string|Date} utcDateTime - UTC datetime string or Date object
     * @param {string} timezone - Target timezone (default: detected)
     * @param {object} options - Intl.DateTimeFormat options
     * @returns {string} Formatted datetime string in local timezone
     */
    formatDateTime(utcDateTime, timezone = null, options = null) {
        if (!utcDateTime) return '';

        timezone = timezone || this.detectTimezone();

        // Parse the datetime
        const date = typeof utcDateTime === 'string' ? new Date(utcDateTime) : utcDateTime;

        if (isNaN(date.getTime())) {
            console.error('Invalid datetime:', utcDateTime);
            return '';
        }

        // Default options
        const defaultOptions = {
            timeZone: timezone,
            year: 'numeric',
            month: 'numeric',
            day: 'numeric',
            hour: 'numeric',
            minute: '2-digit',
            hour12: true
        };

        const formatOptions = options ? { ...defaultOptions, ...options } : defaultOptions;

        try {
            return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
        } catch (e) {
            console.error('Error formatting datetime:', e);
            return date.toLocaleString();
        }
    },

    /**
     * Format date only (no time)
     * @param {string|Date} utcDateTime - UTC datetime string or Date object
     * @param {string} timezone - Target timezone (default: detected)
     * @param {object} options - Intl.DateTimeFormat options
     * @returns {string} Formatted date string
     */
    formatDate(utcDateTime, timezone = null, options = null) {
        if (!utcDateTime) return '';

        timezone = timezone || this.detectTimezone();
        const date = typeof utcDateTime === 'string' ? new Date(utcDateTime) : utcDateTime;

        const defaultOptions = {
            timeZone: timezone,
            year: 'numeric',
            month: 'long',
            day: 'numeric'
        };

        const formatOptions = options ? { ...defaultOptions, ...options } : defaultOptions;

        try {
            return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
        } catch (e) {
            console.error('Error formatting date:', e);
            return date.toLocaleDateString();
        }
    },

    /**
     * Format time only (no date)
     * @param {string|Date} utcDateTime - UTC datetime string or Date object
     * @param {string} timezone - Target timezone (default: detected)
     * @param {object} options - Intl.DateTimeFormat options
     * @returns {string} Formatted time string
     */
    formatTime(utcDateTime, timezone = null, options = null) {
        if (!utcDateTime) return '';

        timezone = timezone || this.detectTimezone();
        const date = typeof utcDateTime === 'string' ? new Date(utcDateTime) : utcDateTime;

        const defaultOptions = {
            timeZone: timezone,
            hour: 'numeric',
            minute: '2-digit',
            hour12: true
        };

        const formatOptions = options ? { ...defaultOptions, ...options } : defaultOptions;

        try {
            return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
        } catch (e) {
            console.error('Error formatting time:', e);
            return date.toLocaleTimeString();
        }
    },

    /**
     * Convert UTC datetime to HTML5 datetime-local format in user's timezone
     * @param {string|Date} utcDateTime - UTC datetime
     * @param {string} timezone - Target timezone (default: detected)
     * @returns {string} Datetime in YYYY-MM-DDTHH:mm format (local time)
     */
    toLocalInput(utcDateTime, timezone = null) {
        if (!utcDateTime) return '';

        timezone = timezone || this.detectTimezone();
        const date = typeof utcDateTime === 'string' ? new Date(utcDateTime) : utcDateTime;

        if (isNaN(date.getTime())) {
            console.error('Invalid datetime for input:', utcDateTime);
            return '';
        }

        try {
            // Get date parts 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',
                hour12: false
            });

            const parts = formatter.formatToParts(date);
            const dateParts = {};

            parts.forEach(part => {
                if (part.type !== 'literal') {
                    dateParts[part.type] = part.value;
                }
            });

            // Format as YYYY-MM-DDTHH:mm
            return `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}`;
        } catch (e) {
            console.error('Error converting to local input:', e);
            return '';
        }
    },

    /**
     * Convert datetime-local input value (local time) to UTC for storage
     * @param {string} localDateTime - Datetime in YYYY-MM-DDTHH:mm or YYYY-MM-DD HH:mm:ss format
     * @param {string} timezone - Source timezone (default: detected)
     * @returns {string} ISO 8601 UTC datetime string
     */
    toUTC(localDateTime, timezone = null) {
        if (!localDateTime) return '';

        timezone = timezone || this.detectTimezone();

        try {
            // Parse the local datetime string
            // Format: YYYY-MM-DDTHH:mm or YYYY-MM-DD HH:mm:ss
            const dateStr = localDateTime.replace(' ', 'T');

            // Create a date string with timezone offset
            // We'll use a hack: create date in target timezone by building ISO string
            const parts = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/);

            if (!parts) {
                console.error('Invalid datetime format:', localDateTime);
                return '';
            }

            const [, year, month, day, hour, minute, second = '00'] = parts;

            // Create a formatter to get timezone offset
            const tempDate = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`);

            // Format in target timezone to get the actual date/time
            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,
                timeZoneName: 'short'
            });

            // Create date assuming it's in the target timezone
            // This is tricky - we need to find the UTC time that produces this local time
            const localString = `${year}-${month}-${day}T${hour}:${minute}:${second}`;

            // Use a more reliable method: temporarily set to target timezone
            const utcDate = new Date(localString + 'Z'); // Treat as UTC first
            const offset = this.getTimezoneOffset(timezone, utcDate);

            // Adjust by the offset to get the correct UTC time
            const adjustedDate = new Date(utcDate.getTime() - (offset * 60000));

            return adjustedDate.toISOString();
        } catch (e) {
            console.error('Error converting to UTC:', e);
            return '';
        }
    },

    /**
     * Get timezone offset in minutes for a specific timezone and date
     * @param {string} timezone - IANA timezone identifier
     * @param {Date} date - Date to calculate offset for (handles DST)
     * @returns {number} Offset in minutes
     */
    getTimezoneOffset(timezone, date = new Date()) {
        try {
            // Get UTC time
            const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));

            // Get time in target timezone
            const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));

            // Calculate difference in minutes
            return (tzDate.getTime() - utcDate.getTime()) / 60000;
        } catch (e) {
            console.error('Error getting timezone offset:', e);
            return 0;
        }
    },

    /**
     * Get timezone abbreviation (e.g., CDT, EST, PST)
     * @param {string} timezone - IANA timezone identifier
     * @param {Date} date - Date for DST calculation (default: now)
     * @returns {string} Timezone abbreviation
     */
    getAbbreviation(timezone = null, date = new Date()) {
        timezone = timezone || this.detectTimezone();

        try {
            const formatter = new Intl.DateTimeFormat('en-US', {
                timeZone: timezone,
                timeZoneName: 'short'
            });

            const parts = formatter.formatToParts(date);
            const abbr = parts.find(part => part.type === 'timeZoneName');

            return abbr ? abbr.value : '';
        } catch (e) {
            console.error('Error getting timezone abbreviation:', e);
            return '';
        }
    },

    /**
     * Initialize timezone conversion for all datetime inputs on page
     * Finds inputs with data-utc-value and converts to local time
     * @param {HTMLElement} container - Container to search in (default: document)
     */
    initializeDatetimeInputs(container = document) {
        const inputs = container.querySelectorAll('input[type="datetime-local"][data-utc-value]');

        inputs.forEach(input => {
            const utcValue = input.dataset.utcValue;
            const timezone = this.getTimezone(input);

            if (utcValue) {
                input.value = this.toLocalInput(utcValue, timezone);
            }
        });
    },

    /**
     * Convert all datetime-local inputs to UTC before form submission
     * Creates hidden inputs with UTC values, disables originals
     * @param {HTMLFormElement} form - Form element
     * @param {string} timezone - Timezone to use for conversion (default: detected)
     */
    convertFormDatetimesToUTC(form, timezone = null) {
        timezone = timezone || this.detectTimezone();

        const inputs = form.querySelectorAll('input[type="datetime-local"]');

        inputs.forEach(input => {
            if (input.value) {
                // Store original value in case needed
                input.dataset.originalValue = input.value;

                // Convert to UTC
                const utcValue = this.toUTC(input.value, timezone);

                // Only proceed if conversion was successful
                if (utcValue) {
                    // Create hidden input with UTC value
                    const hiddenInput = document.createElement('input');
                    hiddenInput.type = 'hidden';
                    hiddenInput.name = input.name;
                    hiddenInput.value = utcValue;

                    // Disable original input so it doesn't submit
                    input.disabled = true;

                    // Add hidden input to form
                    form.appendChild(hiddenInput);
                } else {
                    // Conversion failed - preserve original value and flag error
                    console.error('Failed to convert datetime to UTC:', input.value);
                    input.dataset.conversionError = 'true';
                    // Original input remains enabled with user's value
                }
            }
        });
    }
};

// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
    module.exports = KMP_Timezone;
}

// Make available globally
if (typeof window !== 'undefined') {
    window.KMP_Timezone = KMP_Timezone;
}

// Auto-initialize on DOM ready
if (typeof document !== 'undefined') {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            KMP_Timezone.initializeDatetimeInputs();
        });
    } else {
        // DOM already loaded
        KMP_Timezone.initializeDatetimeInputs();
    }
}