summaryrefslogtreecommitdiff
path: root/internal/tui/history_box.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/history_box.go')
-rw-r--r--internal/tui/history_box.go516
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