fix/duty-week-rollover #1
|
|
@ -0,0 +1,27 @@
|
|||
# Copy to .env and fill in the required values. Never commit .env or the
|
||||
# service-account key file.
|
||||
|
||||
# --- Required ---
|
||||
# Telegram bot token from @BotFather.
|
||||
BOT_TOKEN=
|
||||
# Path to the Google service-account JSON key. The sheet must be shared with
|
||||
# the service account's client_email (Editor for assignment writes).
|
||||
GOOGLE_CREDENTIALS_FILE=
|
||||
# Spreadsheet ID from the Google Sheets URL.
|
||||
GOOGLE_SHEETS_ID=
|
||||
|
||||
# --- Optional (defaults shown) ---
|
||||
# Range to read; leave unset to follow the current year's tab automatically
|
||||
# (e.g. 2026!A1:G200), including the New Year rollover.
|
||||
#SHEET_RANGE=
|
||||
# Path of the local JSON rota cache.
|
||||
#ROTA_CACHE_PATH=rota.json
|
||||
# Background sync frequency (Go duration).
|
||||
#SYNC_INTERVAL=30m
|
||||
|
||||
# --- Reminders (disabled unless GROUP_CHAT_ID is set) ---
|
||||
# Telegram chat ID to post weekly reminders to.
|
||||
#GROUP_CHAT_ID=
|
||||
# Day and hour (0-23, Europe/Warsaw time) the weekly reminder fires.
|
||||
#REMINDER_WEEKDAY=Monday
|
||||
#REMINDER_HOUR=9
|
||||
|
|
@ -8,16 +8,24 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container: golang:1.26-alpine
|
||||
container: golang:1.26.4-alpine
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: apk add --no-cache git docker-cli
|
||||
# build-base provides the C toolchain the race detector needs (-race
|
||||
# implies CGO).
|
||||
run: apk add --no-cache git docker-cli build-base
|
||||
|
||||
- name: Checkout
|
||||
run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }}
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
run: go test -race ./...
|
||||
|
||||
- name: Run linter
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run
|
||||
|
||||
- name: Check for known vulnerabilities
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
|
||||
- name: Log in to container registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.kambr.pl -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ Backend read the schedule from a Google sheet, store it in local JSON file.
|
|||
|
||||
Requires `GOOGLE_CREDENTIALS_FILE` (path to a Google service-account key,
|
||||
shared as an editor on the sheet), `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the
|
||||
environment or a `.env` file (see `service.go`/`sheet.go` for optional
|
||||
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
|
||||
environment or a `.env` file — copy `.env.example` to `.env` as a starting
|
||||
point; it lists every variable (including the optional `SHEET_RANGE`,
|
||||
`ROTA_CACHE_PATH`, `SYNC_INTERVAL`, and the reminder settings) with defaults.
|
||||
Then:
|
||||
|
||||
```sh
|
||||
go run ./cmd/dyzur-bot
|
||||
|
|
@ -20,7 +22,7 @@ The service loads the cached rota, syncs from Google Sheets in the background on
|
|||
interrupted (Ctrl-C / SIGTERM).
|
||||
|
||||
When `SHEET_RANGE` is unset, the bot reads the current year's tab (e.g.
|
||||
`2026!A1:G100`), resolved on every sync — so it follows the New Year rollover
|
||||
`2026!A1:G200`), resolved on every sync — so it follows the New Year rollover
|
||||
automatically without a restart. A sync that fails, or that returns no duty
|
||||
weeks (e.g. a next-year tab that exists but hasn't been filled in yet), keeps
|
||||
the last-good cache instead of clearing it; if no sync succeeds for 6h the bot
|
||||
|
|
|
|||
45
service.go
45
service.go
|
|
@ -218,14 +218,27 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
|
|||
return func(ctx context.Context) (Rota, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
rows, err := ReadSheet(ctx, spreadsheetID, resolveRange(readRange, time.Now()))
|
||||
rng := resolveRange(readRange, time.Now())
|
||||
rows, err := ReadSheet(ctx, spreadsheetID, rng)
|
||||
if err != nil {
|
||||
return Rota{}, err
|
||||
}
|
||||
return ParseRota(rows), nil
|
||||
return stampTabs(ParseRota(rows), rng), nil
|
||||
}
|
||||
}
|
||||
|
||||
// stampTabs records on each week the tab its rows were fetched from, so an
|
||||
// assignment written later (possibly after the clock-resolved tab has rolled to
|
||||
// the next year while the cache still holds this fetch) targets the tab the
|
||||
// week's Row is valid on.
|
||||
func stampTabs(rota Rota, resolvedRange string) Rota {
|
||||
tab, _, _ := strings.Cut(resolvedRange, "!")
|
||||
for i := range rota.Weeks {
|
||||
rota.Weeks[i].Tab = tab
|
||||
}
|
||||
return rota
|
||||
}
|
||||
|
||||
// SheetAssigner returns an AssignFunc that writes a week's two names into its
|
||||
// B:C cells on the same tab the rota is read from. sheetRange mirrors the
|
||||
// fetcher's range so the tab (year or explicit) matches.
|
||||
|
|
@ -233,28 +246,38 @@ func SheetAssigner(spreadsheetID, sheetRange string) AssignFunc {
|
|||
return func(ctx context.Context, week DutyWeek, p1, p2 string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
rng := assignRange(sheetRange, time.Now(), week.Row)
|
||||
rng := assignRangeFor(week, sheetRange, time.Now())
|
||||
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
|
||||
}
|
||||
}
|
||||
|
||||
// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are
|
||||
// columns B and C). It reuses the tab from resolveRange so the year rollover and
|
||||
// any explicit range are honored identically to reads.
|
||||
func assignRange(sheetRange string, now time.Time, row int) string {
|
||||
tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!")
|
||||
return fmt.Sprintf("%s!B%d:C%d", tab, row, row)
|
||||
// assignRangeFor builds the A1 range for a week's assignment cells (Osoba 1/2
|
||||
// are columns B and C). An explicit sheetRange keeps its tab verbatim, matching
|
||||
// reads. Otherwise the week's fetch-time tab is preferred over clock resolution:
|
||||
// around New Year the cache can hold the old tab's rows (the empty-rota guard
|
||||
// keeps them while the next year's tab is unfilled) after resolveRange has moved
|
||||
// to the new tab, and writing an old-tab Row onto the new tab would corrupt it.
|
||||
// A week without a recorded tab (cache from an older version) falls back to the
|
||||
// clock, preserving prior behavior.
|
||||
func assignRangeFor(week DutyWeek, sheetRange string, now time.Time) string {
|
||||
tab := week.Tab
|
||||
if sheetRange != "" || tab == "" {
|
||||
tab, _, _ = strings.Cut(resolveRange(sheetRange, now), "!")
|
||||
}
|
||||
return fmt.Sprintf("%s!B%d:C%d", tab, week.Row, week.Row)
|
||||
}
|
||||
|
||||
// resolveRange returns the explicit range when set, otherwise the default that
|
||||
// targets the current year's tab (sheets are named per year, e.g. "2026").
|
||||
// Resolving against now (not a startup-captured value) is what lets the range
|
||||
// advance to the next year's tab automatically.
|
||||
// advance to the next year's tab automatically. The 200-row cap bounds the
|
||||
// fetch; rows past it are silently dropped, so it must stay comfortably above
|
||||
// one year of weekly rows (~53) plus roster entries.
|
||||
func resolveRange(explicit string, now time.Time) string {
|
||||
if explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
return fmt.Sprintf("%d!A1:G100", now.Year())
|
||||
return fmt.Sprintf("%d!A1:G200", now.Year())
|
||||
}
|
||||
|
||||
// Service ties the Store and Syncer together for the bot to consume.
|
||||
|
|
|
|||
|
|
@ -181,16 +181,43 @@ func TestSyncSuccessReplacesCache(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAssignRange(t *testing.T) {
|
||||
now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC)
|
||||
// Regression test for the year-boundary tab/row divergence: a week cached from
|
||||
// the old year's tab (e.g. kept by the empty-rota guard across New Year) must be
|
||||
// assigned on the tab it was fetched from, not the tab the wall clock resolves
|
||||
// to — its Row is only meaningful on the fetch tab.
|
||||
func TestAssignRangeForUsesFetchTab(t *testing.T) {
|
||||
jan2027 := time.Date(2027, 1, 4, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
// Empty range -> current-year tab; the assignment targets cols B:C of the row.
|
||||
if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want {
|
||||
t.Errorf("assignRange(\"\"): got %q, want %q", got, want)
|
||||
// Week fetched from the 2026 tab, assigned after the clock rolled to 2027.
|
||||
week := DutyWeek{Row: 54, Tab: "2026"}
|
||||
if got, want := assignRangeFor(week, "", jan2027), "2026!B54:C54"; got != want {
|
||||
t.Errorf("assignRangeFor(fetch tab): got %q, want %q", got, want)
|
||||
}
|
||||
// Explicit range -> its tab is reused verbatim.
|
||||
if got, want := assignRange("Dyżury!A1:C30", now, 7), "Dyżury!B7:C7"; got != want {
|
||||
t.Errorf("assignRange(explicit): got %q, want %q", got, want)
|
||||
|
||||
// A week without a recorded tab (cache written by an older version) falls
|
||||
// back to clock-resolved tab, matching the previous behavior.
|
||||
legacy := DutyWeek{Row: 54}
|
||||
if got, want := assignRangeFor(legacy, "", jan2027), "2027!B54:C54"; got != want {
|
||||
t.Errorf("assignRangeFor(legacy fallback): got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// An explicit range keeps its tab verbatim over the week's recorded tab.
|
||||
explicit := DutyWeek{Row: 7, Tab: "2026"}
|
||||
if got, want := assignRangeFor(explicit, "Dyżury!A1:C30", jan2027), "Dyżury!B7:C7"; got != want {
|
||||
t.Errorf("assignRangeFor(explicit): got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampTabs(t *testing.T) {
|
||||
rota := ParseRota(sampleRows())
|
||||
stamped := stampTabs(rota, "2026!A1:G200")
|
||||
if len(stamped.Weeks) == 0 {
|
||||
t.Fatal("sample rota has no weeks")
|
||||
}
|
||||
for _, w := range stamped.Weeks {
|
||||
if w.Tab != "2026" {
|
||||
t.Errorf("week %s: Tab = %q, want %q", w.WeekStart.Format("2006-01-02"), w.Tab, "2026")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,8 +225,10 @@ func TestResolveRange(t *testing.T) {
|
|||
now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Empty range resolves to the current year's tab, evaluated against now —
|
||||
// this is what advances the range after a New Year rollover.
|
||||
if got, want := resolveRange("", now), "2027!A1:G100"; got != want {
|
||||
// this is what advances the range after a New Year rollover. The row cap
|
||||
// (200) is a deliberate constant: rows past it are silently dropped by the
|
||||
// fetch, so keep it comfortably above one year of weekly rows.
|
||||
if got, want := resolveRange("", now), "2027!A1:G200"; got != want {
|
||||
t.Errorf("resolveRange(\"\"): got %q, want %q", got, want)
|
||||
}
|
||||
// An explicit range is honored verbatim regardless of the year.
|
||||
|
|
|
|||
5
sheet.go
5
sheet.go
|
|
@ -32,6 +32,11 @@ type DutyWeek struct {
|
|||
// cells when writing an assignment back. Valid only when the read range
|
||||
// starts at A1 (as resolveRange always produces); 0 means unknown.
|
||||
Row int
|
||||
// Tab is the sheet tab Row was fetched from (e.g. "2026"). Row is only
|
||||
// meaningful on this tab: around New Year the cache may still hold the old
|
||||
// year's rows while the clock-resolved tab has moved on, so an assignment
|
||||
// must target Tab, not the current year. "" means unknown (older cache).
|
||||
Tab string
|
||||
}
|
||||
|
||||
// Person is a single entry from the people list (columns E–G).
|
||||
|
|
|
|||
Loading…
Reference in New Issue