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

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