diff --git a/scheduler.go b/scheduler.go index d458fa6..b122caf 100644 --- a/scheduler.go +++ b/scheduler.go @@ -70,44 +70,49 @@ func (r *Reminder) Run(ctx context.Context) { } } -// fire performs one weekly run: it finalizes this week (announcing who is on -// duty, or auto-assigning when nobody volunteered) and warns when next week is -// still unassigned. Dates are computed in the schedule's zone so the calendar -// day matches the wall-clock fire day (consistent with nextFire); reading them -// in server-local time could land on the prior day on a UTC host. +// fire performs one weekly run across three horizons: it reminds about the +// current week, auto-assigns the week one week out when nobody volunteered, and +// warns two weeks out while there is still time to volunteer. Dates are computed +// in the schedule's zone so the calendar day matches the wall-clock fire day +// (consistent with nextFire); reading them in server-local time could land on +// the prior day on a UTC host. func (r *Reminder) fire(ctx context.Context) { rota, _ := r.store.Snapshot() now := r.now().In(r.cfg.Loc) handles := handlesByName(rota.People) - r.handleThisWeek(ctx, rota, now, handles) - r.handleNextWeek(ctx, rota, now) + r.handleThisWeek(ctx, rota, now) + r.handleWeekAhead(ctx, rota, now, handles) + r.handleTwoWeeksAhead(ctx, rota, now) } -// handleThisWeek announces the current duty, or auto-assigns it when both slots -// are empty. -func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time, handles map[string]string) { - week, ok := currentDuty(rota, now) - if !ok { - slog.Info("reminder: no week row for this week, skipping", "date", now.Format("2006-01-02")) +// handleThisWeek sends the reminder for the current duty week to whoever is +// assigned. It never auto-assigns: assignment happens a week earlier (see +// handleWeekAhead), so a still-empty current week just renders reminderText's +// "nieobsadzony" branch rather than being filled at the last moment. +func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time) { + if text, ok := reminderText(rota, now); ok { + r.sendLogged(ctx, text) + } +} + +// handleWeekAhead auto-assigns the week one week out when it is still completely +// unassigned: it picks the two people with the fewest duties, writes them into +// the sheet, and announces the assignment. A week with either slot filled is +// left alone. +func (r *Reminder) handleWeekAhead(ctx context.Context, rota Rota, now time.Time, handles map[string]string) { + week, ok := currentDuty(rota, now.AddDate(0, 0, 7)) + if !ok || week.Person1 != "" || week.Person2 != "" { return } - if week.Person1 != "" || week.Person2 != "" { - if text, ok := reminderText(rota, now); ok { - r.sendLogged(ctx, text) - } - return - } - - // Both slots empty: assign the two people with the fewest duties. p1, p2, ok := chooseTwo(rota.People, r.rng) if !ok { - slog.Info("reminder: this week empty but fewer than 2 people, skipping assignment") + slog.Info("reminder: week ahead empty but fewer than 2 people, skipping assignment") return } if week.Row == 0 { - slog.Warn("reminder: this week empty but sheet row unknown, cannot assign", + slog.Warn("reminder: week ahead empty but sheet row unknown, cannot assign", "week", week.WeekStart.Format("2006-01-02")) return } @@ -118,13 +123,15 @@ func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time, r.sendLogged(ctx, assignedText(week, p1, p2, handles)) } -// handleNextWeek warns when next week is still completely unassigned. -func (r *Reminder) handleNextWeek(ctx context.Context, rota Rota, now time.Time) { - week, ok := currentDuty(rota, now.AddDate(0, 0, 7)) +// handleTwoWeeksAhead warns when the week two weeks out is still completely +// unassigned, giving people a week to volunteer before it is auto-assigned (see +// handleWeekAhead). +func (r *Reminder) handleTwoWeeksAhead(ctx context.Context, rota Rota, now time.Time) { + week, ok := currentDuty(rota, now.AddDate(0, 0, 14)) if !ok || week.Person1 != "" || week.Person2 != "" { return } - r.sendLogged(ctx, nextWeekWarningText(week)) + r.sendLogged(ctx, weekWarningText(week)) } // sendLogged sends text, logging (not propagating) any failure so one bad send @@ -188,19 +195,21 @@ func reminderText(rota Rota, target time.Time) (string, bool) { } // assignedText announces an auto-assigned duty week (both people just chosen). +// The week is stated by its start date, not "this week", because assignment now +// happens a week before the duty week begins. func assignedText(week DutyWeek, p1, p2 string, handles map[string]string) string { - return "Nikt nie zgłosił się na dyżur w tym tygodniu (od " + - week.WeekStart.Format("2006-01-02") + "), więc został przydzielony losowo: " + + return "Nikt nie zgłosił się na dyżur w tygodniu od " + + week.WeekStart.Format("2006-01-02") + ", więc został przydzielony losowo: " + mention(p1, handles) + " i " + mention(p2, handles) + ". Z góry dziękujemy za sprzątanie!" } -// nextWeekWarningText asks people to volunteer for an as-yet-unassigned next -// week before it is filled automatically. -func nextWeekWarningText(week DutyWeek) string { - return "Dyżur na przyszły tydzień (od " + +// weekWarningText asks people to volunteer for an as-yet-unassigned week before +// it is filled automatically. The week is identified by its start date. +func weekWarningText(week DutyWeek) string { + return "Dyżur w tygodniu od " + week.WeekStart.Format("2006-01-02") + - ") jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo." + " jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo." } // handlesByName indexes the roster's Telegram handles by person name, so a diff --git a/scheduler_test.go b/scheduler_test.go index 2886bab..0fc2322 100644 --- a/scheduler_test.go +++ b/scheduler_test.go @@ -54,12 +54,40 @@ func TestFire(t *testing.T) { {Name: "Bartek", DutyCount: 2}, {Name: "Cela", DutyCount: 3}, } + // filled is a fully-assigned week reused wherever a horizon must be left alone. + filled := func(start string, row int) DutyWeek { + return DutyWeek{WeekStart: day(t, start), Person1: "X", Person2: "Y", Row: row} + } + thisWeekFilled := DutyWeek{WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5} + wantThisReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur sprzątają Jan i Anna. Proszę pamiętać o sprzątaniu." - t.Run("this week empty -> assign two lowest and announce", func(t *testing.T) { + t.Run("this week empty -> reminder only, never assigns", func(t *testing.T) { rota := Rota{ Weeks: []DutyWeek{ - {WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty - {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}, // next: filled + {WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty + filled("2026-06-22", 6), // +1: filled + filled("2026-06-29", 7), // +2: filled + }, + People: people, + } + r, h := newFireHarness(t, rota, mon, 1) + r.fire(context.Background()) + + if len(h.assigns) != 0 { + t.Errorf("assigns = %d, want 0 (this week is never auto-assigned)", len(h.assigns)) + } + want := "Przypomnienie: w tygodniu od 2026-06-15 dyżur jest nieobsadzony." + if len(h.sends) != 1 || h.sends[0] != want { + t.Errorf("sends = %q, want [%q]", h.sends, want) + } + }) + + t.Run("week ahead empty -> assign two lowest, back-fill row 6, announce", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{ + thisWeekFilled, + {WeekStart: day(t, "2026-06-22"), Row: 6}, // +1: empty + filled("2026-06-29", 7), // +2: filled }, People: people, } @@ -70,20 +98,21 @@ func TestFire(t *testing.T) { t.Fatalf("assigns = %d, want 1", len(h.assigns)) } a := h.assigns[0] - if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 5 { - t.Errorf("assign = %+v, want Ala/Bartek on row 5", a) + if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 6 { + t.Errorf("assign = %+v, want Ala/Bartek on row 6", a) } - want := "Nikt nie zgłosił się na dyżur w tym tygodniu (od 2026-06-15), więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!" - if len(h.sends) != 1 || h.sends[0] != want { - t.Errorf("sends = %q, want [%q]", h.sends, want) + wantAnnounce := "Nikt nie zgłosił się na dyżur w tygodniu od 2026-06-22, więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!" + if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantAnnounce { + t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantAnnounce) } }) - t.Run("this week filled, next week empty -> reminder + warning", func(t *testing.T) { + t.Run("two weeks ahead empty -> first warning", func(t *testing.T) { rota := Rota{ Weeks: []DutyWeek{ - {WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5}, - {WeekStart: day(t, "2026-06-22"), Row: 6}, // next: empty + thisWeekFilled, + filled("2026-06-22", 6), // +1: filled + {WeekStart: day(t, "2026-06-29"), Row: 7}, // +2: empty }, People: people, } @@ -91,44 +120,61 @@ func TestFire(t *testing.T) { r.fire(context.Background()) if len(h.assigns) != 0 { - t.Errorf("assigns = %d, want 0 (this week already filled)", len(h.assigns)) + t.Errorf("assigns = %d, want 0 (warning only, no assign at +2)", len(h.assigns)) } - wantReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur sprzątają Jan i Anna. Proszę pamiętać o sprzątaniu." - wantWarning := "Dyżur na przyszły tydzień (od 2026-06-22) jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo." - if len(h.sends) != 2 || h.sends[0] != wantReminder || h.sends[1] != wantWarning { - t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantReminder, wantWarning) + wantWarning := "Dyżur w tygodniu od 2026-06-29 jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo." + if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantWarning { + t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantWarning) } }) - t.Run("this week empty but fewer than 2 people -> no assign, no send", func(t *testing.T) { + t.Run("week ahead empty but fewer than 2 people -> no assign", func(t *testing.T) { rota := Rota{ - Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}}, + Weeks: []DutyWeek{ + thisWeekFilled, + {WeekStart: day(t, "2026-06-22"), Row: 6}, + filled("2026-06-29", 7), + }, People: []Person{{Name: "Ala", DutyCount: 1}}, } r, h := newFireHarness(t, rota, mon, 1) r.fire(context.Background()) - if len(h.assigns) != 0 || len(h.sends) != 0 { - t.Errorf("assigns=%d sends=%q, want 0 and none", len(h.assigns), h.sends) + if len(h.assigns) != 0 { + t.Errorf("assigns = %d, want 0", len(h.assigns)) + } + if len(h.sends) != 1 || h.sends[0] != wantThisReminder { + t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder) } }) - t.Run("this week empty but row unknown -> no assign, no send", func(t *testing.T) { + t.Run("week ahead empty but row unknown -> no assign", func(t *testing.T) { rota := Rota{ - Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 0}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}}, + Weeks: []DutyWeek{ + thisWeekFilled, + {WeekStart: day(t, "2026-06-22"), Row: 0}, // unwritable row + filled("2026-06-29", 7), + }, People: people, } r, h := newFireHarness(t, rota, mon, 1) r.fire(context.Background()) - if len(h.assigns) != 0 || len(h.sends) != 0 { - t.Errorf("assigns=%d sends=%q, want 0 and none (unwritable row)", len(h.assigns), h.sends) + if len(h.assigns) != 0 { + t.Errorf("assigns = %d, want 0 (unwritable row)", len(h.assigns)) + } + if len(h.sends) != 1 || h.sends[0] != wantThisReminder { + t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder) } }) - t.Run("assign write fails -> no announcement", func(t *testing.T) { + t.Run("assign write fails -> attempted, no announcement", func(t *testing.T) { rota := Rota{ - Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}}, + Weeks: []DutyWeek{ + thisWeekFilled, + {WeekStart: day(t, "2026-06-22"), Row: 6}, + filled("2026-06-29", 7), + }, People: people, } r, h := newFireHarness(t, rota, mon, 1) @@ -138,8 +184,28 @@ func TestFire(t *testing.T) { if len(h.assigns) != 1 { t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns)) } - if len(h.sends) != 0 { - t.Errorf("sends = %q, want none (write failed)", h.sends) + if len(h.sends) != 1 || h.sends[0] != wantThisReminder { + t.Errorf("sends = %q, want [%q] (no announce on write failure)", h.sends, wantThisReminder) + } + }) + + t.Run("partially-filled week ahead -> left alone", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{ + thisWeekFilled, + {WeekStart: day(t, "2026-06-22"), Person1: "Ola", Row: 6}, // +1: one slot filled + filled("2026-06-29", 7), + }, + People: people, + } + r, h := newFireHarness(t, rota, mon, 1) + r.fire(context.Background()) + + if len(h.assigns) != 0 { + t.Errorf("assigns = %d, want 0 (partial week is not empty)", len(h.assigns)) + } + if len(h.sends) != 1 || h.sends[0] != wantThisReminder { + t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder) } }) }