config = array_merge($this->getDefaultConfig(), $config); $this->calculator = new DSTCalculator($this->config); $this->detector = new DSTDetector($this->config); $this->transition = new DSTTransition($this->config); $this->dstRules = $this->loadDSTRules(); } /** * Check if DST is active for timezone at specific datetime. */ public function isDSTActive(string $timezone, \DateTimeInterface $datetime = null): bool { $datetime = $datetime ?? new \DateTime(); if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $tz = new \DateTimeZone($timezone); $dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz); return $dt->format('I') === '1'; } /** * Get DST information for timezone at specific datetime. */ public function getDSTInfo(string $timezone, \DateTimeInterface $datetime = null): array { $datetime = $datetime ?? new \DateTime(); if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $tz = new \DateTimeZone($timezone); $dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz); $isDST = $dt->format('I') === '1'; $offset = $tz->getOffset($dt); $abbreviation = $dt->format('T'); return [ 'timezone' => $timezone, 'datetime' => $datetime->format('Y-m-d H:i:s'), 'is_dst' => $isDST, 'offset' => $offset, 'offset_hours' => $offset / 3600, 'abbreviation' => $abbreviation, 'dst_offset' => $isDST ? $this->getDSTOffset($timezone) : 0, 'standard_offset' => $isDST ? $offset - $this->getDSTOffset($timezone) : $offset ]; } /** * Get DST transitions for timezone in specific year. */ public function getDSTTransitions(string $timezone, int $year = null): array { $year = $year ?? (int) date('Y'); if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $cacheKey = "{$timezone}_{$year}"; if (isset($this->transitionCache[$cacheKey])) { return $this->transitionCache[$cacheKey]; } $tz = new \DateTimeZone($timezone); // Get transitions for the entire year $transitions = $tz->getTransitions( strtotime("{$year}-01-01"), strtotime("{$year}-12-31 23:59:59") ); $dstTransitions = []; foreach ($transitions as $transition) { if ($transition['dst'] !== $transition['isdst']) { $dstTransitions[] = [ 'timestamp' => $transition['ts'], 'datetime' => date('Y-m-d H:i:s', $transition['ts']), 'offset' => $transition['offset'], 'is_dst' => $transition['dst'], 'abbreviation' => $transition['abbr'], 'type' => $transition['dst'] ? 'start' : 'end', 'offset_change' => $this->calculateOffsetChange($transition, $tz, $year) ]; } } $this->transitionCache[$cacheKey] = $dstTransitions; return $dstTransitions; } /** * Get next DST transition for timezone. */ public function getNextDSTTransition(string $timezone, \DateTimeInterface $datetime = null): ?array { $datetime = $datetime ?? new \DateTime(); $transitions = $this->getDSTTransitions($timezone, (int) $datetime->format('Y')); $timestamp = $datetime->getTimestamp(); foreach ($transitions as $transition) { if ($transition['timestamp'] > $timestamp) { return $transition; } } // Check next year if no transitions found this year $nextYear = (int) $datetime->format('Y') + 1; $nextYearTransitions = $this->getDSTTransitions($timezone, $nextYear); return !empty($nextYearTransitions) ? $nextYearTransitions[0] : null; } /** * Get previous DST transition for timezone. */ public function getPreviousDSTTransition(string $timezone, \DateTimeInterface $datetime = null): ?array { $datetime = $datetime ?? new \DateTime(); $transitions = $this->getDSTTransitions($timezone, (int) $datetime->format('Y')); $timestamp = $datetime->getTimestamp(); $previousTransition = null; foreach ($transitions as $transition) { if ($transition['timestamp'] < $timestamp) { $previousTransition = $transition; } else { break; } } // Check previous year if no transitions found this year if ($previousTransition === null) { $previousYear = (int) $datetime->format('Y') - 1; $previousYearTransitions = $this->getDSTTransitions($timezone, $previousYear); return !empty($previousYearTransitions) ? end($previousYearTransitions) : null; } return $previousTransition; } /** * Get DST offset for timezone. */ public function getDSTOffset(string $timezone): int { if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } // Most DST offsets are 1 hour (3600 seconds) // Some exceptions exist (like Lord Howe Island with 30 minutes) $exceptions = [ 'Australia/Lord_Howe' => 1800, // 30 minutes 'Antarctica/Macquarie' => 1800, // 30 minutes ]; return $exceptions[$timezone] ?? 3600; } /** * Check if timezone observes DST. */ public function observesDST(string $timezone): bool { if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } // Check if timezone has any DST transitions in current year $transitions = $this->getDSTTransitions($timezone); return !empty($transitions); } /** * Get DST periods for timezone in specific year. */ public function getDSTPeriods(string $timezone, int $year = null): array { $year = $year ?? (int) date('Y'); $transitions = $this->getDSTTransitions($timezone, $year); $periods = []; $startTransition = null; foreach ($transitions as $transition) { if ($transition['type'] === 'start') { $startTransition = $transition; } elseif ($transition['type'] === 'end' && $startTransition) { $periods[] = [ 'start' => $startTransition, 'end' => $transition, 'duration' => $transition['timestamp'] - $startTransition['timestamp'], 'duration_hours' => ($transition['timestamp'] - $startTransition['timestamp']) / 3600 ]; $startTransition = null; } } return $periods; } /** * Convert datetime with DST awareness. */ public function convertWithDST(\DateTimeInterface $datetime, string $fromTimezone, string $toTimezone): \DateTime { if (!$this->isValidTimezone($fromTimezone)) { throw new \InvalidArgumentException("Invalid source timezone: {$fromTimezone}"); } if (!$this->isValidTimezone($toTimezone)) { throw new \InvalidArgumentException("Invalid target timezone: {$toTimezone}"); } $fromTz = new \DateTimeZone($fromTimezone); $toTz = new \DateTimeZone($toTimezone); // Create DateTime in source timezone $sourceDt = new \DateTime($datetime->format('Y-m-d H:i:s'), $fromTz); // Convert to target timezone (PHP handles DST automatically) $targetDt = $sourceDt->setTimezone($toTz); // Add DST metadata $targetDt->dstInfo = [ 'source_dst' => $this->isDSTActive($fromTimezone, $datetime), 'target_dst' => $this->isDSTActive($toTimezone, $targetDt), 'source_offset' => $fromTz->getOffset($datetime), 'target_offset' => $toTz->getOffset($targetDt), 'offset_change' => $toTz->getOffset($targetDt) - $fromTz->getOffset($datetime) ]; return $targetDt; } /** * Handle ambiguous or non-existent times during DST transitions. */ public function handleAmbiguousTime(\DateTimeInterface $datetime, string $timezone, string $preference = 'standard'): \DateTime { if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $tz = new \DateTimeZone($timezone); $dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz); // Check if this is an ambiguous time (during fall back transition) $previousTransition = $this->getPreviousDSTTransition($timezone, $datetime); if ($previousTransition && $previousTransition['type'] === 'end') { $transitionDateTime = new \DateTime($previousTransition['datetime']); $transitionDateTime->setTimezone($tz); // Check if our datetime is within the ambiguous hour $diff = $datetime->getTimestamp() - $transitionDateTime->getTimestamp(); if ($diff >= 0 && $diff < 3600) { // Within the ambiguous hour switch ($preference) { case 'standard': // Use standard time (first occurrence) return $dt; case 'dst': // Use DST time (second occurrence) $dt->modify('+1 hour'); return $dt; case 'earlier': // Use earlier time return $dt; case 'later': // Use later time $dt->modify('+1 hour'); return $dt; default: throw new \InvalidArgumentException("Invalid preference: {$preference}"); } } } return $dt; } /** * Handle non-existent time during DST transition. */ public function handleNonExistentTime(\DateTimeInterface $datetime, string $timezone, string $strategy = 'forward'): \DateTime { if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $tz = new \DateTimeZone($timezone); // Check if this is a non-existent time (during spring forward transition) $previousTransition = $this->getPreviousDSTTransition($timezone, $datetime); if ($previousTransition && $previousTransition['type'] === 'start') { $transitionDateTime = new \DateTime($previousTransition['datetime']); $transitionDateTime->setTimezone($tz); // Check if our datetime is within the skipped hour $diff = $datetime->getTimestamp() - $transitionDateTime->getTimestamp(); if ($diff >= 0 && $diff < 3600) { // Within the skipped hour switch ($strategy) { case 'forward': // Move forward to the next valid time return new \DateTime($previousTransition['datetime'], $tz); case 'backward': // Move backward to the previous valid time $dt = new \DateTime($previousTransition['datetime'], $tz); $dt->modify('-1 hour'); return $dt; case 'adjust': // Adjust to the nearest valid time return new \DateTime($previousTransition['datetime'], $tz); default: throw new \InvalidArgumentException("Invalid strategy: {$strategy}"); } } } return new \DateTime($datetime->format('Y-m-d H:i:s'), $tz); } /** * Get DST rules for timezone. */ public function getDSTRules(string $timezone): array { if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } return $this->dstRules[$timezone] ?? $this->generateDSTRules($timezone); } /** * Calculate DST-aware time difference. */ public function calculateTimeDifference(\DateTimeInterface $start, \DateTimeInterface $end, string $timezone): array { if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $tz = new \DateTimeZone($timezone); $startDt = new \DateTime($start->format('Y-m-d H:i:s'), $tz); $endDt = new \DateTime($end->format('Y-m-d H:i:s'), $tz); $interval = $endDt->diff($startDt); // Check if DST transitions occurred between the two times $transitions = $this->getDSTTransitions($timezone, (int) $start->format('Y')); $dstTransitionsInRange = []; foreach ($transitions as $transition) { if ($transition['timestamp'] > $start->getTimestamp() && $transition['timestamp'] < $end->getTimestamp()) { $dstTransitionsInRange[] = $transition; } } return [ 'interval' => $interval, 'total_seconds' => $end->getTimestamp() - $start->getTimestamp(), 'dst_transitions' => $dstTransitionsInRange, 'dst_adjusted_seconds' => $this->calculateDSTAdjustedDifference($start, $end, $timezone), 'has_dst_transition' => !empty($dstTransitionsInRange) ]; } /** * Get DST-aware business hours calculation. */ public function calculateBusinessHours(\DateTimeInterface $datetime, string $timezone, array $businessHours = null): array { $businessHours = $businessHours ?? $this->config['business_hours']; if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $tz = new \DateTimeZone($timezone); $dt = new \DateTime($datetime->format('Y-m-d H:i:s'), $tz); $isDST = $this->isDSTActive($timezone, $dt); $hour = (int) $dt->format('H'); $dayOfWeek = (int) $dt->format('w'); // Adjust business hours for DST if needed $adjustedBusinessHours = $businessHours; if ($isDST && $this->config['adjust_business_hours_for_dst']) { $adjustedBusinessHours = $this->adjustBusinessHoursForDST($businessHours, $timezone); } $isBusinessHours = in_array($dayOfWeek, $adjustedBusinessHours['weekdays']) && $hour >= $adjustedBusinessHours['start_hour'] && $hour < $adjustedBusinessHours['end_hour']; return [ 'datetime' => $dt, 'timezone' => $timezone, 'is_dst' => $isDST, 'is_business_hours' => $isBusinessHours, 'business_hours' => $adjustedBusinessHours, 'next_business_hour' => $this->getNextBusinessHour($dt, $timezone, $adjustedBusinessHours), 'previous_business_hour' => $this->getPreviousBusinessHour($dt, $timezone, $adjustedBusinessHours) ]; } /** * Validate DST configuration. */ public function validateDSTConfig(array $config): array { $errors = []; $warnings = []; // Validate timezone if (isset($config['timezone']) && !$this->isValidTimezone($config['timezone'])) { $errors[] = "Invalid timezone: {$config['timezone']}"; } // Validate business hours if (isset($config['business_hours'])) { $bh = $config['business_hours']; if (!isset($bh['start_hour']) || !is_int($bh['start_hour']) || $bh['start_hour'] < 0 || $bh['start_hour'] > 23) { $errors[] = "Invalid start_hour in business_hours"; } if (!isset($bh['end_hour']) || !is_int($bh['end_hour']) || $bh['end_hour'] < 0 || $bh['end_hour'] > 24) { $errors[] = "Invalid end_hour in business_hours"; } if (isset($bh['start_hour']) && isset($bh['end_hour']) && $bh['start_hour'] >= $bh['end_hour']) { $warnings[] = "Business hours start_hour should be less than end_hour"; } } // Validate preference settings if (isset($config['ambiguous_time_preference'])) { $validPreferences = ['standard', 'dst', 'earlier', 'later']; if (!in_array($config['ambiguous_time_preference'], $validPreferences)) { $errors[] = "Invalid ambiguous_time_preference"; } } if (isset($config['non_existent_time_strategy'])) { $validStrategies = ['forward', 'backward', 'adjust']; if (!in_array($config['non_existent_time_strategy'], $validStrategies)) { $errors[] = "Invalid non_existent_time_strategy"; } } return [ 'valid' => empty($errors), 'errors' => $errors, 'warnings' => $warnings ]; } /** * Generate DST report for timezone. */ public function generateDSTReport(string $timezone, int $year = null): array { $year = $year ?? (int) date('Y'); if (!$this->isValidTimezone($timezone)) { throw new \InvalidArgumentException("Invalid timezone: {$timezone}"); } $transitions = $this->getDSTTransitions($timezone, $year); $periods = $this->getDSTPeriods($timezone, $year); $observesDST = $this->observesDST($timezone); $report = [ 'timezone' => $timezone, 'year' => $year, 'observes_dst' => $observesDST, 'total_transitions' => count($transitions), 'total_periods' => count($periods), 'dst_offset' => $this->getDSTOffset($timezone), 'current_status' => $this->getDSTInfo($timezone), 'transitions' => $transitions, 'periods' => $periods ]; if ($observesDST) { $report['statistics'] = [ 'total_dst_hours' => $this->calculateTotalDSTHours($timezone, $year), 'dst_percentage' => $this->calculateDSTPercentage($timezone, $year), 'longest_dst_period' => $this->getLongestDSTPeriod($timezone, $year), 'shortest_dst_period' => $this->getShortestDSTPeriod($timezone, $year) ]; } return $report; } /** * Check if timezone is valid. */ protected function isValidTimezone(string $timezone): bool { return in_array($timezone, \DateTimeZone::listIdentifiers()); } /** * Load DST rules. */ protected function loadDSTRules(): array { return [ // US DST rules (historical and current) 'America/New_York' => [ 'start_month' => 3, 'start_day' => 'second_sunday', 'start_time' => '02:00', 'end_month' => 11, 'end_day' => 'first_sunday', 'end_time' => '02:00', 'offset' => 3600 ], 'America/Chicago' => [ 'start_month' => 3, 'start_day' => 'second_sunday', 'start_time' => '02:00', 'end_month' => 11, 'end_day' => 'first_sunday', 'end_time' => '02:00', 'offset' => 3600 ], 'America/Denver' => [ 'start_month' => 3, 'start_day' => 'second_sunday', 'start_time' => '02:00', 'end_month' => 11, 'end_day' => 'first_sunday', 'end_time' => '02:00', 'offset' => 3600 ], 'America/Los_Angeles' => [ 'start_month' => 3, 'start_day' => 'second_sunday', 'start_time' => '02:00', 'end_month' => 11, 'end_day' => 'first_sunday', 'end_time' => '02:00', 'offset' => 3600 ], // European DST rules 'Europe/London' => [ 'start_month' => 3, 'start_day' => 'last_sunday', 'start_time' => '01:00', 'end_month' => 10, 'end_day' => 'last_sunday', 'end_time' => '01:00', 'offset' => 3600 ], 'Europe/Paris' => [ 'start_month' => 3, 'start_day' => 'last_sunday', 'start_time' => '01:00', 'end_month' => 10, 'end_day' => 'last_sunday', 'end_time' => '01:00', 'offset' => 3600 ], 'Europe/Berlin' => [ 'start_month' => 3, 'start_day' => 'last_sunday', 'start_time' => '01:00', 'end_month' => 10, 'end_day' => 'last_sunday', 'end_time' => '01:00', 'offset' => 3600 ] ]; } /** * Generate DST rules for timezone. */ protected function generateDSTRules(string $timezone): array { // Try to infer rules from transitions $transitions = $this->getDSTTransitions($timezone); if (empty($transitions)) { return [ 'observes_dst' => false ]; } // Analyze transitions to generate rules $rules = ['observes_dst' => true]; // This is a simplified rule generation // In practice, you'd want more sophisticated analysis foreach ($transitions as $transition) { $dt = new \DateTime($transition['datetime']); if ($transition['type'] === 'start') { $rules['start_month'] = (int) $dt->format('m'); $rules['start_day'] = 'unknown'; // Would need more analysis $rules['start_time'] = $dt->format('H:i'); } elseif ($transition['type'] === 'end') { $rules['end_month'] = (int) $dt->format('m'); $rules['end_day'] = 'unknown'; // Would need more analysis $rules['end_time'] = $dt->format('H:i'); } } $rules['offset'] = $this->getDSTOffset($timezone); return $rules; } /** * Calculate offset change for transition. */ protected function calculateOffsetChange(array $transition, \DateTimeZone $tz, int $year): int { // Get offset before and after transition $before = new \DateTime($transition['datetime'], $tz); $before->modify('-1 second'); $after = new \DateTime($transition['datetime'], $tz); $after->modify('+1 second'); return $after->getOffset() - $before->getOffset(); } /** * Calculate DST-adjusted time difference. */ protected function calculateDSTAdjustedDifference(\DateTimeInterface $start, \DateTimeInterface $end, string $timezone): int { $tz = new \DateTimeZone($timezone); $startDt = new \DateTime($start->format('Y-m-d H:i:s'), $tz); $endDt = new \DateTime($end->format('Y-m-d H:i:s'), $tz); // Calculate actual difference in seconds return $endDt->getTimestamp() - $startDt->getTimestamp(); } /** * Adjust business hours for DST. */ protected function adjustBusinessHoursForDST(array $businessHours, string $timezone): array { // Some businesses may want to adjust their hours during DST // This is a placeholder for more complex logic return $businessHours; } /** * Get next business hour. */ protected function getNextBusinessHour(\DateTime $datetime, string $timezone, array $businessHours): \DateTime { $next = clone $datetime; $dayOfWeek = (int) $next->format('w'); // Move to next business day if needed while (!in_array($dayOfWeek, $businessHours['weekdays'])) { $next->modify('+1 day'); $dayOfWeek = (int) $next->format('w'); } // Set to start of business hours $next->setTime($businessHours['start_hour'], 0, 0); return $next; } /** * Get previous business hour. */ protected function getPreviousBusinessHour(\DateTime $datetime, string $timezone, array $businessHours): \DateTime { $previous = clone $datetime; $dayOfWeek = (int) $previous->format('w'); // Move to previous business day if needed while (!in_array($dayOfWeek, $businessHours['weekdays'])) { $previous->modify('-1 day'); $dayOfWeek = (int) $previous->format('w'); } // Set to end of business hours $previous->setTime($businessHours['end_hour'] - 1, 59, 59); return $previous; } /** * Calculate total DST hours in year. */ protected function calculateTotalDSTHours(string $timezone, int $year): int { $periods = $this->getDSTPeriods($timezone, $year); $totalHours = 0; foreach ($periods as $period) { $totalHours += $period['duration_hours']; } return (int) $totalHours; } /** * Calculate DST percentage for year. */ protected function calculateDSTPercentage(string $timezone, int $year): float { $totalHours = $this->calculateTotalDSTHours($timezone, $year); $yearHours = ($this->isLeapYear($year) ? 366 : 365) * 24; return ($totalHours / $yearHours) * 100; } /** * Check if year is leap year. */ protected function isLeapYear(int $year): bool { return ($year % 4 === 0 && $year % 100 !== 0) || ($year % 400 === 0); } /** * Get longest DST period. */ protected function getLongestDSTPeriod(string $timezone, int $year): ?array { $periods = $this->getDSTPeriods($timezone, $year); if (empty($periods)) { return null; } $longest = $periods[0]; foreach ($periods as $period) { if ($period['duration_hours'] > $longest['duration_hours']) { $longest = $period; } } return $longest; } /** * Get shortest DST period. */ protected function getShortestDSTPeriod(string $timezone, int $year): ?array { $periods = $this->getDSTPeriods($timezone, $year); if (empty($periods)) { return null; } $shortest = $periods[0]; foreach ($periods as $period) { if ($period['duration_hours'] < $shortest['duration_hours']) { $shortest = $period; } } return $shortest; } /** * Clear transition cache. */ public function clearCache(): void { $this->transitionCache = []; } /** * Get default configuration. */ protected function getDefaultConfig(): array { return [ 'business_hours' => [ 'weekdays' => [1, 2, 3, 4, 5], // Monday to Friday 'start_hour' => 9, 'end_hour' => 17 ], 'adjust_business_hours_for_dst' => false, 'ambiguous_time_preference' => 'standard', 'non_existent_time_strategy' => 'forward', 'cache_enabled' => true, 'cache_ttl' => 3600 ]; } /** * Get configuration. */ public function getConfig(): array { return $this->config; } /** * Set configuration. */ public function setConfig(array $config): void { $this->config = array_merge($this->config, $config); } /** * Create DST handler instance. */ public static function create(array $config = []): self { return new self($config); } /** * Create for US timezones. */ public static function forUS(): self { return new self([ 'business_hours' => [ 'weekdays' => [1, 2, 3, 4, 5], 'start_hour' => 9, 'end_hour' => 17 ], 'ambiguous_time_preference' => 'standard', 'non_existent_time_strategy' => 'forward' ]); } /** * Create for European timezones. */ public static function forEurope(): self { return new self([ 'business_hours' => [ 'weekdays' => [1, 2, 3, 4, 5], 'start_hour' => 9, 'end_hour' => 17 ], 'ambiguous_time_preference' => 'standard', 'non_existent_time_strategy' => 'forward' ]); } }