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),