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:
parent
c07d83d86f
commit
8b96376d1f
7 changed files with 489 additions and 199 deletions
0
docs/implementation-todo.md
Normal file
0
docs/implementation-todo.md
Normal file
216
docs/timeformatter-specification.md
Normal file
216
docs/timeformatter-specification.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { eventBus } from './EventBus';
|
import { eventBus } from './EventBus';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
import { CalendarConfig as ICalendarConfig, ViewPeriod, CalendarMode } from '../types/CalendarTypes';
|
import { CalendarConfig as ICalendarConfig, ViewPeriod, CalendarMode } from '../types/CalendarTypes';
|
||||||
|
import { TimeFormatter, TimeFormatSettings } from '../utils/TimeFormatter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All-day event layout constants
|
* All-day event layout constants
|
||||||
|
|
@ -69,6 +70,15 @@ interface ResourceViewSettings {
|
||||||
showAllDay: boolean; // Show all-day event row
|
showAllDay: boolean; // Show all-day event row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time format configuration settings
|
||||||
|
*/
|
||||||
|
interface TimeFormatConfig {
|
||||||
|
timezone: string;
|
||||||
|
use24HourFormat: boolean;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar configuration management
|
* Calendar configuration management
|
||||||
*/
|
*/
|
||||||
|
|
@ -80,6 +90,7 @@ export class CalendarConfig {
|
||||||
private dateViewSettings: DateViewSettings;
|
private dateViewSettings: DateViewSettings;
|
||||||
private resourceViewSettings: ResourceViewSettings;
|
private resourceViewSettings: ResourceViewSettings;
|
||||||
private currentWorkWeek: string = 'standard';
|
private currentWorkWeek: string = 'standard';
|
||||||
|
private timeFormatConfig: TimeFormatConfig;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = {
|
this.config = {
|
||||||
|
|
@ -142,9 +153,19 @@ export class CalendarConfig {
|
||||||
showAllDay: true
|
showAllDay: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Time format settings - default to Denmark
|
||||||
|
this.timeFormatConfig = {
|
||||||
|
timezone: 'Europe/Copenhagen',
|
||||||
|
use24HourFormat: true,
|
||||||
|
locale: 'da-DK'
|
||||||
|
};
|
||||||
|
|
||||||
// Set computed values
|
// Set computed values
|
||||||
this.config.minEventDuration = this.gridSettings.snapInterval;
|
this.config.minEventDuration = this.gridSettings.snapInterval;
|
||||||
|
|
||||||
|
// Initialize TimeFormatter with default settings
|
||||||
|
TimeFormatter.configure(this.timeFormatConfig);
|
||||||
|
|
||||||
// Load calendar type from URL parameter
|
// Load calendar type from URL parameter
|
||||||
this.loadCalendarType();
|
this.loadCalendarType();
|
||||||
|
|
||||||
|
|
@ -472,6 +493,57 @@ export class CalendarConfig {
|
||||||
return this.currentWorkWeek;
|
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
|
// Create singleton instance
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CalendarEvent } from '../types/CalendarTypes';
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||||||
import { calendarConfig } from '../core/CalendarConfig';
|
import { calendarConfig } from '../core/CalendarConfig';
|
||||||
|
import { TimeFormatter } from '../utils/TimeFormatter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for event DOM elements
|
* 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 {
|
protected formatTime(date: Date): string {
|
||||||
const hours = date.getHours();
|
return TimeFormatter.formatTime(date);
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -87,12 +84,11 @@ export class SwpEventElement extends BaseEventElement {
|
||||||
* Create inner HTML structure
|
* Create inner HTML structure
|
||||||
*/
|
*/
|
||||||
private createInnerStructure(): void {
|
private createInnerStructure(): void {
|
||||||
const startTime = this.formatTime(this.event.start);
|
const timeRange = TimeFormatter.formatTimeRange(this.event.start, this.event.end);
|
||||||
const endTime = this.formatTime(this.event.end);
|
|
||||||
const durationMinutes = (this.event.end.getTime() - this.event.start.getTime()) / (1000 * 60);
|
const durationMinutes = (this.event.end.getTime() - this.event.start.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
this.element.innerHTML = `
|
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>
|
<swp-event-title>${this.event.title}</swp-event-title>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,165 +10,6 @@ import { DateCalculator } from '../utils/DateCalculator';
|
||||||
*/
|
*/
|
||||||
export class AllDayEventRenderer {
|
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { eventBus } from '../core/EventBus';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
|
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
|
||||||
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
|
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||||
|
import { TimeFormatter } from '../utils/TimeFormatter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for event rendering strategies
|
* 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)
|
* Create event inner structure (swp-event-time and swp-event-title)
|
||||||
*/
|
*/
|
||||||
private createEventInnerStructure(event: CalendarEvent): string {
|
private createEventInnerStructure(event: CalendarEvent): string {
|
||||||
const startTime = this.formatTime(event.start);
|
const timeRange = TimeFormatter.formatTimeRange(event.start, event.end);
|
||||||
const endTime = this.formatTime(event.end);
|
|
||||||
const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
|
const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
return `
|
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>
|
<swp-event-title>${event.title}</swp-event-title>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -269,33 +269,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
// Update display
|
// Update display
|
||||||
const timeElement = clone.querySelector('swp-event-time');
|
const timeElement = clone.querySelector('swp-event-time');
|
||||||
if (timeElement) {
|
if (timeElement) {
|
||||||
const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`;
|
const startTime = TimeFormatter.formatTimeFromMinutes(snappedStartMinutes);
|
||||||
timeElement.textContent = newTimeText;
|
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
|
* Handle drag start event
|
||||||
*/
|
*/
|
||||||
|
|
@ -590,9 +569,8 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
// Update the time display
|
// Update the time display
|
||||||
const timeElement = element.querySelector('swp-event-time');
|
const timeElement = element.querySelector('swp-event-time');
|
||||||
if (timeElement) {
|
if (timeElement) {
|
||||||
const startTime = this.formatTime(event.start);
|
const timeRange = TimeFormatter.formatTimeRange(event.start, event.end);
|
||||||
const endTime = this.formatTime(event.end);
|
timeElement.textContent = timeRange;
|
||||||
timeElement.textContent = `${startTime} - ${endTime}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
187
src/utils/TimeFormatter.ts
Normal file
187
src/utils/TimeFormatter.ts
Normal 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() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue