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

feat(cmd): tt autotrack subcommand (--once, --list-calendars) + timeline activity wiring

ac9707d92b801d0155040b90c9f84aa354f3eda3
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:55

parent b44c48eb

2 files changed

cmd/ticktock/main.go +110 −1
@@ -1,13 +1,19 @@
 package main
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
+	"os/signal"
+	"path/filepath"
+	"syscall"
 	"time"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/spf13/cobra"
 
+	"ticktock/internal/activity"
+	"ticktock/internal/autotrack"
 	"ticktock/internal/config"
 	"ticktock/internal/history"
 	"ticktock/internal/store"
@@ -35,6 +41,7 @@ 	}
 	root.AddCommand(newDayCmd())
 	root.AddCommand(newTimelineCmd())
 	root.AddCommand(newMenuCmd())
+	root.AddCommand(newAutotrackCmd())
 	return root
 }
 
@@ -49,7 +56,9 @@ 			arg := ""
 			if len(args) == 1 {
 				arg = args[0]
 			}
-			m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg), loadSuggest())
+			cfg := config.Load()
+			m := grid.New(store.New(store.ExecRunner{}), cfg, resolveDate(arg), loadSuggest()).
+				WithActivity(activitySource(cfg))
 			_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
 			return err
 		},
@@ -94,6 +103,106 @@ 		return now.AddDate(0, 0, -1).Format("2006-01-02")
 	default:
 		return arg
 	}
+}
+
+// dataDir is the shared activity-log directory (unchanged from the Python
+// daemon): ~/.local/share/ticktock.
+func dataDir() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(home, ".local", "share", "ticktock"), nil
+}
+
+// activitySource wires the grid's ACTIVITY lane: the shared data dir, the
+// cal-events helper, and the calendars.show/hide filter from config.
+func activitySource(cfg config.Config) grid.ActivitySource {
+	dir, err := dataDir()
+	if err != nil {
+		dir = "" // lane degrades to the static gap lane
+	}
+	return grid.ActivitySource{
+		DataDir: dir,
+		CalBin:  autotrack.HelperPath("cal-events"),
+		Filter:  activity.CalendarFilter{Show: cfg.Calendars.Show, Hide: cfg.Calendars.Hide},
+	}
+}
+
+func newAutotrackCmd() *cobra.Command {
+	var once, listCals bool
+	cmd := &cobra.Command{
+		Use:   "autotrack",
+		Short: "Passive activity daemon (runs under launchd; writes only its staging log)",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if listCals {
+				return runListCalendars()
+			}
+			if once {
+				return runOnce()
+			}
+			dir, err := dataDir()
+			if err != nil {
+				return err
+			}
+			sigc := make(chan os.Signal, 1)
+			signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
+			autotrack.Run(dir, config.Load().Tracking, autotrack.ReadSample, sigc)
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&once, "once", false, "sample once and print the classified context")
+	cmd.Flags().BoolVar(&listCals, "list-calendars", false, "print distinct calendar names cal-events reports for today")
+	return cmd
+}
+
+// runOnce samples and prints the classified context — parity with the Python
+// daemon's --once, for quick permission/sanity checks.
+func runOnce() error {
+	s := autotrack.ReadSample()
+	ctx := autotrack.Classify(s.Idle, s.Raw, config.Load().Tracking.IdleGraceSecs, s.Locked)
+	switch {
+	case ctx == nil:
+		fmt.Println("no foreground app")
+	case ctx.Idle:
+		fmt.Println("idle")
+	default:
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetEscapeHTML(false)
+		enc.SetIndent("", "  ")
+		return enc.Encode(ctx)
+	}
+	return nil
+}
+
+// runListCalendars prints the distinct calendar names cal-events reports for
+// today, unfiltered, so the user can copy exact names into calendars.show /
+// calendars.hide in config.json.
+func runListCalendars() error {
+	bin := autotrack.HelperPath("cal-events")
+	// LoadCalendar degrades to (nil, nil) when bin is "" (helper not found),
+	// access is denied, or output is unparsable — never an error here either,
+	// per the CLI's degradation contract.
+	events, err := activity.LoadCalendar(bin, time.Now().Format("2006-01-02"), activity.CalendarFilter{})
+	if err != nil {
+		return err
+	}
+	seen := map[string]bool{}
+	for _, e := range events {
+		if !seen[e.Calendar] {
+			seen[e.Calendar] = true
+			fmt.Println(e.Calendar)
+		}
+	}
+	if len(seen) == 0 {
+		if bin == "" {
+			fmt.Println("(no calendars — cal-events helper not found; build it with `make helpers`)")
+		} else {
+			fmt.Println("(no events today — try a busier day, or check Calendar permission for cal-events)")
+		}
+	}
+	return nil
 }
 
 func main() {
cmd/ticktock/main_test.go +14 −0
@@ -51,3 +51,17 @@ 	if newRootCmd().Version == "" {
 		t.Error("root command should carry a version")
 	}
 }
+
+func TestRootHasAutotrackCommand(t *testing.T) {
+	root := newRootCmd()
+	cmd, _, err := root.Find([]string{"autotrack"})
+	if err != nil || cmd == nil || cmd.Name() != "autotrack" {
+		t.Fatalf("autotrack subcommand missing: %v", err)
+	}
+	if cmd.Flags().Lookup("once") == nil {
+		t.Error("autotrack should have --once")
+	}
+	if cmd.Flags().Lookup("list-calendars") == nil {
+		t.Error("autotrack should have --list-calendars")
+	}
+}