#include "Scheduler.h" #include "ScheduleValidator.h" #include namespace timbre_core { void Scheduler::reset() noexcept { hasLastKey_ = false; lastKey_ = LocalMinuteKey{}; } bool Scheduler::isHoliday(const AppState& state, std::uint8_t month, std::uint8_t day) noexcept { for (std::size_t i = 0; i < state.holidays.size(); ++i) { const Holiday& holiday = state.holidays[i]; if (holiday.enabled && holiday.month == month && holiday.day == day) { return true; } } return false; } bool Scheduler::scheduleMatches(const Schedule& schedule, const AppState& state, const CivilDateTime& localTime) noexcept { if (!schedule.enabled || schedule.minuteOfDay != localTime.minuteOfDay()) { return false; } const Profile* activeProfile = state.profile(state.activeProfileId); if (activeProfile == nullptr || !activeProfile->enabled || schedule.profileId != state.activeProfileId) { return false; } const std::uint8_t weekday = CivilTime::weekdayForDate(localTime.year, localTime.month, localTime.day); if ((schedule.weekdayMask & static_cast(1u << weekday)) == 0) { return false; } if (isHoliday(state, localTime.month, localTime.day)) { return false; } const Pattern* pattern = state.pattern(schedule.patternId); return pattern != nullptr && isPatternId(schedule.patternId) && ScheduleValidator::validatePattern(*pattern).valid; } ScheduleDecision Scheduler::poll(const AppState& state, const CivilDateTime& localTime, bool firstObservation) { ScheduleDecision decision; decision.firstObservation = firstObservation; if (!localTime.valid()) { decision.invalidTime = true; return decision; } const LocalMinuteKey currentKey = LocalMinuteKey::from(localTime); const std::int64_t currentSerial = currentKey.serial(); if (currentSerial == std::numeric_limits::min()) { decision.invalidTime = true; return decision; } if (hasLastKey_) { const std::int64_t previousSerial = lastKey_.serial(); if (currentSerial <= previousSerial) { decision.alreadyProcessed = true; return decision; } const std::int64_t distance = currentSerial - previousSerial; decision.missedMinutes = distance > 1 ? static_cast(distance - 1) : 0; } // Consume the current key before evaluating matches. Therefore a failed // lookup (holiday, disabled profile, etc.) cannot be replayed later. lastKey_ = currentKey; hasLastKey_ = true; if (firstObservation) { return decision; } if (isHoliday(state, localTime.month, localTime.day)) { decision.blockedByHoliday = true; return decision; } const Schedule* selected = nullptr; for (std::size_t i = 0; i < state.schedules.size(); ++i) { const Schedule& candidate = state.schedules[i]; if (!scheduleMatches(candidate, state, localTime)) { continue; } // Stable IDs, rather than array positions, define deterministic // precedence when two schedules intentionally share a minute. if (selected == nullptr || candidate.id < selected->id) { selected = &candidate; } } if (selected != nullptr) { decision.due = true; decision.scheduleId = selected->id; decision.patternId = selected->patternId; } return decision; } ScheduleDecision Scheduler::poll(const AppState& state, SessionClock& clock, std::int64_t utcEpochSeconds) { const SessionClock::Observation observation = clock.observe(utcEpochSeconds); if (!observation.valid) { ScheduleDecision decision; decision.invalidTime = true; return decision; } return poll(state, observation.local, observation.firstObservation); } } // namespace timbre_core