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

feat(config): calendars show/hide block for timeline calendar filtering

37e5d689acfe027dc90d3d34a7780245200f2991
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:18

parent dd5263db

2 files changed

internal/config/config.go +24 −5
@@ -17,6 +17,7 @@ 	GridStart   string
 	GridEnd     string
 	Project     string
 	Tracking    Tracking
+	Calendars   Calendars
 }
 
 // Tracking tunes the autotrack daemon; read from config.json's `tracking`
@@ -27,6 +28,14 @@ 	IdleGraceSecs int // HID gap < this stays active; >= → idle
 	MinSecs       int // drop segments shorter than this
 }
 
+// Calendars selects which calendar names appear in the grid's ACTIVITY lane.
+// Non-empty Show is an allowlist (case-insensitive exact match on calendar
+// name); Hide is a blocklist applied after Show. Both empty ⇒ all pass.
+type Calendars struct {
+	Show []string
+	Hide []string
+}
+
 // Default returns the documented fallbacks used when config is absent.
 func Default() Config {
 	return Config{
@@ -37,17 +46,23 @@ }
 
 // raw mirrors the on-disk JSON; pointers distinguish "absent" from "zero".
 type raw struct {
-	SlotMinutes *int         `json:"slot_minutes"`
-	GridStart   *string      `json:"grid_start"`
-	GridEnd     *string      `json:"grid_end"`
-	Project     *string      `json:"project"`
-	Tracking    *rawTracking `json:"tracking"`
+	SlotMinutes *int          `json:"slot_minutes"`
+	GridStart   *string       `json:"grid_start"`
+	GridEnd     *string       `json:"grid_end"`
+	Project     *string       `json:"project"`
+	Tracking    *rawTracking  `json:"tracking"`
+	Calendars   *rawCalendars `json:"calendars"`
 }
 
 type rawTracking struct {
 	PollSecs      *int `json:"poll_secs"`
 	IdleGraceSecs *int `json:"idle_grace_secs"`
 	MinSecs       *int `json:"min_secs"`
+}
+
+type rawCalendars struct {
+	Show []string `json:"show"`
+	Hide []string `json:"hide"`
 }
 
 // Load reads ~/.config/ticktock/config.json, falling back to defaults.
@@ -93,6 +108,10 @@ 		}
 		if r.Tracking.MinSecs != nil {
 			c.Tracking.MinSecs = *r.Tracking.MinSecs
 		}
+	}
+	if r.Calendars != nil {
+		c.Calendars.Show = r.Calendars.Show
+		c.Calendars.Hide = r.Calendars.Hide
 	}
 	if c.SlotMinutes < 5 {
 		c.SlotMinutes = 5
internal/config/config_test.go +34 −1
@@ -3,6 +3,7 @@
 import (
 	"os"
 	"path/filepath"
+	"reflect"
 	"testing"
 )
 
@@ -15,7 +16,7 @@ }
 
 func TestLoadFromMissingFileReturnsDefaults(t *testing.T) {
 	c := LoadFrom(filepath.Join(t.TempDir(), "nope.json"))
-	if c != Default() {
+	if !reflect.DeepEqual(c, Default()) {
 		t.Fatalf("missing file should give defaults, got %+v", c)
 	}
 }
@@ -75,3 +76,35 @@ 	if c.Tracking != Default().Tracking {
 		t.Errorf("malformed tracking should keep defaults, got %+v", c.Tracking)
 	}
 }
+
+func TestLoadFromMergesCalendars(t *testing.T) {
+	p := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(p, []byte(`{"calendars":{"show":["Work","Focus"],"hide":["Holidays"]}}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	c := LoadFrom(p)
+	if !reflect.DeepEqual(c.Calendars.Show, []string{"Work", "Focus"}) {
+		t.Errorf("show=%v, want [Work Focus]", c.Calendars.Show)
+	}
+	if !reflect.DeepEqual(c.Calendars.Hide, []string{"Holidays"}) {
+		t.Errorf("hide=%v, want [Holidays]", c.Calendars.Hide)
+	}
+}
+
+func TestCalendarsDefaultEmpty(t *testing.T) {
+	c := Default()
+	if len(c.Calendars.Show) != 0 || len(c.Calendars.Hide) != 0 {
+		t.Errorf("default calendars should be empty (all pass), got %+v", c.Calendars)
+	}
+}
+
+func TestLoadFromMalformedCalendarsFallsBack(t *testing.T) {
+	p := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(p, []byte(`{"calendars":"nope"}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	c := LoadFrom(p)
+	if !reflect.DeepEqual(c.Calendars, Default().Calendars) {
+		t.Errorf("malformed calendars should keep defaults, got %+v", c.Calendars)
+	}
+}