diff options
Diffstat (limited to 'internal/tui/history_box.go')
-rw-r--r-- | internal/tui/history_box.go | 516 |
1 files changed, 516 insertions, 0 deletions
diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go new file mode 100644 index 0000000..813eb17 --- /dev/null +++ b/internal/tui/history_box.go @@ -0,0 +1,516 @@ +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 + } +}
\ No newline at end of file |