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