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

feat(grid): paintActivity with calendar/active/idle overlap precedence

29ebada9487cf0500dd141bfdef94afd6595b7ed
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:31

parent 1f1d27af

2 files changed

internal/tui/grid/paint.go +77 −1
@@ -5,6 +5,7 @@ 	"fmt"
 	"strings"
 	"time"
 
+	"ticktock/internal/activity"
 	"ticktock/internal/store"
 )
 
@@ -17,14 +18,25 @@ 	CellLabel
 	CellCont
 )
 
-// Cell is one LOGGED-column slot.
+// Cell is one grid-column slot (LOGGED or ACTIVITY).
 type Cell struct {
 	Kind    CellKind
 	Key     string
 	Project string
 	Text    string
+	Act     ActKind // ACTIVITY-lane flavor; ActNone for LOGGED cells
 }
 
+// ActKind flavors an ACTIVITY-column cell for styling.
+type ActKind int
+
+const (
+	ActNone ActKind = iota
+	ActCalendar
+	ActActive
+	ActIdle
+)
+
 func hmm(d time.Duration) string {
 	total := int(d.Minutes())
 	return fmt.Sprintf("%d:%02d", total/60, total%60)
@@ -59,6 +71,70 @@ 		}
 		cells[startRow] = Cell{Kind: CellLabel, Key: e.Key, Project: e.Project, Text: labelFor(e)}
 		for r := startRow + 1; r <= endRow && r < g.Rows; r++ {
 			cells[r] = Cell{Kind: CellCont, Key: e.Key, Project: e.Project}
+		}
+	}
+	return cells
+}
+
+// Per-slot precedence ranks for the ACTIVITY lane. A calendar event's label
+// slot is the human-meaningful anchor and always shows; captured activity
+// beats idle; a calendar continuation fills otherwise-empty slots without
+// hiding activity. Equal rank ⇒ later-painted wins (mirrors paintLogged).
+const (
+	rankCalCont  = 1
+	rankIdle     = 2
+	rankActive   = 3
+	rankCalLabel = 4
+)
+
+// activityLabel picks the display text for an active segment:
+// domain, else title, else app (real logs have segments with both empty).
+func activityLabel(s activity.Segment) string {
+	if s.Domain != "" {
+		return s.Domain
+	}
+	if s.Title != "" {
+		return s.Title
+	}
+	return s.App
+}
+
+// paintActivity builds the ACTIVITY column: one label on a segment/event's
+// first covered slot, continuation glyphs on later covered slots, exactly as
+// paintLogged does for tock entries, with rank-based overlap precedence.
+func paintActivity(g Grid, segs []activity.Segment, events []activity.Event) []Cell {
+	cells := make([]Cell, g.Rows)
+	ranks := make([]int, g.Rows)
+	put := func(row, rank int, c Cell) {
+		if row < 0 || row >= g.Rows || rank < ranks[row] {
+			return
+		}
+		cells[row], ranks[row] = c, rank
+	}
+	span := func(start, end time.Time) (int, int) {
+		s := g.RowOf(minuteOfDay(start))
+		e := g.RowOf(minuteOfDay(end) - 1) // end exclusive, same as paintLogged
+		if e < s {
+			e = s
+		}
+		return s, e
+	}
+	for _, sg := range segs {
+		s, e := span(sg.Start, sg.End)
+		rank, act, label := rankActive, ActActive, activityLabel(sg)
+		if sg.Idle {
+			rank, act, label = rankIdle, ActIdle, "(idle)"
+		}
+		put(s, rank, Cell{Kind: CellLabel, Text: label, Act: act})
+		for r := s + 1; r <= e; r++ {
+			put(r, rank, Cell{Kind: CellCont, Act: act})
+		}
+	}
+	for _, ev := range events {
+		s, e := span(ev.Start, ev.End)
+		put(s, rankCalLabel, Cell{Kind: CellLabel, Text: "▪ " + ev.Title, Act: ActCalendar})
+		for r := s + 1; r <= e; r++ {
+			put(r, rankCalCont, Cell{Kind: CellCont, Act: ActCalendar})
 		}
 	}
 	return cells
internal/tui/grid/paint_test.go +79 −0
@@ -3,6 +3,7 @@
 import (
 	"testing"
 
+	"ticktock/internal/activity"
 	"ticktock/internal/store"
 )
 
@@ -49,3 +50,81 @@ 	if got != "✓ emails [admin] (0:50)" {
 		t.Errorf("labelFor=%q", got)
 	}
 }
+
+func TestPaintActivityLabelsAndContinuation(t *testing.T) {
+	g := BuildGrid(30, 7*60, 10*60, nil) // rows 07:00..10:00 = 6
+	segs := []activity.Segment{
+		{Start: at(7, 0), End: at(8, 0), App: "Safari", Title: "GitHub", Domain: "github.com"},
+		{Start: at(8, 0), End: at(8, 30), App: "Trace"}, // empty title+domain → app fallback
+		{Start: at(8, 30), End: at(9, 30), Idle: true},
+	}
+	cells := paintActivity(g, segs, nil)
+	if cells[0].Kind != CellLabel || cells[0].Text != "github.com" || cells[0].Act != ActActive {
+		t.Errorf("row0 = %+v, want github.com active label", cells[0])
+	}
+	if cells[1].Kind != CellCont || cells[1].Act != ActActive {
+		t.Errorf("row1 = %+v, want active continuation", cells[1])
+	}
+	if cells[2].Text != "Trace" {
+		t.Errorf("row2 = %+v, want app fallback label", cells[2])
+	}
+	if cells[3].Kind != CellLabel || cells[3].Text != "(idle)" || cells[3].Act != ActIdle {
+		t.Errorf("row3 = %+v, want (idle) label", cells[3])
+	}
+	if cells[4].Kind != CellCont || cells[4].Act != ActIdle {
+		t.Errorf("row4 = %+v, want idle continuation", cells[4])
+	}
+	if cells[5].Kind != CellEmpty {
+		t.Errorf("row5 = %+v, want empty gap", cells[5])
+	}
+}
+
+func TestPaintActivityTitleWhenNoDomain(t *testing.T) {
+	g := BuildGrid(30, 7*60, 8*60, nil)
+	segs := []activity.Segment{{Start: at(7, 0), End: at(7, 30), App: "Trace", Title: "notes.md"}}
+	cells := paintActivity(g, segs, nil)
+	if cells[0].Text != "notes.md" {
+		t.Errorf("row0 = %+v, want title label", cells[0])
+	}
+}
+
+func TestPaintActivityOverlapPrecedence(t *testing.T) {
+	g := BuildGrid(30, 9*60, 11*60, nil) // rows 09:00,09:30,10:00,10:30
+	segs := []activity.Segment{
+		{Start: at(9, 30), End: at(10, 0), App: "Safari", Domain: "github.com"},
+		{Start: at(10, 0), End: at(10, 30), Idle: true},
+	}
+	events := []activity.Event{
+		{Start: at(9, 0), End: at(11, 0), Title: "Sprint review", Calendar: "Archer"},
+	}
+	cells := paintActivity(g, segs, events)
+	// row0: calendar label — the human-meaningful anchor always wins
+	if cells[0].Kind != CellLabel || cells[0].Text != "▪ Sprint review" || cells[0].Act != ActCalendar {
+		t.Errorf("row0 = %+v, want calendar label", cells[0])
+	}
+	// row1: active segment beats the calendar continuation
+	if cells[1].Act != ActActive || cells[1].Text != "github.com" {
+		t.Errorf("row1 = %+v, want active over cal-cont", cells[1])
+	}
+	// row2: idle beats the calendar continuation
+	if cells[2].Act != ActIdle {
+		t.Errorf("row2 = %+v, want idle over cal-cont", cells[2])
+	}
+	// row3: nothing else there — calendar continuation fills the slot
+	if cells[3].Kind != CellCont || cells[3].Act != ActCalendar {
+		t.Errorf("row3 = %+v, want calendar continuation", cells[3])
+	}
+}
+
+func TestPaintActivityCalendarLabelBeatsActive(t *testing.T) {
+	g := BuildGrid(30, 9*60, 10*60, nil)
+	segs := []activity.Segment{{Start: at(9, 0), End: at(10, 0), App: "Safari", Domain: "github.com"}}
+	events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}}
+	cells := paintActivity(g, segs, events)
+	if cells[0].Text != "▪ Standup" || cells[0].Act != ActCalendar {
+		t.Errorf("row0 = %+v, calendar label must beat active", cells[0])
+	}
+	if cells[1].Act != ActActive {
+		t.Errorf("row1 = %+v, active continues past the event", cells[1])
+	}
+}