package scheduler import ( "sync" "time" "woke/db" "github.com/robfig/cron/v3" ) // AlarmEvent is sent when an alarm triggers. type AlarmEvent struct { Alarm db.Alarm } // Scheduler checks alarms every second and fires events when they trigger. type Scheduler struct { store *db.Store events chan AlarmEvent stop chan struct{} mu sync.Mutex parser cron.Parser } func New(store *db.Store) *Scheduler { return &Scheduler{ store: store, events: make(chan AlarmEvent, 10), stop: make(chan struct{}), parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow), } } // Events returns the channel that receives alarm trigger events. func (s *Scheduler) Events() <-chan AlarmEvent { return s.events } // Start begins the alarm checking loop. func (s *Scheduler) Start() { go s.loop() } // Stop halts the scheduler. func (s *Scheduler) Stop() { close(s.stop) } func (s *Scheduler) loop() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() // Track which alarms already fired this minute to avoid duplicates firedThisMinute := make(map[int]string) // alarm ID -> "HH:MM" when last fired for { select { case <-s.stop: return case now := <-ticker.C: s.check(now, firedThisMinute) } } } func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) { alarms, err := s.store.ListAlarms() if err != nil { return } currentMinute := now.Format("15:04") // Clean up old entries from firedThisMinute for id, minute := range firedThisMinute { if minute != currentMinute { delete(firedThisMinute, id) } } for _, a := range alarms { if !a.Enabled { continue } // Already fired this minute if firedThisMinute[a.ID] == currentMinute { continue } if s.shouldTrigger(a, now) { firedThisMinute[a.ID] = currentMinute _ = s.store.MarkTriggered(a.ID) // Disable one-shot alarms after triggering if a.Trigger == "once" { _ = s.store.ToggleAlarm(a.ID) } select { case s.events <- AlarmEvent{Alarm: a}: default: // Don't block if channel is full } } } } func (s *Scheduler) shouldTrigger(a db.Alarm, now time.Time) bool { if a.Trigger == "once" { // Simple time match: HH:MM return now.Format("15:04") == a.Time } // Cron expression sched, err := s.parser.Parse(a.Trigger) if err != nil { return false } // Check if now falls within the current minute of the cron schedule. // We truncate to the minute and check if the next time after (now - 1min) is now. truncated := now.Truncate(time.Minute) prev := truncated.Add(-time.Minute) next := sched.Next(prev) return next.Equal(truncated) }