▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0

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)
+	}
+}