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