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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: golang:1.26-alpine
|
container: golang:1.26.4-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Install tools
|
- 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
|
- name: Checkout
|
||||||
run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }}
|
run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }}
|
||||||
|
|
||||||
- name: Run tests
|
- 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
|
- name: Log in to container registry
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.kambr.pl -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
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,
|
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
|
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
|
environment or a `.env` file — copy `.env.example` to `.env` as a starting
|
||||||
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
|
point; it lists every variable (including the optional `SHEET_RANGE`,
|
||||||
|
`ROTA_CACHE_PATH`, `SYNC_INTERVAL`, and the reminder settings) with defaults.
|
||||||
|
Then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/dyzur-bot
|
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).
|
interrupted (Ctrl-C / SIGTERM).
|
||||||
|
|
||||||
When `SHEET_RANGE` is unset, the bot reads the current year's tab (e.g.
|
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
|
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
|
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
|
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) {
|
return func(ctx context.Context) (Rota, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
return Rota{}, err
|
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
|
// 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
|
// 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.
|
// 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 {
|
return func(ctx context.Context, week DutyWeek, p1, p2 string) error {
|
||||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rng := assignRange(sheetRange, time.Now(), week.Row)
|
rng := assignRangeFor(week, sheetRange, time.Now())
|
||||||
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
|
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are
|
// assignRangeFor builds the A1 range for a week's assignment cells (Osoba 1/2
|
||||||
// columns B and C). It reuses the tab from resolveRange so the year rollover and
|
// are columns B and C). An explicit sheetRange keeps its tab verbatim, matching
|
||||||
// any explicit range are honored identically to reads.
|
// reads. Otherwise the week's fetch-time tab is preferred over clock resolution:
|
||||||
func assignRange(sheetRange string, now time.Time, row int) string {
|
// around New Year the cache can hold the old tab's rows (the empty-rota guard
|
||||||
tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!")
|
// keeps them while the next year's tab is unfilled) after resolveRange has moved
|
||||||
return fmt.Sprintf("%s!B%d:C%d", tab, row, row)
|
// 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
|
// 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").
|
// 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
|
// 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 {
|
func resolveRange(explicit string, now time.Time) string {
|
||||||
if explicit != "" {
|
if explicit != "" {
|
||||||
return 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.
|
// 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) {
|
// Regression test for the year-boundary tab/row divergence: a week cached from
|
||||||
now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC)
|
// 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.
|
// Week fetched from the 2026 tab, assigned after the clock rolled to 2027.
|
||||||
if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want {
|
week := DutyWeek{Row: 54, Tab: "2026"}
|
||||||
t.Errorf("assignRange(\"\"): got %q, want %q", got, want)
|
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 {
|
// A week without a recorded tab (cache written by an older version) falls
|
||||||
t.Errorf("assignRange(explicit): got %q, want %q", got, want)
|
// 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)
|
now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
// Empty range resolves to the current year's tab, evaluated against now —
|
// Empty range resolves to the current year's tab, evaluated against now —
|
||||||
// this is what advances the range after a New Year rollover.
|
// this is what advances the range after a New Year rollover. The row cap
|
||||||
if got, want := resolveRange("", now), "2027!A1:G100"; got != want {
|
// (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)
|
t.Errorf("resolveRange(\"\"): got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
// An explicit range is honored verbatim regardless of the year.
|
// 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
|
// cells when writing an assignment back. Valid only when the read range
|
||||||
// starts at A1 (as resolveRange always produces); 0 means unknown.
|
// starts at A1 (as resolveRange always produces); 0 means unknown.
|
||||||
Row int
|
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).
|
// Person is a single entry from the people list (columns E–G).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue