From 81b9d87935e1b099890d754a75f8880d70303f28 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Mon, 2 Feb 2026 12:44:12 +0100 Subject: [PATCH] Add niceties to time page --- .gitignore | 1 + common/keymap.go | 6 + components/timetable/table.go | 88 ++++++++++- pages/timePage.go | 266 +++++++++++++++++++++++++++++++--- timewarrior/models.go | 44 +++++- timewarrior/timewarrior.go | 16 ++ 6 files changed, 395 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index c65b854..7933cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ app.log test/taskchampion.sqlite3 tasksquire +test/*.sqlite3* diff --git a/common/keymap.go b/common/keymap.go index e6df97a..4b77d76 100644 --- a/common/keymap.go +++ b/common/keymap.go @@ -30,6 +30,7 @@ type Keymap struct { Undo key.Binding Fill key.Binding StartStop key.Binding + Join key.Binding } // TODO: use config values for key bindings @@ -155,5 +156,10 @@ func NewKeymap() *Keymap { key.WithKeys("s"), key.WithHelp("start/stop", "Start/Stop"), ), + + Join: key.NewBinding( + key.WithKeys("J"), + key.WithHelp("J", "Join with previous"), + ), } } diff --git a/components/timetable/table.go b/components/timetable/table.go index 44ecbb6..0c52217 100644 --- a/components/timetable/table.go +++ b/components/timetable/table.go @@ -330,11 +330,17 @@ func (m *Model) UpdateViewport() { } // SelectedRow returns the selected row. +// Returns nil if cursor is on a gap row or out of bounds. func (m Model) SelectedRow() Row { if m.cursor < 0 || m.cursor >= len(m.rows) { return nil } + // Don't return gap rows as selected + if m.rows[m.cursor].IsGap { + return nil + } + return m.rows[m.cursor] } @@ -388,15 +394,61 @@ func (m Model) Cursor() int { } // SetCursor sets the cursor position in the table. +// Skips gap rows by moving to the nearest non-gap row. func (m *Model) SetCursor(n int) { m.cursor = clamp(n, 0, len(m.rows)-1) + + // Skip gap rows - try moving down first, then up + if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap { + // Try moving down to find non-gap + found := false + for i := m.cursor; i < len(m.rows); i++ { + if !m.rows[i].IsGap { + m.cursor = i + found = true + break + } + } + // If not found down, try moving up + if !found { + for i := m.cursor; i >= 0; i-- { + if !m.rows[i].IsGap { + m.cursor = i + break + } + } + } + } + m.UpdateViewport() } // MoveUp moves the selection up by any number of rows. -// It can not go above the first row. +// It can not go above the first row. Skips gap rows. func (m *Model) MoveUp(n int) { + originalCursor := m.cursor m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) + + // Skip gap rows + for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap { + m.cursor-- + } + + // If we went past the beginning, find the first non-gap row + if m.cursor < 0 { + for i := 0; i < len(m.rows); i++ { + if !m.rows[i].IsGap { + m.cursor = i + break + } + } + } + + // If no non-gap row found, restore original cursor + if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) { + m.cursor = originalCursor + } + switch { case m.start == 0: m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) @@ -409,9 +461,31 @@ func (m *Model) MoveUp(n int) { } // MoveDown moves the selection down by any number of rows. -// It can not go below the last row. +// It can not go below the last row. Skips gap rows. func (m *Model) MoveDown(n int) { + originalCursor := m.cursor m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) + + // Skip gap rows + for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap { + m.cursor++ + } + + // If we went past the end, find the last non-gap row + if m.cursor >= len(m.rows) { + for i := len(m.rows) - 1; i >= 0; i-- { + if !m.rows[i].IsGap { + m.cursor = i + break + } + } + } + + // If no non-gap row found, restore original cursor + if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) { + m.cursor = originalCursor + } + m.UpdateViewport() switch { @@ -452,6 +526,16 @@ func (m Model) headersView() string { } func (m *Model) renderRow(r int) string { + // Special rendering for gap rows + if m.rows[r].IsGap { + gapText := m.rows[r].GetString("gap_display") + gapStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Align(lipgloss.Center). + Width(m.Width()) + return gapStyle.Render(gapText) + } + var s = make([]string, 0, len(m.cols)) for i, col := range m.cols { if m.cols[i].Width <= 0 { diff --git a/pages/timePage.go b/pages/timePage.go index 44c8541..7085462 100644 --- a/pages/timePage.go +++ b/pages/timePage.go @@ -1,6 +1,8 @@ package pages import ( + "fmt" + "log/slog" "time" "tasksquire/common" @@ -9,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type TimePage struct { @@ -18,26 +21,145 @@ type TimePage struct { data timewarrior.Intervals shouldSelectActive bool + + selectedTimespan string + subpage common.Component } func NewTimePage(com *common.Common) *TimePage { p := &TimePage{ - common: com, + common: com, + selectedTimespan: ":day", } p.populateTable(timewarrior.Intervals{}) return p } +func (p *TimePage) isMultiDayTimespan() bool { + switch p.selectedTimespan { + case ":day", ":yesterday": + return false + case ":week", ":lastweek", ":month", ":lastmonth", ":year": + return true + default: + return true + } +} + +func (p *TimePage) getTimespanLabel() string { + switch p.selectedTimespan { + case ":day": + return "Today" + case ":yesterday": + return "Yesterday" + case ":week": + return "Week" + case ":lastweek": + return "Last Week" + case ":month": + return "Month" + case ":lastmonth": + return "Last Month" + case ":year": + return "Year" + default: + return p.selectedTimespan + } +} + +func (p *TimePage) getTimespanDateRange() (start, end time.Time) { + now := time.Now() + + switch p.selectedTimespan { + case ":day": + start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end = start.AddDate(0, 0, 1) + case ":yesterday": + start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location()) + end = start.AddDate(0, 0, 1) + case ":week": + // Find the start of the week (Monday) + offset := int(time.Monday - now.Weekday()) + if offset > 0 { + offset = -6 + } + start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location()) + end = start.AddDate(0, 0, 7) + case ":lastweek": + // Find the start of last week + offset := int(time.Monday - now.Weekday()) + if offset > 0 { + offset = -6 + } + start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location()) + end = start.AddDate(0, 0, 7) + case ":month": + start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + end = start.AddDate(0, 1, 0) + case ":lastmonth": + start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location()) + end = start.AddDate(0, 1, 0) + case ":year": + start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + end = start.AddDate(1, 0, 0) + default: + start = now + end = now + } + + return start, end +} + +func (p *TimePage) renderHeader() string { + label := p.getTimespanLabel() + start, end := p.getTimespanDateRange() + + var headerText string + if p.isMultiDayTimespan() { + // Multi-day format: "Week: Feb 02 - Feb 08, 2026" + if start.Year() == end.AddDate(0, 0, -1).Year() { + headerText = fmt.Sprintf("%s: %s - %s, %d", + label, + start.Format("Jan 02"), + end.AddDate(0, 0, -1).Format("Jan 02"), + start.Year()) + } else { + headerText = fmt.Sprintf("%s: %s, %d - %s, %d", + label, + start.Format("Jan 02"), + start.Year(), + end.AddDate(0, 0, -1).Format("Jan 02"), + end.AddDate(0, 0, -1).Year()) + } + } else { + // Single-day format: "Today (Mon, Feb 02, 2026)" + headerText = fmt.Sprintf("%s (%s, %s, %d)", + label, + start.Format("Mon"), + start.Format("Jan 02"), + start.Year()) + } + + slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan) + // Make header bold and prominent + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + return headerStyle.Render(headerText) +} + func (p *TimePage) Init() tea.Cmd { return tea.Batch(p.getIntervals(), doTick()) } func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) + case UpdateTimespanMsg: + p.selectedTimespan = string(msg) + cmds = append(cmds, p.getIntervals()) case intervalsMsg: p.data = timewarrior.Intervals(msg) p.populateTable(p.data) @@ -50,6 +172,12 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, p.common.Keymap.Quit): return p, tea.Quit + case key.Matches(msg, p.common.Keymap.SetReport): + // Use 'r' key to show timespan picker + p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan) + cmd := p.subpage.Init() + p.common.PushPage(p) + return p.subpage, cmd case key.Matches(msg, p.common.Keymap.StartStop): row := p.intervals.SelectedRow() if row != nil { @@ -93,6 +221,16 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, p.common.Keymap.Undo): p.common.TimeW.Undo() return p, tea.Batch(p.getIntervals(), doTick()) + case key.Matches(msg, p.common.Keymap.Join): + row := p.intervals.SelectedRow() + if row != nil { + interval := (*timewarrior.Interval)(row) + // Don't join if this is the last (oldest) interval + if interval.ID < len(p.data) { + p.common.TimeW.JoinInterval(interval.ID) + return p, tea.Batch(p.getIntervals(), doTick()) + } + } } } @@ -110,49 +248,132 @@ func refreshIntervals() tea.Msg { } func (p *TimePage) View() string { + header := p.renderHeader() if len(p.data) == 0 { - return p.common.Styles.Base.Render("No intervals found for today") + noDataMsg := p.common.Styles.Base.Render("No intervals found") + content := header + "\n\n" + noDataMsg + return lipgloss.Place( + p.common.Width(), + p.common.Height(), + lipgloss.Left, + lipgloss.Top, + content, + ) } - return p.intervals.View() + + tableView := p.intervals.View() + content := header + "\n\n" + tableView + + contentHeight := lipgloss.Height(content) + tableHeight := lipgloss.Height(tableView) + headerHeight := lipgloss.Height(header) + + slog.Info("TimePage View rendered", + "headerLen", len(header), + "dataCount", len(p.data), + "headerHeight", headerHeight, + "tableHeight", tableHeight, + "contentHeight", contentHeight, + "termHeight", p.common.Height()) + + return content } func (p *TimePage) SetSize(width int, height int) { p.common.SetSize(width, height) + frameSize := p.common.Styles.Base.GetVerticalFrameSize() + tableHeight := height - frameSize - 3 + slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight) p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize()) - p.intervals.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize()) + // Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin + p.intervals.SetHeight(tableHeight) +} + +// insertGaps inserts gap intervals between actual intervals where there is untracked time. +// Gaps are not inserted before the first interval or after the last interval. +// Note: intervals are in reverse chronological order (newest first), so we need to account for that. +func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals { + if len(intervals) <= 1 { + return intervals + } + + result := make(timewarrior.Intervals, 0, len(intervals)*2) + + for i := 0; i < len(intervals); i++ { + result = append(result, intervals[i]) + + // Don't add gap after the last interval + if i < len(intervals)-1 { + // Since intervals are reversed (newest first), the gap is between + // the end of the NEXT interval and the start of the CURRENT interval + currentStart := intervals[i].GetStartTime() + nextEnd := intervals[i+1].GetEndTime() + + // Calculate gap duration + gap := currentStart.Sub(nextEnd) + + // Only insert gap if there is untracked time + if gap > 0 { + gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart) + result = append(result, gapInterval) + } + } + } + + return result } func (p *TimePage) populateTable(intervals timewarrior.Intervals) { var selectedStart string - currentIdx := p.intervals.Cursor() if row := p.intervals.SelectedRow(); row != nil { selectedStart = row.Start } + // Insert gap intervals between actual intervals + intervalsWithGaps := insertGaps(intervals) + + // Determine column configuration based on timespan + var startEndWidth int + var startField, endField string + if p.isMultiDayTimespan() { + startEndWidth = 16 // "2006-01-02 15:04" + startField = "start" + endField = "end" + } else { + startEndWidth = 5 // "15:04" + startField = "start_time" + endField = "end_time" + } + columns := []timetable.Column{ {Title: "ID", Name: "id", Width: 4}, - {Title: "Start", Name: "start", Width: 16}, - {Title: "End", Name: "end", Width: 16}, + {Title: "Weekday", Name: "weekday", Width: 9}, + {Title: "Start", Name: startField, Width: startEndWidth}, + {Title: "End", Name: endField, Width: startEndWidth}, {Title: "Duration", Name: "duration", Width: 10}, {Title: "Tags", Name: "tags", Width: 0}, // flexible width } + // Calculate table height: total height - header (1 line) - blank line (1) - safety (1) + frameSize := p.common.Styles.Base.GetVerticalFrameSize() + tableHeight := p.common.Height() - frameSize - 3 + p.intervals = timetable.New( p.common, timetable.WithColumns(columns), - timetable.WithIntervals(intervals), + timetable.WithIntervals(intervalsWithGaps), timetable.WithFocused(true), - timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()), - timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()), + timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()), + timetable.WithHeight(tableHeight), timetable.WithStyles(p.common.Styles.TableStyle), ) - if len(intervals) > 0 { + if len(intervalsWithGaps) > 0 { newIdx := -1 if p.shouldSelectActive { - for i, interval := range intervals { - if interval.IsActive() { + for i, interval := range intervalsWithGaps { + if !interval.IsGap && interval.IsActive() { newIdx = i break } @@ -161,8 +382,8 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) { } if newIdx == -1 && selectedStart != "" { - for i, interval := range intervals { - if interval.Start == selectedStart { + for i, interval := range intervalsWithGaps { + if !interval.IsGap && interval.Start == selectedStart { newIdx = i break } @@ -170,11 +391,17 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) { } if newIdx == -1 { - newIdx = currentIdx + // Default to first non-gap interval + for i, interval := range intervalsWithGaps { + if !interval.IsGap { + newIdx = i + break + } + } } - if newIdx >= len(intervals) { - newIdx = len(intervals) - 1 + if newIdx >= len(intervalsWithGaps) { + newIdx = len(intervalsWithGaps) - 1 } if newIdx < 0 { newIdx = 0 @@ -188,8 +415,7 @@ type intervalsMsg timewarrior.Intervals func (p *TimePage) getIntervals() tea.Cmd { return func() tea.Msg { - // ":day" is a timewarrior hint for "today" - intervals := p.common.TimeW.GetIntervals(":day") + intervals := p.common.TimeW.GetIntervals(p.selectedTimespan) return intervalsMsg(intervals) } } diff --git a/timewarrior/models.go b/timewarrior/models.go index b448486..d9b4298 100644 --- a/timewarrior/models.go +++ b/timewarrior/models.go @@ -20,6 +20,7 @@ type Interval struct { Start string `json:"start,omitempty"` End string `json:"end,omitempty"` Tags []string `json:"tags,omitempty"` + IsGap bool `json:"-"` // True if this represents an untracked time gap } func NewInterval() *Interval { @@ -28,7 +29,31 @@ func NewInterval() *Interval { } } +// NewGapInterval creates a new gap interval representing untracked time. +// start and end are the times between which the gap occurred. +func NewGapInterval(start, end time.Time) *Interval { + return &Interval{ + ID: -1, // Gap intervals have no real ID + Start: start.UTC().Format(dtformat), + End: end.UTC().Format(dtformat), + Tags: make([]string, 0), + IsGap: true, + } +} + func (i *Interval) GetString(field string) string { + // Special handling for gap intervals + if i.IsGap { + switch field { + case "duration": + return i.GetDuration() + case "gap_display": + return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration()) + default: + return "" + } + } + switch field { case "id": return strconv.Itoa(i.ID) @@ -36,12 +61,24 @@ func (i *Interval) GetString(field string) string { case "start": return formatDate(i.Start, "formatted") + case "start_time": + return formatDate(i.Start, "time") + case "end": if i.End == "" { return "now" } return formatDate(i.End, "formatted") + case "end_time": + if i.End == "" { + return "now" + } + return formatDate(i.End, "time") + + case "weekday": + return formatDate(i.Start, "weekday") + case "tags": if len(i.Tags) == 0 { return "" @@ -154,6 +191,8 @@ func formatDate(date string, format string) string { return dt.Format("15:04") case "date": return dt.Format("2006-01-02") + case "weekday": + return dt.Format("Mon") case "iso": return dt.Format("2006-01-02T150405Z") case "epoch": @@ -173,10 +212,7 @@ func formatDuration(d time.Duration) string { minutes := int(d.Minutes()) % 60 seconds := int(d.Seconds()) % 60 - if hours > 0 { - return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) - } - return fmt.Sprintf("%d:%02d", minutes, seconds) + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } func parseDurationVague(d time.Duration) string { diff --git a/timewarrior/timewarrior.go b/timewarrior/timewarrior.go index 5a6336d..e470465 100644 --- a/timewarrior/timewarrior.go +++ b/timewarrior/timewarrior.go @@ -31,6 +31,7 @@ type TimeWarrior interface { CancelTracking() error DeleteInterval(id int) error FillInterval(id int) error + JoinInterval(id int) error ModifyInterval(interval *Interval, adjust bool) error GetSummary(filter ...string) string GetActive() *Interval @@ -232,6 +233,21 @@ func (ts *TimeSquire) FillInterval(id int) error { return nil } +func (ts *TimeSquire) JoinInterval(id int) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + // Join the current interval with the previous one + // The previous interval has id+1 (since intervals are ordered newest first) + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...) + if err := cmd.Run(); err != nil { + slog.Error("Failed joining interval:", err) + return err + } + + return nil +} + func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error { ts.mutex.Lock() defer ts.mutex.Unlock()