cacheTimeout = $this->coreParametersHelper->get('peak_interaction_timer_cache_timeout'); $this->bestDefaultHourStart = $this->coreParametersHelper->get('peak_interaction_timer_best_default_hour_start'); $this->bestDefaultHourEnd = $this->coreParametersHelper->get('peak_interaction_timer_best_default_hour_end'); $this->bestDefaultDays = $this->coreParametersHelper->get('peak_interaction_timer_best_default_days'); $this->fetchInteractionsFrom = $this->coreParametersHelper->get('peak_interaction_timer_fetch_interactions_from'); $this->fetchLimit = $this->coreParametersHelper->get('peak_interaction_timer_fetch_limit'); $this->maxOptimalDays = count($this->bestDefaultDays); } /** * Get the optimal time for a contact. */ public function getOptimalTime(Lead $contact): \DateTime { $this->resetBias(); $currentDateTime = $this->getContactDateTime($contact); $interactions = $this->getContactInteractions($contact, $currentDateTime->getTimezone()); if (count($interactions) >= self::MIN_INTERACTIONS) { $hours = array_column($interactions, 'hourOfDay'); [$this->bestHourStart, $this->bestHourEnd] = $this->calculateOptimalTime($hours); } return $this->isTimeOptimal($currentDateTime) ? $currentDateTime : $this->getAdjustedDateTime($currentDateTime); } /** * Get the optimal time and day for a contact. */ public function getOptimalTimeAndDay(Lead $contact): \DateTime { $this->resetBias(); $currentDateTime = $this->getContactDateTime($contact); $interactions = $this->getContactInteractions($contact, $currentDateTime->getTimezone()); if (count($interactions) >= self::MIN_INTERACTIONS) { $hours = array_column($interactions, 'hourOfDay'); $days = array_column($interactions, 'dayOfWeek'); [$this->bestHourStart, $this->bestHourEnd] = $this->calculateOptimalTime($hours); $this->bestDays = $this->calculateOptimalDays($days); } return $this->isDayAndTimeOptimal($currentDateTime) ? $currentDateTime : $this->findOptimalDateTime($currentDateTime); } private function resetBias(): void { $this->bestHourStart = (int) $this->bestDefaultHourStart; $this->bestHourEnd = (int) $this->bestDefaultHourEnd; $bestDays = array_map('intval', $this->bestDefaultDays); $this->bestDays = !empty($bestDays) ? $bestDays : self::DEFAULT_BEST_DAYS; $this->maxOptimalDays = count($this->bestDays); } private function isTimeOptimal(\DateTime $dateTime): bool { $hour = (int) $dateTime->format(self::HOUR_FORMAT); return $hour >= $this->bestHourStart && $hour < $this->bestHourEnd; } private function isDayAndTimeOptimal(\DateTime $dateTime): bool { return in_array((int) $dateTime->format(self::DAY_FORMAT), $this->bestDays, true) && $this->isTimeOptimal($dateTime); } private function getAdjustedDateTime(\DateTime $dateTime): \DateTime { $adjustedDateTime = clone $dateTime; $adjustedDateTime->setTime($this->bestHourStart, self::MINUTES_START_OF_HOUR); return $adjustedDateTime <= $dateTime ? $adjustedDateTime->modify('+1 day') : $adjustedDateTime; } private function findOptimalDateTime(\DateTime $dateTime): \DateTime { $optimalDateTime = $this->getAdjustedDateTime($dateTime); while (!in_array((int) $optimalDateTime->format(self::DAY_FORMAT), $this->bestDays, true)) { $optimalDateTime->modify('+1 day'); } return $optimalDateTime; } private function getContactDateTime(Lead $contact): \DateTime { $timezone = $contact->getTimezone() ? new \DateTimeZone($contact->getTimezone()) : $this->getDefaultTimezone(); return $this->getCurrentDateTime($timezone); } protected function getCurrentDateTime(\DateTimeZone $timezone): \DateTime { return new \DateTime('now', $timezone); } private function getDefaultTimezone(): \DateTimeZone { return $this->defaultTimezone ??= new \DateTimeZone( $this->coreParametersHelper->get('default_timezone', 'UTC') ); } /** * @return array> */ private function getContactInteractions(Lead $contact, \DateTimeZone $dateTimeZone): array { $cacheItem = $this->cacheProvider->getItem('contact.interactions.'.$contact->getId()); if ($cacheItem->isHit()) { $interactions = $cacheItem->get(); } else { $fetchInteractionsFromDate = $this->getCurrentDateTime($dateTimeZone) ->modify($this->fetchInteractionsFrom); $emailReads = $this->getLeadStats($contact->getId(), $fetchInteractionsFromDate); $pageHits = $this->getLeadHits($contact->getId(), $fetchInteractionsFromDate); $formSubmissions = $this->getFormSubmissions($contact->getId(), $fetchInteractionsFromDate); $emailReadInteractions = $this->processInteractions($emailReads, 'email.read', $dateTimeZone); $pageHitInteractions = $this->processInteractions($pageHits, 'page.hit', $dateTimeZone); $formInteractions = $this->processInteractions($formSubmissions, 'form.submit', $dateTimeZone); $interactions = array_merge($emailReadInteractions, $pageHitInteractions, $formInteractions); $cacheItem->set($interactions); $cacheItem->expiresAfter($this->cacheTimeout * 60); $this->cacheProvider->save($cacheItem); } return $interactions; } /** * @param array> $interactionsData * * @return array> * * @throws \Exception */ private function processInteractions(array $interactionsData, string $type, \DateTimeZone $dateTimeZone): array { $interactions = []; $registeredInteractions = []; // Keep track of registered interactions to ensure one interaction type per hour foreach ($interactionsData as $interaction) { $dateKey = match ($type) { 'email.read' => 'dateRead', 'page.hit' => 'dateHit', 'form.submit' => 'dateSubmitted', default => throw new \Exception('Unhandled interaction type: '.$type), }; $interactionDate = $interaction[$dateKey]; $interactionDate->setTimezone($dateTimeZone); $interactionKey = $type.':'.$interactionDate->format('Y-m-d_H'); if (!in_array($interactionKey, $registeredInteractions)) { $interactions[] = [ 'type' => $type, 'date' => $interactionDate->format('Y-m-d H:i:s'), 'hourOfDay' => (int) $interactionDate->format(self::HOUR_FORMAT), 'dayOfWeek' => (int) $interactionDate->format(self::DAY_FORMAT), 'time' => $interactionDate->format('H:i:s'), ]; $registeredInteractions[] = $interactionKey; } } return $interactions; } /** * @return array> */ private function getLeadStats(int $leadId, ?\DateTime $fromDate = null): array { return $this->statRepository->getLeadStats($leadId, [ 'order' => ['timestamp', 'DESC'], 'limit' => $this->fetchLimit, 'state' => 'read', 'basic_select' => true, 'fromDate' => $fromDate, ]); } /** * @return array> */ private function getLeadHits(int $leadId, ?\DateTime $fromDate = null): array { return $this->hitRepository->getLeadHits($leadId, [ 'order' => ['timestamp', 'DESC'], 'limit' => $this->fetchLimit, 'fromDate' => $fromDate, ]); } /** * @return array> */ private function getFormSubmissions(int $leadId, ?\DateTime $fromDate = null): array { return $this->submissionRepository->getSubmissions([ 'leadId' => $leadId, 'order' => ['timestamp', 'DESC'], 'limit' => $this->fetchLimit, 'fromDate' => $fromDate, ]); } /** * Calculates the optimal time range based on an array of elements. * * @param int[] $elements Hours (0-23) * * @return int[] Hours (0-23) */ private function calculateOptimalTime(array $elements): array { sort($elements); $count = count($elements); if ($count > 0) { $middleIndex = (int) floor(($count - 1) / 2); $result = $elements[$middleIndex]; } else { throw new \Exception('Not enough elements to calculate optimal time'); } $start = ($result + 23) % 24; // hour before $end = ($result + 1) % 24; // hour after // Return the start and end hours as an array return [$start, $end]; } /** * Calculates the optimal days based on the frequency of elements. * * @param int[] $elements Days of the week (ISO 8601) * * @return int[] Days of the week (ISO 8601) * * @throws \Exception */ private function calculateOptimalDays(array $elements): array { if (0 === count($elements)) { throw new \Exception('Not enough elements to calculate optimal days'); } // Count the frequency of each element. $frequency = array_count_values($elements); // Sort frequencies in descending order. arsort($frequency); // Get the elements sorted by frequency. $optimalDays = array_keys($frequency); // Return the top elements up to the max optimal days limit. return array_slice($optimalDays, 0, min($this->maxOptimalDays, count($optimalDays))); } }