feat: warn 2 weeks early and auto-assign 1 week early for empty duty weeks
This commit is contained in:
parent
43b4b85de4
commit
66364c044a
71
scheduler.go
71
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
|
// fire performs one weekly run across three horizons: it reminds about the
|
||||||
// duty, or auto-assigning when nobody volunteered) and warns when next week is
|
// current week, auto-assigns the week one week out when nobody volunteered, and
|
||||||
// still unassigned. Dates are computed in the schedule's zone so the calendar
|
// warns two weeks out while there is still time to volunteer. Dates are computed
|
||||||
// day matches the wall-clock fire day (consistent with nextFire); reading them
|
// in the schedule's zone so the calendar day matches the wall-clock fire day
|
||||||
// in server-local time could land on the prior day on a UTC host.
|
// (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) {
|
func (r *Reminder) fire(ctx context.Context) {
|
||||||
rota, _ := r.store.Snapshot()
|
rota, _ := r.store.Snapshot()
|
||||||
now := r.now().In(r.cfg.Loc)
|
now := r.now().In(r.cfg.Loc)
|
||||||
handles := handlesByName(rota.People)
|
handles := handlesByName(rota.People)
|
||||||
|
|
||||||
r.handleThisWeek(ctx, rota, now, handles)
|
r.handleThisWeek(ctx, rota, now)
|
||||||
r.handleNextWeek(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
|
// handleThisWeek sends the reminder for the current duty week to whoever is
|
||||||
// are empty.
|
// assigned. It never auto-assigns: assignment happens a week earlier (see
|
||||||
func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time, handles map[string]string) {
|
// handleWeekAhead), so a still-empty current week just renders reminderText's
|
||||||
week, ok := currentDuty(rota, now)
|
// "nieobsadzony" branch rather than being filled at the last moment.
|
||||||
if !ok {
|
func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time) {
|
||||||
slog.Info("reminder: no week row for this week, skipping", "date", now.Format("2006-01-02"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if week.Person1 != "" || week.Person2 != "" {
|
|
||||||
if text, ok := reminderText(rota, now); ok {
|
if text, ok := reminderText(rota, now); ok {
|
||||||
r.sendLogged(ctx, text)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both slots empty: assign the two people with the fewest duties.
|
|
||||||
p1, p2, ok := chooseTwo(rota.People, r.rng)
|
p1, p2, ok := chooseTwo(rota.People, r.rng)
|
||||||
if !ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
if week.Row == 0 {
|
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"))
|
"week", week.WeekStart.Format("2006-01-02"))
|
||||||
return
|
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))
|
r.sendLogged(ctx, assignedText(week, p1, p2, handles))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNextWeek warns when next week is still completely unassigned.
|
// handleTwoWeeksAhead warns when the week two weeks out is still completely
|
||||||
func (r *Reminder) handleNextWeek(ctx context.Context, rota Rota, now time.Time) {
|
// unassigned, giving people a week to volunteer before it is auto-assigned (see
|
||||||
week, ok := currentDuty(rota, now.AddDate(0, 0, 7))
|
// 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 != "" {
|
if !ok || week.Person1 != "" || week.Person2 != "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.sendLogged(ctx, nextWeekWarningText(week))
|
r.sendLogged(ctx, weekWarningText(week))
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendLogged sends text, logging (not propagating) any failure so one bad send
|
// 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).
|
// 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 {
|
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 " +
|
return "Nikt nie zgłosił się na dyżur w tygodniu od " +
|
||||||
week.WeekStart.Format("2006-01-02") + "), więc został przydzielony losowo: " +
|
week.WeekStart.Format("2006-01-02") + ", więc został przydzielony losowo: " +
|
||||||
mention(p1, handles) + " i " + mention(p2, handles) +
|
mention(p1, handles) + " i " + mention(p2, handles) +
|
||||||
". Z góry dziękujemy za sprzątanie!"
|
". Z góry dziękujemy za sprzątanie!"
|
||||||
}
|
}
|
||||||
|
|
||||||
// nextWeekWarningText asks people to volunteer for an as-yet-unassigned next
|
// weekWarningText asks people to volunteer for an as-yet-unassigned week before
|
||||||
// week before it is filled automatically.
|
// it is filled automatically. The week is identified by its start date.
|
||||||
func nextWeekWarningText(week DutyWeek) string {
|
func weekWarningText(week DutyWeek) string {
|
||||||
return "Dyżur na przyszły tydzień (od " +
|
return "Dyżur w tygodniu od " +
|
||||||
week.WeekStart.Format("2006-01-02") +
|
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
|
// handlesByName indexes the roster's Telegram handles by person name, so a
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,40 @@ func TestFire(t *testing.T) {
|
||||||
{Name: "Bartek", DutyCount: 2},
|
{Name: "Bartek", DutyCount: 2},
|
||||||
{Name: "Cela", DutyCount: 3},
|
{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{
|
rota := Rota{
|
||||||
Weeks: []DutyWeek{
|
Weeks: []DutyWeek{
|
||||||
{WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty
|
{WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty
|
||||||
{WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}, // next: filled
|
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,
|
People: people,
|
||||||
}
|
}
|
||||||
|
|
@ -70,20 +98,21 @@ func TestFire(t *testing.T) {
|
||||||
t.Fatalf("assigns = %d, want 1", len(h.assigns))
|
t.Fatalf("assigns = %d, want 1", len(h.assigns))
|
||||||
}
|
}
|
||||||
a := h.assigns[0]
|
a := h.assigns[0]
|
||||||
if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 5 {
|
if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 6 {
|
||||||
t.Errorf("assign = %+v, want Ala/Bartek on row 5", a)
|
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!"
|
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) != 1 || h.sends[0] != want {
|
if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantAnnounce {
|
||||||
t.Errorf("sends = %q, want [%q]", h.sends, want)
|
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{
|
rota := Rota{
|
||||||
Weeks: []DutyWeek{
|
Weeks: []DutyWeek{
|
||||||
{WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5},
|
thisWeekFilled,
|
||||||
{WeekStart: day(t, "2026-06-22"), Row: 6}, // next: empty
|
filled("2026-06-22", 6), // +1: filled
|
||||||
|
{WeekStart: day(t, "2026-06-29"), Row: 7}, // +2: empty
|
||||||
},
|
},
|
||||||
People: people,
|
People: people,
|
||||||
}
|
}
|
||||||
|
|
@ -91,44 +120,61 @@ func TestFire(t *testing.T) {
|
||||||
r.fire(context.Background())
|
r.fire(context.Background())
|
||||||
|
|
||||||
if len(h.assigns) != 0 {
|
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 w tygodniu od 2026-06-29 jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
|
||||||
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] != wantThisReminder || h.sends[1] != wantWarning {
|
||||||
if len(h.sends) != 2 || h.sends[0] != wantReminder || h.sends[1] != wantWarning {
|
t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantWarning)
|
||||||
t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantReminder, 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{
|
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}},
|
People: []Person{{Name: "Ala", DutyCount: 1}},
|
||||||
}
|
}
|
||||||
r, h := newFireHarness(t, rota, mon, 1)
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
r.fire(context.Background())
|
r.fire(context.Background())
|
||||||
|
|
||||||
if len(h.assigns) != 0 || len(h.sends) != 0 {
|
if len(h.assigns) != 0 {
|
||||||
t.Errorf("assigns=%d sends=%q, want 0 and none", len(h.assigns), h.sends)
|
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{
|
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,
|
People: people,
|
||||||
}
|
}
|
||||||
r, h := newFireHarness(t, rota, mon, 1)
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
r.fire(context.Background())
|
r.fire(context.Background())
|
||||||
|
|
||||||
if len(h.assigns) != 0 || len(h.sends) != 0 {
|
if len(h.assigns) != 0 {
|
||||||
t.Errorf("assigns=%d sends=%q, want 0 and none (unwritable row)", len(h.assigns), h.sends)
|
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{
|
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,
|
People: people,
|
||||||
}
|
}
|
||||||
r, h := newFireHarness(t, rota, mon, 1)
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
|
|
@ -138,8 +184,28 @@ func TestFire(t *testing.T) {
|
||||||
if len(h.assigns) != 1 {
|
if len(h.assigns) != 1 {
|
||||||
t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns))
|
t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns))
|
||||||
}
|
}
|
||||||
if len(h.sends) != 0 {
|
if len(h.sends) != 1 || h.sends[0] != wantThisReminder {
|
||||||
t.Errorf("sends = %q, want none (write failed)", h.sends)
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue