▍ humdrum codex / ticktock v0.0.2
license AGPL-3.0
4.0 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
package grid

import (
	"fmt"
	"strings"
	"time"

	"ticktock/internal/activity"
	"ticktock/internal/store"
)

// CellKind tags a LOGGED-column slot.
type CellKind int

const (
	CellEmpty CellKind = iota
	CellLabel
	CellCont
)

// Cell is one grid-column slot (LOGGED or ACTIVITY).
type Cell struct {
	Kind    CellKind
	Key     string
	Project string
	Text    string
	Act     ActKind // ACTIVITY-lane flavor; ActNone for LOGGED cells
}

// ActKind flavors an ACTIVITY-column cell for styling.
type ActKind int

const (
	ActNone ActKind = iota
	ActCalendar
	ActActive
	ActIdle
)

func hmm(d time.Duration) string {
	total := int(d.Minutes())
	return fmt.Sprintf("%d:%02d", total/60, total%60)
}

// labelFor renders a session's LOGGED label.
func labelFor(e store.Entry) string {
	tags := ""
	if len(e.Tags) > 0 {
		tags = " [" + strings.Join(e.Tags, ",") + "]"
	}
	return fmt.Sprintf("✓ %s%s (%s)", e.Description, tags, hmm(e.Duration))
}

// paintLogged builds the LOGGED column: one label per session on its first
// covered slot, continuation glyphs on later covered slots. Later-start wins a
// contested slot. Running entries run to the last row.
func paintLogged(g Grid, entries []store.Entry) []Cell {
	cells := make([]Cell, g.Rows)
	for _, e := range entries {
		startRow := g.RowOf(minuteOfDay(e.Start))
		var endRow int
		if e.Running() {
			endRow = g.Rows - 1
		} else {
			// end is exclusive; last covered slot holds end-1 minute
			endRow = g.RowOf(minuteOfDay(e.End) - 1)
		}
		if endRow < startRow {
			endRow = startRow
		}
		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, Project: e.Project}
		}
	}
	return cells
}

// Per-slot precedence ranks for the ACTIVITY lane. A calendar event's label
// slot is the human-meaningful anchor and always shows; captured activity
// beats idle; a calendar continuation fills otherwise-empty slots without
// hiding activity. Equal rank ⇒ later-painted wins (mirrors paintLogged).
const (
	rankCalCont  = 1
	rankIdle     = 2
	rankActive   = 3
	rankCalLabel = 4
)

// activityLabel picks the display text for an active segment:
// domain, else title, else app (real logs have segments with both empty).
func activityLabel(s activity.Segment) string {
	if s.Domain != "" {
		return s.Domain
	}
	if s.Title != "" {
		return s.Title
	}
	return s.App
}

// paintActivity builds the ACTIVITY column: one label on a segment/event's
// first covered slot, continuation glyphs on later covered slots, exactly as
// paintLogged does for tock entries, with rank-based overlap precedence.
func paintActivity(g Grid, segs []activity.Segment, events []activity.Event) []Cell {
	cells := make([]Cell, g.Rows)
	ranks := make([]int, g.Rows)
	put := func(row, rank int, c Cell) {
		if row < 0 || row >= g.Rows || rank < ranks[row] {
			return
		}
		cells[row], ranks[row] = c, rank
	}
	span := func(start, end time.Time) (int, int) {
		s := g.RowOf(minuteOfDay(start))
		e := g.RowOf(minuteOfDay(end) - 1) // end exclusive, same as paintLogged
		if e < s {
			e = s
		}
		return s, e
	}
	for _, sg := range segs {
		s, e := span(sg.Start, sg.End)
		rank, act, label := rankActive, ActActive, activityLabel(sg)
		if sg.Idle {
			rank, act, label = rankIdle, ActIdle, "(idle)"
		}
		put(s, rank, Cell{Kind: CellLabel, Text: label, Act: act})
		for r := s + 1; r <= e; r++ {
			put(r, rank, Cell{Kind: CellCont, Act: act})
		}
	}
	for _, ev := range events {
		s, e := span(ev.Start, ev.End)
		put(s, rankCalLabel, Cell{Kind: CellLabel, Text: "▪ " + ev.Title, Act: ActCalendar})
		for r := s + 1; r <= e; r++ {
			put(r, rankCalCont, Cell{Kind: CellCont, Act: ActCalendar})
		}
	}
	return cells
}

// entryKeyAtRow returns the Key painted at row, if any.
func entryKeyAtRow(cells []Cell, row int) (string, bool) {
	if row < 0 || row >= len(cells) {
		return "", false
	}
	if cells[row].Kind == CellEmpty || cells[row].Key == "" {
		return "", false
	}
	return cells[row].Key, true
}