Add niceties to time page

This commit is contained in:
Martin Pander
2026-02-02 12:44:12 +01:00
parent 9940316ace
commit 81b9d87935
6 changed files with 395 additions and 26 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
app.log
test/taskchampion.sqlite3
tasksquire
test/*.sqlite3*

View File

@ -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"),
),
}
}

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -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()