▍ humdrum codex / ticktock v0.0.2

feat(store): read a day from tock with correct date-index Keys (TASK-028)

4602022742063aa84f64caf9254d26b34cf1ae73
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-07 16:38

parent 233f44da

4 files changed

internal/store/runner.go +15 −0
@@ -0,0 +1,15 @@
+package store
+
+import "os/exec"
+
+// Runner executes an external command and returns its stdout.
+type Runner interface {
+	Run(name string, args ...string) ([]byte, error)
+}
+
+// ExecRunner runs real commands.
+type ExecRunner struct{}
+
+func (ExecRunner) Run(name string, args ...string) ([]byte, error) {
+	return exec.Command(name, args...).Output()
+}
internal/store/store.go +26 −0
@@ -0,0 +1,26 @@
+package store
+
+import "time"
+
+// Entry is one tock activity for a day.
+type Entry struct {
+	Key         string    // "YYYY-MM-DD-NN" — tock's date-index
+	Start       time.Time // wall clock
+	End         time.Time // zero => running
+	Project     string
+	Description string
+	Tags        []string
+	Notes       string
+	Duration    time.Duration
+}
+
+// Running reports whether the entry has no end time yet.
+func (e Entry) Running() bool { return e.End.IsZero() }
+
+// Store is the tock adapter. tock stays the source of truth.
+type Store interface {
+	Day(date string) ([]Entry, error)
+	Add(e Entry) error
+	Remove(key string) error
+	SafeReplace(key string, updated, original Entry) error
+}
internal/store/store_test.go +63 −0
@@ -0,0 +1,63 @@
+package store
+
+import (
+	"testing"
+	"time"
+)
+
+// fakeRunner returns canned output per first-arg and records calls.
+type fakeRunner struct {
+	out   []byte
+	err   error
+	calls [][]string
+}
+
+func (f *fakeRunner) Run(name string, args ...string) ([]byte, error) {
+	f.calls = append(f.calls, append([]string{name}, args...))
+	return f.out, f.err
+}
+
+const dayJSON = `[
+ {"description":"Admin: organize","project":"ARCHER","start_time":"2026-07-07T10:10:00-07:00","end_time":"2026-07-07T10:37:00-07:00","tags":[],"notes":"emails, folders","duration":"00:27:00"},
+ {"description":"Admin","project":"ARCHER","start_time":"2026-07-07T09:01:00-07:00","end_time":"2026-07-07T09:51:00-07:00","tags":["emails"],"duration":"00:50:00"},
+ {"description":"Standup","project":"ARCHER","start_time":"2026-07-07T09:51:00-07:00","end_time":"","tags":[],"duration":"00:00:00"}
+]`
+
+func TestDayKeysAreDateIndexOverAllEntriesSortedByStart(t *testing.T) {
+	fr := &fakeRunner{out: []byte(dayJSON)}
+	got, err := New(fr).Day("2026-07-07")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != 3 {
+		t.Fatalf("want 3 entries, got %d", len(got))
+	}
+	// sorted by start: 09:01 (Admin), 09:51 (Standup, running), 10:10 (organize)
+	wantKeys := []string{"2026-07-07-01", "2026-07-07-02", "2026-07-07-03"}
+	wantDesc := []string{"Admin", "Standup", "Admin: organize"}
+	for i := range got {
+		if got[i].Key != wantKeys[i] {
+			t.Errorf("entry %d Key = %q, want %q", i, got[i].Key, wantKeys[i])
+		}
+		if got[i].Description != wantDesc[i] {
+			t.Errorf("entry %d Desc = %q, want %q", i, got[i].Description, wantDesc[i])
+		}
+	}
+	// The running entry (Standup) holds slot 02 — the TASK-028 alignment.
+	if !got[1].Running() {
+		t.Errorf("entry 02 should be running")
+	}
+	if got[0].Duration != 50*time.Minute {
+		t.Errorf("Admin duration = %v, want 50m", got[0].Duration)
+	}
+	if len(got[0].Tags) != 1 || got[0].Tags[0] != "emails" {
+		t.Errorf("Admin tags = %v, want [emails]", got[0].Tags)
+	}
+}
+
+func TestDayEmptyIsNoError(t *testing.T) {
+	got, err := New(&fakeRunner{out: []byte("")}).Day("2026-07-07")
+	if err != nil || got != nil {
+		t.Fatalf("empty output: got (%v, %v), want (nil, nil)", got, err)
+	}
+}
internal/store/tock.go +72 −0
@@ -0,0 +1,72 @@
+package store
+
+import (
+	"encoding/json"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+)
+
+// TockStore implements Store by shelling out to the tock CLI.
+type TockStore struct{ R Runner }
+
+// New returns a TockStore over the given Runner.
+func New(r Runner) *TockStore { return &TockStore{R: r} }
+
+type rawEntry struct {
+	Description string   `json:"description"`
+	Project     string   `json:"project"`
+	StartTime   string   `json:"start_time"`
+	EndTime     string   `json:"end_time"`
+	Tags        []string `json:"tags"`
+	Notes       string   `json:"notes"`
+	Duration    string   `json:"duration"`
+}
+
+func parseTime(s string) (time.Time, error) {
+	if strings.TrimSpace(s) == "" {
+		return time.Time{}, nil
+	}
+	return time.Parse(time.RFC3339Nano, s)
+}
+
+func parseDur(s string) time.Duration {
+	var h, m, sec int
+	fmt.Sscanf(strings.TrimSpace(s), "%d:%d:%d", &h, &m, &sec)
+	return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second
+}
+
+// Day returns a day's entries sorted by start, each stamped with its tock Key.
+func (t *TockStore) Day(date string) ([]Entry, error) {
+	out, _ := t.R.Run("tock", "report", "--date", date, "--json")
+	if len(strings.TrimSpace(string(out))) == 0 {
+		return nil, nil // tock prints nothing on an empty day
+	}
+	var raws []rawEntry
+	if err := json.Unmarshal(out, &raws); err != nil {
+		return nil, fmt.Errorf("parse tock report: %w", err)
+	}
+	entries := make([]Entry, 0, len(raws))
+	for _, r := range raws {
+		st, err := parseTime(r.StartTime)
+		if err != nil {
+			return nil, fmt.Errorf("parse start_time %q: %w", r.StartTime, err)
+		}
+		et, err := parseTime(r.EndTime)
+		if err != nil {
+			return nil, fmt.Errorf("parse end_time %q: %w", r.EndTime, err)
+		}
+		entries = append(entries, Entry{
+			Start: st, End: et, Project: r.Project, Description: r.Description,
+			Tags: r.Tags, Notes: r.Notes, Duration: parseDur(r.Duration),
+		})
+	}
+	sort.SliceStable(entries, func(i, j int) bool {
+		return entries[i].Start.Before(entries[j].Start)
+	})
+	for i := range entries {
+		entries[i].Key = fmt.Sprintf("%s-%02d", date, i+1)
+	}
+	return entries, nil
+}