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