package tui import ( "fmt" "sort" "time" "punchcard/internal/queries" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss/v2" ) // NewHistoryBoxModel creates a new history box model func NewHistoryBoxModel() HistoryBoxModel { return HistoryBoxModel{ viewLevel: HistoryLevelSummary, selectedIndex: 0, } } // Update handles messages for the history box func (m HistoryBoxModel) Update(msg tea.Msg) (HistoryBoxModel, tea.Cmd) { return m, nil } // View renders the history box func (m HistoryBoxModel) View(width, height int, isSelected bool) 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" } 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" content = m.renderSummaryView() } } // Apply box styling style := unselectedBoxStyle if isSelected { style = selectedBoxStyle } return style.Width(width).Height(height).Render( fmt.Sprintf("%s\n\n%s", title, content), ) } // 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." } // 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 } } } 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() } } content += itemStyle.Render(line) + "\n" } return content } // renderDetailsView renders the details view (level 2) showing individual entries func (m HistoryBoxModel) renderDetailsView() string { var content string if len(m.detailsEntries) == 0 { return "No entries found for this selection." } for i, entry := range m.detailsEntries { // Calculate duration 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) } } // Format time range 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) } // 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")) } else { // Non-selected active entry entryStyle = activeTimerStyle } } 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")) } content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) + "\n" } // Add spacing between entries if i < len(m.detailsEntries)-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 title } // 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 } // 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 } } 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 } } 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 } } 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] } } else { if m.selectedIndex >= 0 && m.selectedIndex < len(m.entries) { return &m.entries[m.selectedIndex] } } 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 } // 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 } // 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 } } 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) } } 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 } // 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 } // 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 } }