diff options
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/app.go | 26 | ||||
-rw-r--r-- | internal/tui/commands.go | 13 | ||||
-rw-r--r-- | internal/tui/history_box.go | 40 | ||||
-rw-r--r-- | internal/tui/keys.go | 7 | ||||
-rw-r--r-- | internal/tui/modal.go | 59 | ||||
-rw-r--r-- | internal/tui/shared.go | 5 | ||||
-rw-r--r-- | internal/tui/timer_box.go | 25 |
7 files changed, 143 insertions, 32 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go index 2ba2a9b..c94adfd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -65,6 +65,7 @@ type AppModel struct { timerBox TimerBoxModel projectsBox ClientsProjectsModel historyBox HistoryBoxModel + modalBox ModalBoxModel width int height int @@ -161,6 +162,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyBox.drillUp() } + case searchActivated: + m.modalBox.activate(ModalTypeSearch) + } return m, tea.Batch(cmds...) @@ -173,23 +177,23 @@ func (m AppModel) View() string { } topBarHeight := 1 - bottomBarHeight := 1 + bottomBarHeight := 2 contentHeight := m.height - topBarHeight - bottomBarHeight - vertBoxOverhead := 6 // 2 border, 4 padding - horizBoxOverhead := 4 // 2 border, 2 padding - // Timer box top-left - timerBoxWidth := (m.width / 3) - horizBoxOverhead - timerBoxHeight := (contentHeight / 2) - vertBoxOverhead + timerBoxWidth := (m.width / 3) + timerBoxHeight := (contentHeight / 2) + if timerBoxWidth < 30 { + timerBoxWidth = 30 + } // Projects box bottom-left - projectsBoxWidth := (m.width / 3) - horizBoxOverhead - projectsBoxHeight := (contentHeight / 2) - vertBoxOverhead + projectsBoxWidth := timerBoxWidth + projectsBoxHeight := (contentHeight / 2) // History box right side full height - historyBoxWidth := (m.width * 2 / 3) - horizBoxOverhead - historyBoxHeight := contentHeight - vertBoxOverhead + historyBoxWidth := m.width - projectsBoxWidth + historyBoxHeight := contentHeight activeDur := m.timerBox.activeTime() stats := m.timeStats @@ -208,7 +212,7 @@ func (m AppModel) View() string { keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel) bottomBar := RenderBottomBar(m, keyBindings, m.err) - return topBar + "\n" + mainContent + "\n" + bottomBar + return m.modalBox.RenderCenteredOver(topBar + "\n" + mainContent + "\n" + bottomBar, m) } // dataUpdatedMsg is sent when data is updated from the database diff --git a/internal/tui/commands.go b/internal/tui/commands.go index c54df29..4a19551 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -9,10 +9,11 @@ import ( ) type ( - navigationMsg struct{ Forward bool } - selectionMsg struct{ Forward bool } - drillDownMsg struct{} - drillUpMsg struct{} + navigationMsg struct{ Forward bool } + selectionMsg struct{ Forward bool } + drillDownMsg struct{} + drillUpMsg struct{} + searchActivated struct{} ) func navigate(forward bool) tea.Cmd { @@ -63,3 +64,7 @@ func backToHistorySummary() tea.Cmd { func changeSelection(forward bool) tea.Cmd { return func() tea.Msg { return selectionMsg{forward} } } + +func activateSearch() tea.Cmd { + return func() tea.Msg { return searchActivated{} } +} diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index a524d6d..10ede60 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -8,6 +8,7 @@ import ( "punchcard/internal/queries" + "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss/v2" ) @@ -46,6 +47,17 @@ type HistorySummaryItem struct { EntryCount int } +func (item HistorySummaryItem) key() HistorySummaryKey { + key := HistorySummaryKey{ + Date: dateOnly(item.Date), + ClientID: item.ClientID, + } + if item.ProjectID != nil { + key.ProjectID = *item.ProjectID + } + return key +} + // NewHistoryBoxModel creates a new history box model func NewHistoryBoxModel() HistoryBoxModel { return HistoryBoxModel{} @@ -151,7 +163,7 @@ func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBox } else { switch m.viewLevel { case HistoryLevelSummary: - content = m.renderSummaryView() + content = m.renderSummaryView(timer) case HistoryLevelDetails: content = m.renderDetailsView(timer) } @@ -161,8 +173,11 @@ func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBox if isSelected { style = selectedBoxStyle } + style = style.Width(width).Height(height) - return style.Width(width).Height(height).Render(content) + vp := viewport.New(width-2, height-4) + vp.SetContent(content) + return style.Render(vp.View()) } var ( @@ -178,9 +193,20 @@ var ( ) // renderSummaryView renders the summary view (level 1) with date headers and client/project summaries -func (m HistoryBoxModel) renderSummaryView() string { +func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel) string { content := "📝 Recent History" + var activeKey HistorySummaryKey + if timer.timerInfo.IsActive { + activeKey = HistorySummaryKey{ + Date: dateOnly(timer.timerInfo.StartTime), + ClientID: timer.timerInfo.ClientID, + } + if timer.timerInfo.ProjectID != nil { + activeKey.ProjectID = *timer.timerInfo.ProjectID + } + } + if len(m.summaryItems) == 0 { return "\n\nNo recent entries found." } @@ -197,8 +223,12 @@ func (m HistoryBoxModel) renderSummaryView() string { style = selectedItemStyle } - // TODO: add in duration from the currently running timer (requires other data from AppModel) - line := fmt.Sprintf(" %s (%s)", m.formatSummaryTitle(item), FormatDuration(item.TotalDuration)) + dur := item.TotalDuration + if item.key() == activeKey { + dur += timer.currentTime.Sub(timer.timerInfo.StartTime) + } + + line := fmt.Sprintf(" %s (%s)", m.formatSummaryTitle(item), FormatDuration(dur)) content += fmt.Sprintf("\n%s", style.Render(line)) } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index d2e08f4..46b664c 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -26,11 +26,10 @@ type KeyBinding struct { } type ( - createProjectMsg struct{} createClientMsg struct{} - activateSearch struct{} - editHistoryEntry struct{} + createProjectMsg struct{} deleteHistoryEntry struct{} + editHistoryEntry struct{} ) func msgAsCmd(msg tea.Msg) tea.Cmd { @@ -71,7 +70,7 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map Key: "/", Description: func(am AppModel) string { return "Search" }, Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return msgAsCmd(activateSearch{}) }, + Result: func(AppModel) tea.Cmd { return activateSearch() }, }, "r": KeyBinding{ Key: "r", diff --git a/internal/tui/modal.go b/internal/tui/modal.go new file mode 100644 index 0000000..3a57880 --- /dev/null +++ b/internal/tui/modal.go @@ -0,0 +1,59 @@ +package tui + +import "github.com/charmbracelet/lipgloss/v2" + +type ModalType int + +const ( + ModalTypeSearch ModalType = iota + ModalTypeClient + ModalTypeProject + ModalTypeDeleteConfirmation + ModalTypeEntry +) + +type ModalBoxModel struct { + Active bool + + Type ModalType +} + +func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) string { + if !m.Active { + return mainContent + } + + modalContent := m.Render() + + base := lipgloss.NewLayer(mainContent) + + overlayWidth := 60 + overlayHeight := 5 + overlayLeft := (app.width - overlayWidth) / 2 + overlayTop := (app.height - overlayHeight) / 2 + + overlayStyle := lipgloss.NewStyle().Height(overlayHeight).Width(overlayWidth).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")) + + overlay := lipgloss.NewLayer(overlayStyle.Render(modalContent)).Width(overlayWidth).Height(overlayHeight) + + canvas := lipgloss.NewCanvas( + base.Z(0), + overlay.X(overlayLeft).Y(overlayTop).Z(1), + ) + + return canvas.Render() +} + +func (m ModalBoxModel) Render() string { + switch m.Type { + case ModalTypeSearch: + return "SEARCH BOX" + default: // REMOVE ME + return "DEFAULT CONTENT" + } +} + +func (m *ModalBoxModel) activate(t ModalType) { + m.Active = true + m.Type = t +} diff --git a/internal/tui/shared.go b/internal/tui/shared.go index b6bca20..0b0a8c2 100644 --- a/internal/tui/shared.go +++ b/internal/tui/shared.go @@ -22,8 +22,7 @@ var ( bottomBarStyle = lipgloss.NewStyle(). Background(lipgloss.Color("238")). - Foreground(lipgloss.Color("252")). - Padding(0, 1) + Foreground(lipgloss.Color("252")) // Box styles selectedBoxStyle = lipgloss.NewStyle(). @@ -52,7 +51,7 @@ func FormatDuration(d time.Duration) string { seconds := int(d.Seconds()) % 60 if hours > 0 { - return fmt.Sprintf("%dh %02dm", hours, minutes) + return fmt.Sprintf("%dh %02dm %02ds", hours, minutes, seconds) } if minutes > 0 { return fmt.Sprintf("%dm %02ds", minutes, seconds) diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go index 408c3b5..1a88870 100644 --- a/internal/tui/timer_box.go +++ b/internal/tui/timer_box.go @@ -50,7 +50,9 @@ type TimerBoxModel struct { // NewTimerBoxModel creates a new timer box model func NewTimerBoxModel() TimerBoxModel { - return TimerBoxModel{} + return TimerBoxModel{ + currentTime: time.Now(), + } } // View renders the timer box @@ -114,10 +116,23 @@ func (m TimerBoxModel) renderActiveTimer() string { func (m TimerBoxModel) renderInactiveTimer() string { content := "⚪ Last Timer (Inactive)\n\n" - content += "No active timer\n\n" - content += "Ready to start tracking time.\n" - content += "Use 'p' to punch in, or select\n" - content += "a client/project from the left." + timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration)) + content += inactiveTimerStyle.Render(timerLine) + "\n\n" + + if m.timerInfo.ProjectName != "" { + content += inactiveTimerStyle.Render(fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName)) + "\n" + } else { + content += inactiveTimerStyle.Render(fmt.Sprintf("Client: %s", m.timerInfo.ClientName)) + "\n" + } + + content += inactiveTimerStyle.Render(fmt.Sprintf("Started: %s", m.timerInfo.StartTime.Local().Format("3:04 PM"))) + "\n" + + if m.timerInfo.Description != nil { + content += "\n" + inactiveTimerStyle.Render(fmt.Sprintf("Description: %s", *m.timerInfo.Description)) + "\n" + } + if m.timerInfo.BillableRate != nil { + content += inactiveTimerStyle.Render(fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate)) + "\n" + } return content } |