package dyzurbot import ( "context" "fmt" "os" "time" "google.golang.org/api/option" "google.golang.org/api/sheets/v4" ) // Column indices in the "Dyżury" sheet (see the header in row 0). const ( colWeek = 0 // "Tydzień zaczynający się od" — week start date (serial) colPerson1 = 1 // "Osoba 1" colPerson2 = 2 // "Osoba 2" colName = 4 // "Lista osób" colCount = 5 // "Liczba dyżurów" ) // sheetEpoch is the date corresponding to serial number 0 in Google Sheets. var sheetEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) // DutyWeek is a single week of the duty rota (columns A–C). type DutyWeek struct { WeekStart time.Time // Monday that starts the week Person1 string // Osoba 1 — "" if unassigned Person2 string // Osoba 2 — "" if unassigned } // Person is a single entry from the people list (columns E–F). type Person struct { Name string // Lista osób DutyCount int // Liczba dyżurów } // Rota is the parsed sheet contents: the week schedule and the people list. type Rota struct { Weeks []DutyWeek People []Person } // ReadSheet reads the given range from the sheet and returns its rows. // spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30". // The API key is read from GOOGLE_API_KEY (loaded into the environment by // LoadConfig, which calls godotenv.Load at startup). func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) { apiKey := os.Getenv("GOOGLE_API_KEY") if apiKey == "" { return nil, fmt.Errorf("missing GOOGLE_API_KEY in environment/.env") } srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey)) if err != nil { return nil, fmt.Errorf("init sheets service: %w", err) } resp, err := srv.Spreadsheets.Values. Get(spreadsheetID, readRange). ValueRenderOption("UNFORMATTED_VALUE"). // raw values, without cell formatting Context(ctx). Do() if err != nil { return nil, fmt.Errorf("get values %q: %w", readRange, err) } return resp.Values, nil } // 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 header row is skipped; malformed cells are ignored, not reported. func ParseRota(rows [][]interface{}) Rota { var rota Rota for i, row := range rows { if i == 0 { continue // header } // Schedule: a row belongs to the weeks table if column A holds a // date serial number. if serial, ok := cellFloat(row, colWeek); ok { rota.Weeks = append(rota.Weeks, DutyWeek{ WeekStart: serialToDate(serial), Person1: cellString(row, colPerson1), Person2: cellString(row, colPerson2), }) } // People list: add only non-empty entries (empty roster slots are // skipped). if name := cellString(row, colName); name != "" { count, _ := cellFloat(row, colCount) rota.People = append(rota.People, Person{ Name: name, DutyCount: int(count), }) } } return rota } // serialToDate converts a Google Sheets serial number to a date. func serialToDate(serial float64) time.Time { return sheetEpoch.AddDate(0, 0, int(serial)) } // cellString returns the cell value as text, accounting for trailing empty // cells that the API truncates. A missing cell yields "". func cellString(row []interface{}, i int) string { if i >= len(row) { return "" } if s, ok := row[i].(string); ok { return s } return "" } // cellFloat returns the numeric value of a cell. ok=false when the cell is // missing or not a number (e.g. an empty string). func cellFloat(row []interface{}, i int) (float64, bool) { if i >= len(row) { return 0, false } if f, ok := row[i].(float64); ok { return f, true } return 0, false }