package dyzurbot import ( "context" "fmt" "log/slog" "strings" "time" ) // ReminderConfig is the resolved schedule for the weekly duty reminder. type ReminderConfig struct { ChatID int64 // Telegram chat to post to Weekday time.Weekday // day of week to fire Hour int // hour of day (0–23) to fire AheadDays int // announce the duty this many days from the fire time Loc *time.Location // timezone the schedule is expressed in } // SendFunc sends a reminder message. It abstracts the Telegram send so Reminder // can be tested without a live bot (see BotSendFunc for the real wiring). type SendFunc func(ctx context.Context, text string) error // Reminder posts a duty reminder to a chat on a weekly schedule. type Reminder struct { store *Store send SendFunc cfg ReminderConfig now func() time.Time } // NewReminder wires a reminder to the rota cache and a send function. func NewReminder(store *Store, send SendFunc, cfg ReminderConfig) *Reminder { return &Reminder{store: store, send: send, cfg: cfg, now: time.Now} } // Run fires the reminder on schedule until ctx is canceled. Each iteration // recomputes the next fire time, sleeps until then, and on fire looks up the // duty AheadDays out and posts it (skipping weeks with no data). Send failures // are logged and the loop continues. func (r *Reminder) Run(ctx context.Context) { for { now := r.now() next := nextFire(now, r.cfg.Weekday, r.cfg.Hour, r.cfg.Loc) timer := time.NewTimer(next.Sub(now)) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: r.fire(ctx) } } } // fire performs one reminder: snapshot the cache, format, and send. func (r *Reminder) fire(ctx context.Context) { rota, _ := r.store.Snapshot() // Compute the target in the schedule's zone so the calendar date matches // the wall-clock fire day (consistent with nextFire); reading it in // server-local time could land on the prior day on a UTC host. target := r.now().In(r.cfg.Loc).AddDate(0, 0, r.cfg.AheadDays) text, ok := reminderText(rota, target) if !ok { slog.Info("reminder: no duty week, skipping", "target", target.Format("2006-01-02")) return } if err := r.send(ctx, text); err != nil { slog.Error("reminder send failed", "err", err) } } // nextFire returns the next instant strictly after now that falls on weekday at // hour:00:00 in loc. It is recomputed from now on every scheduler iteration, so // the wall-clock hour stays correct across DST transitions (the result is // resolved in loc rather than derived by +7d UTC arithmetic) and a fire missed // while the process was down is skipped rather than replayed. func nextFire(now time.Time, weekday time.Weekday, hour int, loc *time.Location) time.Time { n := now.In(loc) daysAhead := (int(weekday) - int(n.Weekday()) + 7) % 7 candidate := time.Date(n.Year(), n.Month(), n.Day(), hour, 0, 0, 0, loc). AddDate(0, 0, daysAhead) if !candidate.After(now) { candidate = candidate.AddDate(0, 0, 7) } return candidate } // reminderText renders the Polish reminder for the duty week containing target. // ok is false when no week matches, signaling the caller to skip sending. func reminderText(rota Rota, target time.Time) (string, bool) { week, ok := currentDuty(rota, target) if !ok { return "", false } var people []string if week.Person1 != "" { people = append(people, week.Person1) } if week.Person2 != "" { people = append(people, week.Person2) } var body string switch len(people) { case 0: body = "dyżur jest nieobsadzony" case 1: body = "dyżur sprząta " + people[0] default: body = "dyżur sprzątają " + strings.Join(people, " i ") } return "Przypomnienie: w tygodniu od " + week.WeekStart.Format("2006-01-02") + " " + body + ".", true } // parseWeekday maps an English weekday name (case-insensitive) to time.Weekday. func parseWeekday(s string) (time.Weekday, error) { days := map[string]time.Weekday{ "sunday": time.Sunday, "monday": time.Monday, "tuesday": time.Tuesday, "wednesday": time.Wednesday, "thursday": time.Thursday, "friday": time.Friday, "saturday": time.Saturday, } if w, ok := days[strings.ToLower(s)]; ok { return w, nil } return 0, fmt.Errorf("invalid weekday %q", s) }