Add niceties to time page
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
app.log
|
app.log
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
tasksquire
|
tasksquire
|
||||||
|
test/*.sqlite3*
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type Keymap struct {
|
|||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
Fill key.Binding
|
Fill key.Binding
|
||||||
StartStop key.Binding
|
StartStop key.Binding
|
||||||
|
Join key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use config values for key bindings
|
// TODO: use config values for key bindings
|
||||||
@ -155,5 +156,10 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithKeys("s"),
|
key.WithKeys("s"),
|
||||||
key.WithHelp("start/stop", "Start/Stop"),
|
key.WithHelp("start/stop", "Start/Stop"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Join: key.NewBinding(
|
||||||
|
key.WithKeys("J"),
|
||||||
|
key.WithHelp("J", "Join with previous"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -330,11 +330,17 @@ func (m *Model) UpdateViewport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SelectedRow returns the selected row.
|
// SelectedRow returns the selected row.
|
||||||
|
// Returns nil if cursor is on a gap row or out of bounds.
|
||||||
func (m Model) SelectedRow() Row {
|
func (m Model) SelectedRow() Row {
|
||||||
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't return gap rows as selected
|
||||||
|
if m.rows[m.cursor].IsGap {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return m.rows[m.cursor]
|
return m.rows[m.cursor]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,15 +394,61 @@ func (m Model) Cursor() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetCursor sets the cursor position in the table.
|
// 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) {
|
func (m *Model) SetCursor(n int) {
|
||||||
m.cursor = clamp(n, 0, len(m.rows)-1)
|
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()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveUp moves the selection up by any number of rows.
|
// 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) {
|
func (m *Model) MoveUp(n int) {
|
||||||
|
originalCursor := m.cursor
|
||||||
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
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 {
|
switch {
|
||||||
case m.start == 0:
|
case m.start == 0:
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
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.
|
// 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) {
|
func (m *Model) MoveDown(n int) {
|
||||||
|
originalCursor := m.cursor
|
||||||
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
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()
|
m.UpdateViewport()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@ -452,6 +526,16 @@ func (m Model) headersView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderRow(r int) 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))
|
var s = make([]string, 0, len(m.cols))
|
||||||
for i, col := range m.cols {
|
for i, col := range m.cols {
|
||||||
if m.cols[i].Width <= 0 {
|
if m.cols[i].Width <= 0 {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
@ -9,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TimePage struct {
|
type TimePage struct {
|
||||||
@ -18,26 +21,145 @@ type TimePage struct {
|
|||||||
data timewarrior.Intervals
|
data timewarrior.Intervals
|
||||||
|
|
||||||
shouldSelectActive bool
|
shouldSelectActive bool
|
||||||
|
|
||||||
|
selectedTimespan string
|
||||||
|
subpage common.Component
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimePage(com *common.Common) *TimePage {
|
func NewTimePage(com *common.Common) *TimePage {
|
||||||
p := &TimePage{
|
p := &TimePage{
|
||||||
common: com,
|
common: com,
|
||||||
|
selectedTimespan: ":day",
|
||||||
}
|
}
|
||||||
|
|
||||||
p.populateTable(timewarrior.Intervals{})
|
p.populateTable(timewarrior.Intervals{})
|
||||||
return p
|
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 {
|
func (p *TimePage) Init() tea.Cmd {
|
||||||
return tea.Batch(p.getIntervals(), doTick())
|
return tea.Batch(p.getIntervals(), doTick())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
p.SetSize(msg.Width, msg.Height)
|
||||||
|
case UpdateTimespanMsg:
|
||||||
|
p.selectedTimespan = string(msg)
|
||||||
|
cmds = append(cmds, p.getIntervals())
|
||||||
case intervalsMsg:
|
case intervalsMsg:
|
||||||
p.data = timewarrior.Intervals(msg)
|
p.data = timewarrior.Intervals(msg)
|
||||||
p.populateTable(p.data)
|
p.populateTable(p.data)
|
||||||
@ -50,6 +172,12 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Quit):
|
case key.Matches(msg, p.common.Keymap.Quit):
|
||||||
return p, tea.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):
|
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
row := p.intervals.SelectedRow()
|
row := p.intervals.SelectedRow()
|
||||||
if row != nil {
|
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):
|
case key.Matches(msg, p.common.Keymap.Undo):
|
||||||
p.common.TimeW.Undo()
|
p.common.TimeW.Undo()
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
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 {
|
func (p *TimePage) View() string {
|
||||||
|
header := p.renderHeader()
|
||||||
if len(p.data) == 0 {
|
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) {
|
func (p *TimePage) SetSize(width int, height int) {
|
||||||
p.common.SetSize(width, height)
|
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.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) {
|
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
||||||
var selectedStart string
|
var selectedStart string
|
||||||
currentIdx := p.intervals.Cursor()
|
|
||||||
if row := p.intervals.SelectedRow(); row != nil {
|
if row := p.intervals.SelectedRow(); row != nil {
|
||||||
selectedStart = row.Start
|
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{
|
columns := []timetable.Column{
|
||||||
{Title: "ID", Name: "id", Width: 4},
|
{Title: "ID", Name: "id", Width: 4},
|
||||||
{Title: "Start", Name: "start", Width: 16},
|
{Title: "Weekday", Name: "weekday", Width: 9},
|
||||||
{Title: "End", Name: "end", Width: 16},
|
{Title: "Start", Name: startField, Width: startEndWidth},
|
||||||
|
{Title: "End", Name: endField, Width: startEndWidth},
|
||||||
{Title: "Duration", Name: "duration", Width: 10},
|
{Title: "Duration", Name: "duration", Width: 10},
|
||||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
{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.intervals = timetable.New(
|
||||||
p.common,
|
p.common,
|
||||||
timetable.WithColumns(columns),
|
timetable.WithColumns(columns),
|
||||||
timetable.WithIntervals(intervals),
|
timetable.WithIntervals(intervalsWithGaps),
|
||||||
timetable.WithFocused(true),
|
timetable.WithFocused(true),
|
||||||
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
|
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
||||||
timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
timetable.WithHeight(tableHeight),
|
||||||
timetable.WithStyles(p.common.Styles.TableStyle),
|
timetable.WithStyles(p.common.Styles.TableStyle),
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(intervals) > 0 {
|
if len(intervalsWithGaps) > 0 {
|
||||||
newIdx := -1
|
newIdx := -1
|
||||||
|
|
||||||
if p.shouldSelectActive {
|
if p.shouldSelectActive {
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervalsWithGaps {
|
||||||
if interval.IsActive() {
|
if !interval.IsGap && interval.IsActive() {
|
||||||
newIdx = i
|
newIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -161,8 +382,8 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if newIdx == -1 && selectedStart != "" {
|
if newIdx == -1 && selectedStart != "" {
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervalsWithGaps {
|
||||||
if interval.Start == selectedStart {
|
if !interval.IsGap && interval.Start == selectedStart {
|
||||||
newIdx = i
|
newIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -170,11 +391,17 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if newIdx == -1 {
|
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) {
|
if newIdx >= len(intervalsWithGaps) {
|
||||||
newIdx = len(intervals) - 1
|
newIdx = len(intervalsWithGaps) - 1
|
||||||
}
|
}
|
||||||
if newIdx < 0 {
|
if newIdx < 0 {
|
||||||
newIdx = 0
|
newIdx = 0
|
||||||
@ -188,8 +415,7 @@ type intervalsMsg timewarrior.Intervals
|
|||||||
|
|
||||||
func (p *TimePage) getIntervals() tea.Cmd {
|
func (p *TimePage) getIntervals() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// ":day" is a timewarrior hint for "today"
|
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
|
||||||
intervals := p.common.TimeW.GetIntervals(":day")
|
|
||||||
return intervalsMsg(intervals)
|
return intervalsMsg(intervals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type Interval struct {
|
|||||||
Start string `json:"start,omitempty"`
|
Start string `json:"start,omitempty"`
|
||||||
End string `json:"end,omitempty"`
|
End string `json:"end,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
IsGap bool `json:"-"` // True if this represents an untracked time gap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInterval() *Interval {
|
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 {
|
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 {
|
switch field {
|
||||||
case "id":
|
case "id":
|
||||||
return strconv.Itoa(i.ID)
|
return strconv.Itoa(i.ID)
|
||||||
@ -36,12 +61,24 @@ func (i *Interval) GetString(field string) string {
|
|||||||
case "start":
|
case "start":
|
||||||
return formatDate(i.Start, "formatted")
|
return formatDate(i.Start, "formatted")
|
||||||
|
|
||||||
|
case "start_time":
|
||||||
|
return formatDate(i.Start, "time")
|
||||||
|
|
||||||
case "end":
|
case "end":
|
||||||
if i.End == "" {
|
if i.End == "" {
|
||||||
return "now"
|
return "now"
|
||||||
}
|
}
|
||||||
return formatDate(i.End, "formatted")
|
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":
|
case "tags":
|
||||||
if len(i.Tags) == 0 {
|
if len(i.Tags) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@ -154,6 +191,8 @@ func formatDate(date string, format string) string {
|
|||||||
return dt.Format("15:04")
|
return dt.Format("15:04")
|
||||||
case "date":
|
case "date":
|
||||||
return dt.Format("2006-01-02")
|
return dt.Format("2006-01-02")
|
||||||
|
case "weekday":
|
||||||
|
return dt.Format("Mon")
|
||||||
case "iso":
|
case "iso":
|
||||||
return dt.Format("2006-01-02T150405Z")
|
return dt.Format("2006-01-02T150405Z")
|
||||||
case "epoch":
|
case "epoch":
|
||||||
@ -173,10 +212,7 @@ func formatDuration(d time.Duration) string {
|
|||||||
minutes := int(d.Minutes()) % 60
|
minutes := int(d.Minutes()) % 60
|
||||||
seconds := int(d.Seconds()) % 60
|
seconds := int(d.Seconds()) % 60
|
||||||
|
|
||||||
if hours > 0 {
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d:%02d", minutes, seconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDurationVague(d time.Duration) string {
|
func parseDurationVague(d time.Duration) string {
|
||||||
|
|||||||
@ -31,6 +31,7 @@ type TimeWarrior interface {
|
|||||||
CancelTracking() error
|
CancelTracking() error
|
||||||
DeleteInterval(id int) error
|
DeleteInterval(id int) error
|
||||||
FillInterval(id int) error
|
FillInterval(id int) error
|
||||||
|
JoinInterval(id int) error
|
||||||
ModifyInterval(interval *Interval, adjust bool) error
|
ModifyInterval(interval *Interval, adjust bool) error
|
||||||
GetSummary(filter ...string) string
|
GetSummary(filter ...string) string
|
||||||
GetActive() *Interval
|
GetActive() *Interval
|
||||||
@ -232,6 +233,21 @@ func (ts *TimeSquire) FillInterval(id int) error {
|
|||||||
return nil
|
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 {
|
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user