Files
woke/scheduler/scheduler.go
2026-02-01 21:15:17 +02:00

129 lines
2.6 KiB
Go

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)
}