Add niceties to time page
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
app.log
|
||||
test/taskchampion.sqlite3
|
||||
tasksquire
|
||||
test/*.sqlite3*
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user