feat: warn 2 weeks early and auto-assign 1 week early for empty duty weeks

This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-07-13 17:45:41 +02:00
parent 43b4b85de4
commit 66364c044a
2 changed files with 137 additions and 62 deletions

View File

@ -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

View File

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