add two-week availability calendar
11 files changed, 489 insertions(+), 10 deletions(-)
M go.mod → go.mod
@@ -13,6 +13,7 @@ github.com/andybalholm/brotli v1.1.1 github.com/antchfx/xmlquery v1.4.4 github.com/antchfx/xpath v1.3.3 github.com/ardanlabs/conf/v3 v3.4.0 + github.com/arran4/golang-ical v0.3.2 github.com/benpate/digit v0.13.4 github.com/crewjam/csp v0.0.2 github.com/deckarep/golang-set/v2 v2.7.0
M go.sum → go.sum
@@ -39,6 +39,8 @@ github.com/ardanlabs/conf/v3 v3.4.0 h1:Qy7/doJjhsv7Lvzqd9tbvH8fAZ9jzqKtwnwcmZ+sxGs= github.com/ardanlabs/conf/v3 v3.4.0/go.mod h1:OIi6NK95fj8jKFPdZ/UmcPlY37JBg99hdP9o5XmNK9c= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/arran4/golang-ical v0.3.2 h1:MGNjcXJFSuCXmYX/RpZhR2HDCYoFuK8vTPFLEdFC3JY= +github.com/arran4/golang-ical v0.3.2/go.mod h1:xblDGxxIUMWwFZk9dlECUlc1iXNV65LJZOTHLVwu8bo= github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
M gomod2nix.toml → gomod2nix.toml
@@ -49,6 +49,9 @@ hash = "sha256-Ent9bgBTjKS8/61LKrIu/JcBI/Qsv6EEIojwsMjCgdY=" [mod."github.com/ardanlabs/conf/v3"] version = "v3.4.0" hash = "sha256-FwIPi48V6Vmt3E41CZ2PO2m3Rg59PAM41k1RGVkpOWo=" + [mod."github.com/arran4/golang-ical"] + version = "v0.3.2" + hash = "sha256-afFuM8b+llveV5vlwx4rb4nAd10Us5L4nSgaC95eIN8=" [mod."github.com/aws/aws-sdk-go-v2"] version = "v1.36.0" hash = "sha256-j1XPvTErqlSE8iJzi3nZPeDV8oOt+UWhRuK7TyJwIK8="
A internal/cache/cache.go
@@ -0,0 +1,37 @@ +package cache + +import ( + "os" + "path/filepath" + + "go.alanpearce.eu/homestead/internal/file" +) + +var home string +var Root *os.Root + +func init() { + var err error + home, err = os.UserCacheDir() + if err != nil { + panic("could not determine user cache directory: " + err.Error()) + } + + dir := filepath.Join(home, "homestead") + + if !file.Exists(dir) { + err = os.MkdirAll(dir, 0o750) + if err != nil { + panic("could not create cache sub-directory: " + err.Error()) + } + } + + Root, err = os.OpenRoot(dir) + if err != nil { + panic("could not open cache sub-directory: " + err.Error()) + } +} + +func JoinPath(path string) string { + return filepath.Join(Root.Name(), path) +}
A internal/calendar/calendar.go
@@ -0,0 +1,267 @@ +package calendar + +import ( + "context" + "io" + "io/fs" + "net/http" + "os" + "slices" + "time" + + ical "github.com/arran4/golang-ical" + "gitlab.com/tozd/go/errors" + "go.alanpearce.eu/x/log" + + "go.alanpearce.eu/homestead/internal/cache" + "go.alanpearce.eu/homestead/internal/config" +) + +const Filename = "calendar.ics" +const Refresh = 30 * time.Minute + +type Options struct { + URL config.URL +} + +type Calendar struct { + opts *Options + log *log.Logger + client *http.Client + + *ical.Calendar +} + +type Event struct { + ical.VEvent + StartTime Date + EndTime Date +} + +type Date struct { + time.Time +} + +type CalendarDate struct { + Date + Events []*Event +} + +func New(opts *Options, logger *log.Logger) *Calendar { + if opts.URL.Scheme == "webcal" { + opts.URL.Scheme = "https" + } + + return &Calendar{ + opts: opts, + log: logger, + client: &http.Client{ + Timeout: time.Second * 10, + }, + } +} + +func (c *Calendar) FetchIfNeeded(ctx context.Context) error { + stat, err := cache.Root.Stat(Filename) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return errors.WithMessage(err, "could not stat calendar file") + } + + if stat == nil || time.Since(stat.ModTime()) > Refresh { + err := c.fetch(ctx) + if err != nil { + return err + } + } + + f, err := c.open() + if err != nil { + return err + } + defer f.Close() + + c.Calendar, err = ical.ParseCalendar(f) + if err != nil { + c.log.Warn("error parsing calendar", "error", err) + + return errors.WithMessage(err, "could not parse calendar") + } + + return err +} + +func (c *Calendar) open() (*os.File, errors.E) { + f, err := cache.Root.Open(Filename) + if err != nil { + return nil, errors.WithMessage(err, "could not open calendar file") + } + + return f, nil +} + +func (c *Calendar) fetch(ctx context.Context) errors.E { + c.log.Debug("fetching calendar", "url", c.opts.URL.String()) + + f, err := cache.Root.OpenFile(Filename, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return errors.WithMessage(err, "could not create temp file") + } + defer f.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.opts.URL.String(), nil) + if err != nil { + return errors.WithMessage(err, "could not create request") + } + + res, err := c.client.Do(req) + if err != nil { + return errors.WithMessage(err, "could not fetch calendar") + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return errors.New("unexpected status code") + } + + if _, err := io.Copy(f, res.Body); err != nil { + return errors.WithMessage(err, "could not write calendar to file") + } + + err = f.Sync() + if err != nil { + return errors.WithMessage(err, "could not sync file") + } + + return nil +} + +func (c *Calendar) EventsBetween(from time.Time, to time.Time) ([]*Event, error) { + if c.Calendar == nil { + return nil, errors.New("calendar not initialised") + } + + evs := c.Events() + c.log.Debug("processing events", "count", len(evs)) + events := make([]*Event, 0, len(evs)) + + for _, ev := range evs { + st, err := ev.GetStartAt() + if err != nil { + c.log.Warn("could not get start time", "event", ev.Id(), "error", err) + + continue + } + + et, err := ev.GetEndAt() + if err != nil { + c.log.Warn("could not get end time", "event", ev.Id(), "error", err) + + continue + } + + if st.Before(from) && et.Before(from) || + st.After(to) && et.After(to) { + continue + } + + events = append(events, &Event{ + VEvent: *ev, + StartTime: Date{Time: st}, + EndTime: Date{Time: et}, + }) + } + + slices.SortFunc(events, func(a, b *Event) int { + return a.StartTime.Compare(b.StartTime.Time) + }) + + return events, nil +} + +func (c *Calendar) Weeks(count int) []Date { + now := time.Now() + weekday := int(now.Weekday()-time.Monday) % 7 + start := time.Date( + now.Year(), + now.Month(), + now.Day()-(weekday), + 0, + 0, + 0, + 0, + now.Location(), + ) + days := count*7 + weekday + 1 + dates := make([]Date, 0, days) + + for i := start; i.Before(start.AddDate(0, 0, days)); i = i.AddDate(0, 0, 1) { + dates = append(dates, Date{Time: i}) + } + + return dates +} + +func (c *Calendar) Availability(weeks int) ([]*CalendarDate, error) { + cds := make([]*CalendarDate, 0, weeks*7) + dates := c.Weeks(weeks) + + for _, date := range dates { + c.log.Debug("processing date", "date", date.DateOnly()) + + cd := &CalendarDate{Date: date} + + evs, err := c.EventsBetween(date.Time, date.NextDay().Time) + if err != nil { + return nil, errors.WithMessage(err, "could not get events") + } + + for _, ev := range evs { + c.log.Debug("processing event", "id", ev.Id()) + + cd.Events = append(cd.Events, ev) + } + + cds = append(cds, cd) + } + + return cds, nil +} + +func (d Date) Between(lower, upper Date) bool { + return d.After(lower.Time) && d.Before(upper.Time) +} + +func (d Date) Key() int { + return d.Year()*10000 + + int(d.Month())*100 + + d.Day() +} + +func (d Date) BeginningOfDay() Date { + return Date{ + Time: time.Date( + d.Year(), + d.Month(), + d.Day(), + 0, + 0, + 0, + 0, + d.Location(), + ), + } +} + +func (d Date) DateOnly() string { + return d.Format(time.DateOnly) +} + +func (d Date) NextDay() Date { + return Date{ + Time: d.AddDate(0, 0, 1), + } +} + +func (d Date) IsToday() bool { + return d.Key() == Date{time.Now()}.Key() +}
M internal/config/config.go → internal/config/config.go
@@ -4,6 +4,7 @@ import ( "io/fs" "net/url" "path/filepath" + "time" "go.alanpearce.eu/x/log"@@ -40,21 +41,36 @@ return errors.WithMessagef(err, "could not parse URL %s", string(text)) } +type Timezone struct { + *time.Location +} + +func (t *Timezone) UnmarshalText(text []byte) (err error) { + t.Location, err = time.LoadLocation(string(text)) + + return errors.WithMessagef(err, "could not parse timezone %s", string(text)) +} + type Config struct { - Language string `toml:"language"` + Title string + Email string + Description string + BaseURL URL `toml:"base_url"` - Title string - Email string - Description string + OriginalDomain string `toml:"original_domain"` DomainStartDate string `toml:"domain_start_date"` - OriginalDomain string `toml:"original_domain"` GoatCounter URL `toml:"goatcounter"` - Domains []string - WildcardDomain string `toml:"wildcard_domain"` OIDCHost URL `toml:"oidc_host"` - Taxonomies []Taxonomy - Menu []MenuItem - RelMe []MenuItem `toml:"rel_me"` + + Domains []string + WildcardDomain string `toml:"wildcard_domain"` + + Language string + Timezone Timezone + + Taxonomies []Taxonomy + Menu []MenuItem + RelMe []MenuItem `toml:"rel_me"` } func GetConfig(dir string, log *log.Logger) (*Config, errors.E) {
M internal/website/mux.go → internal/website/mux.go
@@ -86,6 +86,24 @@ return nil } +func (website *Website) Calendar(w http.ResponseWriter, r *http.Request) *ihttp.Error { + website.counter.Count(r, "Calendar") + err := templates.CalendarPage(*website.siteSettings, templates.PageSettings{ + Title: "Calendar", + TitleAttrs: templates.Attrs{}, + BodyAttrs: templates.Attrs{}, + }, *website.calendar).Render(w) + if err != nil { + return &ihttp.Error{ + Code: http.StatusInternalServerError, + Message: "", + Cause: err, + } + } + + return nil +} + func (website *Website) MakeRedirectorApp() *server.App { mux := http.NewServeMux()
M internal/website/website.go → internal/website/website.go
@@ -1,12 +1,15 @@ package website import ( + "context" "os" "path/filepath" "slices" "sync" + "time" "gitlab.com/tozd/go/errors" + "go.alanpearce.eu/homestead/internal/calendar" "go.alanpearce.eu/homestead/internal/config" "go.alanpearce.eu/homestead/internal/events" "go.alanpearce.eu/homestead/internal/fetcher"@@ -32,6 +35,7 @@ Redirect bool `conf:"default:true"` Development bool `conf:"default:false,flag:dev"` FetchURL config.URL `conf:"default:https://ci.alanpearce.eu/archive/website/"` BaseURL config.URL + CalendarURL config.URL GoatcounterToken string Redis *events.RedisOptions@@ -44,6 +48,7 @@ siteSettings *templates.SiteSettings counter stats.Counter log *log.Logger reader storage.Reader + calendar *calendar.Calendar me digit.Resource acctResource string CSP *csp.Header@@ -113,6 +118,15 @@ CSPHeader.ScriptSrc = slices.Insert(CSPHeader.ScriptSrc, 0, "'unsafe-inline'") CSPHeader.ConnectSrc = slices.Insert(CSPHeader.ConnectSrc, 0, "'self'") } + website.calendar = calendar.New(&calendar.Options{ + URL: opts.CalendarURL, + }, log.Named("calendar")) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := website.calendar.FetchIfNeeded(ctx); err != nil { + log.Error("could not fetch calendar", "error", err) + } + cancel() + firstUpdate := make(chan bool) go func() { updated := sync.OnceFunc(func() {@@ -126,9 +140,11 @@ if err != nil { log.Panic("could not load configuration", "error", err) } website.config = cfg + website.siteSettings = &templates.SiteSettings{ Title: cfg.Title, Language: cfg.Language, + Timezone: cfg.Timezone, Menu: cfg.Menu, InjectLiveReload: opts.Development, }@@ -178,6 +194,7 @@ mux := ihttp.NewServeMux() mux.HandleError(website.ErrorHandler) mux.Handle("/", website) + mux.HandleFunc("/calendar", website.Calendar) mux.HandleFunc("/.well-known/webfinger", website.webfinger) const oidcPath = "/.well-known/openid-configuration" mux.ServeMux.Handle(oidcPath, ihttp.RedirectHandler(cfg.OIDCHost.JoinPath(oidcPath), 302))
A templates/calendar.go
@@ -0,0 +1,68 @@ +package templates + +import ( + "time" + + g "go.alanpearce.eu/gomponents" + c "go.alanpearce.eu/gomponents/components" + . "go.alanpearce.eu/gomponents/html" + + "go.alanpearce.eu/homestead/internal/calendar" +) + +func CalendarPage( + site SiteSettings, + page PageSettings, + cal calendar.Calendar, +) g.Node { + return Layout(site, page, Calendar(site, cal)) +} + +func Calendar(site SiteSettings, cal calendar.Calendar) g.Node { + past := true + dates, err := cal.Availability(2) + if err != nil { + panic(err) + } + + return Div(Class("calendar"), + H2(g.Text("Calendar")), + P(g.Text("Here you can roughly see when I'm busy in the next two weeks")), + Section(Class("calendar-grid"), + g.Map(dates, func(date *calendar.CalendarDate) g.Node { + if past && date.IsToday() { + past = false + } + + return Section(c.Classes{ + "day": true, + "past": past, + "today": date.IsToday(), + }, + H3( + Time( + DateTime(date.UTC().Format(time.DateOnly)), + g.Text(date.Format("Mon _2"))), + ), + g.Map(date.Events, func(e *calendar.Event) g.Node { + return Div( + Class("event"), + Time( + DateTime(e.StartTime.UTC().Format(time.DateOnly)), + g.Text(e.StartTime.Format("15:04")), + ), + g.Text("–"), + Time( + DateTime(e.EndTime.UTC().Format(time.DateOnly)), + g.Text(e.EndTime.Format("15:04")), + ), + ) + }), + ) + }), + ), + Footer( + P(g.Textf("Timezone is %s", site.Timezone.String())), + ), + ) +}
M templates/layout.go → templates/layout.go
@@ -14,6 +14,7 @@ type SiteSettings struct { Title string Language string + Timezone config.Timezone Menu []config.MenuItem InjectLiveReload bool }
M templates/style.css → templates/style.css
@@ -6,11 +6,14 @@ --font-scale: 1em; --background-color: #fff; --heading-color: #222; --text-color: #444; + --muted-color: #888; --link-color: #3273dc; --visited-color: #8b6fcb; --code-background-color: #f2f2f2; + --today-background-color: #f2f2f2; --code-color: #222; --blockquote-color: #222; + --border-color: #222; } @media (prefers-color-scheme: dark) {@@ -18,11 +21,14 @@ :root { --background-color: #01242e; --heading-color: #eee; --text-color: #ddd; + --muted-color: #ccc; --link-color: #8cc2dd; --visited-color: #8b6fcb; --code-background-color: #000; + --today-background-color: #000; --code-color: #ddd; --blockquote-color: #ccc; + --border-color: #ccc; } }@@ -250,3 +256,46 @@ list-style-type: none; padding-left: unset; margin-block-start: 0.5rex; } + +time { + font-variant-numeric: tabular-nums; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 0.5ch; +} + +.day { + padding: 0.2ch 0.5ch 0.5ch; + min-height: 6rem; +} + +.past { + opacity: 0.5; +} + +.day h3 { + margin-block: 0 0.5ch; + font-size: 1.1rem; + font-weight: normal; +} + +.day.today h3 { + font-weight: bold; +} + +.event { + font-size: 0.9rem; +} + +.today { + background-color: var(--today-background-color); +} + +.calendar footer { + font-size: small; + color: var(--muted-color); +}