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

feat(activity): tolerant LoadDay jsonl reader with writer round-trip parity

695b1c37a2107b78a753aa516bb9d296da19864c
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:23

parent 37e5d689

2 files changed

internal/activity/activity.go +70 −0
@@ -0,0 +1,70 @@
+// Package activity reads the autotrack staging log and calendar events for
+// display in the grid's ACTIVITY column. All parsing is tolerant: a bad line,
+// missing file, or missing helper never fails the view.
+package activity
+
+import (
+	"bufio"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// Segment is one recorded activity span from the daily jsonl log.
+type Segment struct {
+	Start, End              time.Time
+	App, Title, URL, Domain string
+	Idle                    bool
+}
+
+// rawSegment mirrors one jsonl line; timestamps stay strings for tolerant
+// parsing (secs is derivable and unused by the reader).
+type rawSegment struct {
+	Start  string `json:"start"`
+	End    string `json:"end"`
+	App    string `json:"app"`
+	Title  string `json:"title"`
+	URL    string `json:"url"`
+	Domain string `json:"domain"`
+	Idle   bool   `json:"idle"`
+}
+
+// stampLayout matches the daemon's isoformat(timespec="seconds") local stamps.
+const stampLayout = "2006-01-02T15:04:05"
+
+// LoadDay reads activity-<date>.jsonl under dataDir. Malformed lines are
+// skipped — one bad row never fails the view. A missing file (or empty
+// dataDir) yields an empty slice with no error.
+func LoadDay(dataDir, date string) ([]Segment, error) {
+	if dataDir == "" {
+		return nil, nil
+	}
+	f, err := os.Open(filepath.Join(dataDir, "activity-"+date+".jsonl"))
+	if err != nil {
+		return nil, nil
+	}
+	defer f.Close()
+	var segs []Segment
+	sc := bufio.NewScanner(f)
+	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // long window titles/URLs
+	for sc.Scan() {
+		var r rawSegment
+		if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
+			continue
+		}
+		start, err := time.ParseInLocation(stampLayout, r.Start, time.Local)
+		if err != nil {
+			continue
+		}
+		end, err := time.ParseInLocation(stampLayout, r.End, time.Local)
+		if err != nil {
+			continue
+		}
+		segs = append(segs, Segment{
+			Start: start, End: end,
+			App: r.App, Title: r.Title, URL: r.URL, Domain: r.Domain, Idle: r.Idle,
+		})
+	}
+	return segs, nil
+}
internal/activity/activity_test.go +70 −0
@@ -0,0 +1,70 @@
+package activity
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"ticktock/internal/autotrack"
+)
+
+func TestLoadDayParsesAndSkipsMalformed(t *testing.T) {
+	dir := t.TempDir()
+	lines := `{"start": "2026-07-07T08:59:19", "end": "2026-07-07T09:00:09", "secs": 49, "app": "Trace", "title": "", "url": "", "domain": "", "idle": false}
+this line is not json
+{"start": "not-a-time", "end": "2026-07-07T09:10:00", "secs": 1, "app": "Bad", "title": "", "url": "", "domain": "", "idle": false}
+{"start": "2026-07-07T09:30:00", "end": "2026-07-07T10:00:00", "secs": 1800, "app": "", "title": "", "url": "", "domain": "", "idle": true}
+`
+	if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(lines), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	segs, err := LoadDay(dir, "2026-07-07")
+	if err != nil {
+		t.Fatalf("tolerant parse must not error: %v", err)
+	}
+	if len(segs) != 2 {
+		t.Fatalf("want 2 valid segments (malformed skipped), got %d", len(segs))
+	}
+	want := time.Date(2026, 7, 7, 8, 59, 19, 0, time.Local)
+	if segs[0].App != "Trace" || !segs[0].Start.Equal(want) {
+		t.Errorf("seg0 = %+v", segs[0])
+	}
+	if !segs[1].Idle {
+		t.Errorf("seg1 should be idle: %+v", segs[1])
+	}
+}
+
+func TestLoadDayMissingFileIsEmptyNoError(t *testing.T) {
+	segs, err := LoadDay(t.TempDir(), "2026-01-01")
+	if err != nil || len(segs) != 0 {
+		t.Fatalf("missing file ⇒ empty, nil; got %v, %v", segs, err)
+	}
+	segs, err = LoadDay("", "2026-01-01")
+	if err != nil || len(segs) != 0 {
+		t.Fatalf("empty dataDir ⇒ empty, nil; got %v, %v", segs, err)
+	}
+}
+
+// Round-trip: what the writer persists, the reader loads back — the two
+// halves of the port agree on the byte shape.
+func TestRoundTripWithWriter(t *testing.T) {
+	dir := t.TempDir()
+	start := time.Date(2026, 7, 7, 9, 0, 0, 0, time.Local)
+	seg := &autotrack.Segment{
+		Ctx:   autotrack.Ctx{App: "Safari", Title: "GitHub", URL: "https://github.com/x", Domain: "github.com"},
+		Start: start, End: start.Add(120 * time.Second),
+	}
+	if err := autotrack.WriteSegment(dir, seg, 15); err != nil {
+		t.Fatal(err)
+	}
+	segs, err := LoadDay(dir, "2026-07-07")
+	if err != nil || len(segs) != 1 {
+		t.Fatalf("round trip failed: %v, %v", segs, err)
+	}
+	got := segs[0]
+	if got.App != "Safari" || got.Domain != "github.com" || got.URL != "https://github.com/x" ||
+		!got.Start.Equal(start) || !got.End.Equal(start.Add(120*time.Second)) || got.Idle {
+		t.Errorf("round-trip mismatch: %+v", got)
+	}
+}