diff options
author | T <t@tjp.lol> | 2025-08-05 11:37:02 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-05 11:37:08 -0600 |
commit | 665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch) | |
tree | f34f9ec77891308c600c680683f60951599429c3 /internal/tui | |
parent | dc895cec9d8a84af89ce2501db234dff33c757e2 (diff) |
WIP TUI
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/app.go | 538 | ||||
-rw-r--r-- | internal/tui/clients_projects_box.go | 247 | ||||
-rw-r--r-- | internal/tui/history_box.go | 516 | ||||
-rw-r--r-- | internal/tui/keys.go | 229 | ||||
-rw-r--r-- | internal/tui/shared.go | 225 | ||||
-rw-r--r-- | internal/tui/timer.go | 150 | ||||
-rw-r--r-- | internal/tui/timer_box.go | 101 | ||||
-rw-r--r-- | internal/tui/types.go | 165 |
8 files changed, 2171 insertions, 0 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..98bad4f --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,538 @@ +package tui + +import ( + "context" + "database/sql" + "fmt" + "time" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss/v2" +) + +// NewApp creates a new TUI application +func NewApp(ctx context.Context, q *queries.Queries) *AppModel { + return &AppModel{ + ctx: ctx, + queries: q, + selectedBox: TimerBox, + timerBoxModel: NewTimerBoxModel(), + clientsProjectsModel: NewClientsProjectsModel(), + historyBoxModel: NewHistoryBoxModel(), + } +} + +// Init initializes the app +func (m AppModel) Init() tea.Cmd { + return tea.Batch( + m.updateDataCmd(), + m.tickCmd(), + ) +} + +// Update handles messages for the app +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + if m.showModal { + // Handle modal input + cmd := m.handleModalInput(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + } else { + // Handle normal input + action := HandleKeyPress(msg, m.selectedBox, m.historyBoxModel.viewLevel, m.timerBoxModel.timerInfo.IsActive) + cmd := m.handleAction(action) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + case TickMsg: + // Update timer duration if active using cached start time + if m.runningTimerStart != nil { + m.timerBoxModel.timerInfo.IsActive = true + m.timerBoxModel.timerInfo.Duration = time.Since(*m.runningTimerStart) + m.timerBoxModel.timerInfo.StartTime = *m.runningTimerStart + // Keep history model in sync + m.historyBoxModel.runningTimerStart = m.runningTimerStart + } else { + m.timerBoxModel.timerInfo.IsActive = false + m.timerBoxModel.timerInfo.Duration = 0 + // Keep history model in sync + m.historyBoxModel.runningTimerStart = nil + } + cmds = append(cmds, m.tickCmd()) + + case dataUpdatedMsg: + // Update all models with fresh data + m.timerBoxModel = m.timerBoxModel.UpdateTimerInfo(msg.timerInfo) + m.clientsProjectsModel = m.clientsProjectsModel.UpdateData(msg.clients, msg.projects) + m.historyBoxModel = m.historyBoxModel.UpdateData(msg.entries, msg.clients, msg.projects) + // Update running timer data in history model too + if msg.timerInfo.IsActive { + m.historyBoxModel.runningTimerStart = &msg.timerInfo.StartTime + } else { + m.historyBoxModel.runningTimerStart = nil + } + // Cache stats and running timer start time + m.stats = msg.stats + if msg.timerInfo.IsActive { + m.runningTimerStart = &msg.timerInfo.StartTime + } else { + m.runningTimerStart = nil + } + + // Schedule next data update in 30 seconds + cmds = append(cmds, tea.Tick(30*time.Second, func(t time.Time) tea.Msg { + return updateDataCmd{} + })) + + case updateDataCmd: + cmds = append(cmds, m.updateDataCmd()) + } + + return m, tea.Batch(cmds...) +} + +// handleAction processes the action returned by key handling +func (m *AppModel) handleAction(action KeyAction) tea.Cmd { + switch action { + // Global actions + case ActionNextPane: + m.selectedBox = (m.selectedBox + 1) % 3 + case ActionPrevPane: + m.selectedBox = (m.selectedBox + 2) % 3 // +2 is like -1 in mod 3 + case ActionPunchToggle: + return m.handlePunchToggle() + case ActionSearch: + return m.handleSearch() + case ActionRefresh: + return m.handleRefresh() + case ActionQuit: + return tea.Quit + + // Timer pane actions + case ActionTimerEnter: + return m.handleTimerEnter() + case ActionTimerDescribe: + m.handleTimerDescribe() + + // Projects pane actions + case ActionProjectsNext: + m.handleProjectsNext() + case ActionProjectsPrev: + m.handleProjectsPrev() + case ActionProjectsEnter: + return m.handleProjectsEnter() + case ActionProjectsNewProject: + return m.handleProjectsNewProject() + case ActionProjectsNewClient: + return m.handleProjectsNewClient() + + // History pane actions + case ActionHistoryNext: + m.handleHistoryNext() + case ActionHistoryPrev: + m.handleHistoryPrev() + case ActionHistoryEnter: + return m.handleHistoryEnter() + case ActionHistoryEdit: + return m.handleHistoryEdit() + case ActionHistoryDelete: + return m.handleHistoryDelete() + case ActionHistoryResume: + return m.handleHistoryResume() + case ActionHistoryBack: + m.handleHistoryBack() + } + + return nil +} + +// Global action handlers +func (m *AppModel) handlePunchToggle() tea.Cmd { + // TODO: Implement punch in/out toggle + return nil +} + +func (m *AppModel) handleSearch() tea.Cmd { + // TODO: Implement search modal + return nil +} + +func (m *AppModel) handleRefresh() tea.Cmd { + // Immediately refresh data from database + return m.updateDataCmd() +} + +// Timer pane action handlers +func (m *AppModel) handleTimerEnter() tea.Cmd { + if m.timerBoxModel.timerInfo.IsActive { + // TODO: Implement punch out + return nil + } else { + // TODO: Implement resume recent (punch back in to most recent project) + return nil + } +} + +// Projects pane action handlers +func (m *AppModel) handleProjectsNext() { + m.clientsProjectsModel = m.clientsProjectsModel.NextSelection() +} + +func (m *AppModel) handleProjectsPrev() { + m.clientsProjectsModel = m.clientsProjectsModel.PrevSelection() +} + +func (m *AppModel) handleProjectsEnter() tea.Cmd { + // TODO: Punch in to selected client/project + return nil +} + +func (m *AppModel) handleProjectsNewProject() tea.Cmd { + // TODO: Open new project modal + return nil +} + +func (m *AppModel) handleProjectsNewClient() tea.Cmd { + // TODO: Open new client modal + return nil +} + +// History pane action handlers +func (m *AppModel) handleHistoryNext() { + m.historyBoxModel = m.historyBoxModel.NextSelection() +} + +func (m *AppModel) handleHistoryPrev() { + m.historyBoxModel = m.historyBoxModel.PrevSelection() +} + +func (m *AppModel) handleHistoryEnter() tea.Cmd { + if m.historyBoxModel.viewLevel == HistoryLevelSummary { + // Drill down to details view + m.historyBoxModel = m.historyBoxModel.DrillDown() + return nil + } else { + // TODO: Resume selected entry (punch in with same details) + return nil + } +} + +func (m *AppModel) handleHistoryEdit() tea.Cmd { + // TODO: Open edit modal for selected entry + return nil +} + +func (m *AppModel) handleHistoryDelete() tea.Cmd { + // TODO: Delete selected entry + return nil +} + +func (m *AppModel) handleHistoryResume() tea.Cmd { + // TODO: Resume selected entry (punch in with same details) + return nil +} + +func (m *AppModel) handleHistoryBack() { + // Switch back to summary view + m.historyBoxModel = m.historyBoxModel.GoBack() +} + +// Timer action handlers +func (m *AppModel) handleTimerDescribe() { + // Show modal for timer description + m.showModal = true + m.modalType = ModalDescribeTimer + + // Get current description if any + currentDesc := "" + if m.timerBoxModel.timerInfo.Description != "" { + currentDesc = m.timerBoxModel.timerInfo.Description + } + + m.textInputModel = TextInputModel{ + prompt: "Enter timer description:", + value: currentDesc, + placeholder: "Working on...", + cursorPos: len(currentDesc), + } +} + +// Modal handling methods +func (m *AppModel) handleModalInput(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + switch key { + case "enter": + return m.submitModal() + case "escape", "ctrl+c": + m.closeModal() + return nil + case "backspace": + if m.textInputModel.cursorPos > 0 { + // Remove character before cursor + value := m.textInputModel.value + m.textInputModel.value = value[:m.textInputModel.cursorPos-1] + value[m.textInputModel.cursorPos:] + m.textInputModel.cursorPos-- + } + case "left": + if m.textInputModel.cursorPos > 0 { + m.textInputModel.cursorPos-- + } + case "right": + if m.textInputModel.cursorPos < len(m.textInputModel.value) { + m.textInputModel.cursorPos++ + } + case "home", "ctrl+a": + m.textInputModel.cursorPos = 0 + case "end", "ctrl+e": + m.textInputModel.cursorPos = len(m.textInputModel.value) + default: + // Handle character input + if len(key) == 1 && key[0] >= 32 && key[0] <= 126 { + // Insert character at cursor position + value := m.textInputModel.value + m.textInputModel.value = value[:m.textInputModel.cursorPos] + key + value[m.textInputModel.cursorPos:] + m.textInputModel.cursorPos++ + } + } + + return nil +} + +func (m *AppModel) submitModal() tea.Cmd { + var cmd tea.Cmd + + switch m.modalType { + case ModalDescribeTimer: + // Update the active timer description + cmd = m.updateTimerDescription(m.textInputModel.value) + } + + m.closeModal() + return cmd +} + +func (m *AppModel) closeModal() { + m.showModal = false + m.textInputModel = TextInputModel{} +} + +func (m *AppModel) updateTimerDescription(description string) tea.Cmd { + return func() tea.Msg { + // Update the active timer's description in the database + var desc sql.NullString + if description != "" { + desc = sql.NullString{String: description, Valid: true} + } + + err := m.queries.UpdateActiveTimerDescription(m.ctx, desc) + if err != nil { + // Handle error silently for now + return nil + } + + // Trigger a data refresh to update the UI + return updateDataCmd{} + } +} + +// View renders the app +func (m AppModel) View() string { + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + // Calculate dimensions + topBarHeight := 1 + bottomBarHeight := 1 + contentHeight := m.height - topBarHeight - bottomBarHeight + + vertBoxOverhead := 6 // 2 border, 4 padding + horizBoxOverhead := 4 // 2 border, 2 padding + + // Timer box is in top-left + timerBoxWidth := (m.width / 3) - horizBoxOverhead + timerBoxHeight := (contentHeight / 2) - vertBoxOverhead + + // Clients/Projects box is in bottom-left + clientsProjectsBoxWidth := (m.width / 3) - horizBoxOverhead + clientsProjectsBoxHeight := (contentHeight - timerBoxHeight) - vertBoxOverhead + + // History box takes the right side + historyBoxWidth := (m.width - (m.width / 3)) - horizBoxOverhead + historyBoxHeight := contentHeight - vertBoxOverhead + + // Render top bar with current box info and time stats + viewName := fmt.Sprintf("Selected: %s", m.selectedBox.String()) + // Use cached stats, but add running timer duration to both today's and week's totals if active + currentStats := m.stats + if m.runningTimerStart != nil { + runningDuration := time.Since(*m.runningTimerStart) + currentStats.TodayTotal += runningDuration + currentStats.WeekTotal += runningDuration + } + topBar := RenderTopBar(viewName, currentStats, m.width) + + // Render boxes + timerBox := m.timerBoxModel.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox) + clientsProjectsBox := m.clientsProjectsModel.View(clientsProjectsBoxWidth, clientsProjectsBoxHeight, m.selectedBox == ClientsProjectsBox) + historyBox := m.historyBoxModel.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox) + + // Layout: Timer box above Clients/Projects box on the left, History box on the right + leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, clientsProjectsBox) + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) + + // Render bottom bar + keyBindings := GetContextualKeyBindings(m.selectedBox, m.historyBoxModel.viewLevel, m.timerBoxModel.timerInfo.IsActive) + bottomBar := RenderBottomBar(keyBindings, m.width) + + // Combine everything + finalView := topBar + "\n" + mainContent + "\n" + bottomBar + + // Overlay modal if one is active using lipgloss v2 Layers + if m.showModal { + modal := m.renderModal() + + // Create layers for base content and modal + baseLayer := lipgloss.NewLayer(finalView) + modalLayer := lipgloss.NewLayer(modal). + X((m.width - 60) / 2). // Center horizontally + Y((m.height - 8) / 2). // Center vertically + Z(1) // Put modal on top + + // Use lipgloss v2 Canvas to overlay the modal + canvas := lipgloss.NewCanvas(baseLayer, modalLayer) + finalView = canvas.Render() + } + + return finalView +} + +// renderModal renders the modal content with proper styling +func (m AppModel) renderModal() string { + // Modal dimensions + modalWidth := 60 + + // Create modal content based on type + var modalContent string + switch m.modalType { + case ModalDescribeTimer: + modalContent = m.renderDescribeTimerModal(modalWidth-8) // Account for border and padding + } + + // Create modal box with border using lipgloss v2 + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Background(lipgloss.Color("235")). + Padding(1, 2). + Width(modalWidth-4) + + return modalStyle.Render(modalContent) +} + + +// renderDescribeTimerModal renders the timer description modal +func (m AppModel) renderDescribeTimerModal(width int) string { + prompt := m.textInputModel.prompt + value := m.textInputModel.value + placeholder := m.textInputModel.placeholder + cursorPos := m.textInputModel.cursorPos + + // Show placeholder if value is empty + displayValue := value + if displayValue == "" { + displayValue = placeholder + // Style placeholder differently + displayValue = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(displayValue) + } else { + // Insert cursor + if cursorPos >= 0 && cursorPos <= len(value) { + if cursorPos == len(value) { + displayValue = value + "│" + } else { + displayValue = value[:cursorPos] + "│" + value[cursorPos:] + } + } + } + + // Input field styling + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("6")). + Padding(0, 1). + Width(width - 2) + + inputField := inputStyle.Render(displayValue) + + instructions := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render("Press Enter to save, Escape to cancel") + + return prompt + "\n\n" + inputField + "\n\n" + instructions +} + +// tickCmd returns a command that sends a tick message every second +func (m AppModel) tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return TickMsg(t) + }) +} + +// updateDataCmd triggers a data update +type updateDataCmd struct{} + +// dataUpdatedMsg is sent when data is updated from the database +type dataUpdatedMsg struct { + timerInfo TimerInfo + stats TimeStats + clients []queries.Client + projects []queries.ListAllProjectsRow + entries []queries.TimeEntry +} + +// updateDataCmd returns a command to update data +func (m AppModel) updateDataCmd() tea.Cmd { + return func() tea.Msg { + timerInfo, stats, clients, projects, entries, err := GetAppData(m.ctx, m.queries) + if err != nil { + // Handle error silently for now - return empty data + return dataUpdatedMsg{ + timerInfo: TimerInfo{}, + stats: TimeStats{}, + clients: []queries.Client{}, + projects: []queries.ListAllProjectsRow{}, + entries: []queries.TimeEntry{}, + } + } + + return dataUpdatedMsg{ + timerInfo: timerInfo, + stats: stats, + clients: clients, + projects: projects, + entries: entries, + } + } +} + +// Run starts the TUI application +func Run(ctx context.Context, q *queries.Queries) error { + app := NewApp(ctx, q) + p := tea.NewProgram(app, tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/internal/tui/clients_projects_box.go b/internal/tui/clients_projects_box.go new file mode 100644 index 0000000..c52e964 --- /dev/null +++ b/internal/tui/clients_projects_box.go @@ -0,0 +1,247 @@ +package tui + +import ( + "fmt" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss/v2" +) + +// NewClientsProjectsModel creates a new clients/projects model +func NewClientsProjectsModel() ClientsProjectsModel { + return ClientsProjectsModel{ + selectedIndex: 0, + selectedIsClient: true, + } +} + +// Update handles messages for the clients/projects box +func (m ClientsProjectsModel) Update(msg tea.Msg) (ClientsProjectsModel, tea.Cmd) { + return m, nil +} + +// View renders the clients/projects box +func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { + var content string + + if len(m.clients) == 0 { + content = "No clients found\n\nUse 'punch add client' to\nadd your first client." + } else { + content = m.renderClientsAndProjects() + } + + // Apply box styling + style := unselectedBoxStyle + if isSelected { + style = selectedBoxStyle + } + + title := "👥 Clients & Projects" + + return style.Width(width).Height(height).Render( + fmt.Sprintf("%s\n\n%s", title, content), + ) +} + +// renderClientsAndProjects renders the clients and their projects +func (m ClientsProjectsModel) renderClientsAndProjects() string { + var content string + + // Group projects by client + projectsByClient := make(map[int64][]queries.ListAllProjectsRow) + for _, project := range m.projects { + projectsByClient[project.ClientID] = append(projectsByClient[project.ClientID], project) + } + + // Track the absolute row index for selection highlighting + absoluteRowIndex := 0 + + for i, client := range m.clients { + if i > 0 { + content += "\n" + } + + // Client name with rate if available + clientLine := fmt.Sprintf("• %s", client.Name) + if client.BillableRate.Valid { + rateInDollars := float64(client.BillableRate.Int64) / 100.0 + clientLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + } + + // Highlight if this client is selected + clientStyle := lipgloss.NewStyle().Bold(true) + if m.selectedIsClient && m.selectedIndex == i { + clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } + content += clientStyle.Render(clientLine) + "\n" + absoluteRowIndex++ + + // Projects for this client + clientProjects := projectsByClient[client.ID] + if len(clientProjects) == 0 { + content += " └── (no projects)\n" + } else { + for j, project := range clientProjects { + prefix := "├──" + if j == len(clientProjects)-1 { + prefix = "└──" + } + + projectLine := fmt.Sprintf(" %s %s", prefix, project.Name) + if project.BillableRate.Valid { + rateInDollars := float64(project.BillableRate.Int64) / 100.0 + projectLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + } + + // Highlight if this project is selected + // We need to check against the absolute project index in m.projects + projectStyle := lipgloss.NewStyle() + if !m.selectedIsClient { + // Find this project's index in the m.projects slice + for k, p := range m.projects { + if p.ID == project.ID && m.selectedIndex == k { + projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + break + } + } + } + content += projectStyle.Render(projectLine) + "\n" + } + } + } + + return content +} + +// UpdateData updates the clients and projects data +func (m ClientsProjectsModel) UpdateData(clients []queries.Client, projects []queries.ListAllProjectsRow) ClientsProjectsModel { + m.clients = clients + m.projects = projects + // Reset selection if we have new data + if len(clients) > 0 { + m.selectedIndex = 0 + m.selectedIsClient = true + } + return m +} + +// NextSelection moves to the next selectable row +func (m ClientsProjectsModel) NextSelection() ClientsProjectsModel { + totalRows := m.getTotalSelectableRows() + if totalRows == 0 { + return m + } + + currentIndex := m.getCurrentRowIndex() + if currentIndex < totalRows-1 { + m.setRowIndex(currentIndex + 1) + } + return m +} + +// PrevSelection moves to the previous selectable row +func (m ClientsProjectsModel) PrevSelection() ClientsProjectsModel { + totalRows := m.getTotalSelectableRows() + if totalRows == 0 { + return m + } + + currentIndex := m.getCurrentRowIndex() + if currentIndex > 0 { + m.setRowIndex(currentIndex - 1) + } + return m +} + +// getDisplayOrder returns items in the order they are displayed (tree structure) +func (m ClientsProjectsModel) getDisplayOrder() []ProjectsDisplayItem { + var items []ProjectsDisplayItem + + // Group projects by client + projectsByClient := make(map[int64][]queries.ListAllProjectsRow) + projectIndexByID := make(map[int64]int) + for i, project := range m.projects { + projectsByClient[project.ClientID] = append(projectsByClient[project.ClientID], project) + projectIndexByID[project.ID] = i + } + + // Build display order: client followed by its projects + for i, client := range m.clients { + // Add client + items = append(items, ProjectsDisplayItem{ + IsClient: true, + ClientIndex: i, + Client: &client, + }) + + // Add projects for this client + clientProjects := projectsByClient[client.ID] + for _, project := range clientProjects { + projectCopy := project // Copy to avoid reference issues + items = append(items, ProjectsDisplayItem{ + IsClient: false, + ClientIndex: i, + ProjectIndex: projectIndexByID[project.ID], + Project: &projectCopy, + }) + } + } + + return items +} + +// getTotalSelectableRows counts total items in display order +func (m ClientsProjectsModel) getTotalSelectableRows() int { + return len(m.getDisplayOrder()) +} + +// getCurrentRowIndex gets the current absolute row index in display order +func (m ClientsProjectsModel) getCurrentRowIndex() int { + displayOrder := m.getDisplayOrder() + + for i, item := range displayOrder { + if item.IsClient && m.selectedIsClient && item.ClientIndex == m.selectedIndex { + return i + } + if !item.IsClient && !m.selectedIsClient && item.ProjectIndex == m.selectedIndex { + return i + } + } + + return 0 // Default to first item if not found +} + +// setRowIndex sets the selection to the given absolute row index in display order +func (m *ClientsProjectsModel) setRowIndex(index int) { + displayOrder := m.getDisplayOrder() + if index < 0 || index >= len(displayOrder) { + return + } + + item := displayOrder[index] + if item.IsClient { + m.selectedIndex = item.ClientIndex + m.selectedIsClient = true + } else { + m.selectedIndex = item.ProjectIndex + m.selectedIsClient = false + } +} + +// GetSelectedClient returns the selected client if one is selected +func (m ClientsProjectsModel) GetSelectedClient() *queries.Client { + if m.selectedIsClient && m.selectedIndex < len(m.clients) { + return &m.clients[m.selectedIndex] + } + return nil +} + +// GetSelectedProject returns the selected project if one is selected +func (m ClientsProjectsModel) GetSelectedProject() *queries.ListAllProjectsRow { + if !m.selectedIsClient && m.selectedIndex < len(m.projects) { + return &m.projects[m.selectedIndex] + } + return nil +}
\ No newline at end of file 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 diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..7f23407 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,229 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// KeyAction represents the action to take for a key press +type KeyAction int + +const ( + // Global actions + ActionNone KeyAction = iota + ActionNextPane + ActionPrevPane + ActionPunchToggle + ActionSearch + ActionRefresh + ActionQuit + + // Timer pane actions + ActionTimerEnter + ActionTimerDescribe + + // Projects pane actions + ActionProjectsNext + ActionProjectsPrev + ActionProjectsEnter + ActionProjectsNewProject + ActionProjectsNewClient + + // History pane actions (level 1) + ActionHistoryNext + ActionHistoryPrev + ActionHistoryEnter + + // History pane actions (level 2) + ActionHistoryEdit + ActionHistoryDelete + ActionHistoryResume + ActionHistoryBack +) + +// KeyHandler processes key messages and returns the appropriate action +func HandleKeyPress(msg tea.KeyMsg, selectedBox BoxType, historyLevel HistoryViewLevel, hasActiveTimer bool) KeyAction { + key := msg.String() + + // Global keybindings (always available) + switch key { + case "ctrl+n": + return ActionNextPane + case "ctrl+p": + return ActionPrevPane + case "p": + return ActionPunchToggle + case "/": + return ActionSearch + case "r": + return ActionRefresh + case "q", "ctrl+c", "ctrl+d": + return ActionQuit + } + + // Context-specific keybindings based on selected box + switch selectedBox { + case TimerBox: + return handleTimerKeys(key, hasActiveTimer) + case ClientsProjectsBox: + return handleProjectsKeys(key) + case HistoryBox: + return handleHistoryKeys(key, historyLevel) + } + + return ActionNone +} + +// handleTimerKeys handles keys specific to the timer box +func handleTimerKeys(key string, hasActiveTimer bool) KeyAction { + switch key { + case "enter": + return ActionTimerEnter + case "d": + if hasActiveTimer { + return ActionTimerDescribe + } + } + return ActionNone +} + +// handleProjectsKeys handles keys specific to the projects box +func handleProjectsKeys(key string) KeyAction { + switch key { + case "j", "down": + return ActionProjectsNext + case "k", "up": + return ActionProjectsPrev + case "enter": + return ActionProjectsEnter + case "n": + return ActionProjectsNewProject + case "N": + return ActionProjectsNewClient + } + return ActionNone +} + +// handleHistoryKeys handles keys specific to the history box +func handleHistoryKeys(key string, level HistoryViewLevel) KeyAction { + switch level { + case HistoryLevelSummary: + return handleHistoryLevel1Keys(key) + case HistoryLevelDetails: + return handleHistoryLevel2Keys(key) + } + return ActionNone +} + +// handleHistoryLevel1Keys handles keys for history summary view +func handleHistoryLevel1Keys(key string) KeyAction { + switch key { + case "j", "down": + return ActionHistoryNext + case "k", "up": + return ActionHistoryPrev + case "enter": + return ActionHistoryEnter + } + return ActionNone +} + +// handleHistoryLevel2Keys handles keys for history details view +func handleHistoryLevel2Keys(key string) KeyAction { + switch key { + case "j", "down": + return ActionHistoryNext + case "k", "up": + return ActionHistoryPrev + case "e": + return ActionHistoryEdit + case "d": + return ActionHistoryDelete + case "enter": + return ActionHistoryResume + case "b", "escape": + return ActionHistoryBack + } + return ActionNone +} + +// GetContextualKeyBindings returns the key bindings that should be shown in the bottom bar +func GetContextualKeyBindings(selectedBox BoxType, historyLevel HistoryViewLevel, hasActiveTimer bool) []KeyBinding { + var bindings []KeyBinding + + // Global bindings (always shown) + bindings = append(bindings, []KeyBinding{ + {"Ctrl+n", "Next"}, + {"Ctrl+p", "Prev"}, + }...) + + // Add punch toggle binding + if hasActiveTimer { + bindings = append(bindings, KeyBinding{"p", "Punch Out"}) + } else { + bindings = append(bindings, KeyBinding{"p", "Punch In"}) + } + + // Add search and refresh bindings + bindings = append(bindings, []KeyBinding{ + {"/", "Search"}, + {"r", "Refresh"}, + }...) + + // Context-specific bindings + switch selectedBox { + case TimerBox: + bindings = append(bindings, getTimerKeyBindings(hasActiveTimer)...) + case ClientsProjectsBox: + bindings = append(bindings, getProjectsKeyBindings()...) + case HistoryBox: + bindings = append(bindings, getHistoryKeyBindings(historyLevel)...) + } + + // Always end with quit + bindings = append(bindings, KeyBinding{"q", "Quit"}) + + return bindings +} + +// getTimerKeyBindings returns key bindings for the timer box +func getTimerKeyBindings(hasActiveTimer bool) []KeyBinding { + if hasActiveTimer { + return []KeyBinding{ + {"Enter", "Punch Out"}, + {"d", "Describe"}, + } + } + return []KeyBinding{ + {"Enter", "Resume Recent"}, + } +} + +// getProjectsKeyBindings returns key bindings for the projects box +func getProjectsKeyBindings() []KeyBinding { + return []KeyBinding{ + {"j/k", "Navigate"}, + {"Enter", "Select"}, + {"n", "New Project"}, + {"N", "New Client"}, + } +} + +// getHistoryKeyBindings returns key bindings for the history box +func getHistoryKeyBindings(level HistoryViewLevel) []KeyBinding { + switch level { + case HistoryLevelSummary: + return []KeyBinding{ + {"j/k", "Navigate"}, + {"Enter", "Details"}, + } + case HistoryLevelDetails: + return []KeyBinding{ + {"j/k", "Navigate"}, + {"Enter", "Resume"}, + {"e", "Edit"}, + {"d", "Delete"}, + {"b", "Back"}, + } + } + return []KeyBinding{} +} diff --git a/internal/tui/shared.go b/internal/tui/shared.go new file mode 100644 index 0000000..77b282d --- /dev/null +++ b/internal/tui/shared.go @@ -0,0 +1,225 @@ +package tui + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "punchcard/internal/queries" + + "github.com/charmbracelet/lipgloss/v2" +) + +var ( + // Styles for the TUI + topBarInactiveStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("21")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + bottomBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("238")). + Foreground(lipgloss.Color("252")). + Padding(0, 1) + + // Box styles + selectedBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2) + + unselectedBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(1, 2) + + activeTimerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + inactiveTimerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")) +) + +// FormatDuration formats a duration in a human-readable way +func FormatDuration(d time.Duration) string { + d = d.Round(time.Second) + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if hours > 0 { + return fmt.Sprintf("%dh %02dm", hours, minutes) + } + if minutes > 0 { + return fmt.Sprintf("%dm %02ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +// GetTimeStats retrieves today's and week's time statistics +func GetTimeStats(ctx context.Context, q *queries.Queries) (TimeStats, error) { + var stats TimeStats + + // Get today's total + todaySeconds, err := q.GetTodaySummary(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return stats, fmt.Errorf("failed to get today's summary: %w", err) + } + if err == nil { + stats.TodayTotal = time.Duration(todaySeconds) * time.Second + } + + // Get week's total + weekSummary, err := q.GetWeekSummaryByProject(ctx) + if err != nil { + return stats, fmt.Errorf("failed to get week summary: %w", err) + } + + var weekTotal time.Duration + for _, row := range weekSummary { + weekTotal += time.Duration(row.TotalSeconds) * time.Second + } + stats.WeekTotal = weekTotal + + return stats, nil +} + +// GetTimerInfo retrieves current timer information +func GetTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { + var info TimerInfo + + activeEntry, err := q.GetActiveTimeEntry(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return info, fmt.Errorf("failed to get active timer: %w", err) + } + + if errors.Is(err, sql.ErrNoRows) { + // No active timer + return info, nil + } + + // Active timer found + info.IsActive = true + info.StartTime = activeEntry.StartTime + info.Duration = time.Since(activeEntry.StartTime) + + // Get client information + client, err := q.FindClient(ctx, queries.FindClientParams{ + ID: activeEntry.ClientID, + Name: "", + }) + if err == nil && len(client) > 0 { + info.ClientName = client[0].Name + if client[0].BillableRate.Valid { + rate := float64(client[0].BillableRate.Int64) / 100.0 + info.BillableRate = &rate + } + } + + // Get project information if exists + if activeEntry.ProjectID.Valid { + project, err := q.FindProject(ctx, queries.FindProjectParams{ + ID: activeEntry.ProjectID.Int64, + Name: "", + }) + if err == nil && len(project) > 0 { + info.ProjectName = project[0].Name + if project[0].BillableRate.Valid { + projectRate := float64(project[0].BillableRate.Int64) / 100.0 + info.BillableRate = &projectRate + } + } + } + + // Get description + if activeEntry.Description.Valid { + info.Description = activeEntry.Description.String + } + + // Use entry-specific billable rate if set + if activeEntry.BillableRate.Valid { + entryRate := float64(activeEntry.BillableRate.Int64) / 100.0 + info.BillableRate = &entryRate + } + + return info, nil +} + +// RenderTopBar renders the top bar with view name and time stats +func RenderTopBar(viewName string, stats TimeStats, width int) string { + left := viewName + right := fmt.Sprintf("Today: %s | Week: %s", + FormatDuration(stats.TodayTotal), + FormatDuration(stats.WeekTotal)) + + // Use lipgloss to create left and right aligned content + leftStyle := lipgloss.NewStyle().Align(lipgloss.Left) + rightStyle := lipgloss.NewStyle().Align(lipgloss.Right) + + // Calculate available width for content (minus padding) + contentWidth := width - 2 // Account for horizontal padding + + // Create a layout with left and right content + content := lipgloss.JoinHorizontal( + lipgloss.Top, + leftStyle.Width(contentWidth/2).Render(left), + rightStyle.Width(contentWidth/2).Render(right), + ) + + return topBarInactiveStyle.Width(width).Render(content) +} + +// RenderBottomBar renders the bottom bar with key bindings +func RenderBottomBar(bindings []KeyBinding, width int) string { + var content string + for i, binding := range bindings { + if i > 0 { + content += " " + } + // Format key with bold and square brackets + keyStyle := lipgloss.NewStyle().Bold(true) + formattedKey := keyStyle.Render(fmt.Sprintf("[%s]", binding.Key)) + content += fmt.Sprintf("%s %s", formattedKey, binding.Description) + } + + return bottomBarStyle.Width(width).Render(content) +} + +// GetAppData fetches all data needed for the TUI +func GetAppData(ctx context.Context, q *queries.Queries) (TimerInfo, TimeStats, []queries.Client, []queries.ListAllProjectsRow, []queries.TimeEntry, error) { + // Get timer info + timerInfo, err := GetTimerInfo(ctx, q) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get timer info: %w", err) + } + + // Get time stats + stats, err := GetTimeStats(ctx, q) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get time stats: %w", err) + } + + // Get clients + clients, err := q.ListAllClients(ctx) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get clients: %w", err) + } + + // Get projects + projects, err := q.ListAllProjects(ctx) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get projects: %w", err) + } + + // Get recent entries + entries, err := q.GetRecentTimeEntries(ctx, 20) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get recent entries: %w", err) + } + + return timerInfo, stats, clients, projects, entries, nil +} + diff --git a/internal/tui/timer.go b/internal/tui/timer.go new file mode 100644 index 0000000..827951d --- /dev/null +++ b/internal/tui/timer.go @@ -0,0 +1,150 @@ +package tui + +import ( + "context" + "fmt" + "time" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" +) + +// TimerModel represents the timer view model +type TimerModel struct { + ctx context.Context + queries *queries.Queries + timerInfo TimerInfo + stats TimeStats + lastTick time.Time +} + +// NewTimerModel creates a new timer model +func NewTimerModel(ctx context.Context, q *queries.Queries) TimerModel { + return TimerModel{ + ctx: ctx, + queries: q, + } +} + +// Init initializes the timer model +func (m TimerModel) Init() tea.Cmd { + return tea.Batch( + m.updateData(), + m.tickCmd(), + ) +} + +// Update handles messages for the timer model +func (m TimerModel) Update(msg tea.Msg) (TimerModel, tea.Cmd) { + switch msg := msg.(type) { + case TickMsg: + // Update timer duration if active + if m.timerInfo.IsActive { + m.timerInfo.Duration = time.Since(m.timerInfo.StartTime) + } + m.lastTick = time.Time(msg) + return m, m.tickCmd() + } + + return m, nil +} + +// View renders the timer view +func (m TimerModel) View(width, height int) string { + var content string + + if m.timerInfo.IsActive { + content += m.renderActiveTimer() + } else { + content += m.renderInactiveTimer() + } + + return content +} + +// renderActiveTimer renders the active timer display +func (m TimerModel) renderActiveTimer() string { + var content string + + // Timer status + timerLine := fmt.Sprintf("⏱ Tracking: %s", FormatDuration(m.timerInfo.Duration)) + content += activeTimerStyle.Render(timerLine) + "\n" + + // Project/Client info + if m.timerInfo.ProjectName != "" { + projectLine := fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName) + content += projectLine + "\n" + } else { + clientLine := fmt.Sprintf("Client: %s", m.timerInfo.ClientName) + content += clientLine + "\n" + } + + // Description if available + if m.timerInfo.Description != "" { + descLine := fmt.Sprintf("Description: %s", m.timerInfo.Description) + content += descLine + "\n" + } + + // Billable rate if available + if m.timerInfo.BillableRate != nil { + rateLine := fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate) + content += rateLine + "\n" + } + + // Start time (convert from UTC to local) + localStartTime := m.timerInfo.StartTime.Local() + startLine := fmt.Sprintf("Started: %s", localStartTime.Format("3:04 PM")) + content += startLine + "\n" + + return content +} + +// renderInactiveTimer renders the inactive timer display +func (m TimerModel) renderInactiveTimer() string { + var content string + + content += inactiveTimerStyle.Render("⚪ No active timer") + "\n" + content += "\n" + content += "Ready to start tracking time.\n" + + return content +} + +// updateData fetches fresh data from the database +func (m TimerModel) updateData() tea.Cmd { + return func() tea.Msg { + // Get timer info + timerInfo, err := GetTimerInfo(m.ctx, m.queries) + if err != nil { + // Handle error silently for now + return nil + } + + // Get time stats + stats, err := GetTimeStats(m.ctx, m.queries) + if err != nil { + // Handle error silently for now + return nil + } + + return dataUpdatedMsg{ + timerInfo: timerInfo, + stats: stats, + } + } +} + +// tickCmd returns a command that sends a tick message every second +func (m TimerModel) tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return TickMsg(t) + }) +} + +// UpdateData updates the model with fresh data +func (m TimerModel) UpdateData(timerInfo TimerInfo, stats TimeStats) TimerModel { + m.timerInfo = timerInfo + m.stats = stats + return m +} diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go new file mode 100644 index 0000000..17781ee --- /dev/null +++ b/internal/tui/timer_box.go @@ -0,0 +1,101 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// NewTimerBoxModel creates a new timer box model +func NewTimerBoxModel() TimerBoxModel { + return TimerBoxModel{} +} + +// Update handles messages for the timer box +func (m TimerBoxModel) Update(msg tea.Msg) (TimerBoxModel, tea.Cmd) { + return m, nil +} + +// View renders the timer box +func (m TimerBoxModel) View(width, height int, isSelected bool) string { + var content string + + if m.timerInfo.IsActive { + content = m.renderActiveTimer() + } else { + content = m.renderInactiveTimer() + } + + // Apply box styling + style := unselectedBoxStyle + if isSelected { + style = selectedBoxStyle + } + + var title string + if m.timerInfo.IsActive { + title = "⏱ Active Timer" + } else { + title = "⚪ Timer (Inactive)" + } + + return style.Width(width).Height(height).Render( + fmt.Sprintf("%s\n\n%s", title, content), + ) +} + +// renderActiveTimer renders the active timer display +func (m TimerBoxModel) renderActiveTimer() string { + var content string + + // Timer duration + timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration)) + content += activeTimerStyle.Render(timerLine) + "\n\n" + + // Project/Client info + if m.timerInfo.ProjectName != "" { + projectLine := fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName) + content += projectLine + "\n" + } else { + clientLine := fmt.Sprintf("Client: %s", m.timerInfo.ClientName) + content += clientLine + "\n" + } + + // Start time (convert from UTC to local) + localStartTime := m.timerInfo.StartTime.Local() + startLine := fmt.Sprintf("Started: %s", localStartTime.Format("3:04 PM")) + content += startLine + "\n" + + // Description if available + if m.timerInfo.Description != "" { + content += "\n" + descLine := fmt.Sprintf("Description: %s", m.timerInfo.Description) + content += descLine + "\n" + } + + // Billable rate if available + if m.timerInfo.BillableRate != nil { + rateLine := fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate) + content += rateLine + "\n" + } + + return content +} + +// renderInactiveTimer renders the inactive timer display +func (m TimerBoxModel) renderInactiveTimer() string { + var content string + + content += "No active timer\n\n" + content += "Ready to start tracking time.\n" + content += "Use 'i' to punch in or select\n" + content += "a client/project from the left." + + return content +} + +// UpdateTimerInfo updates the timer info +func (m TimerBoxModel) UpdateTimerInfo(timerInfo TimerInfo) TimerBoxModel { + m.timerInfo = timerInfo + return m +}
\ No newline at end of file diff --git a/internal/tui/types.go b/internal/tui/types.go new file mode 100644 index 0000000..2fcf55c --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,165 @@ +package tui + +import ( + "context" + "time" + + "punchcard/internal/queries" +) + +// BoxType represents the different boxes that can be selected +type BoxType int + +const ( + TimerBox BoxType = iota + ClientsProjectsBox + HistoryBox +) + +func (b BoxType) String() string { + switch b { + case TimerBox: + return "Timer" + case ClientsProjectsBox: + return "Clients & Projects" + case HistoryBox: + return "History" + default: + return "Unknown" + } +} + +// AppModel is the main model for the TUI application +type AppModel struct { + ctx context.Context + queries *queries.Queries + selectedBox BoxType + timerBoxModel TimerBoxModel + clientsProjectsModel ClientsProjectsModel + historyBoxModel HistoryBoxModel + width int + height int + // Cached data to avoid DB queries in View() + stats TimeStats + runningTimerStart *time.Time // UTC timestamp when timer started, nil if not active + + // Modal state + showModal bool + modalType ModalType + textInputModel TextInputModel +} + +// ModalType represents different types of modals +type ModalType int + +const ( + ModalDescribeTimer ModalType = iota +) + +// TextInputModel represents a text input modal +type TextInputModel struct { + prompt string + value string + placeholder string + cursorPos int +} + +// TimerInfo holds information about the current timer state +type TimerInfo struct { + IsActive bool + Duration time.Duration + StartTime time.Time + ClientName string + ProjectName string + Description string + BillableRate *float64 +} + +// TimeStats holds time statistics for display +type TimeStats struct { + TodayTotal time.Duration + WeekTotal time.Duration +} + +// TickMsg is sent every second to update the timer +type TickMsg time.Time + +// KeyBinding represents the available key bindings for a view +type KeyBinding struct { + Key string + Description string +} + +// 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 +) + +// Box models for the three main components +type TimerBoxModel struct { + timerInfo TimerInfo +} + +type ClientsProjectsModel struct { + clients []queries.Client + projects []queries.ListAllProjectsRow + selectedIndex int // Index of selected row (client or project) + selectedIsClient bool // True if selected row is a client, false if project +} + +type HistoryBoxModel struct { + entries []queries.TimeEntry + clients []queries.Client // For looking up client names + projects []queries.ListAllProjectsRow // For looking up project names + viewLevel HistoryViewLevel + selectedIndex int // Index of selected row + // Cached running timer data to avoid recalculating in View() + runningTimerStart *time.Time // UTC timestamp when timer started, nil if not active + + // Summary view data (level 1) + summaryItems []HistorySummaryItem + + // Details view data (level 2) + detailsEntries []queries.TimeEntry + selectedSummaryItem *HistorySummaryItem // Which summary item we drilled down from +} + +// HistorySummaryItem represents a date + client/project combination with total duration +type HistorySummaryItem struct { + Date time.Time + ClientID int64 + ClientName string + ProjectID *int64 // nil if no project + ProjectName *string // nil if no project + TotalDuration time.Duration + EntryCount int +} + +// HistoryDisplayItem represents an item in the history view (either date header or summary/detail item) +type HistoryDisplayItem struct { + Type HistoryDisplayItemType + DateHeader *string // Set if Type is DateHeader + Summary *HistorySummaryItem // Set if Type is Summary + Entry *queries.TimeEntry // Set if Type is Entry + IsSelectable bool +} + +type HistoryDisplayItemType int + +const ( + HistoryItemDateHeader HistoryDisplayItemType = iota + HistoryItemSummary + HistoryItemEntry +) + +// ProjectsDisplayItem represents an item in the projects display order (either client or project) +type ProjectsDisplayItem struct { + IsClient bool + ClientIndex int // Index in m.clients + ProjectIndex int // Index in m.projects, only used when IsClient=false + Client *queries.Client + Project *queries.ListAllProjectsRow +} |