summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--go.mod3
-rw-r--r--go.sum6
-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
11 files changed, 151 insertions, 37 deletions
diff --git a/README.md b/README.md
index bf723e7..39f5586 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# 👊 Punchcard ♣️
+# 👊 Punchcard ♦️
A simple time tracking CLI tool for freelancers and consultants.
diff --git a/go.mod b/go.mod
index b9b28f6..e17202f 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,9 @@ module punchcard
go 1.24.4
require (
+ github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
- github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2
+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
github.com/spf13/cobra v1.9.1
modernc.org/sqlite v1.38.2
)
diff --git a/go.sum b/go.sum
index 1a90f86..c6bc1e8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,13 +1,15 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 h1:vq2enzx1Hr3UenVefpPEf+E2xMmqtZoSHhx8IE+V8ug=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
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
}