Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ This repository is a Go service that syncs calendar status and exposes availabil
- Pebble key design includes:
- `status` for the current status record
- `event:{eventID}` for stored calendar events
- `channel:{channelID}` for push notification channels
- `sync:{calendarID}` for incremental sync tokens
- `availability` for the latest raw availability snapshot
- `availability_dirty` for tracking if availability changed since last deploy (stores pending JSON)
- `availability_last_deployed` for tracking the availability entries JSON from the last successful deploy
Expand Down
4 changes: 2 additions & 2 deletions chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ description: A Helm chart for Kubernetes

type: application

version: 0.2.6
appVersion: "v0.2.6"
version: 0.2.7
appVersion: "v0.2.7"
7 changes: 6 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
# AVAILABILITY_WORKING_HOURS_START
# AVAILABILITY_WORKING_HOURS_END
# AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS
# BUILD_IS_ENABLED
# BUILD_INTERVAL
# BUILD_CF_DEPLOY_HOOK
#
# Usage:
# podman compose up -d
Expand All @@ -29,10 +32,12 @@ services:
PEBBLE_PATH: ${PEBBLE_PATH:-/data}
CALENDAR_URL: ${CALENDAR_URL}
GITHUB_TOKEN: ${GITHUB_TOKEN}
GITHUB_USERNAME: ${GITHUB_USERNAME}
AVAILABILITY_IS_ENABLED: ${AVAILABILITY_IS_ENABLED}
AVAILABILITY_CALENDAR_URL: ${AVAILABILITY_CALENDAR_URL}
AVAILABILITY_API_KEY: ${AVAILABILITY_API_KEY}
AVAILABILITY_WORKING_HOURS_START: ${AVAILABILITY_WORKING_HOURS_START}
AVAILABILITY_WORKING_HOURS_END: ${AVAILABILITY_WORKING_HOURS_END}
AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS: ${AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS}
BUILD_IS_ENABLED: ${BUILD_IS_ENABLED}
BUILD_INTERVAL: ${BUILD_INTERVAL}
BUILD_CF_DEPLOY_HOOK: ${BUILD_CF_DEPLOY_HOOK}
8 changes: 8 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ availability:
- name: Evening
start: "17:30"
end: "22:00"

build:
# Disabled by default. Set to true to trigger Cloudflare Pages deploys when availability changes.
is_enabled: false
# Deploy checks are aligned to HH:01, HH:11, HH:21, ... when interval is 10m.
interval: 10m
# Cloudflare Pages build hook URL. Prefer BUILD_CF_DEPLOY_HOOK for secrets.
cf_deploy_hook: ""
172 changes: 81 additions & 91 deletions internal/availability/availability.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package availability

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"github.com/gldraphael/status/internal/calendar"
"github.com/gldraphael/status/internal/config"
"github.com/gldraphael/status/internal/store"
"github.com/gldraphael/status/internal/timeutil"
)

var (
Expand Down Expand Up @@ -46,30 +44,22 @@ type ComputeOptions struct {
Now time.Time
}

// ParseBlocks converts configured blocks into runtime blocks.
func ParseBlocks(blocks []config.AvailabilityBlockConfig) ([]Block, error) {
parsed := make([]Block, 0, len(blocks))
for i, block := range blocks {
start, err := parseClock(block.Start)
if err != nil {
return nil, fmt.Errorf("availability.blocks[%d].start: %w", i, err)
}
end, err := parseClock(block.End)
if err != nil {
return nil, fmt.Errorf("availability.blocks[%d].end: %w", i, err)
}
parsed = append(parsed, Block{
Name: block.Name,
Start: start,
End: end,
})
// ParseBlock converts a named clock range into a runtime availability block.
func ParseBlock(name, startValue, endValue string) (Block, error) {
start, err := timeutil.ParseClock(startValue)
if err != nil {
return Block{}, fmt.Errorf("start: %w", err)
}
end, err := timeutil.ParseClock(endValue)
if err != nil {
return Block{}, fmt.Errorf("end: %w", err)
}
return parsed, nil
return Block{Name: name, Start: start, End: end}, nil
}

// ParseWorkingHours converts the configured weekday working-hours window.
func ParseWorkingHours(startValue, endValue string) (WorkingHours, error) {
start, end, err := parseClockRange(startValue, endValue)
start, end, err := timeutil.ParseClockRange(startValue, endValue)
if err != nil {
return WorkingHours{}, err
}
Expand Down Expand Up @@ -97,7 +87,7 @@ func NewProvider(st *store.Store, blocks []Block, workingHours WorkingHours, exc
}

// GetEntries returns current availability entries.
func (p *Provider) GetEntries(ctx context.Context) ([]Entry, error) {
func (p *Provider) GetEntries() ([]Entry, error) {
snap, ok, err := p.store.GetAvailabilitySnapshot()
if err != nil {
return nil, fmt.Errorf("get availability snapshot: %w", err)
Expand Down Expand Up @@ -126,8 +116,8 @@ func (p *Provider) GetEntries(ctx context.Context) ([]Entry, error) {
}

// GetEntriesJSON returns current availability entries serialized as JSON.
func (p *Provider) GetEntriesJSON(ctx context.Context) ([]byte, error) {
entries, err := p.GetEntries(ctx)
func (p *Provider) GetEntriesJSON() ([]byte, error) {
entries, err := p.GetEntries()
if err != nil {
return nil, err
}
Expand All @@ -146,97 +136,101 @@ func Compute(body string, timezone string, blocks []Block, opts ComputeOptions)
opts.Now = time.Now()
}

loc := loadLocation(timezone)
loc := timeutil.LoadLocation(timezone)
nowLocal := opts.Now.In(loc)
dayStart := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc)
windowStart := dayStart
windowEnd := dayStart.AddDate(0, 0, 10)

parsed, err := calendar.ParseICalendar([]byte(body), windowStart, windowEnd)
parsed, err := calendar.ParseICalendar([]byte(body), dayStart, windowEnd)
if err != nil {
return nil, err
}

holidaySet := make(map[string]struct{}, len(opts.HolidayDates))
if opts.ExcludeEnglandBankHolidays {
for _, date := range opts.HolidayDates {
if date == "" {
continue
}
holidaySet[date] = struct{}{}
}
}
holidaySet := holidayDatesSet(opts.HolidayDates, opts.ExcludeEnglandBankHolidays)

entries := make([]Entry, 0, 10)
for dayOffset := 0; dayOffset < 10; dayOffset++ {
day := dayStart.AddDate(0, 0, dayOffset)
dayKey := day.Format("2006-01-02")
_, isHoliday := holidaySet[dayKey]
applyWorkingHours := day.Weekday() != time.Saturday && day.Weekday() != time.Sunday
if opts.ExcludeEnglandBankHolidays && isHoliday {
applyWorkingHours = false
}
workingStart := day.Add(opts.WorkingHours.Start)
workingEnd := day.Add(opts.WorkingHours.End)

for _, block := range blocks {
blockStart := day.Add(block.Start)
blockEnd := day.Add(block.End)

if dayOffset == 0 && blockStart.Before(nowLocal) {
continue
}
if applyWorkingHours && overlaps(blockStart, blockEnd, workingStart, workingEnd) {
continue
}
if !blockIsFree(parsed.Events, blockStart, blockEnd, loc) {
continue
}

block, ok := firstFreeBlock(
parsed.Events,
blocks,
day,
dayOffset == 0,
nowLocal,
opts.WorkingHours,
holidaySet,
opts.ExcludeEnglandBankHolidays,
loc,
)
if ok {
entries = append(entries, Entry{
DayOfWeek: day.Weekday().String(),
Block: block.Name,
Date: day.Format("2006-01-02"),
})
break
}
}

return entries, nil
}

func parseClockRange(startValue, endValue string) (time.Duration, time.Duration, error) {
start, err := parseClock(strings.TrimSpace(startValue))
if err != nil {
return 0, 0, err
}
end, err := parseClock(strings.TrimSpace(endValue))
if err != nil {
return 0, 0, err
func holidayDatesSet(dates []string, enabled bool) map[string]struct{} {
holidaySet := make(map[string]struct{}, len(dates))
if !enabled {
return holidaySet
}
if end <= start {
return 0, 0, fmt.Errorf("end must be after start")
for _, date := range dates {
if date == "" {
continue
}
holidaySet[date] = struct{}{}
}
return holidaySet
}

func firstFreeBlock(
events []calendar.ParsedEvent,
blocks []Block,
day time.Time,
isToday bool,
now time.Time,
workingHours WorkingHours,
holidaySet map[string]struct{},
excludeHolidays bool,
loc *time.Location,
) (Block, bool) {
applyWorkingHours := isWeekday(day) && !isExcludedHoliday(day, holidaySet, excludeHolidays)
workingStart := day.Add(workingHours.Start)
workingEnd := day.Add(workingHours.End)

for _, block := range blocks {
blockStart := day.Add(block.Start)
blockEnd := day.Add(block.End)

if isToday && blockStart.Before(now) {
continue
}
if applyWorkingHours && timeutil.Overlaps(blockStart, blockEnd, workingStart, workingEnd) {
continue
}
if !blockIsFree(events, blockStart, blockEnd, loc) {
continue
}
return block, true
}
return start, end, nil
return Block{}, false
}

func parseClock(value string) (time.Duration, error) {
t, err := time.Parse("15:04", value)
if err != nil {
return 0, err
}
return time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute, nil
func isWeekday(day time.Time) bool {
return day.Weekday() != time.Saturday && day.Weekday() != time.Sunday
}

func loadLocation(name string) *time.Location {
if name == "" {
return time.UTC
func isExcludedHoliday(day time.Time, holidaySet map[string]struct{}, enabled bool) bool {
if !enabled {
return false
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
_, ok := holidaySet[day.Format("2006-01-02")]
return ok
}

func blockIsFree(events []calendar.ParsedEvent, start, end time.Time, loc *time.Location) bool {
Expand All @@ -247,13 +241,9 @@ func blockIsFree(events []calendar.ParsedEvent, start, end time.Time, loc *time.

eventStart := event.StartTime.In(loc)
eventEnd := event.EndTime.In(loc)
if eventStart.Before(end) && eventEnd.After(start) {
if timeutil.Overlaps(eventStart, eventEnd, start, end) {
return false
}
}
return true
}

func overlaps(start, end, windowStart, windowEnd time.Time) bool {
return start.Before(windowEnd) && end.After(windowStart)
}
26 changes: 17 additions & 9 deletions internal/availability/availability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

"github.com/rs/zerolog"

"github.com/gldraphael/status/internal/config"
"github.com/gldraphael/status/internal/store"
)

Expand All @@ -26,13 +25,22 @@ func newTestStore(t *testing.T) *store.Store {

func testBlocks(t *testing.T) []Block {
t.Helper()
blocks, err := ParseBlocks([]config.AvailabilityBlockConfig{
{Name: "Morning", Start: "09:00", End: "12:00"},
{Name: "Afternoon", Start: "12:00", End: "16:30"},
{Name: "Evening", Start: "17:30", End: "22:00"},
})
if err != nil {
t.Fatalf("ParseBlocks: %v", err)
specs := []struct {
name string
start string
end string
}{
{name: "Morning", start: "09:00", end: "12:00"},
{name: "Afternoon", start: "12:00", end: "16:30"},
{name: "Evening", start: "17:30", end: "22:00"},
}
blocks := make([]Block, 0, len(specs))
for _, spec := range specs {
block, err := ParseBlock(spec.name, spec.start, spec.end)
if err != nil {
t.Fatalf("ParseBlock(%q): %v", spec.name, err)
}
blocks = append(blocks, block)
}
return blocks
}
Expand Down Expand Up @@ -317,7 +325,7 @@ func TestProvider_GetEntriesJSON(t *testing.T) {

p := NewProvider(st, blocks, testWorkingHours(t), false)

data, err := p.GetEntriesJSON(context.Background())
data, err := p.GetEntriesJSON()
if err != nil {
t.Fatalf("GetEntriesJSON: %v", err)
}
Expand Down
30 changes: 0 additions & 30 deletions internal/availability/client.go

This file was deleted.

2 changes: 1 addition & 1 deletion internal/availability/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

entries, err := h.provider.GetEntries(r.Context())
entries, err := h.provider.GetEntries()
if err != nil {
if errors.Is(err, ErrSnapshotNotFound) {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
Expand Down
Loading