Files
tasksquire/pages/timePage.go
2026-02-03 20:13:09 +01:00

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
}
}