diff options
Diffstat (limited to 'internal/tui/history_box.go')
-rw-r--r-- | internal/tui/history_box.go | 683 |
1 files changed, 268 insertions, 415 deletions
diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index 813eb17..a524d6d 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -2,134 +2,238 @@ package tui import ( "fmt" - "sort" + "slices" + "strconv" "time" "punchcard/internal/queries" - tea "github.com/charmbracelet/bubbletea" "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 +} + // NewHistoryBoxModel creates a new history box model func NewHistoryBoxModel() HistoryBoxModel { - return HistoryBoxModel{ - viewLevel: HistoryLevelSummary, - selectedIndex: 0, + 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 } -// Update handles messages for the history box -func (m HistoryBoxModel) Update(msg tea.Msg) (HistoryBoxModel, tea.Cmd) { - return m, nil +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), 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, + 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) string { +func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBoxModel) string { var content string - var title string - + if len(m.entries) == 0 { - content = "No recent entries\n\nStart tracking time to\nsee your history here." - title = "📝 Recent History" + content = "📝 Recent History\n\nNo recent entries\n\nStart tracking time to\nsee your history here." } else { - if m.viewLevel == HistoryLevelDetails && m.selectedSummaryItem != nil { - // Details view - title = fmt.Sprintf("📝 Details: %s", m.formatSummaryTitle(*m.selectedSummaryItem)) - content = m.renderDetailsView() - } else { - // Summary view - title = "📝 Recent History" + switch m.viewLevel { + case HistoryLevelSummary: content = m.renderSummaryView() + case HistoryLevelDetails: + content = m.renderDetailsView(timer) } } - - // Apply box styling + style := unselectedBoxStyle if isSelected { style = selectedBoxStyle } - - return style.Width(width).Height(height).Render( - fmt.Sprintf("%s\n\n%s", title, content), - ) + + return style.Width(width).Height(height).Render(content) } +var ( + 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() string { - var content string - displayItems := m.getDisplayItems() - - if len(displayItems) == 0 { - return "No recent entries found." + content := "📝 Recent History" + + if len(m.summaryItems) == 0 { + return "\n\nNo recent entries found." } - - // Find a valid selected index for rendering (don't modify the model) - selectedIndex := m.selectedIndex - if selectedIndex < 0 || selectedIndex >= len(displayItems) || !displayItems[selectedIndex].IsSelectable { - // Find the first selectable item for display purposes - for i, item := range displayItems { - if item.IsSelectable { - selectedIndex = i - break - } + + 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("2006/01/02"))) } - } - - for i, item := range displayItems { - var itemStyle lipgloss.Style - var line string - - switch item.Type { - case HistoryItemDateHeader: - // Date header - line = *item.DateHeader - itemStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")) - - case HistoryItemSummary: - // Summary item - summary := item.Summary - clientProject := m.formatSummaryTitle(*summary) - line = fmt.Sprintf(" %s (%s)", clientProject, FormatDuration(summary.TotalDuration)) - - // Highlight if selected - if item.IsSelectable && selectedIndex == i { - itemStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - } else { - itemStyle = lipgloss.NewStyle() - } + + style := summaryItemStyle + if m.summarySelection == i { + style = selectedItemStyle } - - content += itemStyle.Render(line) + "\n" + + // TODO: add in duration from the currently running timer (requires other data from AppModel) + line := fmt.Sprintf(" %s (%s)", m.formatSummaryTitle(item), FormatDuration(item.TotalDuration)) + 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, + 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() string { - var content string - - if len(m.detailsEntries) == 0 { +func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string { + content := fmt.Sprintf("📝 Details: %s\n\n", m.formatSummaryTitle(m.summaryItems[m.summarySelection])) + entries := m.selectedEntries() + + if len(entries) == 0 { return "No entries found for this selection." } - - for i, entry := range m.detailsEntries { - // Calculate duration + + for i, entry := range entries { var duration time.Duration if entry.EndTime.Valid { duration = entry.EndTime.Time.Sub(entry.StartTime) } else { - // Active entry - use cached running timer data if available - if m.runningTimerStart != nil { - duration = time.Since(*m.runningTimerStart) - } else { - // Fallback to entry start time if cache not available - duration = time.Since(entry.StartTime) - } + duration = timer.currentTime.Sub(entry.StartTime) } - - // Format time range + startTime := entry.StartTime.Local().Format("3:04 PM") var timeRange string if entry.EndTime.Valid { @@ -138,379 +242,128 @@ func (m HistoryBoxModel) renderDetailsView() string { } else { timeRange = fmt.Sprintf("%s - now", startTime) } - - // Entry line + entryLine := fmt.Sprintf("%s (%s)", timeRange, FormatDuration(duration)) - - // Apply selection highlighting - entryStyle := lipgloss.NewStyle() - if m.selectedIndex == i { - entryStyle = entryStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - } - - // Also highlight active entries differently - if !entry.EndTime.Valid { - if m.selectedIndex == i { - // Selected active entry - entryStyle = entryStyle.Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230")) + + var style lipgloss.Style + if m.detailSelection == i { + if !entry.EndTime.Valid { + style = selectedActiveEntryStyle } else { - // Non-selected active entry - entryStyle = activeTimerStyle + style = selectedEntryStyle } - } - - content += entryStyle.Render(entryLine) + "\n" - - // Description if available - if entry.Description.Valid && entry.Description.String != "" { - descStyle := lipgloss.NewStyle() - if m.selectedIndex == i { - descStyle = descStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } else { + if !entry.EndTime.Valid { + style = activeEntryStyle + } else { + style = entryStyle } - content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) + "\n" } - + + 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(m.detailsEntries)-1 { + 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 { - var title string - if summary.ClientName != "" { - title = summary.ClientName - } else { - title = fmt.Sprintf("Client %d", summary.ClientID) - } - if summary.ProjectID != nil { - if summary.ProjectName != nil && *summary.ProjectName != "" { - title += fmt.Sprintf(" / %s", *summary.ProjectName) - } else { - title += fmt.Sprintf(" / Project %d", *summary.ProjectID) - } + return fmt.Sprintf("%s / %s", summary.ClientName, *summary.ProjectName) } - - return title + return fmt.Sprintf("%s / General work", summary.ClientName) } -// UpdateEntries updates the history entries and regenerates summary data -func (m HistoryBoxModel) UpdateEntries(entries []queries.TimeEntry) HistoryBoxModel { - m.entries = entries - // Reset view to summary level - m.viewLevel = HistoryLevelSummary - m.selectedSummaryItem = nil - // Regenerate summary data - m.summaryItems = m.generateSummaryItems(entries) - // Ensure we have a valid selection pointing to a selectable item - m.selectedIndex = 0 - m = m.ensureValidSelection() - return m +func dateOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } -// UpdateData updates the history entries along with client and project data for name lookups -func (m HistoryBoxModel) UpdateData(entries []queries.TimeEntry, clients []queries.Client, projects []queries.ListAllProjectsRow) HistoryBoxModel { - m.entries = entries - m.clients = clients - m.projects = projects - // Reset view to summary level - m.viewLevel = HistoryLevelSummary - m.selectedSummaryItem = nil - // Regenerate summary data with the new client/project data - m.summaryItems = m.generateSummaryItems(entries) - // Ensure we have a valid selection pointing to a selectable item - m.selectedIndex = 0 - m = m.ensureValidSelection() - return m -} - -// NextSelection moves to the next selectable row -func (m HistoryBoxModel) NextSelection() HistoryBoxModel { - displayItems := m.getDisplayItems() - if len(displayItems) == 0 { - return m - } - - // Ensure current selection is valid - m = m.ensureValidSelection() - - // Find next selectable item - for i := m.selectedIndex + 1; i < len(displayItems); i++ { - if displayItems[i].IsSelectable { - m.selectedIndex = i - break - } +func (m *HistoryBoxModel) changeSelection(forward bool) { + switch m.viewLevel { + case HistoryLevelSummary: + m.changeSummarySelection(forward) + case HistoryLevelDetails: + m.changeDetailsSelection(forward) } - - return m } -// PrevSelection moves to the previous selectable row -func (m HistoryBoxModel) PrevSelection() HistoryBoxModel { - displayItems := m.getDisplayItems() - if len(displayItems) == 0 { - return m - } - - // Ensure current selection is valid - m = m.ensureValidSelection() - - // Find previous selectable item - for i := m.selectedIndex - 1; i >= 0; i-- { - if displayItems[i].IsSelectable { - m.selectedIndex = i - break +func (m *HistoryBoxModel) changeSummarySelection(forward bool) { + newIdx := m.summarySelection + if forward { + newIdx++ + if newIdx < len(m.summaryItems) { + m.summarySelection = newIdx } - } - - return m -} - -// ensureValidSelection ensures the selected index points to a valid selectable item -func (m HistoryBoxModel) ensureValidSelection() HistoryBoxModel { - displayItems := m.getDisplayItems() - if len(displayItems) == 0 { - m.selectedIndex = 0 - return m - } - - // If current selection is valid and selectable, keep it - if m.selectedIndex >= 0 && m.selectedIndex < len(displayItems) && displayItems[m.selectedIndex].IsSelectable { - return m - } - - // Find the first selectable item - for i, item := range displayItems { - if item.IsSelectable { - m.selectedIndex = i - break + } else { + newIdx-- + if newIdx >= 0 { + m.summarySelection = newIdx } } - - return m } -// GetSelectedEntry returns the currently selected entry -func (m HistoryBoxModel) GetSelectedEntry() *queries.TimeEntry { - if m.viewLevel == HistoryLevelDetails { - if m.selectedIndex >= 0 && m.selectedIndex < len(m.detailsEntries) { - return &m.detailsEntries[m.selectedIndex] +func (m *HistoryBoxModel) changeDetailsSelection(forward bool) { + newIdx := m.detailSelection + entries := m.selectedEntries() + if forward { + newIdx++ + if newIdx < len(entries) { + m.detailSelection = newIdx } } else { - if m.selectedIndex >= 0 && m.selectedIndex < len(m.entries) { - return &m.entries[m.selectedIndex] + newIdx-- + if newIdx >= 0 { + m.detailSelection = newIdx } } - return nil } -// generateSummaryItems creates summary items grouped by date and client/project -func (m HistoryBoxModel) generateSummaryItems(entries []queries.TimeEntry) []HistorySummaryItem { - // Group entries by date and client/project combination - groupMap := make(map[string]*HistorySummaryItem) - - for _, entry := range entries { - // Get the date (year-month-day only) - date := entry.StartTime.Truncate(24 * time.Hour) - - // Create a key for grouping - key := fmt.Sprintf("%s-%d", date.Format("2006-01-02"), entry.ClientID) - if entry.ProjectID.Valid { - key += fmt.Sprintf("-%d", entry.ProjectID.Int64) - } - - // Calculate duration for this entry - var duration time.Duration - if entry.EndTime.Valid { - duration = entry.EndTime.Time.Sub(entry.StartTime) - } else { - // Active entry - use cached running timer data if available - if m.runningTimerStart != nil { - duration = time.Since(*m.runningTimerStart) - } else { - // Fallback to entry start time if cache not available - duration = time.Since(entry.StartTime) - } - } - - // Add to or update existing group - if existing, exists := groupMap[key]; exists { - existing.TotalDuration += duration - existing.EntryCount++ - } else { - // Create new summary item - item := &HistorySummaryItem{ - Date: date, - ClientID: entry.ClientID, - ClientName: m.lookupClientName(entry.ClientID), - TotalDuration: duration, - EntryCount: 1, - } - - if entry.ProjectID.Valid { - projectID := entry.ProjectID.Int64 - item.ProjectID = &projectID - projectName := m.lookupProjectName(projectID) - item.ProjectName = &projectName - } - - groupMap[key] = item - } - } - - // Convert map to slice and sort by date (descending) then by client name - var items []HistorySummaryItem - for _, item := range groupMap { - items = append(items, *item) - } - - sort.Slice(items, func(i, j int) bool { - // Sort by date descending, then by client name ascending - if !items[i].Date.Equal(items[j].Date) { - return items[i].Date.After(items[j].Date) - } - return items[i].ClientName < items[j].ClientName - }) - - return items -} +func (m HistoryBoxModel) selection() (string, string, string, *float64) { + item := m.summaryItems[m.summarySelection] -// lookupClientName finds the client name by ID -func (m HistoryBoxModel) lookupClientName(clientID int64) string { - for _, client := range m.clients { - if client.ID == clientID { - return client.Name - } - } - return fmt.Sprintf("Client %d", clientID) // Fallback if not found -} + clientID := strconv.FormatInt(item.ClientID, 10) -// lookupProjectName finds the project name by ID -func (m HistoryBoxModel) lookupProjectName(projectID int64) string { - for _, project := range m.projects { - if project.ID == projectID { - return project.Name - } + projectID := "" + if item.ProjectID != nil { + projectID = strconv.FormatInt(*item.ProjectID, 10) } - return fmt.Sprintf("Project %d", projectID) // Fallback if not found -} -// DrillDown drills down into the selected summary item -func (m HistoryBoxModel) DrillDown() HistoryBoxModel { - if m.viewLevel != HistoryLevelSummary { - return m - } - - // Get the selected summary item - displayItems := m.getDisplayItems() - if m.selectedIndex >= 0 && m.selectedIndex < len(displayItems) { - item := displayItems[m.selectedIndex] - if item.Type == HistoryItemSummary && item.Summary != nil { - // Switch to details view - m.viewLevel = HistoryLevelDetails - m.selectedSummaryItem = item.Summary - m.selectedIndex = 0 - - // Filter entries for this date/client/project combination - m.detailsEntries = m.getEntriesForSummaryItem(*item.Summary) + 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 m -} -// GoBack goes back to summary view from details view -func (m HistoryBoxModel) GoBack() HistoryBoxModel { - if m.viewLevel == HistoryLevelDetails { - m.viewLevel = HistoryLevelSummary - m.selectedSummaryItem = nil - m.selectedIndex = 0 - m.detailsEntries = nil - // Ensure we have a valid selection pointing to a selectable item - m = m.ensureValidSelection() - } - return m + return clientID, projectID, description, rate } -// getEntriesForSummaryItem returns all entries that match the given summary item -func (m HistoryBoxModel) getEntriesForSummaryItem(summary HistorySummaryItem) []queries.TimeEntry { - var matchingEntries []queries.TimeEntry - - for _, entry := range m.entries { - // Check if entry matches the summary item criteria - entryDate := entry.StartTime.Truncate(24 * time.Hour) - if !entryDate.Equal(summary.Date) { - continue - } - - if entry.ClientID != summary.ClientID { - continue - } - - // Check project ID match - if summary.ProjectID == nil && entry.ProjectID.Valid { - continue - } - if summary.ProjectID != nil && (!entry.ProjectID.Valid || entry.ProjectID.Int64 != *summary.ProjectID) { - continue - } - - matchingEntries = append(matchingEntries, entry) - } - - // Sort by start time descending (most recent first) - sort.Slice(matchingEntries, func(i, j int) bool { - return matchingEntries[i].StartTime.After(matchingEntries[j].StartTime) - }) - - return matchingEntries +func (m *HistoryBoxModel) drillDown() { + m.viewLevel = HistoryLevelDetails + m.detailSelection = 0 } -// getDisplayItems returns the items to display based on current view level -func (m HistoryBoxModel) getDisplayItems() []HistoryDisplayItem { - if m.viewLevel == HistoryLevelDetails { - // Details view - show individual entries - var items []HistoryDisplayItem - for _, entry := range m.detailsEntries { - entryCopy := entry - items = append(items, HistoryDisplayItem{ - Type: HistoryItemEntry, - Entry: &entryCopy, - IsSelectable: true, - }) - } - return items - } else { - // Summary view - show date headers and summary items - var items []HistoryDisplayItem - var currentDate *time.Time - - for _, summary := range m.summaryItems { - // Add date header if this is a new date - if currentDate == nil || !currentDate.Equal(summary.Date) { - dateStr := summary.Date.Format("Monday, January 2, 2006") - items = append(items, HistoryDisplayItem{ - Type: HistoryItemDateHeader, - DateHeader: &dateStr, - IsSelectable: false, - }) - currentDate = &summary.Date - } - - // Add summary item - summaryCopy := summary - items = append(items, HistoryDisplayItem{ - Type: HistoryItemSummary, - Summary: &summaryCopy, - IsSelectable: true, - }) - } - - return items - } -}
\ No newline at end of file +func (m *HistoryBoxModel) drillUp() { + m.viewLevel = HistoryLevelSummary +} |