package tui import ( "fmt" "slices" "strconv" "time" "punchcard/internal/queries" "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss/v2" ) // HistoryViewLevel represents the level of detail in history view type HistoryViewLevel int const ( HistoryLevelSummary HistoryViewLevel = iota // Level 1: Date/project summaries HistoryLevelDetails // Level 2: Individual entries ) type HistorySummaryKey struct { Date time.Time ClientID int64 ProjectID int64 } type HistoryBoxModel struct { viewLevel HistoryViewLevel summaryItems []HistorySummaryItem summarySelection int entries map[HistorySummaryKey][]queries.TimeEntry detailSelection int } // HistorySummaryItem represents a date + client/project combination with total duration type HistorySummaryItem struct { Date time.Time ClientID int64 ClientName string ProjectID *int64 ProjectName *string TotalDuration time.Duration // will exclude the currently running timer, if any EntryCount int } func (item HistorySummaryItem) key() HistorySummaryKey { key := HistorySummaryKey{ Date: dateOnly(item.Date), ClientID: item.ClientID, } if item.ProjectID != nil { key.ProjectID = *item.ProjectID } return key } // NewHistoryBoxModel creates a new history box model func NewHistoryBoxModel() HistoryBoxModel { return HistoryBoxModel{} } func buildIndex[T any, K comparable](items []T, keyf func(T) K) map[K][]T { idx := make(map[K][]T) for _, item := range items { key := keyf(item) idx[key] = append(idx[key], item) } return idx } func (m *HistoryBoxModel) regenerateSummaries( clients []queries.Client, projects map[int64][]queries.Project, entries []queries.TimeEntry, active TimerInfo, ) { m.summaryItems = make([]HistorySummaryItem, 0) clientNames := make(map[int64]string) for _, client := range clients { clientNames[client.ID] = client.Name } projectNames := make(map[int64]string) for _, group := range projects { for _, project := range group { projectNames[project.ID] = project.Name } } m.entries = buildIndex(entries, func(entry queries.TimeEntry) HistorySummaryKey { var projectID int64 = 0 if entry.ProjectID.Valid { projectID = entry.ProjectID.Int64 } return HistorySummaryKey{dateOnly(entry.StartTime.Local()), entry.ClientID, projectID} }) for key, entries := range m.entries { var totalDur time.Duration = 0 for _, entry := range entries { if active.IsActive && active.EntryID == entry.ID { continue } totalDur += entry.EndTime.Time.Sub(entry.StartTime) } item := HistorySummaryItem{ Date: key.Date.Local(), ClientID: key.ClientID, ClientName: clientNames[key.ClientID], TotalDuration: totalDur, EntryCount: len(entries), } if key.ProjectID != 0 { item.ProjectID = &key.ProjectID for _, project := range projects[key.ClientID] { if project.ID == key.ProjectID { item.ProjectName = &project.Name break } } } m.summaryItems = append(m.summaryItems, item) } slices.SortFunc(m.summaryItems, func(a, b HistorySummaryItem) int { if a.Date.Before(b.Date) { return 1 } else if a.Date.After(b.Date) { return -1 } if a.ClientName < b.ClientName { return -1 } else if a.ClientName > b.ClientName { return 1 } if a.ProjectName == nil { return -1 } if b.ProjectName == nil { return 1 } if *a.ProjectName < *b.ProjectName { return -1 } return 1 }) } // View renders the history box func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBoxModel) string { var content string if len(m.entries) == 0 { content = "📝 Recent History\n\nNo recent entries\n\nStart tracking time to\nsee your history here." } else { switch m.viewLevel { case HistoryLevelSummary: content = m.renderSummaryView(timer) case HistoryLevelDetails: content = m.renderDetailsView(timer) } } style := unselectedBoxStyle if isSelected { style = selectedBoxStyle } style = style.Width(width).Height(height) vp := viewport.New(width-2, height-4) vp.SetContent(content) selectionHeight := m.selectionHeight() visible := vp.VisibleLineCount() if selectionHeight > vp.VisibleLineCount() { vp.ScrollDown(selectionHeight - visible) } return style.Render(vp.View()) } func (m HistoryBoxModel) selectionHeight() int { switch m.viewLevel { case HistoryLevelSummary: return m.summarySelectionHeight() case HistoryLevelDetails: return m.detailsSelectionHeight() } return 0 } func (m HistoryBoxModel) summarySelectionHeight() int { height := 1 // "Recent History" title line var date *time.Time for i, item := range m.summaryItems { if date == nil || !date.Equal(item.Date) { date = &item.Date height += 4 // 2 newlines, the date, 1 more newline } height += 1 // newline before the selectable line if i == m.summarySelection { return height } height += 1 // the selectable line that's not selected } return 0 } func (m HistoryBoxModel) detailsSelectionHeight() int { height := 3 // "Details" title line + 2 newlines for i := range m.selectedEntries() { if i == m.detailSelection { return height } height += 3 // un-selected line + 2 new lines } return 0 } var ( titleStyle = lipgloss.NewStyle().Bold(true) dateStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")) summaryItemStyle = lipgloss.NewStyle() selectedItemStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) entryStyle = lipgloss.NewStyle() selectedEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) activeEntryStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")) selectedActiveEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230")) descriptionStyle = lipgloss.NewStyle() activeDescriptionStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) ) // renderSummaryView renders the summary view (level 1) with date headers and client/project summaries func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel) string { content := titleStyle.Render("📝 Recent History") var activeKey HistorySummaryKey if timer.timerInfo.IsActive { activeKey = HistorySummaryKey{ Date: dateOnly(timer.timerInfo.StartTime), ClientID: timer.timerInfo.ClientID, } if timer.timerInfo.ProjectID != nil { activeKey.ProjectID = *timer.timerInfo.ProjectID } } if len(m.summaryItems) == 0 { return "\n\nNo recent entries found." } var date *time.Time for i, item := range m.summaryItems { if date == nil || !date.Equal(item.Date) { date = &item.Date content += fmt.Sprintf("\n\n%s\n", dateStyle.Render(date.Format("Mon 2006/01/02"))) } style := summaryItemStyle if m.summarySelection == i { style = selectedItemStyle } dur := item.TotalDuration if item.key() == activeKey { dur += timer.currentTime.Sub(timer.timerInfo.StartTime) } line := fmt.Sprintf(" %s (%s)", m.formatSummaryTitle(item), FormatDuration(dur)) content += fmt.Sprintf("\n%s", style.Render(line)) } return content } func (m HistoryBoxModel) selectedEntries() []queries.TimeEntry { summary := m.summaryItems[m.summarySelection] key := HistorySummaryKey{ Date: summary.Date.Local(), ClientID: summary.ClientID, } if summary.ProjectID != nil { key.ProjectID = *summary.ProjectID } return m.entries[key] } // renderDetailsView renders the details view (level 2) showing individual entries func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string { content := titleStyle.Render(fmt.Sprintf("📝 Details: %s", m.formatSummaryTitle(m.summaryItems[m.summarySelection]))) + "\n\n" entries := m.selectedEntries() if len(entries) == 0 { return "No entries found for this selection." } for i, entry := range entries { var duration time.Duration if entry.EndTime.Valid { duration = entry.EndTime.Time.Sub(entry.StartTime) } else { duration = timer.currentTime.Sub(entry.StartTime) } startTime := entry.StartTime.Local().Format("3:04 PM") var timeRange string if entry.EndTime.Valid { endTime := entry.EndTime.Time.Local().Format("3:04 PM") timeRange = fmt.Sprintf("%s - %s", startTime, endTime) } else { timeRange = fmt.Sprintf("%s - now", startTime) } entryLine := fmt.Sprintf("%s (%s)", timeRange, FormatDuration(duration)) var style lipgloss.Style if m.detailSelection == i { if !entry.EndTime.Valid { style = selectedActiveEntryStyle } else { style = selectedEntryStyle } } else { if !entry.EndTime.Valid { style = activeEntryStyle } else { style = entryStyle } } content += style.Render(entryLine) descStyle := descriptionStyle if m.detailSelection == i { descStyle = activeDescriptionStyle } if entry.Description.Valid { content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) } content += "\n" // Add spacing between entries if i < len(entries)-1 { content += "\n" } } return content } // formatSummaryTitle creates a display title for a summary item func (m HistoryBoxModel) formatSummaryTitle(summary HistorySummaryItem) string { if summary.ProjectID != nil { return fmt.Sprintf("%s / %s", summary.ClientName, *summary.ProjectName) } return summary.ClientName } func dateOnly(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } func (m *HistoryBoxModel) changeSelection(forward bool) { switch m.viewLevel { case HistoryLevelSummary: m.changeSummarySelection(forward) case HistoryLevelDetails: m.changeDetailsSelection(forward) } } func (m *HistoryBoxModel) changeSummarySelection(forward bool) { newIdx := m.summarySelection if forward { newIdx++ if newIdx < len(m.summaryItems) { m.summarySelection = newIdx } } else { newIdx-- if newIdx >= 0 { m.summarySelection = newIdx } } } func (m *HistoryBoxModel) changeDetailsSelection(forward bool) { newIdx := m.detailSelection entries := m.selectedEntries() if forward { newIdx++ if newIdx < len(entries) { m.detailSelection = newIdx } } else { newIdx-- if newIdx >= 0 { m.detailSelection = newIdx } } } func (m HistoryBoxModel) selection() (string, string, string, *float64) { item := m.summaryItems[m.summarySelection] clientID := strconv.FormatInt(item.ClientID, 10) projectID := "" if item.ProjectID != nil { projectID = strconv.FormatInt(*item.ProjectID, 10) } description := "" var rate *float64 if m.viewLevel == HistoryLevelDetails { entry := m.selectedEntries()[m.detailSelection] if entry.Description.Valid { description = entry.Description.String } if entry.BillableRate.Valid { cents := entry.BillableRate.Int64 dollars := float64(cents) / 100 rate = &dollars } } return clientID, projectID, description, rate } func (m *HistoryBoxModel) drillDown() { m.viewLevel = HistoryLevelDetails m.detailSelection = 0 } func (m *HistoryBoxModel) drillUp() { m.viewLevel = HistoryLevelSummary }