Adds the weekly Cron Trigger from #13: Friday 17:00 America/Chicago (Central), DST-correct. On the correct fire it lists all pending reports via #10's Manager.ListPending and hands their ids to the publish step.
Closes #13
The core problem: Cloudflare cron is UTC-only
17:00 Central is a different UTC hour depending on DST — 22:00 UTC during CDT (summer, UTC-5) and 23:00 UTC during CST (winter, UTC-6) — so no single UTC cron expresses it.
Design: two UTC crons + a gate.wrangler.jsonc registers both candidate Friday UTC hours:
"triggers":{"crons":["0 22 * * 5","0 23 * * 5"]}
and the scheduled handler gates each fire: only the fire that is actually 17:00 in America/Chicago does the work. Exactly one of the two matches on any given Friday, so publishing runs exactly once per week, correct across the DST boundary, with zero manual maintenance. The sibling fire is a logged no-op.
TinyGo/Wasm tzdata: hand-rolled DST, no tz database
TinyGo/Wasm may not embed the IANA tz database, so time.LoadLocation("America/Chicago") could fail at runtime. The gate therefore does not call LoadLocation. Instead internal/schedule computes the US Central rule from first principles behind a pure function:
funcIsFriday1700Central(ttime.Time)bool
CDT runs from the 2nd Sunday of March 02:00 to the 1st Sunday of November 02:00 (else CST), converted to absolute UTC boundaries (08:00 UTC spring-forward, 07:00 UTC fall-back). Being a pure func(time.Time) bool with no external data, it is exhaustively host-testable without TinyGo. The Wasm build embeds no tzdata; time/tzdata is imported only in the test file to cross-check the hand-rolled rule against the real zone.
Publishing is not implemented here. The handler calls a schedule.Publisher interface; the default schedule.LogPublisher logs the batch. #14 swaps in the real GitHub-issue publisher by replacing schedule.LogPublisher{} in the wasm adapter — no other change needed.
Files
internal/schedule/ — pure DST gate + Run orchestrator + Publisher/PendingLister seams + LogPublisher default. Build-tag-free, host-tested.
worker/scheduled_wasm.go — js && wasm-only adapter that registers the scheduled task via init() + cron.ScheduleTaskNonBlock and wires the R2-backed lifecycle.Manager to schedule.Run.
wrangler.jsonc — added triggers.crons only.
worker/main.go is untouched (scheduled task registers from a separate init()), to avoid conflicts with the parallel worker-wiring ticket (#11).
Tests / evidence
go vet ./... clean; go test ./... and GOOS=js GOARCH=wasm go build ./... both pass.
Timezone/gate coverage in internal/schedule/schedule_test.go:
A Friday in July (CDT) → 22:00 UTC true / 23:00 UTC false; a Friday in January (CST) → 23:00 UTC true / 22:00 UTC false.
The Fridays bracketing both DST transitions (spring-forward and fall-back).
Wrong hours, wrong minutes, non-Fridays (Thu/Sat at the exact Central hour).
TestGateFiresExactlyOncePerFriday: for every Friday 2024–2030, exactly one of the two cron hours passes — never zero, never both.
TestGateMatchesIANAAcrossDSTBoundary: 20-year, 30-minute sweep cross-checked against the real America/Chicago zone — zero mismatches (verifies DST correctness without waiting for a real transition).
Run with a mock lifecycle + mock publisher: publishes exactly the pending ids on a gate-open fire; touches neither on a gate-closed fire; propagates lister/publisher errors.
Note: TinyGo is not run here (not installed locally per the task); the end-to-end TinyGo/Wasm Worker build is exercised by CI (pnpm run build). The standard-compiler GOOS=js GOARCH=wasm build of the wasm adapter passes locally as a proxy.
## What & why
Adds the weekly Cron Trigger from #13: **Friday 17:00 America/Chicago (Central), DST-correct**. On the correct fire it lists all `pending` reports via #10's `Manager.ListPending` and hands their ids to the publish step.
`Closes #13`
## The core problem: Cloudflare cron is UTC-only
17:00 Central is a **different UTC hour depending on DST** — 22:00 UTC during CDT (summer, UTC-5) and 23:00 UTC during CST (winter, UTC-6) — so no single UTC cron expresses it.
**Design: two UTC crons + a gate.** `wrangler.jsonc` registers *both* candidate Friday UTC hours:
```jsonc
"triggers": { "crons": ["0 22 * * 5", "0 23 * * 5"] }
```
and the scheduled handler **gates** each fire: only the fire that is *actually* 17:00 in America/Chicago does the work. Exactly one of the two matches on any given Friday, so publishing runs **exactly once per week**, correct across the DST boundary, with zero manual maintenance. The sibling fire is a logged no-op.
## TinyGo/Wasm tzdata: hand-rolled DST, no tz database
TinyGo/Wasm may not embed the IANA tz database, so `time.LoadLocation("America/Chicago")` could fail at runtime. The gate therefore **does not call `LoadLocation`**. Instead `internal/schedule` computes the US Central rule from first principles behind a pure function:
```go
func IsFriday1700Central(t time.Time) bool
```
CDT runs from the 2nd Sunday of March 02:00 to the 1st Sunday of November 02:00 (else CST), converted to absolute UTC boundaries (08:00 UTC spring-forward, 07:00 UTC fall-back). Being a pure `func(time.Time) bool` with no external data, it is **exhaustively host-testable without TinyGo**. The Wasm build embeds **no** tzdata; `time/tzdata` is imported **only in the test file** to cross-check the hand-rolled rule against the real zone.
## Seam for #14 (publish)
Publishing is **not** implemented here. The handler calls a `schedule.Publisher` interface; the default `schedule.LogPublisher` logs the batch. #14 swaps in the real GitHub-issue publisher by replacing `schedule.LogPublisher{}` in the wasm adapter — no other change needed.
## Files
- `internal/schedule/` — pure DST gate + `Run` orchestrator + `Publisher`/`PendingLister` seams + `LogPublisher` default. Build-tag-free, host-tested.
- `worker/scheduled_wasm.go` — `js && wasm`-only adapter that registers the scheduled task via `init()` + `cron.ScheduleTaskNonBlock` and wires the R2-backed `lifecycle.Manager` to `schedule.Run`.
- `wrangler.jsonc` — added `triggers.crons` only.
**`worker/main.go` is untouched** (scheduled task registers from a separate `init()`), to avoid conflicts with the parallel worker-wiring ticket (#11).
## Tests / evidence
`go vet ./...` clean; `go test ./...` and `GOOS=js GOARCH=wasm go build ./...` both pass.
Timezone/gate coverage in `internal/schedule/schedule_test.go`:
- A Friday in **July (CDT)** → 22:00 UTC true / 23:00 UTC false; a Friday in **January (CST)** → 23:00 UTC true / 22:00 UTC false.
- The Fridays **bracketing both DST transitions** (spring-forward and fall-back).
- Wrong hours, wrong minutes, non-Fridays (Thu/Sat at the exact Central hour).
- `TestGateFiresExactlyOncePerFriday`: for every Friday 2024–2030, **exactly one** of the two cron hours passes — never zero, never both.
- `TestGateMatchesIANAAcrossDSTBoundary`: 20-year, 30-minute sweep cross-checked against the real `America/Chicago` zone — **zero** mismatches (verifies DST correctness without waiting for a real transition).
- `Run` with a mock lifecycle + mock publisher: publishes exactly the pending ids on a gate-open fire; touches neither on a gate-closed fire; propagates lister/publisher errors.
> Note: TinyGo is not run here (not installed locally per the task); the end-to-end TinyGo/Wasm Worker build is exercised by CI (`pnpm run build`). The standard-compiler `GOOS=js GOARCH=wasm` build of the wasm adapter passes locally as a proxy.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What & why
Adds the weekly Cron Trigger from #13: Friday 17:00 America/Chicago (Central), DST-correct. On the correct fire it lists all
pendingreports via #10'sManager.ListPendingand hands their ids to the publish step.Closes #13The core problem: Cloudflare cron is UTC-only
17:00 Central is a different UTC hour depending on DST — 22:00 UTC during CDT (summer, UTC-5) and 23:00 UTC during CST (winter, UTC-6) — so no single UTC cron expresses it.
Design: two UTC crons + a gate.
wrangler.jsoncregisters both candidate Friday UTC hours:and the scheduled handler gates each fire: only the fire that is actually 17:00 in America/Chicago does the work. Exactly one of the two matches on any given Friday, so publishing runs exactly once per week, correct across the DST boundary, with zero manual maintenance. The sibling fire is a logged no-op.
TinyGo/Wasm tzdata: hand-rolled DST, no tz database
TinyGo/Wasm may not embed the IANA tz database, so
time.LoadLocation("America/Chicago")could fail at runtime. The gate therefore does not callLoadLocation. Insteadinternal/schedulecomputes the US Central rule from first principles behind a pure function:CDT runs from the 2nd Sunday of March 02:00 to the 1st Sunday of November 02:00 (else CST), converted to absolute UTC boundaries (08:00 UTC spring-forward, 07:00 UTC fall-back). Being a pure
func(time.Time) boolwith no external data, it is exhaustively host-testable without TinyGo. The Wasm build embeds no tzdata;time/tzdatais imported only in the test file to cross-check the hand-rolled rule against the real zone.Seam for #14 (publish)
Publishing is not implemented here. The handler calls a
schedule.Publisherinterface; the defaultschedule.LogPublisherlogs the batch. #14 swaps in the real GitHub-issue publisher by replacingschedule.LogPublisher{}in the wasm adapter — no other change needed.Files
internal/schedule/— pure DST gate +Runorchestrator +Publisher/PendingListerseams +LogPublisherdefault. Build-tag-free, host-tested.worker/scheduled_wasm.go—js && wasm-only adapter that registers the scheduled task viainit()+cron.ScheduleTaskNonBlockand wires the R2-backedlifecycle.Managertoschedule.Run.wrangler.jsonc— addedtriggers.cronsonly.worker/main.gois untouched (scheduled task registers from a separateinit()), to avoid conflicts with the parallel worker-wiring ticket (#11).Tests / evidence
go vet ./...clean;go test ./...andGOOS=js GOARCH=wasm go build ./...both pass.Timezone/gate coverage in
internal/schedule/schedule_test.go:TestGateFiresExactlyOncePerFriday: for every Friday 2024–2030, exactly one of the two cron hours passes — never zero, never both.TestGateMatchesIANAAcrossDSTBoundary: 20-year, 30-minute sweep cross-checked against the realAmerica/Chicagozone — zero mismatches (verifies DST correctness without waiting for a real transition).Runwith a mock lifecycle + mock publisher: publishes exactly the pending ids on a gate-open fire; touches neither on a gate-closed fire; propagates lister/publisher errors.🤖 Generated with Claude Code