▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
6.9 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
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"
)

type fakeStore struct {
	entries []store.Entry
	added   []store.Entry
	removed []string
	repl    []store.Entry
}

func (f *fakeStore) Day(string) ([]store.Entry, error) { return f.entries, nil }
func (f *fakeStore) Add(e store.Entry) error           { f.added = append(f.added, e); return nil }
func (f *fakeStore) Remove(k string) error             { f.removed = append(f.removed, k); return nil }
func (f *fakeStore) SafeReplace(k string, u, o store.Entry) error {
	f.repl = append(f.repl, u)
	return nil
}

func cfg() config.Config { return config.Default() }

func key(s string) tea.KeyMsg {
	return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
func special(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }

// loaded returns a model that has already ingested its entries.
func loaded(f *fakeStore) Model {
	m := New(f, cfg(), "2026-07-07", form.Suggest{})
	nm, _ := m.Update(entriesMsg{entries: f.entries})
	return nm.(Model)
}

func TestColumnToggle(t *testing.T) {
	m := loaded(&fakeStore{})
	if m.col != ColActivity {
		t.Fatalf("default column should be ACTIVITY")
	}
	nm, _ := m.Update(special(tea.KeyRight))
	if nm.(Model).col != ColLogged {
		t.Errorf("right should switch to LOGGED")
	}
	nm, _ = nm.(Model).Update(special(tea.KeyLeft))
	if nm.(Model).col != ColActivity {
		t.Errorf("left should switch back to ACTIVITY")
	}
}

func TestCursorMovesWithinBounds(t *testing.T) {
	m := loaded(&fakeStore{})
	up, _ := m.Update(special(tea.KeyUp))
	if up.(Model).cursor != 0 {
		t.Errorf("cursor should clamp at 0 going up")
	}
	dn, _ := m.Update(special(tea.KeyDown))
	if dn.(Model).cursor != 1 {
		t.Errorf("down should move to row 1")
	}
}

func TestSpaceAnchorsThenExtends(t *testing.T) {
	m := loaded(&fakeStore{})
	// space anchors at row 0 (ACTIVITY column)
	nm, _ := m.Update(key(" "))
	mm := nm.(Model)
	if mm.anchor == nil || *mm.anchor != 0 {
		t.Fatalf("space should anchor at cursor 0")
	}
	nm, _ = mm.Update(special(tea.KeyDown))
	mm = nm.(Model)
	lo, hi, active := mm.selectionRange()
	if !active || lo != 0 || hi != 1 {
		t.Errorf("range after down = %d..%d active=%v, want 0..1 true", lo, hi, active)
	}
}

func TestEscClearsSelection(t *testing.T) {
	m := loaded(&fakeStore{})
	nm, _ := m.Update(key(" "))
	nm, _ = nm.(Model).Update(special(tea.KeyEsc))
	if _, _, active := nm.(Model).selectionRange(); active {
		t.Errorf("esc should clear selection")
	}
}

func TestDayNavReloads(t *testing.T) {
	f := &fakeStore{}
	m := loaded(f)
	nm, cmd := m.Update(key("p"))
	if nm.(Model).date != "2026-07-06" {
		t.Errorf("p should go to previous day, got %s", nm.(Model).date)
	}
	if cmd == nil {
		t.Errorf("day nav should issue a reload cmd")
	}
	// n goes forward: from 2026-07-06 back to 2026-07-07
	nm, _ = nm.(Model).Update(key("n"))
	if nm.(Model).date != "2026-07-07" {
		t.Errorf("n should go to next day, got %s", nm.(Model).date)
	}
	nm, _ = nm.(Model).Update(key("t"))
	// t jumps to today; just assert it changed away from 2026-07-07
	if nm.(Model).date == "2026-07-06" {
		t.Errorf("t should jump to today")
	}
	_ = time.Now
}

func TestViewRendersColumns(t *testing.T) {
	f := &fakeStore{entries: []store.Entry{{
		Key: "2026-07-07-01", Start: at(8, 0), End: at(8, 30), Description: "standup",
		Duration: 30 * 60 * 1e9,
	}}}
	out := loaded(f).View()
	if !strings.Contains(out, "TIME") || !strings.Contains(out, "LOGGED") {
		t.Errorf("view missing column headers:\n%s", out)
	}
	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)
	}
}

func TestVerticalViewportScrollsAndKeepsHeader(t *testing.T) {
	m := loaded(&fakeStore{})
	// Short window: height 12 → visibleRows = 12 - chromeRows(7) = 5.
	nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
	top := nm.(Model).View()
	if !strings.Contains(top, "ACTIVITY") {
		t.Fatalf("header must always render:\n%s", top)
	}
	if !strings.Contains(top, "07:00") {
		t.Fatalf("first slot should be visible at top:\n%s", top)
	}
	if strings.Contains(top, "20:30") {
		t.Fatalf("last slot should be scrolled out of a short window:\n%s", top)
	}
	// Drive the cursor to the bottom; the window must follow it.
	cur := nm
	for i := 0; i < 40; i++ {
		cur, _ = cur.(Model).Update(special(tea.KeyDown))
	}
	bot := cur.(Model).View()
	if !strings.Contains(bot, "ACTIVITY") {
		t.Fatalf("header must stay visible after scrolling:\n%s", bot)
	}
	if !strings.Contains(bot, "20:30") {
		t.Fatalf("bottom slot should be visible after scrolling down:\n%s", bot)
	}
	if strings.Contains(bot, "07:00") {
		t.Fatalf("top slot should be scrolled off when at the bottom:\n%s", bot)
	}
}