feat(activity): cal-events loader with show/hide CalendarFilter
1f1d27afcf6d0ad3b990ebdd3ed0f36338f79236
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:27
parent 695b1c37
2 files changed
internal/activity/calendar.go +95 −0
@@ -0,0 +1,95 @@
+package activity
+
+import (
+ "context"
+ "encoding/json"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+// Event is one calendar event from the cal-events helper.
+type Event struct {
+ Start, End time.Time
+ Title, Calendar string
+}
+
+// CalendarFilter selects calendars by name, case-insensitively. Non-empty
+// Show is an allowlist; Hide is a blocklist applied after Show. Zero value
+// passes everything.
+type CalendarFilter struct {
+ Show []string
+ Hide []string
+}
+
+// Allow reports whether events from the named calendar should appear.
+func (f CalendarFilter) Allow(name string) bool {
+ n := strings.ToLower(name)
+ if len(f.Show) > 0 {
+ ok := false
+ for _, s := range f.Show {
+ if strings.ToLower(s) == n {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ return false
+ }
+ }
+ for _, h := range f.Hide {
+ if strings.ToLower(h) == n {
+ return false
+ }
+ }
+ return true
+}
+
+// rawEvent mirrors one cal-events JSON object.
+type rawEvent struct {
+ Title string `json:"title"`
+ Start string `json:"start"`
+ End string `json:"end"`
+ Calendar string `json:"calendar"`
+}
+
+// parseEvents decodes the cal-events JSON array, keeping events that pass
+// filter. Any decode error yields an empty slice (tolerant).
+func parseEvents(b []byte, filter CalendarFilter) []Event {
+ var raws []rawEvent
+ if err := json.Unmarshal(b, &raws); err != nil {
+ return nil
+ }
+ var events []Event
+ for _, r := range raws {
+ start, err := time.Parse(time.RFC3339, r.Start)
+ if err != nil {
+ continue
+ }
+ end, err := time.Parse(time.RFC3339, r.End)
+ if err != nil {
+ continue
+ }
+ if !filter.Allow(r.Calendar) {
+ continue
+ }
+ events = append(events, Event{Start: start, End: end, Title: r.Title, Calendar: r.Calendar})
+ }
+ return events
+}
+
+// LoadCalendar runs the cal-events helper for date (YYYY-MM-DD) and filters
+// the result. Missing helper, denied access, or bad output ⇒ empty slice, no
+// error — the timeline still renders without calendar data.
+func LoadCalendar(bin, date string, filter CalendarFilter) ([]Event, error) {
+ if bin == "" {
+ return nil, nil
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, bin, date).Output()
+ if err != nil {
+ return nil, nil
+ }
+ return parseEvents(out, filter), nil
+}
internal/activity/calendar_test.go +88 −0
@@ -0,0 +1,88 @@
+package activity
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// calSample matches the cal-events helper's output shape (ISO-8601 with
+// offset, one object per non-all-day event, tagged with its calendar name).
+const calSample = `[
+ {"title": "Standup", "start": "2026-07-07T09:00:00-05:00", "end": "2026-07-07T09:15:00-05:00", "calendar": "Archer"},
+ {"title": "Dentist", "start": "2026-07-07T13:00:00-05:00", "end": "2026-07-07T14:00:00-05:00", "calendar": "Personal"},
+ {"title": "Focus block", "start": "2026-07-07T15:00:00-05:00", "end": "2026-07-07T17:00:00-05:00", "calendar": "Focus"}
+]`
+
+func TestParseEventsSample(t *testing.T) {
+ events := parseEvents([]byte(calSample), CalendarFilter{})
+ if len(events) != 3 {
+ t.Fatalf("want 3 events, got %d", len(events))
+ }
+ if events[0].Title != "Standup" || events[0].Calendar != "Archer" {
+ t.Errorf("event0 = %+v", events[0])
+ }
+ wantStart, _ := time.Parse(time.RFC3339, "2026-07-07T09:00:00-05:00")
+ if !events[0].Start.Equal(wantStart) {
+ t.Errorf("start = %v, want %v", events[0].Start, wantStart)
+ }
+ if parseEvents([]byte("not json"), CalendarFilter{}) != nil {
+ t.Error("bad output ⇒ empty slice")
+ }
+}
+
+func TestCalendarFilterAllow(t *testing.T) {
+ cases := []struct {
+ name string
+ filter CalendarFilter
+ cal string
+ want bool
+ }{
+ {"empty filter passes all", CalendarFilter{}, "Personal", true},
+ {"show allowlist keeps listed", CalendarFilter{Show: []string{"Archer"}}, "Archer", true},
+ {"show allowlist drops unlisted", CalendarFilter{Show: []string{"Archer"}}, "Personal", false},
+ {"show is case-insensitive", CalendarFilter{Show: []string{"archer"}}, "Archer", true},
+ {"hide drops listed", CalendarFilter{Hide: []string{"Holidays"}}, "Holidays", false},
+ {"hide is case-insensitive", CalendarFilter{Hide: []string{"holidays"}}, "Holidays", false},
+ {"hide applies after show", CalendarFilter{Show: []string{"Archer", "Focus"}, Hide: []string{"Focus"}}, "Focus", false},
+ {"show+hide keeps the rest", CalendarFilter{Show: []string{"Archer", "Focus"}, Hide: []string{"Focus"}}, "Archer", true},
+ }
+ for _, c := range cases {
+ if got := c.filter.Allow(c.cal); got != c.want {
+ t.Errorf("%s: Allow(%q)=%v, want %v", c.name, c.cal, got, c.want)
+ }
+ }
+}
+
+func TestParseEventsAppliesFilter(t *testing.T) {
+ events := parseEvents([]byte(calSample), CalendarFilter{Show: []string{"Archer", "Focus"}})
+ if len(events) != 2 || events[0].Calendar != "Archer" || events[1].Calendar != "Focus" {
+ t.Fatalf("filtered = %+v", events)
+ }
+}
+
+func TestLoadCalendarDegradesGracefully(t *testing.T) {
+ if ev, err := LoadCalendar("", "2026-07-07", CalendarFilter{}); err != nil || ev != nil {
+ t.Errorf("empty bin ⇒ nil, nil; got %v, %v", ev, err)
+ }
+ if ev, err := LoadCalendar("/nonexistent/cal-events", "2026-07-07", CalendarFilter{}); err != nil || ev != nil {
+ t.Errorf("missing helper ⇒ nil, nil; got %v, %v", ev, err)
+ }
+}
+
+func TestLoadCalendarRunsHelper(t *testing.T) {
+ // Stand in for cal-events with a shell script that echoes the sample.
+ bin := filepath.Join(t.TempDir(), "cal-events")
+ script := "#!/bin/sh\ncat <<'EOF'\n" + calSample + "\nEOF\n"
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ events, err := LoadCalendar(bin, "2026-07-07", CalendarFilter{Hide: []string{"Personal"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("want 2 events after hide, got %+v", events)
+ }
+}