summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-06 16:00:05 -0600
committerT <t@tjp.lol>2025-08-06 16:26:00 -0600
commitc53e8c4e41aa88566b101431bcd104ebf7b34312 (patch)
tree614e1c911fae328cfdb1a35050bf94658d9253f8 /internal
parent65e2ed65775d64afbc6065a3b4ac1069020093ca (diff)
TUI fixes, and WIP modal dialog rendering
Diffstat (limited to 'internal')
-rw-r--r--internal/commands/root.go2
-rw-r--r--internal/tui/app.go26
-rw-r--r--internal/tui/commands.go13
-rw-r--r--internal/tui/history_box.go40
-rw-r--r--internal/tui/keys.go7
-rw-r--r--internal/tui/modal.go59
-rw-r--r--internal/tui/shared.go5
-rw-r--r--internal/tui/timer_box.go25
8 files changed, 144 insertions, 33 deletions
diff --git a/internal/commands/root.go b/internal/commands/root.go
index fda3d06..f3611ac 100644
--- a/internal/commands/root.go
+++ b/internal/commands/root.go
@@ -15,7 +15,7 @@ func NewRootCmd() *cobra.Command {
Use: "punch",
Short: "A simple time tracking CLI tool",
Long: "Punchcard helps you track your work hours and generate professional invoices and timesheets.",
- RunE: NewStatusCmd().RunE, // Default to status command when no subcommand is provided
+ RunE: NewTUICmd().RunE,
}
cmd.AddCommand(NewAddCmd())
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
}