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

feat(grid): render activity + calendar lane in the ACTIVITY column

3f432dc486f4086b6704712f9ad5d108d3ce52c2
Kevin Kortum <kevinkortum@me.com> · 2026-07-08 12:37

parent 29ebada9

3 files changed

internal/tui/grid/model.go +29 −3
@@ -6,6 +6,7 @@
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/huh"
 
+	"ticktock/internal/activity"
 	"ticktock/internal/config"
 	"ticktock/internal/store"
 	"ticktock/internal/tui/form"
@@ -29,6 +30,11 @@ 	entries []store.Entry
 	grid    Grid
 	logged  []Cell
 
+	src      ActivitySource
+	segs     []activity.Segment
+	events   []activity.Event
+	activity []Cell
+
 	cursor int
 	col    Column
 	anchor *int // non-nil while a selection is in progress
@@ -56,9 +62,26 @@ }
 
 type entriesMsg struct {
 	entries []store.Entry
+	segs    []activity.Segment
+	events  []activity.Event
 	err     error
 }
 
+// ActivitySource tells the grid where to read the ACTIVITY lane's data.
+// The zero value disables the lane (static gap lane, as before).
+type ActivitySource struct {
+	DataDir string // ~/.local/share/ticktock; "" disables activity
+	CalBin  string // path to the cal-events helper; "" disables calendar
+	Filter  activity.CalendarFilter
+}
+
+// WithActivity returns a copy of the model wired to read activity + calendar
+// data on every load.
+func (m Model) WithActivity(src ActivitySource) Model {
+	m.src = src
+	return m
+}
+
 // New builds a grid model for date (YYYY-MM-DD).
 func New(s store.Store, cfg config.Config, date string, sug form.Suggest) Model {
 	return Model{store: s, cfg: cfg, date: date, col: ColActivity, sug: sug}
@@ -67,10 +90,12 @@
 func (m Model) Init() tea.Cmd { return m.load() }
 
 func (m Model) load() tea.Cmd {
-	date, s := m.date, m.store
+	date, s, src := m.date, m.store, m.src
 	return func() tea.Msg {
 		es, err := s.Day(date)
-		return entriesMsg{entries: es, err: err}
+		segs, _ := activity.LoadDay(src.DataDir, date)      // tolerant: never fails the view
+		events, _ := activity.LoadCalendar(src.CalBin, date, src.Filter)
+		return entriesMsg{entries: es, segs: segs, events: events, err: err}
 	}
 }
 
@@ -86,6 +111,7 @@ 		endMin = 21 * 60
 	}
 	m.grid = BuildGrid(m.cfg.SlotMinutes, startMin, endMin, m.entries)
 	m.logged = paintLogged(m.grid, m.entries)
+	m.activity = paintActivity(m.grid, m.segs, m.events)
 	if m.cursor > m.grid.Rows-1 {
 		m.cursor = m.grid.Rows - 1
 	}
@@ -143,7 +169,7 @@ 		return m.updateEditing(msg)
 	}
 	switch msg := msg.(type) {
 	case entriesMsg:
-		m.entries, m.err = msg.entries, msg.err
+		m.entries, m.segs, m.events, m.err = msg.entries, msg.segs, msg.events, msg.err
 		m.rebuild()
 		return m, nil
 	case tea.WindowSizeMsg:
internal/tui/grid/model_test.go +59 −0
@@ -1,12 +1,15 @@
 package grid
 
 import (
+	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 	"time"
 
 	tea "github.com/charmbracelet/bubbletea"
 
+	"ticktock/internal/activity"
 	"ticktock/internal/config"
 	"ticktock/internal/store"
 	"ticktock/internal/tui/form"
@@ -129,3 +132,59 @@ 	if !strings.Contains(out, "standup") {
 		t.Errorf("view missing logged label:\n%s", out)
 	}
 }
+
+func TestEntriesMsgPaintsActivityLane(t *testing.T) {
+	m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{})
+	segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}}
+	events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}}
+	nm, _ := m.Update(entriesMsg{segs: segs, events: events})
+	got := nm.(Model)
+	if len(got.activity) != got.grid.Rows {
+		t.Fatalf("activity lane not painted: %d cells for %d rows", len(got.activity), got.grid.Rows)
+	}
+	if got.activity[0].Kind != CellLabel || got.activity[0].Act != ActActive {
+		t.Errorf("row0 = %+v, want active label", got.activity[0])
+	}
+	view := got.View()
+	if !strings.Contains(view, "notes") {
+		t.Errorf("view should render the activity label, got:\n%s", view)
+	}
+	if !strings.Contains(view, "▪ Standup") {
+		t.Errorf("view should render the calendar label, got:\n%s", view)
+	}
+}
+
+func TestSelectionBandStillOverridesActivity(t *testing.T) {
+	m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{})
+	segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}}
+	nm, _ := m.Update(entriesMsg{segs: segs})
+	got := nm.(Model)
+	a := 0
+	got.anchor = &a
+	got.cursor = 1
+	view := got.View()
+	if !strings.Contains(view, "▓") {
+		t.Errorf("selection band must still render over the activity lane:\n%s", view)
+	}
+	if strings.Contains(view, "notes") {
+		t.Errorf("banded rows should not leak the activity label:\n%s", view)
+	}
+}
+
+func TestLoadFetchesActivityFromSource(t *testing.T) {
+	dir := t.TempDir()
+	line := `{"start": "2026-07-07T07:10:00", "end": "2026-07-07T07:40:00", "secs": 1800, "app": "Trace", "title": "notes", "url": "", "domain": "", "idle": false}` + "\n"
+	if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(line), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{}).
+		WithActivity(ActivitySource{DataDir: dir})
+	msg := m.load()()
+	em, ok := msg.(entriesMsg)
+	if !ok {
+		t.Fatalf("load returned %T", msg)
+	}
+	if len(em.segs) != 1 || em.segs[0].App != "Trace" {
+		t.Fatalf("load should fetch the day's segments, got %+v", em.segs)
+	}
+}
internal/tui/grid/view.go +38 −1
@@ -25,6 +25,7 @@ 	gBand   = lipgloss.NewStyle().Foreground(gPurple)
 	gCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple)
 	gCurLog = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple).Bold(true)
 	gDelHi  = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gRed).Bold(true)
+	gCal    = lipgloss.NewStyle().Foreground(lipgloss.Color("#6CB6FF")) // calendar accent
 )
 
 // projectPalette gives each project a stable, distinct color. Assignment is by
@@ -133,8 +134,12 @@ 	lo, hi, selecting := m.selectionRange()
 	for row := 0; row < m.grid.Rows; row++ {
 		onCursor := row == m.cursor
 
-		// ACTIVITY column: gap lane, band if selected
+		// ACTIVITY column: painted lane (calendar/active/idle) over the gap
+		// lane; the selection band still overrides while selecting.
 		act := strings.Repeat("░", actWidth)
+		if row < len(m.activity) && m.activity[row].Kind != CellEmpty {
+			act = renderActCell(m.activity[row])
+		}
 		if selecting && row >= lo && row <= hi {
 			act = gBand.Render(strings.Repeat("▓", actWidth))
 		}
@@ -224,3 +229,35 @@ 		desc = e.Project
 	}
 	return fmt.Sprintf("%q  %s", desc, rng)
 }
+
+// padCell pads s with spaces to exactly w runes. The activity cell must be
+// padded BEFORE styling: gridRow's %-*s pads by byte length, which ANSI
+// escapes would defeat, misaligning the column divider.
+func padCell(s string, w int) string {
+	if n := len([]rune(s)); n < w {
+		return s + strings.Repeat(" ", w-n)
+	}
+	return s
+}
+
+// renderActCell paints one ACTIVITY-lane cell: label text or continuation
+// glyph, colored by flavor — calendar accent, idle dimmed, active plain;
+// continuation glyphs render faint.
+func renderActCell(c Cell) string {
+	text := trunc(c.Text, actWidth)
+	if c.Kind == CellCont {
+		text = "│"
+	}
+	text = padCell(text, actWidth)
+	style := lipgloss.NewStyle()
+	switch c.Act {
+	case ActCalendar:
+		style = gCal
+	case ActIdle:
+		style = gDim
+	}
+	if c.Kind == CellCont {
+		style = style.Faint(true)
+	}
+	return style.Render(text)
+}