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 }