▍ humdrum codex / ticktock v0.0.2

feat(grid): color LOGGED sessions by project + legend; fix swapped p/n day nav

646cc4ea6c88cc65a296f11a63bf21f5f3ff80cf
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 22:35

parent 9d5829b4

feat(grid): color LOGGED sessions by project + legend; fix swapped p/n day nav

- each session's label/continuation is drawn in a stable per-project color
  (hashed into a palette) so projects are distinguishable without a column
- footer legend lists today's projects each in its color
- p now goes to the previous day and n to the next (were reversed)

5 files changed

internal/tui/grid/model.go +2 −2
@@ -185,11 +185,11 @@ 	case "enter":
 		return m.handleEnter()
 	case "d":
 		return m.handleDelete()
-	case "n":
+	case "p":
 		m.date = shiftDate(m.date, -1)
 		m.anchor = nil
 		return m, m.load()
-	case "p":
+	case "n":
 		m.date = shiftDate(m.date, 1)
 		m.anchor = nil
 		return m, m.load()
internal/tui/grid/model_test.go +8 −3
@@ -96,15 +96,20 @@
 func TestDayNavReloads(t *testing.T) {
 	f := &fakeStore{}
 	m := loaded(f)
-	nm, cmd := m.Update(key("n"))
+	nm, cmd := m.Update(key("p"))
 	if nm.(Model).date != "2026-07-06" {
-		t.Errorf("n should go to previous day, got %s", nm.(Model).date)
+		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-06
+	// 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")
 	}
internal/tui/grid/paint.go +6 −5
@@ -19,9 +19,10 @@ )
 
 // Cell is one LOGGED-column slot.
 type Cell struct {
-	Kind CellKind
-	Key  string
-	Text string
+	Kind    CellKind
+	Key     string
+	Project string
+	Text    string
 }
 
 func hmm(d time.Duration) string {
@@ -55,9 +56,9 @@ 		}
 		if endRow < startRow {
 			endRow = startRow
 		}
-		cells[startRow] = Cell{Kind: CellLabel, Key: e.Key, Text: labelFor(e)}
+		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}
+			cells[r] = Cell{Kind: CellCont, Key: e.Key, Project: e.Project}
 		}
 	}
 	return cells
internal/tui/grid/paint_test.go +4 −1
@@ -10,11 +10,14 @@ func TestPaintLoggedLabelAndContinuation(t *testing.T) {
 	g := BuildGrid(30, 7*60, 9*60, nil) // FirstMin 420, Rows 4 (07:00..09:00)
 	es := []store.Entry{{
 		Key: "2026-07-07-01", Start: at(7, 0), End: at(8, 30),
-		Description: "work", Duration: 90 * 60 * 1e9,
+		Project: "ARCHER", Description: "work", Duration: 90 * 60 * 1e9,
 	}}
 	cells := paintLogged(g, es)
 	if cells[0].Kind != CellLabel {
 		t.Fatalf("row0 kind=%v, want Label", cells[0].Kind)
+	}
+	if cells[0].Project != "ARCHER" || cells[1].Project != "ARCHER" {
+		t.Errorf("project not carried onto cells: %q / %q", cells[0].Project, cells[1].Project)
 	}
 	if cells[1].Kind != CellCont || cells[2].Kind != CellCont {
 		t.Errorf("rows1-2 should be continuation")
internal/tui/grid/view.go +61 −2
@@ -2,10 +2,13 @@ package grid
 
 import (
 	"fmt"
+	"hash/fnv"
 	"strings"
 	"time"
 
 	"github.com/charmbracelet/lipgloss"
+
+	"ticktock/internal/store"
 )
 
 var (
@@ -24,6 +27,45 @@ 	gCurLog = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple).Bold(true)
 	gDelHi  = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gRed).Bold(true)
 )
 
+// projectPalette gives each project a stable, distinct color. Assignment is by
+// a hash of the project name, so a project keeps the same color across runs.
+var projectPalette = []lipgloss.Color{
+	"#78B892", // green
+	"#D188C4", // pink
+	"#6CB6FF", // blue
+	"#E0A458", // orange
+	"#B48EAD", // mauve
+	"#E8C547", // yellow
+	"#5FB3B3", // teal
+	"#EC6A88", // red
+}
+
+func projectColor(project string) lipgloss.Color {
+	if project == "" {
+		return gMuted
+	}
+	h := fnv.New32a()
+	_, _ = h.Write([]byte(project))
+	return projectPalette[int(h.Sum32())%len(projectPalette)]
+}
+
+func projectStyle(project string) lipgloss.Style {
+	return lipgloss.NewStyle().Foreground(projectColor(project))
+}
+
+// distinctProjects lists the projects present in entries, in first-seen order.
+func distinctProjects(entries []store.Entry) []string {
+	seen := map[string]bool{}
+	var out []string
+	for _, e := range entries {
+		if e.Project != "" && !seen[e.Project] {
+			seen[e.Project] = true
+			out = append(out, e.Project)
+		}
+	}
+	return out
+}
+
 func gHeaderLine(date string) string {
 	t, err := time.ParseInLocation("2006-01-02", date, time.Local)
 	label := date
@@ -113,9 +155,9 @@ 				logged = gDelHi.Render(logged)
 			} else if onCursor && m.col == ColLogged && c.Kind != CellEmpty {
 				logged = gCurLog.Render(logged)
 			} else if c.Kind == CellLabel {
-				logged = gLabel.Render(logged)
+				logged = projectStyle(c.Project).Render(logged)
 			} else if c.Kind == CellCont {
-				logged = gDim.Render(logged)
+				logged = projectStyle(c.Project).Faint(true).Render(logged)
 			}
 		}
 
@@ -132,6 +174,9 @@ 		b.WriteString(gridRow(gutterL, timeStr, act, gutterR, logged) + "\n")
 	}
 
 	b.WriteString("\n")
+	if legend := m.projectLegend(); legend != "" {
+		b.WriteString(legend + "\n")
+	}
 	if m.confirmDelete {
 		// pre-styled; write without an outer dim wrap
 		b.WriteString(gDelHi.Render(" delete ") + " " + m.confirmLabel() + "   " + gDim.Render("y = yes · n = no"))
@@ -146,6 +191,20 @@ 		help = "error: " + m.err.Error()
 	}
 	b.WriteString(gDim.Render(help))
 	return b.String()
+}
+
+// projectLegend renders each project present today in its own color so the
+// grid's colors are legible.
+func (m Model) projectLegend() string {
+	projects := distinctProjects(m.entries)
+	if len(projects) == 0 {
+		return ""
+	}
+	parts := make([]string, 0, len(projects))
+	for _, p := range projects {
+		parts = append(parts, projectStyle(p).Render(p))
+	}
+	return gDim.Render("projects: ") + strings.Join(parts, "  ")
 }
 
 // confirmLabel describes the session pending deletion (description + range),