487 lines
14 KiB
Go
487 lines
14 KiB
Go
package pages
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"tasksquire/common"
|
|
"tasksquire/components/timetable"
|
|
"tasksquire/timewarrior"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type TimePage struct {
|
|
common *common.Common
|
|
|
|
intervals timetable.Model
|
|
data timewarrior.Intervals
|
|
|
|
shouldSelectActive bool
|
|
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
|
|
|
|
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)
|
|
|
|
// If we have a pending sync action (from continuing an interval),
|
|
// execute it now that intervals are refreshed
|
|
if p.pendingSyncAction != "" {
|
|
action := p.pendingSyncAction
|
|
p.pendingSyncAction = ""
|
|
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
|
|
}
|
|
case RefreshIntervalsMsg:
|
|
cmds = append(cmds, p.getIntervals())
|
|
cmds = append(cmds, doTick())
|
|
case BackMsg:
|
|
// Restart tick loop when returning from subpage
|
|
cmds = append(cmds, doTick())
|
|
case tickMsg:
|
|
cmds = append(cmds, p.getIntervals())
|
|
cmds = append(cmds, doTick())
|
|
case tea.KeyMsg:
|
|
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 {
|
|
interval := (*timewarrior.Interval)(row)
|
|
|
|
// Validate interval before proceeding
|
|
if interval.IsGap {
|
|
slog.Debug("Cannot start/stop gap interval")
|
|
return p, nil
|
|
}
|
|
|
|
if interval.IsActive() {
|
|
// Stop tracking
|
|
p.common.TimeW.StopTracking()
|
|
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
|
|
common.SyncIntervalToTask(interval, p.common.TW, "stop")
|
|
} else {
|
|
// Continue tracking - creates a NEW interval
|
|
slog.Info("Continuing interval for task sync",
|
|
"intervalID", interval.ID,
|
|
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
|
|
"uuid", timewarrior.ExtractUUID(interval.Tags))
|
|
p.common.TimeW.ContinueInterval(interval.ID)
|
|
p.shouldSelectActive = true
|
|
// Set pending sync action instead of syncing immediately
|
|
// This ensures we sync AFTER intervals are refreshed
|
|
p.pendingSyncAction = "start"
|
|
}
|
|
cmds = append(cmds, p.getIntervals())
|
|
cmds = append(cmds, doTick())
|
|
return p, tea.Batch(cmds...)
|
|
}
|
|
case key.Matches(msg, p.common.Keymap.Delete):
|
|
row := p.intervals.SelectedRow()
|
|
if row != nil {
|
|
interval := (*timewarrior.Interval)(row)
|
|
p.common.TimeW.DeleteInterval(interval.ID)
|
|
return p, tea.Batch(p.getIntervals(), doTick())
|
|
}
|
|
case key.Matches(msg, p.common.Keymap.Edit):
|
|
row := p.intervals.SelectedRow()
|
|
if row != nil {
|
|
interval := (*timewarrior.Interval)(row)
|
|
editor := NewTimeEditorPage(p.common, interval)
|
|
p.common.PushPage(p)
|
|
return editor, editor.Init()
|
|
}
|
|
case key.Matches(msg, p.common.Keymap.Add):
|
|
interval := timewarrior.NewInterval()
|
|
interval.Start = time.Now().UTC().Format("20060102T150405Z")
|
|
editor := NewTimeEditorPage(p.common, interval)
|
|
p.common.PushPage(p)
|
|
return editor, editor.Init()
|
|
case key.Matches(msg, p.common.Keymap.Fill):
|
|
row := p.intervals.SelectedRow()
|
|
if row != nil {
|
|
interval := (*timewarrior.Interval)(row)
|
|
p.common.TimeW.FillInterval(interval.ID)
|
|
return p, tea.Batch(p.getIntervals(), doTick())
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
p.intervals, cmd = p.intervals.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
return p, tea.Batch(cmds...)
|
|
}
|
|
|
|
type RefreshIntervalsMsg struct{}
|
|
|
|
func refreshIntervals() tea.Msg {
|
|
return RefreshIntervalsMsg{}
|
|
}
|
|
|
|
func (p *TimePage) View() string {
|
|
header := p.renderHeader()
|
|
if len(p.data) == 0 {
|
|
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,
|
|
)
|
|
}
|
|
|
|
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())
|
|
// 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
|
|
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: "Weekday", Name: "weekday", Width: 9},
|
|
{Title: "Start", Name: startField, Width: startEndWidth},
|
|
{Title: "End", Name: endField, Width: startEndWidth},
|
|
{Title: "Duration", Name: "duration", Width: 10},
|
|
{Title: "Project", Name: "project", 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.common,
|
|
timetable.WithColumns(columns),
|
|
timetable.WithIntervals(intervalsWithGaps),
|
|
timetable.WithFocused(true),
|
|
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
|
timetable.WithHeight(tableHeight),
|
|
timetable.WithStyles(p.common.Styles.TableStyle),
|
|
)
|
|
|
|
if len(intervalsWithGaps) > 0 {
|
|
newIdx := -1
|
|
|
|
if p.shouldSelectActive {
|
|
for i, interval := range intervalsWithGaps {
|
|
if !interval.IsGap && interval.IsActive() {
|
|
newIdx = i
|
|
break
|
|
}
|
|
}
|
|
p.shouldSelectActive = false
|
|
}
|
|
|
|
if newIdx == -1 && selectedStart != "" {
|
|
for i, interval := range intervalsWithGaps {
|
|
if !interval.IsGap && interval.Start == selectedStart {
|
|
newIdx = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if newIdx == -1 {
|
|
// Default to first non-gap interval
|
|
for i, interval := range intervalsWithGaps {
|
|
if !interval.IsGap {
|
|
newIdx = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if newIdx >= len(intervalsWithGaps) {
|
|
newIdx = len(intervalsWithGaps) - 1
|
|
}
|
|
if newIdx < 0 {
|
|
newIdx = 0
|
|
}
|
|
|
|
p.intervals.SetCursor(newIdx)
|
|
}
|
|
}
|
|
|
|
type intervalsMsg timewarrior.Intervals
|
|
|
|
func (p *TimePage) getIntervals() tea.Cmd {
|
|
return func() tea.Msg {
|
|
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
|
|
return intervalsMsg(intervals)
|
|
}
|
|
}
|
|
|
|
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
|
|
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Get the currently active interval
|
|
activeInterval := p.common.TimeW.GetActive()
|
|
if activeInterval != nil {
|
|
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
|
|
// to ensure we're working with current data
|
|
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// At this point, intervals have been refreshed, so GetActive() will work
|
|
activeInterval := p.common.TimeW.GetActive()
|
|
if activeInterval != nil {
|
|
slog.Info("Syncing active interval to task after refresh",
|
|
"action", action,
|
|
"intervalID", activeInterval.ID,
|
|
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
|
|
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
|
|
} else {
|
|
slog.Warn("No active interval found after refresh, cannot sync to task")
|
|
}
|
|
return nil
|
|
}
|
|
}
|