From 15b2fc9093fa3127fb43a673d6b1f17c80b28fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=27Kama=C5=9B=27=20Bruchal?= Date: Fri, 19 Jun 2026 01:40:20 +0200 Subject: [PATCH] feat: ping on-duty members by Telegram handle in reminders Read each member's Telegram handle from the new sheet column F (duty count moves to G) and mention on-duty people as @handle in the weekly reminder, so they get a real notification. Names without a handle, or not found in the people list, fall back to plain text. - sheet.go: add colHandle, Person.Handle; parse handle column - scheduler.go: resolve names to @mentions via handlesByName/mention - service.go: widen default SHEET_RANGE to column G --- scheduler.go | 28 ++++++++++++++++++++++++-- scheduler_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++ service.go | 2 +- service_test.go | 2 +- sheet.go | 9 ++++++--- sheet_test.go | 37 +++++++++++++++++++++++++---------- 6 files changed, 111 insertions(+), 17 deletions(-) diff --git a/scheduler.go b/scheduler.go index 1943268..53c7a13 100644 --- a/scheduler.go +++ b/scheduler.go @@ -94,12 +94,14 @@ func reminderText(rota Rota, target time.Time) (string, bool) { return "", false } + handles := handlesByName(rota.People) + var people []string if week.Person1 != "" { - people = append(people, week.Person1) + people = append(people, mention(week.Person1, handles)) } if week.Person2 != "" { - people = append(people, week.Person2) + people = append(people, mention(week.Person2, handles)) } var body string @@ -116,6 +118,28 @@ func reminderText(rota Rota, target time.Time) (string, bool) { week.WeekStart.Format("2006-01-02") + " " + body + ".", true } +// handlesByName indexes the roster's Telegram handles by person name, so a +// duty week's names can be resolved to mentions. +func handlesByName(people []Person) map[string]string { + m := make(map[string]string, len(people)) + for _, p := range people { + m[strings.TrimSpace(p.Name)] = p.Handle + } + return m +} + +// mention renders a duty name as a Telegram mention (@handle) when a handle is +// known, falling back to the plain name otherwise. The handle is normalized to +// carry exactly one leading "@"; Telegram auto-links and notifies chat members +// whose public username matches. +func mention(name string, handles map[string]string) string { + h := strings.TrimSpace(handles[strings.TrimSpace(name)]) + if h == "" { + return name + } + return "@" + strings.TrimPrefix(h, "@") +} + // parseWeekday maps an English weekday name (case-insensitive) to time.Weekday. func parseWeekday(s string) (time.Weekday, error) { days := map[string]time.Weekday{ diff --git a/scheduler_test.go b/scheduler_test.go index 6215cac..56d8f50 100644 --- a/scheduler_test.go +++ b/scheduler_test.go @@ -130,6 +130,56 @@ func TestReminderText(t *testing.T) { } } +func TestReminderTextMentions(t *testing.T) { + target := day(t, "2026-06-30") + people := []Person{ + {Name: "Ala", Handle: "@ala"}, // stored with leading @ + {Name: "Bartek", Handle: "bart"}, // stored without @ -> normalized + {Name: "Cela", Handle: ""}, // present but no handle -> plain name + } + rota := func(p1, p2 string) Rota { + return Rota{Weeks: []DutyWeek{week(t, "2026-06-29", p1, p2)}, People: people} + } + + tests := []struct { + name string + rota Rota + want string + }{ + { + "both have handles", + rota("Ala", "Bartek"), + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart.", + }, + { + "one handle, one missing -> mixed", + rota("Ala", "Cela"), + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela.", + }, + { + "name not in people list -> plain name", + rota("Ala", "Zenon"), + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon.", + }, + { + "single person with handle", + rota("Bartek", ""), + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart.", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := reminderText(tc.rota, target) + if !ok { + t.Fatalf("ok = false, want true") + } + if got != tc.want { + t.Errorf("reminderText = %q, want %q", got, tc.want) + } + }) + } +} + func TestParseWeekday(t *testing.T) { tests := []struct { in string diff --git a/service.go b/service.go index eb3b7e9..e300d7d 100644 --- a/service.go +++ b/service.go @@ -32,7 +32,7 @@ func LoadConfig() (Config, error) { // Default range targets the current year's tab (sheets are named per year, // e.g. "2026"), matching the spreadsheet layout. - defaultRange := fmt.Sprintf("%d!A1:F100", time.Now().Year()) + defaultRange := fmt.Sprintf("%d!A1:G100", time.Now().Year()) cfg := Config{ SpreadsheetID: os.Getenv("GOOGLE_SHEETS_ID"), diff --git a/service_test.go b/service_test.go index 0443bbc..910aeba 100644 --- a/service_test.go +++ b/service_test.go @@ -22,7 +22,7 @@ func TestLoadConfigDefaults(t *testing.T) { if cfg.SpreadsheetID != "sheet-123" { t.Errorf("SpreadsheetID: got %q", cfg.SpreadsheetID) } - wantRange := fmt.Sprintf("%d!A1:F100", time.Now().Year()) + wantRange := fmt.Sprintf("%d!A1:G100", time.Now().Year()) if cfg.SheetRange != wantRange { t.Errorf("SheetRange default: got %q, want %q", cfg.SheetRange, wantRange) } diff --git a/sheet.go b/sheet.go index b7e701c..8b78bf0 100644 --- a/sheet.go +++ b/sheet.go @@ -16,7 +16,8 @@ const ( colPerson1 = 1 // "Osoba 1" colPerson2 = 2 // "Osoba 2" colName = 4 // "Lista osób" - colCount = 5 // "Liczba dyżurów" + colHandle = 5 // Telegram handle (e.g. "@ala") + colCount = 6 // "Liczba dyżurów" ) // sheetEpoch is the date corresponding to serial number 0 in Google Sheets. @@ -29,9 +30,10 @@ type DutyWeek struct { Person2 string // Osoba 2 — "" if unassigned } -// Person is a single entry from the people list (columns E–F). +// Person is a single entry from the people list (columns E–G). type Person struct { Name string // Lista osób + Handle string // Telegram handle, verbatim from the sheet ("" if none) DutyCount int // Liczba dyżurów } @@ -69,7 +71,7 @@ func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interf } // ParseRota interprets the raw rows from ReadSheet as two independent tables: -// the duty schedule (columns A–C) and the people list (columns E–F). +// the duty schedule (columns A–C) and the people list (columns E–G). // The header row is skipped; malformed cells are ignored, not reported. func ParseRota(rows [][]interface{}) Rota { var rota Rota @@ -95,6 +97,7 @@ func ParseRota(rows [][]interface{}) Rota { count, _ := cellFloat(row, colCount) rota.People = append(rota.People, Person{ Name: name, + Handle: cellString(row, colHandle), DutyCount: int(count), }) } diff --git a/sheet_test.go b/sheet_test.go index a1aa40d..4432dae 100644 --- a/sheet_test.go +++ b/sheet_test.go @@ -6,22 +6,23 @@ import ( ) // rows mirrors the real "Dyżury" sheet: cols A–C are the duty schedule, -// cols E–F are the person roster, with UNFORMATTED_VALUE rendering the -// week date and duty count as float64 and trailing empty cells dropped. +// cols E–G are the person roster (name, Telegram handle, duty count), with +// UNFORMATTED_VALUE rendering the week date and duty count as float64 and +// trailing empty cells dropped. func sampleRows() [][]interface{} { return [][]interface{}{ // row 0: header — must be skipped - {"Tydzień zaczynający się od", "Osoba 1", "Osoba 2", "", "Lista osób", "Liczba dyżurów"}, - // full week + roster entry - {float64(46027), "Jan Kowalski", "Anna Nowak", "", "Piotr Wiśniewski", float64(1)}, - // empty week (no one assigned) + roster entry - {float64(46034), "", "", "", "Maria Wójcik", float64(0)}, + {"Tydzień zaczynający się od", "Osoba 1", "Osoba 2", "", "Lista osób", "Telegram", "Liczba dyżurów"}, + // full week + roster entry (handle stored with leading @) + {float64(46027), "Jan Kowalski", "Anna Nowak", "", "Piotr Wiśniewski", "@piotr", float64(1)}, + // empty week (no one assigned) + roster entry (handle without @) + {float64(46034), "", "", "", "Maria Wójcik", "maria", float64(0)}, // one-person week, ragged row (no roster columns at all) {float64(46041), "Tomasz Zieliński"}, // week present, blank roster slot with a count -> person skipped - {float64(46048), "", "", "", "", float64(0)}, - // roster-only row (week column empty) -> person added, no week - {"", "", "", "", "Katarzyna Lewandowska", float64(3)}, + {float64(46048), "", "", "", "", "", float64(0)}, + // roster-only row (week column empty) -> person added, no handle + {"", "", "", "", "Katarzyna Lewandowska", "", float64(3)}, } } @@ -71,3 +72,19 @@ func TestParseRotaRosterSkipsBlankSlots(t *testing.T) { t.Errorf("person2: got %+v, want {Katarzyna Lewandowska 3}", p) } } + +func TestParseRotaReadsHandles(t *testing.T) { + rota := ParseRota(sampleRows()) + + // Handles are stored verbatim from column F; normalization happens at + // render time, not at parse time. + if p := rota.People[0]; p.Handle != "@piotr" { + t.Errorf("person0 handle: got %q, want %q", p.Handle, "@piotr") + } + if p := rota.People[1]; p.Handle != "maria" { + t.Errorf("person1 handle: got %q, want %q", p.Handle, "maria") + } + if p := rota.People[2]; p.Handle != "" { + t.Errorf("person2 handle: got %q, want empty", p.Handle) + } +}