fix: assign duty weeks on their fetch-time tab across year rollover

Around New Year the empty-rota guard keeps the old year's cached rows
while resolveRange has already moved to the new year's tab, so an
auto-assignment could write an old-tab row index onto the new tab.
Stamp each DutyWeek with the tab it was fetched from and prefer it when
building the assignment range (clock fallback for pre-upgrade caches).

Also: widen the default fetch range to A1:G200 so a full year of rows
plus roster can't be silently truncated, add -race/golangci-lint/
govulncheck gates to CI (container pinned to 1.26.4-alpine), and add
.env.example documenting every configuration variable.
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-08-19 16:49:11 +02:00
parent 488f0b9a9b
commit e17aefb9e3
6 changed files with 121 additions and 27 deletions

27
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 EG).