Calendar/docs/timeformatter-specification.md
Janus Knudsen 8b96376d1f Centralizes time formatting with timezone support
Addresses inconsistent time formatting and lack of timezone
handling throughout the application by introducing a
`TimeFormatter` utility.

This class centralizes time formatting logic, providing
timezone conversion (defaults to Europe/Copenhagen) and
support for both 12-hour and 24-hour formats, configurable
via `CalendarConfig`.

It also updates event rendering to utilize the new
`TimeFormatter` for consistent time displays.
2025-09-12 22:21:56 +02:00

5.9 KiB

TimeFormatter Specification

Problem

  • Alle events i systemet/mock JSON er i Zulu tid (UTC)
  • Nuværende formatTime() metoder håndterer ikke timezone konvertering
  • Ingen support for 12/24 timers format baseret på configuration
  • Duplikeret formattering logik flere steder

Løsning: Centraliseret TimeFormatter

Requirements

  1. Timezone Support

    • Konverter fra UTC/Zulu til brugerens lokale timezone
    • Respekter browser timezone settings
    • Håndter sommertid korrekt
  2. 12/24 Timer Format

    • Læs format præference fra CalendarConfig
    • Support både 12-timer (AM/PM) og 24-timer format
    • Gør det konfigurerbart per bruger
  3. Centralisering

    • Én enkelt kilde til al tidsformattering
    • Konsistent formattering gennem hele applikationen
    • Nem at teste og vedligeholde

Proposed Implementation

// src/utils/TimeFormatter.ts
export class TimeFormatter {
  private static use24HourFormat: boolean = false;
  private static userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
  
  /**
   * Initialize formatter with user preferences
   */
  static initialize(config: { use24Hour: boolean; timezone?: string }) {
    this.use24HourFormat = config.use24Hour;
    if (config.timezone) {
      this.userTimezone = config.timezone;
    }
  }
  
  /**
   * Format UTC/Zulu time to local time with correct format
   * @param input - UTC Date, ISO string, or minutes from midnight
   * @returns Formatted time string in user's preferred format
   */
  static formatTime(input: Date | string | number): string {
    let date: Date;
    
    if (typeof input === 'number') {
      // Minutes from midnight - create date for today
      const today = new Date();
      today.setHours(0, 0, 0, 0);
      today.setMinutes(input);
      date = today;
    } else if (typeof input === 'string') {
      // ISO string - parse as UTC
      date = new Date(input);
    } else {
      date = input;
    }
    
    // Convert to local timezone
    const localDate = this.convertToLocalTime(date);
    
    // Format based on user preference
    if (this.use24HourFormat) {
      return this.format24Hour(localDate);
    } else {
      return this.format12Hour(localDate);
    }
  }
  
  /**
   * Convert UTC date to local timezone
   */
  private static convertToLocalTime(utcDate: Date): Date {
    // Use Intl.DateTimeFormat for proper timezone conversion
    const formatter = new Intl.DateTimeFormat('en-US', {
      timeZone: this.userTimezone,
      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 dateParts: any = {};
    
    parts.forEach(part => {
      dateParts[part.type] = part.value;
    });
    
    return new Date(
      parseInt(dateParts.year),
      parseInt(dateParts.month) - 1,
      parseInt(dateParts.day),
      parseInt(dateParts.hour),
      parseInt(dateParts.minute),
      parseInt(dateParts.second)
    );
  }
  
  /**
   * Format time in 24-hour format (HH:mm)
   */
  private static format24Hour(date: Date): string {
    const hours = date.getHours().toString().padStart(2, '0');
    const minutes = date.getMinutes().toString().padStart(2, '0');
    return `${hours}:${minutes}`;
  }
  
  /**
   * Format time in 12-hour format (h:mm AM/PM)
   */
  private static format12Hour(date: Date): string {
    const hours = date.getHours();
    const minutes = date.getMinutes();
    const period = hours >= 12 ? 'PM' : 'AM';
    const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
    return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
  }
  
  /**
   * Format date and time together
   */
  static formatDateTime(date: Date | string): string {
    const localDate = typeof date === 'string' ? new Date(date) : date;
    const convertedDate = this.convertToLocalTime(localDate);
    
    const dateStr = convertedDate.toLocaleDateString();
    const timeStr = this.formatTime(convertedDate);
    
    return `${dateStr} ${timeStr}`;
  }
  
  /**
   * Get timezone offset in hours
   */
  static getTimezoneOffset(): number {
    return new Date().getTimezoneOffset() / -60;
  }
}

Configuration Integration

// src/core/CalendarConfig.ts
export interface TimeFormatSettings {
  use24HourFormat: boolean;
  timezone?: string; // Optional override, defaults to browser timezone
}

// Add to CalendarConfig
getTimeFormatSettings(): TimeFormatSettings {
  return {
    use24HourFormat: this.config.use24HourFormat ?? false,
    timezone: this.config.timezone // undefined = use browser default
  };
}

Usage Examples

// Initialize on app start
TimeFormatter.initialize({
  use24Hour: calendarConfig.getTimeFormatSettings().use24HourFormat,
  timezone: calendarConfig.getTimeFormatSettings().timezone
});

// Format UTC event time to local
const utcEventTime = "2024-01-15T14:30:00Z"; // 2:30 PM UTC
const localTime = TimeFormatter.formatTime(utcEventTime);
// Result (Copenhagen, 24h): "15:30"
// Result (Copenhagen, 12h): "3:30 PM"
// Result (New York, 12h): "9:30 AM"

// Format minutes from midnight
const minutes = 570; // 9:30 AM
const formatted = TimeFormatter.formatTime(minutes);
// Result (24h): "09:30"
// Result (12h): "9:30 AM"

Testing Considerations

  1. Test timezone conversions:

    • UTC to Copenhagen (UTC+1/+2)
    • UTC to New York (UTC-5/-4)
    • UTC to Tokyo (UTC+9)
  2. Test daylight saving transitions

  3. Test 12/24 hour format switching

  4. Test edge cases:

    • Midnight (00:00 / 12:00 AM)
    • Noon (12:00 / 12:00 PM)
    • Events spanning multiple days

Migration Plan

  1. Implement TimeFormatter class
  2. Add configuration options to CalendarConfig
  3. Replace all existing formatTime() calls
  4. Update mock data loader to handle UTC properly
  5. Test thoroughly with different timezones