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.
This commit is contained in:
Janus Knudsen 2025-09-12 22:21:56 +02:00
parent c07d83d86f
commit 8b96376d1f
7 changed files with 489 additions and 199 deletions

View file

@ -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<TimeFormatConfig>): 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

View file

@ -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 = `
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
<swp-event-title>${this.event.title}</swp-event-title>
`;
}

View file

@ -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<string, number>();
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<string, number>) {
// 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
};
}
}
}

View file

@ -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 `
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-time data-duration="${durationMinutes}">${timeRange}</swp-event-time>
<swp-event-title>${event.title}</swp-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;
}
}

187
src/utils/TimeFormatter.ts Normal file
View file

@ -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<TimeFormatSettings>): 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() || '';
}
}