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