add two-week availability calendar
1 file changed, 267 insertions(+), 0 deletions(-)
changed files
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() +}