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 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) case RefreshIntervalsMsg: cmds = append(cmds, p.getIntervals()) 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) if interval.IsActive() { p.common.TimeW.StopTracking() } else { p.common.TimeW.ContinueInterval(interval.ID) p.shouldSelectActive = true } return p, tea.Batch(p.getIntervals(), doTick()) } 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: "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) } }