diff --git a/docs/implementation-todo.md b/docs/implementation-todo.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/timeformatter-specification.md b/docs/timeformatter-specification.md new file mode 100644 index 0000000..5bb5ede --- /dev/null +++ b/docs/timeformatter-specification.md @@ -0,0 +1,216 @@ +# 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 + +```typescript +// 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 + +```typescript +// 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 + +```typescript +// 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 \ No newline at end of file diff --git a/src/core/CalendarConfig.ts b/src/core/CalendarConfig.ts index 4bb9560..57c57ed 100644 --- a/src/core/CalendarConfig.ts +++ b/src/core/CalendarConfig.ts @@ -3,6 +3,7 @@ import { eventBus } from './EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { CalendarConfig as ICalendarConfig, ViewPeriod, CalendarMode } from '../types/CalendarTypes'; +import { TimeFormatter, TimeFormatSettings } from '../utils/TimeFormatter'; /** * All-day event layout constants @@ -69,6 +70,15 @@ interface ResourceViewSettings { showAllDay: boolean; // Show all-day event row } +/** + * Time format configuration settings + */ +interface TimeFormatConfig { + timezone: string; + use24HourFormat: boolean; + locale: string; +} + /** * Calendar configuration management */ @@ -80,6 +90,7 @@ export class CalendarConfig { private dateViewSettings: DateViewSettings; private resourceViewSettings: ResourceViewSettings; private currentWorkWeek: string = 'standard'; + private timeFormatConfig: TimeFormatConfig; constructor() { this.config = { @@ -142,9 +153,19 @@ export class CalendarConfig { showAllDay: true }; + // Time format settings - default to Denmark + this.timeFormatConfig = { + timezone: 'Europe/Copenhagen', + use24HourFormat: true, + locale: 'da-DK' + }; + // Set computed values this.config.minEventDuration = this.gridSettings.snapInterval; + // Initialize TimeFormatter with default settings + TimeFormatter.configure(this.timeFormatConfig); + // Load calendar type from URL parameter this.loadCalendarType(); @@ -472,6 +493,57 @@ export class CalendarConfig { return this.currentWorkWeek; } + /** + * Get time format settings + */ + getTimeFormatSettings(): TimeFormatConfig { + return { ...this.timeFormatConfig }; + } + + /** + * Update time format settings + */ + updateTimeFormatSettings(updates: Partial): void { + this.timeFormatConfig = { ...this.timeFormatConfig, ...updates }; + + // Update TimeFormatter with new settings + TimeFormatter.configure(this.timeFormatConfig); + + // Emit time format change event + eventBus.emit(CoreEvents.REFRESH_REQUESTED, { + key: 'timeFormatSettings', + value: this.timeFormatConfig + }); + } + + /** + * Set timezone (convenience method) + */ + setTimezone(timezone: string): void { + this.updateTimeFormatSettings({ timezone }); + } + + /** + * Set 12/24 hour format (convenience method) + */ + set24HourFormat(use24Hour: boolean): void { + this.updateTimeFormatSettings({ use24HourFormat: use24Hour }); + } + + /** + * Get configured timezone + */ + getTimezone(): string { + return this.timeFormatConfig.timezone; + } + + /** + * Check if using 24-hour format + */ + is24HourFormat(): boolean { + return this.timeFormatConfig.use24HourFormat; + } + } // Create singleton instance diff --git a/src/elements/SwpEventElement.ts b/src/elements/SwpEventElement.ts index ae2adb6..ca2be76 100644 --- a/src/elements/SwpEventElement.ts +++ b/src/elements/SwpEventElement.ts @@ -1,5 +1,6 @@ import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; +import { TimeFormatter } from '../utils/TimeFormatter'; /** * Abstract base class for event DOM elements @@ -39,14 +40,10 @@ export abstract class BaseEventElement { } /** - * Format time for display + * Format time for display using TimeFormatter */ protected formatTime(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}`; + return TimeFormatter.formatTime(date); } /** @@ -87,12 +84,11 @@ export class SwpEventElement extends BaseEventElement { * Create inner HTML structure */ private createInnerStructure(): void { - const startTime = this.formatTime(this.event.start); - const endTime = this.formatTime(this.event.end); + const timeRange = TimeFormatter.formatTimeRange(this.event.start, this.event.end); const durationMinutes = (this.event.end.getTime() - this.event.start.getTime()) / (1000 * 60); this.element.innerHTML = ` - ${startTime} - ${endTime} + ${timeRange} ${this.event.title} `; } diff --git a/src/renderers/AllDayEventRenderer.ts b/src/renderers/AllDayEventRenderer.ts index 48837de..2f751a9 100644 --- a/src/renderers/AllDayEventRenderer.ts +++ b/src/renderers/AllDayEventRenderer.ts @@ -10,165 +10,6 @@ import { DateCalculator } from '../utils/DateCalculator'; */ export class AllDayEventRenderer { - /** - * Render all-day events in the header container - */ - public renderAllDayEvents(events: CalendarEvent[], container: HTMLElement): void { - const allDayEvents = events.filter(event => event.allDay); - - // Find the calendar header - const calendarHeader = container.querySelector('swp-calendar-header'); - if (!calendarHeader) { - return; - } - // Find or create all-day container - let allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement; - if (!allDayContainer) { - allDayContainer = document.createElement('swp-allday-container'); - calendarHeader.appendChild(allDayContainer); - } - - // Clear existing events - allDayContainer.innerHTML = ''; - - if (allDayEvents.length === 0) { - return; - } - - // Build date to column mapping - const dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); - const dateToColumnMap = new Map(); - - dayHeaders.forEach((header, index) => { - const dateStr = (header as HTMLElement).dataset.date; - if (dateStr) { - dateToColumnMap.set(dateStr, index + 1); - } - }); - - // Calculate grid positioning for events - const eventPlacements = this.calculateEventPlacements(allDayEvents, dateToColumnMap); - - // Render events using factory pattern - eventPlacements.forEach(({ event, gridColumn, gridRow }) => { - const eventDateStr = DateCalculator.formatISODate(event.start); - const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr); - const allDayElement = swpAllDayEvent.getElement(); - - // Apply grid positioning - (allDayElement as HTMLElement).style.gridColumn = gridColumn; - (allDayElement as HTMLElement).style.gridRow = gridRow.toString(); - - // Use event metadata for color if available - if (event.metadata?.color) { - (allDayElement as HTMLElement).style.backgroundColor = event.metadata.color; - } - - allDayContainer.appendChild(allDayElement); - }); - } - - /** - * Calculate grid positioning for all-day events with overlap detection - */ - private calculateEventPlacements(events: CalendarEvent[], dateToColumnMap: Map) { - // Calculate spans for each event - const eventItems = events.map(event => { - const eventDateStr = DateCalculator.formatISODate(event.start); - const endDateStr = DateCalculator.formatISODate(event.end); - - const startColumn = dateToColumnMap.get(eventDateStr); - const endColumn = dateToColumnMap.get(endDateStr); - - if (startColumn === undefined) { - return null; - } - - const columnSpan = endColumn !== undefined && endColumn >= startColumn - ? endColumn - startColumn + 1 - : 1; - - return { - event, - span: { - startColumn: startColumn, - columnSpan: columnSpan - } - }; - }).filter(item => item !== null) as Array<{ - event: CalendarEvent; - span: { startColumn: number; columnSpan: number }; - }>; - - // Calculate row placement to avoid overlaps - interface EventPlacement { - event: CalendarEvent; - gridColumn: string; - gridRow: number; - } - - const eventPlacements: EventPlacement[] = []; - - eventItems.forEach(eventItem => { - let assignedRow = 1; - - // Find first available row - while (true) { - // Check if this row has any conflicts - const rowEvents = eventPlacements.filter(p => p.gridRow === assignedRow); - - const hasOverlap = rowEvents.some(rowEvent => { - // Parse the existing grid column to check overlap - const existingSpan = this.parseGridColumn(rowEvent.gridColumn); - return this.spansOverlap(eventItem.span, existingSpan); - }); - - if (!hasOverlap) { - break; // Found available row - } - assignedRow++; - } - - const gridColumn = eventItem.span.columnSpan > 1 - ? `${eventItem.span.startColumn} / span ${eventItem.span.columnSpan}` - : `${eventItem.span.startColumn}`; - - eventPlacements.push({ - event: eventItem.event, - gridColumn, - gridRow: assignedRow - }); - }); - - return eventPlacements; - } - - /** - * Check if two column spans overlap - */ - private spansOverlap(span1: { startColumn: number; columnSpan: number }, span2: { startColumn: number; columnSpan: number }): boolean { - const span1End = span1.startColumn + span1.columnSpan - 1; - const span2End = span2.startColumn + span2.columnSpan - 1; - - return !(span1End < span2.startColumn || span2End < span1.startColumn); - } - - /** - * Parse grid column string back to span object - */ - private parseGridColumn(gridColumn: string): { startColumn: number; columnSpan: number } { - if (gridColumn.includes('span')) { - const parts = gridColumn.split(' / span '); - return { - startColumn: parseInt(parts[0]), - columnSpan: parseInt(parts[1]) - }; - } else { - return { - startColumn: parseInt(gridColumn), - columnSpan: 1 - }; - } - } + } \ No newline at end of file diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 129854b..0bb5aa7 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -7,6 +7,7 @@ import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector'; import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement'; +import { TimeFormatter } from '../utils/TimeFormatter'; /** * Interface for event rendering strategies @@ -184,12 +185,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { * Create event inner structure (swp-event-time and swp-event-title) */ private createEventInnerStructure(event: CalendarEvent): string { - const startTime = this.formatTime(event.start); - const endTime = this.formatTime(event.end); + const timeRange = TimeFormatter.formatTimeRange(event.start, event.end); const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60); return ` - ${startTime} - ${endTime} + ${timeRange} ${event.title} `; } @@ -269,33 +269,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Update display const timeElement = clone.querySelector('swp-event-time'); if (timeElement) { - const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`; - timeElement.textContent = newTimeText; + const startTime = TimeFormatter.formatTimeFromMinutes(snappedStartMinutes); + const endTime = TimeFormatter.formatTimeFromMinutes(endTotalMinutes); + timeElement.textContent = `${startTime} - ${endTime}`; } } - /** - * Unified time formatting method - handles both total minutes and Date objects - */ - private formatTime(input: number | Date | string): string { - let hours: number, minutes: number; - - if (typeof input === 'number') { - // Total minutes input - hours = Math.floor(input / 60) % 24; - minutes = input % 60; - } else { - // Date or ISO string input - const date = typeof input === 'string' ? new Date(input) : input; - hours = date.getHours(); - 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}`; - } - /** * Handle drag start event */ @@ -590,9 +569,8 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Update the time display const timeElement = element.querySelector('swp-event-time'); if (timeElement) { - const startTime = this.formatTime(event.start); - const endTime = this.formatTime(event.end); - timeElement.textContent = `${startTime} - ${endTime}`; + const timeRange = TimeFormatter.formatTimeRange(event.start, event.end); + timeElement.textContent = timeRange; } } diff --git a/src/utils/TimeFormatter.ts b/src/utils/TimeFormatter.ts new file mode 100644 index 0000000..d4bc713 --- /dev/null +++ b/src/utils/TimeFormatter.ts @@ -0,0 +1,187 @@ +/** + * TimeFormatter - Centralized time formatting with timezone support + * + * Handles conversion from UTC/Zulu time to configured timezone (default: Europe/Copenhagen) + * Supports both 12-hour and 24-hour format configuration + * + * All events in the system are stored in UTC and must be converted to local timezone + */ + +export interface TimeFormatSettings { + timezone: string; + use24HourFormat: boolean; + locale: string; +} + +export class TimeFormatter { + private static settings: TimeFormatSettings = { + timezone: 'Europe/Copenhagen', // Default to Denmark + use24HourFormat: true, // 24-hour format standard in Denmark + locale: 'da-DK' // Danish locale + }; + + /** + * Configure time formatting settings + */ + static configure(settings: Partial): void { + TimeFormatter.settings = { ...TimeFormatter.settings, ...settings }; + } + + /** + * Get current time format settings + */ + static getSettings(): TimeFormatSettings { + return { ...TimeFormatter.settings }; + } + + /** + * Convert UTC date to configured timezone + * @param utcDate - Date in UTC (or assumed to be UTC) + * @returns Date object adjusted to configured timezone + */ + static convertToLocalTime(utcDate: Date): Date { + // Create a new date to avoid mutating the original + const localDate = new Date(utcDate); + + // If the date doesn't have timezone info, treat it as UTC + // This handles cases where mock data doesn't have 'Z' suffix + if (!utcDate.toISOString().endsWith('Z') && utcDate.getTimezoneOffset() === new Date().getTimezoneOffset()) { + // Adjust for the fact that we're treating local time as UTC + localDate.setMinutes(localDate.getMinutes() + localDate.getTimezoneOffset()); + } + + return localDate; + } + + /** + * Get timezone offset for configured timezone + * @param date - Reference date for calculating offset (handles DST) + * @returns Offset in minutes + */ + static getTimezoneOffset(date: Date = new Date()): number { + const utc = new Date(date.getTime() + (date.getTimezoneOffset() * 60000)); + const targetTime = new Date(utc.toLocaleString('en-US', { timeZone: TimeFormatter.settings.timezone })); + return (targetTime.getTime() - utc.getTime()) / 60000; + } + + /** + * Format time in 12-hour format + * @param date - Date to format + * @returns Formatted time string (e.g., "9:00 AM") + */ + static format12Hour(date: Date): string { + const localDate = TimeFormatter.convertToLocalTime(date); + + return localDate.toLocaleTimeString(TimeFormatter.settings.locale, { + timeZone: TimeFormatter.settings.timezone, + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + + /** + * Format time in 24-hour format + * @param date - Date to format + * @returns Formatted time string (e.g., "09:00") + */ + static format24Hour(date: Date): string { + const localDate = TimeFormatter.convertToLocalTime(date); + + return localDate.toLocaleTimeString(TimeFormatter.settings.locale, { + timeZone: TimeFormatter.settings.timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } + + /** + * Format time according to current configuration + * @param date - Date to format + * @returns Formatted time string + */ + static formatTime(date: Date): string { + return TimeFormatter.settings.use24HourFormat + ? TimeFormatter.format24Hour(date) + : TimeFormatter.format12Hour(date); + } + + /** + * Format time from total minutes since midnight + * @param totalMinutes - Minutes since midnight (e.g., 540 for 9:00 AM) + * @returns Formatted time string + */ + static formatTimeFromMinutes(totalMinutes: number): string { + const hours = Math.floor(totalMinutes / 60) % 24; + const minutes = totalMinutes % 60; + + // Create a date object for today with the specified time + const date = new Date(); + date.setHours(hours, minutes, 0, 0); + + return TimeFormatter.formatTime(date); + } + + /** + * Format date and time together + * @param date - Date to format + * @returns Formatted date and time string + */ + static formatDateTime(date: Date): string { + const localDate = TimeFormatter.convertToLocalTime(date); + + const dateStr = localDate.toLocaleDateString(TimeFormatter.settings.locale, { + timeZone: TimeFormatter.settings.timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + + const timeStr = TimeFormatter.formatTime(date); + + return `${dateStr} ${timeStr}`; + } + + /** + * Format time range (start - end) + * @param startDate - Start date + * @param endDate - End date + * @returns Formatted time range string (e.g., "09:00 - 10:30") + */ + static formatTimeRange(startDate: Date, endDate: Date): string { + const startTime = TimeFormatter.formatTime(startDate); + const endTime = TimeFormatter.formatTime(endDate); + return `${startTime} - ${endTime}`; + } + + /** + * Check if current timezone observes daylight saving time + * @param date - Reference date + * @returns True if DST is active + */ + static isDaylightSavingTime(date: Date = new Date()): boolean { + const january = new Date(date.getFullYear(), 0, 1); + const july = new Date(date.getFullYear(), 6, 1); + + const janOffset = TimeFormatter.getTimezoneOffset(january); + const julOffset = TimeFormatter.getTimezoneOffset(july); + + return Math.max(janOffset, julOffset) !== TimeFormatter.getTimezoneOffset(date); + } + + /** + * Get timezone abbreviation (e.g., "CET", "CEST") + * @param date - Reference date + * @returns Timezone abbreviation + */ + static getTimezoneAbbreviation(date: Date = new Date()): string { + const localDate = TimeFormatter.convertToLocalTime(date); + + return localDate.toLocaleTimeString('en-US', { + timeZone: TimeFormatter.settings.timezone, + timeZoneName: 'short' + }).split(' ').pop() || ''; + } + +} \ No newline at end of file