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