$reminderKey = 'night_shift';
}
- // Проверяем, нужно ли показать напоминание
- $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey);
+ // Последовательная проверка условий с определением причины отказа.
+ // Это позволяет вернуть next_check_at — оптимальный момент следующей проверки.
- if ($shouldShow) {
+ // Проверка 1: Пользователь — администратор со сменой?
+ if (!ShiftReminderService::isUserAdministrator($userId)) {
return [
'success' => true,
- 'show_reminder' => true,
+ 'show_reminder' => false,
+ 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('not_admin'),
+ ];
+ }
+
+ // Проверка 2: Нужно ли действие со сменой (открытие/закрытие)?
+ if (!ShiftReminderService::isShiftActionNeeded($userId)) {
+ return [
+ 'success' => true,
+ 'show_reminder' => false,
+ 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('no_action_needed'),
+ ];
+ }
+
+ // Проверка 3: Напоминание уже подтверждено для этого периода?
+ $referenceDate = ShiftReminderService::getReferenceDate();
+ $alreadyConfirmed = \app\models\ShiftReminderShown::find()
+ ->where([
+ 'user_id' => $userId,
'reminder_key' => $reminderKey,
- 'reference_date' => ShiftReminderService::getReferenceDate(),
+ 'reference_date' => $referenceDate,
+ ])
+ ->andWhere(['IS NOT', 'confirmed_at', null])
+ ->exists();
+
+ if ($alreadyConfirmed) {
+ return [
+ 'success' => true,
+ 'show_reminder' => false,
+ 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('already_confirmed'),
];
}
+ // Все проверки пройдены — показать напоминание
return [
'success' => true,
- 'show_reminder' => false,
+ 'show_reminder' => true,
+ 'reminder_key' => $reminderKey,
+ 'reference_date' => $referenceDate,
];
} catch (\Exception $e) {
Yii::error('Error in ShiftReminderController::actionCheck: ' . $e->getMessage(), 'shift-reminder');
'success' => true,
'message' => 'Reminder confirmed successfully.',
'reference_date' => $referenceDate,
+ 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('already_confirmed'),
];
}
return $reminder->markAsConfirmed();
}
+ /**
+ * Вычислить Unix timestamp ближайшей границы периода смен минус 5 минут
+ *
+ * Границы периодов: 07:50, 08:10, 19:50, 20:10
+ * Возвращает timestamp за 5 минут до ближайшей будущей границы,
+ * чтобы фронтенд начал поллинг заблаговременно.
+ *
+ * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'
+ * @return int Unix timestamp
+ */
+ private static function getNextWindowBoundary($currentTime = null)
+ {
+ if ($currentTime === null) {
+ $currentTime = date('Y-m-d H:i:s');
+ }
+
+ $dateTime = new \DateTime($currentTime);
+ $currentTimeStr = $dateTime->format('H:i');
+ $today = $dateTime->format('Y-m-d');
+
+ // Границы периодов в хронологическом порядке
+ $boundaries = ['07:50', '08:10', '19:50', '20:10'];
+
+ // Находим ближайшую будущую границу
+ foreach ($boundaries as $boundary) {
+ if ($currentTimeStr < $boundary) {
+ $target = new \DateTime($today . ' ' . $boundary . ':00');
+ $target->modify('-5 minutes');
+ return $target->getTimestamp();
+ }
+ }
+
+ // Все границы на сегодня прошли → следующая 07:50 завтра
+ $tomorrow = (new \DateTime($today))->modify('+1 day')->format('Y-m-d');
+ $target = new \DateTime($tomorrow . ' 07:50:00');
+ $target->modify('-5 minutes');
+ return $target->getTimestamp();
+ }
+
+ /**
+ * Определить Unix timestamp следующей проверки для фронтенда
+ *
+ * На основании причины, по которой show_reminder = false, вычисляет
+ * оптимальный момент для следующего запроса /shift-reminder/check.
+ *
+ * @param string $reason Причина: 'not_admin', 'already_confirmed', 'no_action_needed'
+ * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'
+ * @return int Unix timestamp
+ */
+ public static function getNextCheckTimestamp($reason, $currentTime = null)
+ {
+ if ($currentTime === null) {
+ $currentTime = date('Y-m-d H:i:s');
+ }
+
+ $now = (new \DateTime($currentTime))->getTimestamp();
+ $minimum = $now + 30; // Кламп: не раньше чем через 30 секунд
+
+ switch ($reason) {
+ case 'not_admin':
+ // Статус админа не изменится в течение дня.
+ // Следующая проверка — после rollover reference_date (06:05 следующего дня).
+ $dateTime = new \DateTime($currentTime);
+ $hour = (int)$dateTime->format('H');
+ if ($hour < 6) {
+ // Сейчас 00:00-05:59, rollover будет сегодня в 06:05
+ $target = new \DateTime($dateTime->format('Y-m-d') . ' 06:05:00');
+ } else {
+ // Сейчас после 06:00, rollover будет завтра в 06:05
+ $tomorrow = (clone $dateTime)->modify('+1 day')->format('Y-m-d');
+ $target = new \DateTime($tomorrow . ' 06:05:00');
+ }
+ return max($target->getTimestamp(), $minimum);
+
+ case 'already_confirmed':
+ // Подтверждение действует до конца текущего периода.
+ // Проверить снова перед следующей границей.
+ return max(self::getNextWindowBoundary($currentTime), $minimum);
+
+ case 'no_action_needed':
+ // Смена уже открыта/закрыта, но коллега может изменить состояние.
+ // Перепроверить через 5 минут.
+ return max($now + 300, $minimum);
+
+ default:
+ // Safety net — стандартный интервал 30 секунд
+ return $minimum;
+ }
+ }
+
/**
* Получить список допустимых ключей напоминаний (whitelist)
*
);
}
}
+
+ // ========================================================================
+ // ГРУППА 6: getNextCheckTimestamp() - Серверное планирование следующей проверки
+ // ========================================================================
+
+ /**
+ * Тест: not_admin возвращает 06:05 следующего дня (после 06:00)
+ */
+ public function testGetNextCheckTimestamp_NotAdmin_AfterSixAm_ReturnsTomorrow()
+ {
+ // Arrange: 10:00 утра
+ $currentTime = '2026-02-04 10:00:00';
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('not_admin', $currentTime);
+
+ // Assert: должно быть 2026-02-05 06:05:00
+ $expected = (new \DateTime('2026-02-05 06:05:00'))->getTimestamp();
+ $this->assertEquals($expected, $result, 'not_admin после 06:00 → завтра 06:05');
+ }
+
+ /**
+ * Тест: not_admin возвращает 06:05 сегодня (до 06:00)
+ */
+ public function testGetNextCheckTimestamp_NotAdmin_BeforeSixAm_ReturnsToday()
+ {
+ // Arrange: 03:00 ночи
+ $currentTime = '2026-02-04 03:00:00';
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('not_admin', $currentTime);
+
+ // Assert: должно быть 2026-02-04 06:05:00
+ $expected = (new \DateTime('2026-02-04 06:05:00'))->getTimestamp();
+ $this->assertEquals($expected, $result, 'not_admin до 06:00 → сегодня 06:05');
+ }
+
+ /**
+ * Тест: no_action_needed возвращает now + 5 минут
+ */
+ public function testGetNextCheckTimestamp_NoActionNeeded_ReturnsFiveMinutes()
+ {
+ // Arrange
+ $currentTime = '2026-02-04 12:00:00';
+ $nowTimestamp = (new \DateTime($currentTime))->getTimestamp();
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('no_action_needed', $currentTime);
+
+ // Assert: должно быть now + 300 секунд
+ $this->assertEquals($nowTimestamp + 300, $result, 'no_action_needed → now + 5 минут');
+ }
+
+ /**
+ * Тест: already_confirmed перед первой границей → 07:45 сегодня
+ */
+ public function testGetNextCheckTimestamp_AlreadyConfirmed_BeforeFirstWindow_ReturnsSevenFortyFive()
+ {
+ // Arrange: 06:30 утра — до первой границы 07:50
+ $currentTime = '2026-02-04 06:30:00';
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('already_confirmed', $currentTime);
+
+ // Assert: ближайшая граница 07:50 минус 5 минут = 07:45
+ $expected = (new \DateTime('2026-02-04 07:45:00'))->getTimestamp();
+ $this->assertEquals($expected, $result, 'already_confirmed до 07:50 → 07:45');
+ }
+
+ /**
+ * Тест: already_confirmed между границами → следующая граница минус 5 минут
+ */
+ public function testGetNextCheckTimestamp_AlreadyConfirmed_BetweenWindows_ReturnsNextBoundary()
+ {
+ // Arrange: 10:00 — между 08:10 и 19:50
+ $currentTime = '2026-02-04 10:00:00';
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('already_confirmed', $currentTime);
+
+ // Assert: ближайшая граница 19:50 минус 5 минут = 19:45
+ $expected = (new \DateTime('2026-02-04 19:45:00'))->getTimestamp();
+ $this->assertEquals($expected, $result, 'already_confirmed между 08:10-19:50 → 19:45');
+ }
+
+ /**
+ * Тест: already_confirmed после последней границы → завтра 07:45
+ */
+ public function testGetNextCheckTimestamp_AlreadyConfirmed_AfterLastWindow_ReturnsTomorrow()
+ {
+ // Arrange: 21:00 — после последней границы 20:10
+ $currentTime = '2026-02-04 21:00:00';
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('already_confirmed', $currentTime);
+
+ // Assert: следующая граница — 07:50 завтра минус 5 минут = 07:45 завтра
+ $expected = (new \DateTime('2026-02-05 07:45:00'))->getTimestamp();
+ $this->assertEquals($expected, $result, 'already_confirmed после 20:10 → завтра 07:45');
+ }
+
+ /**
+ * Тест: неизвестная причина → fallback now + 30 секунд
+ */
+ public function testGetNextCheckTimestamp_UnknownReason_ReturnsFallback()
+ {
+ // Arrange
+ $currentTime = '2026-02-04 12:00:00';
+ $nowTimestamp = (new \DateTime($currentTime))->getTimestamp();
+
+ // Act
+ $result = ShiftReminderService::getNextCheckTimestamp('unknown_reason', $currentTime);
+
+ // Assert: fallback = now + 30
+ $this->assertEquals($nowTimestamp + 30, $result, 'Неизвестная причина → now + 30 сек');
+ }
}
/**
* Shift Reminder System
*
- * Polls backend every 30 seconds to check if reminder should be shown.
+ * Uses server-driven scheduling: backend returns next_check_at timestamp
+ * so frontend checks only when needed (not every 30 seconds).
* Displays fullscreen modal with audio alert when reminder is needed.
* Implements day rollover logic and multi-tab coordination.
*/
// Initialize Web Audio API (lazy loading to respect autoplay policies)
initAudioContext();
- // Start polling
- startPolling();
-
- // Check immediately on load
+ // First check on load — response will schedule the next one via next_check_at
checkReminder();
}
}
/**
- * Start polling for reminders
+ * Schedule the next reminder check based on server-provided next_check_at.
+ *
+ * @param {number|null} nextCheckAt Unix timestamp (seconds) from server response.
+ * If null/undefined, falls back to current interval (exponential backoff or default 30s).
*/
- function startPolling() {
+ function scheduleNextCheck(nextCheckAt) {
if (state.pollTimer) {
- clearInterval(state.pollTimer);
+ clearTimeout(state.pollTimer);
+ state.pollTimer = null;
}
- state.pollTimer = setInterval(() => {
+ var delayMs;
+ if (nextCheckAt) {
+ var nowSec = Math.floor(Date.now() / 1000);
+ delayMs = (nextCheckAt - nowSec) * 1000;
+ delayMs = Math.max(delayMs, 5000); // minimum 5 seconds
+ delayMs = Math.min(delayMs, 12 * 60 * 60 * 1000); // maximum 12 hours
+ } else {
+ delayMs = state.currentInterval; // fallback: 30s or backoff value
+ }
+
+ state.pollTimer = setTimeout(function() {
checkReminder();
- }, state.currentInterval);
+ }, delayMs);
}
/**
- * Stop polling
+ * Cancel any scheduled check
*/
- function stopPolling() {
+ function cancelScheduledCheck() {
if (state.pollTimer) {
- clearInterval(state.pollTimer);
+ clearTimeout(state.pollTimer);
state.pollTimer = null;
}
}
// Check if another tab already handled this reminder
if (isReminderHandledByOtherTab()) {
+ scheduleNextCheck(null);
return;
}
if (response.success && response.show_reminder) {
showReminderModal(response);
+ } else {
+ // Schedule next check using server-provided timestamp
+ scheduleNextCheck(response.next_check_at || null);
}
},
error: function(xhr, status, error) {
state.currentInterval * 2,
CONFIG.maxPollInterval
);
- stopPolling();
- startPolling();
+ scheduleNextCheck(null);
return;
}
}
// Ensure retryCount doesn't exceed array bounds (defensive programming)
- const safeRetryCount = Math.min(state.retryCount, CONFIG.retryIntervals.length - 1);
+ var safeRetryCount = Math.min(state.retryCount, CONFIG.retryIntervals.length - 1);
state.currentInterval = CONFIG.retryIntervals[safeRetryCount];
- stopPolling();
- startPolling();
+ scheduleNextCheck(null);
}
/**
timestamp: new Date().getTime()
});
- // Close modal
+ // Close modal and schedule next check using server timestamp
closeModal();
+ scheduleNextCheck(response.next_check_at || null);
} else {
alert('Ошибка подтверждения: ' + (response.message || 'Неизвестная ошибка'));
$('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить');
* Cleanup on page unload
*/
function cleanup() {
- stopPolling();
+ cancelScheduledCheck();
if (state.bcChannel) {
state.bcChannel.close();