feat(autotrack): byte-compatible Record encoding + WriteSegment daily log appender
dd5263db251fda12cf0ca3783ad8903561c248fd
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:13
parent 3c66bf64
2 files changed
internal/autotrack/log.go +74 −0
@@ -0,0 +1,74 @@
+package autotrack
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// stampLayout matches Python isoformat(timespec="seconds"): local time, no
+// offset, e.g. 2026-07-07T08:59:19.
+const stampLayout = "2006-01-02T15:04:05"
+
+// Record is one on-disk activity line: {start, end, secs, app, title, url,
+// domain, idle} — byte-compatible with the Python daemon's
+// json.dumps(..., ensure_ascii=False) output.
+type Record struct {
+ Start, End time.Time
+ Secs int
+ App, Title, URL, Domain string
+ Idle bool
+}
+
+// jsonStr encodes s as a JSON string without HTML escaping (parity with
+// Python, which writes <, >, & and non-ASCII raw).
+func jsonStr(s string) string {
+ var b bytes.Buffer
+ enc := json.NewEncoder(&b)
+ enc.SetEscapeHTML(false)
+ _ = enc.Encode(s) // encoding a plain string cannot fail
+ return string(bytes.TrimRight(b.Bytes(), "\n"))
+}
+
+// EncodeLine renders the record exactly as the Python daemon did, including
+// json.dumps' ", " / ": " separators and key order.
+func (r Record) EncodeLine() string {
+ return fmt.Sprintf(
+ `{"start": %q, "end": %q, "secs": %d, "app": %s, "title": %s, "url": %s, "domain": %s, "idle": %t}`,
+ r.Start.Format(stampLayout), r.End.Format(stampLayout), r.Secs,
+ jsonStr(r.App), jsonStr(r.Title), jsonStr(r.URL), jsonStr(r.Domain), r.Idle)
+}
+
+// LogPath is the daily staging log for the given moment under dir.
+func LogPath(dir string, when time.Time) string {
+ return filepath.Join(dir, "activity-"+when.Format("2006-01-02")+".jsonl")
+}
+
+// WriteSegment appends seg to its start-day's log under dir, creating dir if
+// needed. Segments shorter than minSecs are dropped as noise. nil is a no-op.
+func WriteSegment(dir string, seg *Segment, minSecs int) error {
+ if seg == nil {
+ return nil
+ }
+ secs := seg.End.Sub(seg.Start).Seconds()
+ if secs < float64(minSecs) {
+ return nil
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ rec := Record{
+ Start: seg.Start, End: seg.End, Secs: int(secs),
+ App: seg.App, Title: seg.Title, URL: seg.URL, Domain: seg.Domain, Idle: seg.Idle,
+ }
+ f, err := os.OpenFile(LogPath(dir, seg.Start), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = f.WriteString(rec.EncodeLine() + "\n")
+ return err
+}
internal/autotrack/log_test.go +76 −0
@@ -0,0 +1,76 @@
+package autotrack
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestEncodeLineByteParityWithPython pins the exact byte shape of a real line
+// from ~/.local/share/ticktock/activity-2026-07-07.jsonl written by the
+// Python daemon (json.dumps ", "/": " separators, key order, no escaping).
+func TestEncodeLineByteParityWithPython(t *testing.T) {
+ r := Record{
+ Start: time.Date(2026, 7, 7, 8, 59, 19, 0, time.Local),
+ End: time.Date(2026, 7, 7, 9, 0, 9, 0, time.Local),
+ Secs: 49, App: "Trace",
+ }
+ want := `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}`
+ if got := r.EncodeLine(); got != want {
+ t.Errorf("byte mismatch:\n got %s\nwant %s", got, want)
+ }
+}
+
+func TestEncodeLineNoHTMLEscaping(t *testing.T) {
+ r := Record{
+ Start: time.Date(2026, 7, 7, 10, 0, 0, 0, time.Local),
+ End: time.Date(2026, 7, 7, 10, 1, 0, 0, time.Local),
+ Secs: 60, App: "Safari", Title: `R&D <notes> — café "x"`,
+ URL: "https://example.com/a?b=1&c=2", Domain: "example.com",
+ }
+ got := r.EncodeLine()
+ if !strings.Contains(got, `"title": "R&D <notes> — café \"x\""`) {
+ t.Errorf("ensure_ascii=False parity broken: %s", got)
+ }
+ if !strings.Contains(got, `"url": "https://example.com/a?b=1&c=2"`) {
+ t.Errorf("url should not be HTML-escaped: %s", got)
+ }
+}
+
+func TestWriteSegmentDropsShortAndAppends(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "data") // must be created by WriteSegment
+ start := time.Date(2026, 7, 7, 9, 0, 0, 0, time.Local)
+
+ short := &Segment{Ctx: Ctx{App: "Blip"}, Start: start, End: start.Add(10 * time.Second)}
+ if err := WriteSegment(dir, short, 15); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(LogPath(dir, start)); !os.IsNotExist(err) {
+ t.Fatal("sub-minSecs segment must be dropped (no file written)")
+ }
+
+ long := &Segment{Ctx: Ctx{App: "Trace", Title: "doc"}, Start: start, End: start.Add(50 * time.Second)}
+ if err := WriteSegment(dir, long, 15); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteSegment(dir, long, 15); err != nil { // append, not truncate
+ t.Fatal(err)
+ }
+ b, err := os.ReadFile(LogPath(dir, start))
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("want 2 appended lines, got %d: %q", len(lines), string(b))
+ }
+ want := `{"start": "2026-07-07T09:00:00", "end": "2026-07-07T09:00:50", "secs": 50, "app": "Trace", "title": "doc", "url": "", "domain": "", "idle": false}`
+ if lines[0] != want {
+ t.Errorf("line mismatch:\n got %s\nwant %s", lines[0], want)
+ }
+ if err := WriteSegment(dir, nil, 15); err != nil {
+ t.Errorf("nil segment must be a no-op, got %v", err)
+ }
+}