summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-05 12:36:30 -0600
committerT <t@tjp.lol>2025-08-06 12:13:11 -0600
commit65e2ed65775d64afbc6065a3b4ac1069020093ca (patch)
treef94fabfed5be2d2622429ebc7c8af1bf51085824 /internal
parent665bd389a0a1c8adadcaa1122e846cc81f5ead31 (diff)
most features in TUI working, remaining unimplemented keybinds need a modal view
Diffstat (limited to 'internal')
-rw-r--r--internal/actions/actions.go18
-rw-r--r--internal/actions/clients.go6
-rw-r--r--internal/actions/projects.go6
-rw-r--r--internal/actions/timer.go10
-rw-r--r--internal/database/queries.sql8
-rw-r--r--internal/queries/queries.sql.go16
-rw-r--r--internal/tui/app.go599
-rw-r--r--internal/tui/clients_projects_box.go247
-rw-r--r--internal/tui/commands.go65
-rw-r--r--internal/tui/history_box.go683
-rw-r--r--internal/tui/keys.go486
-rw-r--r--internal/tui/projects_box.go194
-rw-r--r--internal/tui/shared.go294
-rw-r--r--internal/tui/timer.go150
-rw-r--r--internal/tui/timer_box.go107
-rw-r--r--internal/tui/types.go165
16 files changed, 1254 insertions, 1800 deletions
diff --git a/internal/actions/actions.go b/internal/actions/actions.go
index 5e2610a..727b474 100644
--- a/internal/actions/actions.go
+++ b/internal/actions/actions.go
@@ -2,6 +2,7 @@ package actions
import (
"context"
+
"punchcard/internal/queries"
)
@@ -11,24 +12,23 @@ type Actions interface {
PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error)
PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error)
PunchOut(ctx context.Context) (*TimerSession, error)
-
+
// Client operations
CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error)
FindClient(ctx context.Context, nameOrID string) (*queries.Client, error)
-
- // Project operations
+
+ // Project operations
CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error)
FindProject(ctx context.Context, nameOrID string) (*queries.Project, error)
}
// New creates a new Actions instance
func New(q *queries.Queries) Actions {
- return &actionsImpl{
- queries: q,
- }
+ return &actions{queries: q}
}
-// actionsImpl implements the Actions interface
-type actionsImpl struct {
+// actions implements the Actions interface
+type actions struct {
queries *queries.Queries
-} \ No newline at end of file
+}
+
diff --git a/internal/actions/clients.go b/internal/actions/clients.go
index bc77139..c71ef4a 100644
--- a/internal/actions/clients.go
+++ b/internal/actions/clients.go
@@ -12,7 +12,7 @@ import (
)
// CreateClient creates a new client with the given name and optional email/rate
-func (a *actionsImpl) CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) {
+func (a *actions) CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) {
// Parse name and email if name contains email format "Name <email>"
finalName, finalEmail := parseNameAndEmail(name, email)
@@ -40,7 +40,7 @@ func (a *actionsImpl) CreateClient(ctx context.Context, name, email string, bill
}
// FindClient finds a client by name or ID
-func (a *actionsImpl) FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) {
+func (a *actions) FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) {
// Parse as ID if possible, otherwise use 0
var idParam int64
if id, err := strconv.ParseInt(nameOrID, 10, 64); err == nil {
@@ -89,4 +89,4 @@ func parseNameAndEmail(nameArg, emailArg string) (string, string) {
return finalName, finalEmail
}
-var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`) \ No newline at end of file
+var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`)
diff --git a/internal/actions/projects.go b/internal/actions/projects.go
index f991728..21f5ef5 100644
--- a/internal/actions/projects.go
+++ b/internal/actions/projects.go
@@ -10,7 +10,7 @@ import (
)
// CreateProject creates a new project for the specified client
-func (a *actionsImpl) CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) {
+func (a *actions) CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) {
// Find the client first
clientRecord, err := a.FindClient(ctx, client)
if err != nil {
@@ -36,7 +36,7 @@ func (a *actionsImpl) CreateProject(ctx context.Context, name, client string, bi
}
// FindProject finds a project by name or ID
-func (a *actionsImpl) FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) {
+func (a *actions) FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) {
// Parse as ID if possible, otherwise use 0
var idParam int64
if id, err := strconv.ParseInt(nameOrID, 10, 64); err == nil {
@@ -61,4 +61,4 @@ func (a *actionsImpl) FindProject(ctx context.Context, nameOrID string) (*querie
default:
return nil, fmt.Errorf("%w: %s matches multiple projects", ErrAmbiguousProject, nameOrID)
}
-} \ No newline at end of file
+}
diff --git a/internal/actions/timer.go b/internal/actions/timer.go
index 58dbba2..5235a0a 100644
--- a/internal/actions/timer.go
+++ b/internal/actions/timer.go
@@ -11,7 +11,7 @@ import (
// PunchIn starts a timer for the specified client/project
// Use empty strings for client/project to use most recent entry
-func (a *actionsImpl) PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) {
+func (a *actions) PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) {
// If no client specified, delegate to PunchInMostRecent
if client == "" && project == "" {
session, err := a.PunchInMostRecent(ctx, description, billableRate)
@@ -119,7 +119,7 @@ func (a *actionsImpl) PunchIn(ctx context.Context, client, project, description
}
// PunchInMostRecent starts a timer copying the most recent time entry
-func (a *actionsImpl) PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) {
+func (a *actions) PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) {
// Get most recent entry
mostRecent, err := a.queries.GetMostRecentTimeEntry(ctx)
if err != nil {
@@ -219,7 +219,7 @@ func (a *actionsImpl) PunchInMostRecent(ctx context.Context, description string,
}
// PunchOut stops the active timer
-func (a *actionsImpl) PunchOut(ctx context.Context) (*TimerSession, error) {
+func (a *actions) PunchOut(ctx context.Context) (*TimerSession, error) {
stoppedEntry, err := a.queries.StopTimeEntry(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -264,7 +264,7 @@ func (a *actionsImpl) PunchOut(ctx context.Context) (*TimerSession, error) {
// Helper functions
-func (a *actionsImpl) createTimeEntry(ctx context.Context, clientID int64, projectID sql.NullInt64, description string, billableRate *float64) (*queries.TimeEntry, error) {
+func (a *actions) createTimeEntry(ctx context.Context, clientID int64, projectID sql.NullInt64, description string, billableRate *float64) (*queries.TimeEntry, error) {
var descParam sql.NullString
if description != "" {
descParam = sql.NullString{String: description, Valid: true}
@@ -339,4 +339,4 @@ func timeEntriesMatch(clientID int64, projectID sql.NullInt64, description strin
// regardless of what coalesced rate the active entry might have
return true
-} \ No newline at end of file
+}
diff --git a/internal/database/queries.sql b/internal/database/queries.sql
index c68cdad..f46f426 100644
--- a/internal/database/queries.sql
+++ b/internal/database/queries.sql
@@ -300,8 +300,8 @@ where date(te.start_time) = date('now');
-- name: GetRecentTimeEntries :many
select * from time_entry
-order by start_time desc
-limit @limit_count;
+where start_time >= @start_time
+order by start_time desc;
-- name: UpdateActiveTimerDescription :exec
update time_entry
@@ -313,3 +313,7 @@ where id = (
order by start_time desc
limit 1
);
+
+-- name: RemoveTimeEntry :exec
+delete from time_entry
+where id = @entry_id;
diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go
index f5cf70f..1084cea 100644
--- a/internal/queries/queries.sql.go
+++ b/internal/queries/queries.sql.go
@@ -656,12 +656,12 @@ func (q *Queries) GetProjectByNameAndClient(ctx context.Context, arg GetProjectB
const getRecentTimeEntries = `-- name: GetRecentTimeEntries :many
select id, start_time, end_time, description, client_id, project_id, billable_rate from time_entry
+where start_time >= ?1
order by start_time desc
-limit ?1
`
-func (q *Queries) GetRecentTimeEntries(ctx context.Context, limitCount int64) ([]TimeEntry, error) {
- rows, err := q.db.QueryContext(ctx, getRecentTimeEntries, limitCount)
+func (q *Queries) GetRecentTimeEntries(ctx context.Context, startTime time.Time) ([]TimeEntry, error) {
+ rows, err := q.db.QueryContext(ctx, getRecentTimeEntries, startTime)
if err != nil {
return nil, err
}
@@ -1020,6 +1020,16 @@ func (q *Queries) ListAllProjects(ctx context.Context) ([]ListAllProjectsRow, er
return items, nil
}
+const removeTimeEntry = `-- name: RemoveTimeEntry :exec
+delete from time_entry
+where id = ?1
+`
+
+func (q *Queries) RemoveTimeEntry(ctx context.Context, entryID int64) error {
+ _, err := q.db.ExecContext(ctx, removeTimeEntry, entryID)
+ return err
+}
+
const stopTimeEntry = `-- name: StopTimeEntry :one
update time_entry
set end_time = datetime('now', 'utc')
diff --git a/internal/tui/app.go b/internal/tui/app.go
index 98bad4f..2ba2a9b 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -2,8 +2,6 @@ package tui
import (
"context"
- "database/sql"
- "fmt"
"time"
"punchcard/internal/queries"
@@ -12,341 +10,160 @@ import (
"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())
+// BoxType represents the different boxes that can be selected
+type BoxType int
- 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{}
- }))
+const (
+ TimerBox BoxType = iota
+ ProjectsBox
+ HistoryBox
+)
- case updateDataCmd:
- cmds = append(cmds, m.updateDataCmd())
+func (b BoxType) String() string {
+ switch b {
+ case TimerBox:
+ return "Timer"
+ case ProjectsBox:
+ return "Clients & Projects"
+ case HistoryBox:
+ return "History"
+ default:
+ return "Unknown"
}
-
- 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()
+func (b BoxType) Next() BoxType {
+ switch b {
+ case TimerBox:
+ return ProjectsBox
+ case ProjectsBox:
+ return HistoryBox
+ case HistoryBox:
+ return TimerBox
}
-
- return nil
+ return 0
}
-// 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
+func (b BoxType) Prev() BoxType {
+ switch b {
+ case TimerBox:
+ return HistoryBox
+ case HistoryBox:
+ return ProjectsBox
+ case ProjectsBox:
+ return TimerBox
}
+ return 0
}
-// Projects pane action handlers
-func (m *AppModel) handleProjectsNext() {
- m.clientsProjectsModel = m.clientsProjectsModel.NextSelection()
-}
+// AppModel is the main model for the TUI application
+type AppModel struct {
+ ctx context.Context
+ queries *queries.Queries
-func (m *AppModel) handleProjectsPrev() {
- m.clientsProjectsModel = m.clientsProjectsModel.PrevSelection()
-}
+ selectedBox BoxType
+ timerBox TimerBoxModel
+ projectsBox ClientsProjectsModel
+ historyBox HistoryBoxModel
-func (m *AppModel) handleProjectsEnter() tea.Cmd {
- // TODO: Punch in to selected client/project
- return nil
-}
+ width int
+ height int
-func (m *AppModel) handleProjectsNewProject() tea.Cmd {
- // TODO: Open new project modal
- return nil
+ timeStats TimeStats
+ err error
}
-func (m *AppModel) handleProjectsNewClient() tea.Cmd {
- // TODO: Open new client modal
- return nil
+// TimeStats holds time statistics for display (excluding the currently running timer, if any)
+type TimeStats struct {
+ TodayTotal time.Duration
+ WeekTotal time.Duration
}
-// History pane action handlers
-func (m *AppModel) handleHistoryNext() {
- m.historyBoxModel = m.historyBoxModel.NextSelection()
+// NewApp creates a new TUI application
+func NewApp(ctx context.Context, q *queries.Queries) *AppModel {
+ return &AppModel{
+ ctx: ctx,
+ queries: q,
+ selectedBox: TimerBox,
+ timerBox: NewTimerBoxModel(),
+ projectsBox: NewClientsProjectsModel(),
+ historyBox: NewHistoryBoxModel(),
+ timeStats: TimeStats{},
+ }
}
-func (m *AppModel) handleHistoryPrev() {
- m.historyBoxModel = m.historyBoxModel.PrevSelection()
+// Init initializes the app
+func (m AppModel) Init() tea.Cmd {
+ return tea.Batch(
+ m.refreshCmd,
+ doTick(),
+ )
}
-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
- }
-}
+// TickMsg is sent every second to update timers
+type TickMsg time.Time
-func (m *AppModel) handleHistoryEdit() tea.Cmd {
- // TODO: Open edit modal for selected entry
- return nil
+// doTick returns a command that sends a tick message in a second
+func doTick() tea.Cmd {
+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
+ return TickMsg(t)
+ })
}
-func (m *AppModel) handleHistoryDelete() tea.Cmd {
- // TODO: Delete selected entry
- return nil
-}
+// Update handles messages for the app
+func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
-func (m *AppModel) handleHistoryResume() tea.Cmd {
- // TODO: Resume selected entry (punch in with same details)
- return nil
-}
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
-func (m *AppModel) handleHistoryBack() {
- // Switch back to summary view
- m.historyBoxModel = m.historyBoxModel.GoBack()
-}
+ case tea.KeyMsg:
+ cmds = append(cmds, HandleKeyPress(msg, m))
-// 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),
- }
-}
+ case TickMsg:
+ m.timerBox.currentTime = time.Time(msg)
+ cmds = append(cmds, doTick())
-// 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 dataUpdatedMsg:
+ m.timerBox.timerInfo = msg.timerInfo
+ m.timerBox.timerInfo.setNames(msg.clients, msg.projects)
+ m.timeStats = msg.stats
+ m.projectsBox.clients = msg.clients
+ m.projectsBox.projects = msg.projects
+ m.historyBox.entries = nil
+ m.historyBox.regenerateSummaries(msg.clients, msg.projects, msg.entries, m.timerBox.timerInfo)
+ m.err = msg.err
+
+ case navigationMsg:
+ if msg.Forward {
+ m.selectedBox = m.selectedBox.Next()
+ } else {
+ m.selectedBox = m.selectedBox.Prev()
}
- case "left":
- if m.textInputModel.cursorPos > 0 {
- m.textInputModel.cursorPos--
+
+ case selectionMsg:
+ switch m.selectedBox {
+ case ProjectsBox:
+ m.projectsBox.changeSelection(msg.Forward)
+ case HistoryBox:
+ m.historyBox.changeSelection(msg.Forward)
}
- case "right":
- if m.textInputModel.cursorPos < len(m.textInputModel.value) {
- m.textInputModel.cursorPos++
+
+ case drillDownMsg:
+ if m.selectedBox == HistoryBox {
+ m.historyBox.drillDown()
}
- 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++
+
+ case drillUpMsg:
+ if m.selectedBox == HistoryBox {
+ m.historyBox.drillUp()
}
- }
-
- 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{}
- }
+ return m, tea.Batch(cmds...)
}
// View renders the app
@@ -355,177 +172,71 @@ func (m AppModel) View() string {
return "Loading..."
}
- // Calculate dimensions
topBarHeight := 1
bottomBarHeight := 1
contentHeight := m.height - topBarHeight - bottomBarHeight
- vertBoxOverhead := 6 // 2 border, 4 padding
+ vertBoxOverhead := 6 // 2 border, 4 padding
horizBoxOverhead := 4 // 2 border, 2 padding
- // Timer box is in top-left
+ // Timer box 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
+ // Projects box bottom-left
+ projectsBoxWidth := (m.width / 3) - horizBoxOverhead
+ projectsBoxHeight := (contentHeight / 2) - vertBoxOverhead
- // History box takes the right side
- historyBoxWidth := (m.width - (m.width / 3)) - horizBoxOverhead
+ // History box right side full height
+ historyBoxWidth := (m.width * 2 / 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)
+ activeDur := m.timerBox.activeTime()
+ stats := m.timeStats
+ stats.TodayTotal += activeDur
+ stats.WeekTotal += activeDur
- // 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)
+ topBar := RenderTopBar(m)
- // 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)
+ timerBox := m.timerBox.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox)
+ projectsBox := m.projectsBox.View(projectsBoxWidth, projectsBoxHeight, m.selectedBox == ProjectsBox)
+ historyBox := m.historyBox.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox, m.timerBox)
- // 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)
-}
+ leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, projectsBox)
+ mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox)
+ keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel)
+ bottomBar := RenderBottomBar(m, keyBindings, m.err)
-// 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
+ return topBar + "\n" + mainContent + "\n" + bottomBar
}
-// 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
+ projects map[int64][]queries.Project
entries []queries.TimeEntry
+ err error
}
-// 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{},
- }
- }
+// refreshCmd is a command to update all app data
+func (m AppModel) refreshCmd() tea.Msg {
+ timerInfo, stats, clients, projects, entries, err := getAppData(m.ctx, m.queries)
+ if err != nil {
+ msg := dataUpdatedMsg{}
+ msg.err = err
+ return msg
+ }
- return dataUpdatedMsg{
- timerInfo: timerInfo,
- stats: stats,
- clients: clients,
- projects: projects,
- entries: entries,
- }
+ return dataUpdatedMsg{
+ timerInfo: timerInfo,
+ stats: stats,
+ clients: clients,
+ projects: projects,
+ entries: entries,
+ err: nil,
}
}
diff --git a/internal/tui/clients_projects_box.go b/internal/tui/clients_projects_box.go
deleted file mode 100644
index c52e964..0000000
--- a/internal/tui/clients_projects_box.go
+++ /dev/null
@@ -1,247 +0,0 @@
-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/commands.go b/internal/tui/commands.go
new file mode 100644
index 0000000..c54df29
--- /dev/null
+++ b/internal/tui/commands.go
@@ -0,0 +1,65 @@
+package tui
+
+import (
+ "context"
+
+ "punchcard/internal/actions"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type (
+ navigationMsg struct{ Forward bool }
+ selectionMsg struct{ Forward bool }
+ drillDownMsg struct{}
+ drillUpMsg struct{}
+)
+
+func navigate(forward bool) tea.Cmd {
+ return func() tea.Msg { return navigationMsg{forward} }
+}
+
+func punchIn(m AppModel) tea.Cmd {
+ return func() tea.Msg {
+ _, _ = actions.New(m.queries).PunchInMostRecent(context.Background(), "", nil)
+ // TODO: use the returned TimerSession instead of re-querying everything
+ return m.refreshCmd()
+ }
+}
+
+func punchOut(m AppModel) tea.Cmd {
+ return func() tea.Msg {
+ _, _ = actions.New(m.queries).PunchOut(context.Background())
+ // TODO: use the returned TimerSession instead of re-querying everything
+ return m.refreshCmd()
+ }
+}
+
+func punchInOnSelection(m AppModel) tea.Cmd {
+ return func() tea.Msg {
+ var clientID, projectID, description string
+ var entryRate *float64
+ switch m.selectedBox {
+ case ProjectsBox:
+ clientID, projectID, description, entryRate = m.projectsBox.selection()
+ case HistoryBox:
+ clientID, projectID, description, entryRate = m.historyBox.selection()
+ }
+
+ _, _ = actions.New(m.queries).PunchIn(context.Background(), clientID, projectID, description, entryRate)
+ // TODO: use the returned TimerSession instead of re-querying everything
+ return m.refreshCmd()
+ }
+}
+
+func selectHistorySummary() tea.Cmd {
+ return func() tea.Msg { return drillDownMsg{} }
+}
+
+func backToHistorySummary() tea.Cmd {
+ return func() tea.Msg { return drillUpMsg{} }
+}
+
+func changeSelection(forward bool) tea.Cmd {
+ return func() tea.Msg { return selectionMsg{forward} }
+}
diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go
index 813eb17..a524d6d 100644
--- a/internal/tui/history_box.go
+++ b/internal/tui/history_box.go
@@ -2,134 +2,238 @@ package tui
import (
"fmt"
- "sort"
+ "slices"
+ "strconv"
"time"
"punchcard/internal/queries"
- tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss/v2"
)
+// 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
+)
+
+type HistorySummaryKey struct {
+ Date time.Time
+ ClientID int64
+ ProjectID int64
+}
+
+type HistoryBoxModel struct {
+ viewLevel HistoryViewLevel
+
+ summaryItems []HistorySummaryItem
+ summarySelection int
+
+ entries map[HistorySummaryKey][]queries.TimeEntry
+ detailSelection int
+}
+
+// HistorySummaryItem represents a date + client/project combination with total duration
+type HistorySummaryItem struct {
+ Date time.Time
+ ClientID int64
+ ClientName string
+ ProjectID *int64
+ ProjectName *string
+ TotalDuration time.Duration // will exclude the currently running timer, if any
+ EntryCount int
+}
+
// NewHistoryBoxModel creates a new history box model
func NewHistoryBoxModel() HistoryBoxModel {
- return HistoryBoxModel{
- viewLevel: HistoryLevelSummary,
- selectedIndex: 0,
+ return HistoryBoxModel{}
+}
+
+func buildIndex[T any, K comparable](items []T, keyf func(T) K) map[K][]T {
+ idx := make(map[K][]T)
+ for _, item := range items {
+ key := keyf(item)
+ idx[key] = append(idx[key], item)
}
+ return idx
}
-// Update handles messages for the history box
-func (m HistoryBoxModel) Update(msg tea.Msg) (HistoryBoxModel, tea.Cmd) {
- return m, nil
+func (m *HistoryBoxModel) regenerateSummaries(
+ clients []queries.Client,
+ projects map[int64][]queries.Project,
+ entries []queries.TimeEntry,
+ active TimerInfo,
+) {
+ m.summaryItems = make([]HistorySummaryItem, 0)
+
+ clientNames := make(map[int64]string)
+ for _, client := range clients {
+ clientNames[client.ID] = client.Name
+ }
+ projectNames := make(map[int64]string)
+ for _, group := range projects {
+ for _, project := range group {
+ projectNames[project.ID] = project.Name
+ }
+ }
+
+ m.entries = buildIndex(entries, func(entry queries.TimeEntry) HistorySummaryKey {
+ var projectID int64 = 0
+ if entry.ProjectID.Valid {
+ projectID = entry.ProjectID.Int64
+ }
+ return HistorySummaryKey{dateOnly(entry.StartTime), entry.ClientID, projectID}
+ })
+
+ for key, entries := range m.entries {
+ var totalDur time.Duration = 0
+ for _, entry := range entries {
+ if active.IsActive && active.EntryID == entry.ID {
+ continue
+ }
+ totalDur += entry.EndTime.Time.Sub(entry.StartTime)
+ }
+
+ item := HistorySummaryItem{
+ Date: key.Date,
+ ClientID: key.ClientID,
+ ClientName: clientNames[key.ClientID],
+ TotalDuration: totalDur,
+ EntryCount: len(entries),
+ }
+ if key.ProjectID != 0 {
+ item.ProjectID = &key.ProjectID
+ for _, project := range projects[key.ClientID] {
+ if project.ID == key.ProjectID {
+ item.ProjectName = &project.Name
+ break
+ }
+ }
+ }
+
+ m.summaryItems = append(m.summaryItems, item)
+ }
+
+ slices.SortFunc(m.summaryItems, func(a, b HistorySummaryItem) int {
+ if a.Date.Before(b.Date) {
+ return 1
+ } else if a.Date.After(b.Date) {
+ return -1
+ }
+
+ if a.ClientName < b.ClientName {
+ return -1
+ } else if a.ClientName > b.ClientName {
+ return 1
+ }
+
+ if a.ProjectName == nil {
+ return -1
+ }
+ if b.ProjectName == nil {
+ return 1
+ }
+ if *a.ProjectName < *b.ProjectName {
+ return -1
+ }
+ return 1
+ })
}
// View renders the history box
-func (m HistoryBoxModel) View(width, height int, isSelected bool) string {
+func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBoxModel) 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"
+ content = "📝 Recent History\n\nNo recent entries\n\nStart tracking time to\nsee your history here."
} 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"
+ switch m.viewLevel {
+ case HistoryLevelSummary:
content = m.renderSummaryView()
+ case HistoryLevelDetails:
+ content = m.renderDetailsView(timer)
}
}
-
- // Apply box styling
+
style := unselectedBoxStyle
if isSelected {
style = selectedBoxStyle
}
-
- return style.Width(width).Height(height).Render(
- fmt.Sprintf("%s\n\n%s", title, content),
- )
+
+ return style.Width(width).Height(height).Render(content)
}
+var (
+ dateStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3"))
+ summaryItemStyle = lipgloss.NewStyle()
+ selectedItemStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ entryStyle = lipgloss.NewStyle()
+ selectedEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ activeEntryStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
+ selectedActiveEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230"))
+ descriptionStyle = lipgloss.NewStyle()
+ activeDescriptionStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+)
+
// 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."
+ content := "📝 Recent History"
+
+ if len(m.summaryItems) == 0 {
+ return "\n\nNo 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
- }
+
+ var date *time.Time
+ for i, item := range m.summaryItems {
+ if date == nil || !date.Equal(item.Date) {
+ date = &item.Date
+ content += fmt.Sprintf("\n\n%s\n", dateStyle.Render(date.Format("2006/01/02")))
}
- }
-
- 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()
- }
+
+ style := summaryItemStyle
+ if m.summarySelection == i {
+ style = selectedItemStyle
}
-
- content += itemStyle.Render(line) + "\n"
+
+ // 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))
+ content += fmt.Sprintf("\n%s", style.Render(line))
}
-
+
return content
}
+func (m HistoryBoxModel) selectedEntries() []queries.TimeEntry {
+ summary := m.summaryItems[m.summarySelection]
+ key := HistorySummaryKey{
+ Date: summary.Date,
+ ClientID: summary.ClientID,
+ }
+ if summary.ProjectID != nil {
+ key.ProjectID = *summary.ProjectID
+ }
+ return m.entries[key]
+}
+
// renderDetailsView renders the details view (level 2) showing individual entries
-func (m HistoryBoxModel) renderDetailsView() string {
- var content string
-
- if len(m.detailsEntries) == 0 {
+func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string {
+ content := fmt.Sprintf("📝 Details: %s\n\n", m.formatSummaryTitle(m.summaryItems[m.summarySelection]))
+ entries := m.selectedEntries()
+
+ if len(entries) == 0 {
return "No entries found for this selection."
}
-
- for i, entry := range m.detailsEntries {
- // Calculate duration
+
+ for i, entry := range entries {
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)
- }
+ duration = timer.currentTime.Sub(entry.StartTime)
}
-
- // Format time range
+
startTime := entry.StartTime.Local().Format("3:04 PM")
var timeRange string
if entry.EndTime.Valid {
@@ -138,379 +242,128 @@ func (m HistoryBoxModel) renderDetailsView() string {
} 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"))
+
+ var style lipgloss.Style
+ if m.detailSelection == i {
+ if !entry.EndTime.Valid {
+ style = selectedActiveEntryStyle
} else {
- // Non-selected active entry
- entryStyle = activeTimerStyle
+ style = selectedEntryStyle
}
- }
-
- 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"))
+ } else {
+ if !entry.EndTime.Valid {
+ style = activeEntryStyle
+ } else {
+ style = entryStyle
}
- content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) + "\n"
}
-
+
+ content += style.Render(entryLine)
+
+ descStyle := descriptionStyle
+ if m.detailSelection == i {
+ descStyle = activeDescriptionStyle
+ }
+ if entry.Description.Valid {
+ content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String))
+ }
+ content += "\n"
+
// Add spacing between entries
- if i < len(m.detailsEntries)-1 {
+ if i < len(entries)-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 fmt.Sprintf("%s / %s", summary.ClientName, *summary.ProjectName)
}
-
- return title
+ return fmt.Sprintf("%s / General work", summary.ClientName)
}
-// 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
+func dateOnly(t time.Time) time.Time {
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
-// 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
- }
+func (m *HistoryBoxModel) changeSelection(forward bool) {
+ switch m.viewLevel {
+ case HistoryLevelSummary:
+ m.changeSummarySelection(forward)
+ case HistoryLevelDetails:
+ m.changeDetailsSelection(forward)
}
-
- 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
+func (m *HistoryBoxModel) changeSummarySelection(forward bool) {
+ newIdx := m.summarySelection
+ if forward {
+ newIdx++
+ if newIdx < len(m.summaryItems) {
+ m.summarySelection = newIdx
}
- }
-
- 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
+ } else {
+ newIdx--
+ if newIdx >= 0 {
+ m.summarySelection = newIdx
}
}
-
- 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]
+func (m *HistoryBoxModel) changeDetailsSelection(forward bool) {
+ newIdx := m.detailSelection
+ entries := m.selectedEntries()
+ if forward {
+ newIdx++
+ if newIdx < len(entries) {
+ m.detailSelection = newIdx
}
} else {
- if m.selectedIndex >= 0 && m.selectedIndex < len(m.entries) {
- return &m.entries[m.selectedIndex]
+ newIdx--
+ if newIdx >= 0 {
+ m.detailSelection = newIdx
}
}
- 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
-}
+func (m HistoryBoxModel) selection() (string, string, string, *float64) {
+ item := m.summaryItems[m.summarySelection]
-// 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
-}
+ clientID := strconv.FormatInt(item.ClientID, 10)
-// 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
- }
+ projectID := ""
+ if item.ProjectID != nil {
+ projectID = strconv.FormatInt(*item.ProjectID, 10)
}
- 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)
+ description := ""
+ var rate *float64
+ if m.viewLevel == HistoryLevelDetails {
+ entry := m.selectedEntries()[m.detailSelection]
+ if entry.Description.Valid {
+ description = entry.Description.String
+ }
+ if entry.BillableRate.Valid {
+ cents := entry.BillableRate.Int64
+ dollars := float64(cents) / 100
+ rate = &dollars
}
}
-
- 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
+ return clientID, projectID, description, rate
}
-// 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
+func (m *HistoryBoxModel) drillDown() {
+ m.viewLevel = HistoryLevelDetails
+ m.detailSelection = 0
}
-// 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
+func (m *HistoryBoxModel) drillUp() {
+ m.viewLevel = HistoryLevelSummary
+}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 7f23407..d2e08f4 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -1,229 +1,323 @@
package tui
import (
+ "slices"
+
tea "github.com/charmbracelet/bubbletea"
)
-// KeyAction represents the action to take for a key press
-type KeyAction int
+type KeyBindingScope 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
+ ScopeGlobal KeyBindingScope = iota
+ ScopeTimerBox
+ ScopeProjectsBox
+ ScopeHistoryBoxSummaries
+ ScopeHistoryBoxDetails
)
-// 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
+// KeyBinding represents the available key bindings for a view
+type KeyBinding struct {
+ Key string
+ Description func(AppModel) string
+ Scope KeyBindingScope
+ Result func(AppModel) tea.Cmd
+ Hide bool
}
-// 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
+type (
+ createProjectMsg struct{}
+ createClientMsg struct{}
+ activateSearch struct{}
+ editHistoryEntry struct{}
+ deleteHistoryEntry struct{}
+)
+
+func msgAsCmd(msg tea.Msg) tea.Cmd {
+ return func() tea.Msg { return msg }
}
-// 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
+var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map[string]KeyBinding{
+ ScopeGlobal: {
+ "ctrl+n": KeyBinding{
+ Key: "Ctrl+n",
+ Description: func(AppModel) string { return "Next Pane" },
+ Scope: ScopeGlobal,
+ Result: func(AppModel) tea.Cmd { return navigate(true) },
+ },
+ "ctrl+p": KeyBinding{
+ Key: "Ctrl+p",
+ Description: func(AppModel) string { return "Prev Pane" },
+ Scope: ScopeGlobal,
+ Result: func(AppModel) tea.Cmd { return navigate(false) },
+ },
+ "p": KeyBinding{
+ Key: "p",
+ Description: func(am AppModel) string {
+ if am.timerBox.timerInfo.IsActive {
+ return "Punch Out"
+ }
+ return "Punch In"
+ },
+ Scope: ScopeGlobal,
+ Result: func(am AppModel) tea.Cmd {
+ if am.timerBox.timerInfo.IsActive {
+ return punchOut(am)
+ }
+ return punchIn(am)
+ },
+ },
+ "/": KeyBinding{
+ Key: "/",
+ Description: func(am AppModel) string { return "Search" },
+ Scope: ScopeGlobal,
+ Result: func(AppModel) tea.Cmd { return msgAsCmd(activateSearch{}) },
+ },
+ "r": KeyBinding{
+ Key: "r",
+ Description: func(am AppModel) string { return "Refresh" },
+ Scope: ScopeGlobal,
+ Result: func(am AppModel) tea.Cmd { return am.refreshCmd },
+ },
+ "q": KeyBinding{
+ Key: "q",
+ Description: func(am AppModel) string { return "Quit" },
+ Scope: ScopeGlobal,
+ Result: func(AppModel) tea.Cmd { return tea.Quit },
+ },
+ "ctrl+c": KeyBinding{
+ Key: "Ctrl+c",
+ Description: func(am AppModel) string { return "Quit" },
+ Scope: ScopeGlobal,
+ Result: func(AppModel) tea.Cmd { return tea.Quit },
+ Hide: true,
+ },
+ "ctrl+d": KeyBinding{
+ Key: "Ctrl+d",
+ Description: func(am AppModel) string { return "Quit" },
+ Scope: ScopeGlobal,
+ Result: func(AppModel) tea.Cmd { return tea.Quit },
+ Hide: true,
+ },
+ },
+ ScopeTimerBox: {
+ "enter": KeyBinding{
+ Key: "Enter",
+ Description: func(am AppModel) string {
+ if am.timerBox.timerInfo.IsActive {
+ return "Punch Out"
+ }
+ return "Punch In"
+ },
+ Scope: ScopeTimerBox,
+ Result: func(am AppModel) tea.Cmd {
+ if am.timerBox.timerInfo.IsActive {
+ return punchOut(am)
+ }
+ return punchIn(am)
+ },
+ },
+ },
+ ScopeProjectsBox: {
+ "j": KeyBinding{
+ Key: "j",
+ Description: func(AppModel) string { return "Down" },
+ Scope: ScopeProjectsBox,
+ Result: func(AppModel) tea.Cmd { return changeSelection(true) },
+ },
+ "k": KeyBinding{
+ Key: "k",
+ Description: func(AppModel) string { return "Up" },
+ Scope: ScopeProjectsBox,
+ Result: func(AppModel) tea.Cmd { return changeSelection(false) },
+ },
+ "down": KeyBinding{
+ Key: "down",
+ Description: func(AppModel) string { return "Down" },
+ Scope: ScopeProjectsBox,
+ Result: func(AppModel) tea.Cmd { return changeSelection(true) },
+ Hide: true,
+ },
+ "up": KeyBinding{
+ Key: "up",
+ Description: func(AppModel) string { return "Up" },
+ Scope: ScopeProjectsBox,
+ Result: func(AppModel) tea.Cmd { return changeSelection(false) },
+ Hide: true,
+ },
+ "enter": KeyBinding{
+ Key: "Enter",
+ Description: func(AppModel) string { return "Punch In on Selection" },
+ Scope: ScopeProjectsBox,
+ Result: func(am AppModel) tea.Cmd { return punchInOnSelection(am) },
+ },
+ "n": KeyBinding{
+ Key: "n",
+ Description: func(AppModel) string { return "New Project" },
+ Scope: ScopeProjectsBox,
+ Result: func(AppModel) tea.Cmd { return msgAsCmd(createProjectMsg{}) },
+ },
+ "N": KeyBinding{
+ Key: "N",
+ Description: func(AppModel) string { return "New Client" },
+ Scope: ScopeProjectsBox,
+ Result: func(AppModel) tea.Cmd { return msgAsCmd(createClientMsg{}) },
+ },
+ },
+ ScopeHistoryBoxSummaries: {
+ "j": KeyBinding{
+ Key: "j",
+ Description: func(AppModel) string { return "Down" },
+ Scope: ScopeHistoryBoxSummaries,
+ Result: func(AppModel) tea.Cmd { return changeSelection(true) },
+ },
+ "k": KeyBinding{
+ Key: "k",
+ Description: func(AppModel) string { return "Up" },
+ Scope: ScopeHistoryBoxSummaries,
+ Result: func(AppModel) tea.Cmd { return changeSelection(false) },
+ },
+ "down": KeyBinding{
+ Key: "down",
+ Description: func(AppModel) string { return "Down" },
+ Scope: ScopeHistoryBoxSummaries,
+ Result: func(AppModel) tea.Cmd { return changeSelection(true) },
+ Hide: true,
+ },
+ "up": KeyBinding{
+ Key: "up",
+ Description: func(AppModel) string { return "Up" },
+ Scope: ScopeHistoryBoxSummaries,
+ Result: func(AppModel) tea.Cmd { return changeSelection(false) },
+ Hide: true,
+ },
+ "enter": KeyBinding{
+ Key: "Enter",
+ Description: func(AppModel) string { return "Select" },
+ Scope: ScopeHistoryBoxSummaries,
+ Result: func(AppModel) tea.Cmd { return selectHistorySummary() },
+ },
+ },
+ ScopeHistoryBoxDetails: {
+ "j": KeyBinding{
+ Key: "j",
+ Description: func(AppModel) string { return "Down" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return changeSelection(true) },
+ },
+ "k": KeyBinding{
+ Key: "k",
+ Description: func(AppModel) string { return "Up" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return changeSelection(false) },
+ },
+ "down": KeyBinding{
+ Key: "Down",
+ Description: func(AppModel) string { return "Down" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return changeSelection(true) },
+ Hide: true,
+ },
+ "up": KeyBinding{
+ Key: "Up",
+ Description: func(AppModel) string { return "Up" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return changeSelection(false) },
+ Hide: true,
+ },
+ "e": KeyBinding{
+ Key: "e",
+ Description: func(AppModel) string { return "Edit" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return msgAsCmd(editHistoryEntry{}) },
+ },
+ "d": KeyBinding{
+ Key: "d",
+ Description: func(AppModel) string { return "Delete" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return msgAsCmd(deleteHistoryEntry{}) },
+ },
+ "enter": KeyBinding{
+ Key: "Enter",
+ Description: func(AppModel) string { return "Resume" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(am AppModel) tea.Cmd { return punchInOnSelection(am) },
+ },
+ "b": KeyBinding{
+ Key: "b",
+ Description: func(AppModel) string { return "Back" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return backToHistorySummary() },
+ },
+ "esc": KeyBinding{
+ Key: "Esc",
+ Description: func(AppModel) string { return "Back" },
+ Scope: ScopeHistoryBoxDetails,
+ Result: func(AppModel) tea.Cmd { return backToHistorySummary() },
+ Hide: true,
+ },
+ },
}
-// 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)
+// KeyHandler processes key messages and returns the appropriate action
+func HandleKeyPress(msg tea.KeyMsg, data AppModel) tea.Cmd {
+ key := msg.String()
+
+ if binding, ok := Bindings[ScopeGlobal][key]; ok {
+ return binding.Result(data)
}
- 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
+ var local map[string]KeyBinding
+ switch data.selectedBox {
+ case TimerBox:
+ local = Bindings[ScopeTimerBox]
+ case ProjectsBox:
+ local = Bindings[ScopeProjectsBox]
+ case HistoryBox:
+ switch data.historyBox.viewLevel {
+ case HistoryLevelSummary:
+ local = Bindings[ScopeHistoryBoxSummaries]
+ case HistoryLevelDetails:
+ local = Bindings[ScopeHistoryBoxDetails]
+ }
}
- 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
+ if binding, ok := local[key]; ok {
+ return binding.Result(data)
}
- return ActionNone
+ return nil
}
-// 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"})
+func activeBindings(box BoxType, level HistoryViewLevel) []KeyBinding {
+ out := make([]KeyBinding, 0, len(Bindings[ScopeGlobal]))
+ for _, binding := range Bindings[ScopeGlobal] {
+ out = append(out, binding)
}
-
- // Add search and refresh bindings
- bindings = append(bindings, []KeyBinding{
- {"/", "Search"},
- {"r", "Refresh"},
- }...)
-
- // Context-specific bindings
- switch selectedBox {
+
+ var scope KeyBindingScope
+ switch box {
case TimerBox:
- bindings = append(bindings, getTimerKeyBindings(hasActiveTimer)...)
- case ClientsProjectsBox:
- bindings = append(bindings, getProjectsKeyBindings()...)
+ scope = ScopeTimerBox
+ case ProjectsBox:
+ scope = ScopeProjectsBox
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"},
+ switch level {
+ case HistoryLevelSummary:
+ scope = ScopeHistoryBoxSummaries
+ case HistoryLevelDetails:
+ scope = ScopeHistoryBoxDetails
}
}
- 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"},
+ for _, binding := range Bindings[scope] {
+ out = append(out, binding)
}
-}
-// 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"},
+ slices.SortFunc(out, func(a, b KeyBinding) int {
+ if a.Key < b.Key {
+ return -1
}
- }
- return []KeyBinding{}
+ return 1
+ })
+ return out
}
diff --git a/internal/tui/projects_box.go b/internal/tui/projects_box.go
new file mode 100644
index 0000000..f90ac03
--- /dev/null
+++ b/internal/tui/projects_box.go
@@ -0,0 +1,194 @@
+package tui
+
+import (
+ "fmt"
+ "strconv"
+
+ "punchcard/internal/queries"
+
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+type ClientsProjectsModel struct {
+ clients []queries.Client
+ projects map[int64][]queries.Project
+ selectedClient int
+ selectedProject *int
+}
+
+// NewClientsProjectsModel creates a new clients/projects model
+func NewClientsProjectsModel() ClientsProjectsModel {
+ return ClientsProjectsModel{}
+}
+
+// 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
+ absoluteRowIndex := 0
+
+ for i, client := range m.clients {
+ if i > 0 {
+ content += "\n"
+ }
+
+ 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.selectedClient == i && m.selectedProject == nil {
+ clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ }
+ content += clientStyle.Render(clientLine) + "\n"
+ absoluteRowIndex++
+
+ clientProjects := m.projects[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)
+ }
+
+ projectStyle := lipgloss.NewStyle()
+ if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j {
+ projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ }
+ content += projectStyle.Render(projectLine) + "\n"
+ }
+ }
+ }
+
+ return content
+}
+
+func (m *ClientsProjectsModel) changeSelection(forward bool) {
+ if forward {
+ m.changeSelectionForward()
+ } else {
+ m.changeSelectionBackward()
+ }
+}
+
+func (m *ClientsProjectsModel) changeSelectionForward() {
+ selectedClient := m.clients[m.selectedClient]
+ projects := m.projects[selectedClient.ID]
+
+ if m.selectedProject == nil {
+ // starting with a client selected
+ if len(projects) > 0 {
+ // can jump into the first project
+ var zero int = 0
+ m.selectedProject = &zero
+ return
+ }
+
+ // there is no next client - at the bottom, no-op
+ if m.selectedClient == len(m.clients)-1 {
+ return
+ }
+
+ // jump to next client
+ m.selectedClient++
+ return
+ }
+
+ if *m.selectedProject == len(projects)-1 {
+ // at last project
+
+ if m.selectedClient == len(m.clients)-1 {
+ // also at last client - at the bottom, no-op
+ return
+ }
+
+ // jump to next client, no project
+ m.selectedClient++
+ m.selectedProject = nil
+ return
+ }
+
+ // jump to next project
+ *m.selectedProject++
+}
+
+func (m *ClientsProjectsModel) changeSelectionBackward() {
+ selectedClient := m.clients[m.selectedClient]
+ projects := m.projects[selectedClient.ID]
+
+ if m.selectedProject == nil {
+ // starting with a client selected
+ if m.selectedClient == 0 {
+ // at first client - at the start, no-op
+ return
+ }
+
+ m.selectedClient--
+ selectedClient = m.clients[m.selectedClient]
+ projects = m.projects[selectedClient.ID]
+
+ if len(projects) > 0 {
+ // previous client has projects, jump to last one
+ i := len(projects) - 1
+ m.selectedProject = &i
+ }
+
+ // otherwise selectedProject is already nil
+ return
+ }
+
+ if *m.selectedProject == 0 {
+ // selected first project - jump up to client
+ m.selectedProject = nil
+ return
+ }
+
+ // otherwise, jump to previous project in same client
+ *m.selectedProject--
+}
+
+func (m ClientsProjectsModel) selection() (string, string, string, *float64) {
+ client := m.clients[m.selectedClient]
+ clientID := strconv.FormatInt(client.ID, 10)
+
+ projectID := ""
+ if m.selectedProject != nil {
+ project := m.projects[client.ID][*m.selectedProject]
+ projectID = strconv.FormatInt(project.ID, 10)
+ }
+
+ return clientID, projectID, "", nil
+}
diff --git a/internal/tui/shared.go b/internal/tui/shared.go
index 77b282d..b6bca20 100644
--- a/internal/tui/shared.go
+++ b/internal/tui/shared.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "slices"
"time"
"punchcard/internal/queries"
@@ -15,32 +16,32 @@ import (
var (
// Styles for the TUI
topBarInactiveStyle = lipgloss.NewStyle().
- Background(lipgloss.Color("21")).
- Foreground(lipgloss.Color("230")).
- Padding(0, 1)
+ 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)
+ 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)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("62")).
+ Padding(1, 2)
unselectedBoxStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("238")).
- Padding(1, 2)
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("238")).
+ Padding(1, 2)
activeTimerStyle = lipgloss.NewStyle().
- Foreground(lipgloss.Color("196")).
- Bold(true)
+ Foreground(lipgloss.Color("196")).
+ Bold(true)
inactiveTimerStyle = lipgloss.NewStyle().
- Foreground(lipgloss.Color("246"))
+ Foreground(lipgloss.Color("246"))
)
// FormatDuration formats a duration in a human-readable way
@@ -59,167 +60,222 @@ func FormatDuration(d time.Duration) string {
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
+func getTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) {
+ var info TimerInfo
- // Get today's total
- todaySeconds, err := q.GetTodaySummary(ctx)
+ activeEntry, err := q.GetActiveTimeEntry(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
+ return info, fmt.Errorf("failed to get active timer: %w", err)
}
-
- // Get week's total
- weekSummary, err := q.GetWeekSummaryByProject(ctx)
if err != nil {
- return stats, fmt.Errorf("failed to get week summary: %w", err)
+ return getMostRecentTimerInfo(ctx, q)
}
- var weekTotal time.Duration
- for _, row := range weekSummary {
- weekTotal += time.Duration(row.TotalSeconds) * time.Second
+ info.IsActive = true
+ info.EntryID = activeEntry.ID
+ info.Duration = time.Since(activeEntry.StartTime)
+ info.StartTime = activeEntry.StartTime
+ info.ClientID = activeEntry.ClientID
+ if activeEntry.ProjectID.Valid {
+ info.ProjectID = &activeEntry.ProjectID.Int64
+ }
+ if activeEntry.Description.Valid {
+ info.Description = &activeEntry.Description.String
+ }
+ if activeEntry.BillableRate.Valid {
+ rate := float64(activeEntry.BillableRate.Int64) / 100
+ info.BillableRate = &rate
}
- stats.WeekTotal = weekTotal
- return stats, nil
+ return info, nil
}
-// GetTimerInfo retrieves current timer information
-func GetTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) {
+func getMostRecentTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) {
var info TimerInfo
- activeEntry, err := q.GetActiveTimeEntry(ctx)
+ entry, err := q.GetMostRecentTimeEntry(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
- return info, fmt.Errorf("failed to get active timer: %w", err)
+ return info, fmt.Errorf("failed to get most recent timer: %w", err)
}
-
- if errors.Is(err, sql.ErrNoRows) {
- // No active timer
+ if err != nil {
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
- }
+ info.IsActive = false
+ info.EntryID = entry.ID
+ info.Duration = entry.EndTime.Time.Sub(entry.StartTime)
+ info.StartTime = entry.StartTime
+ info.ClientID = entry.ClientID
+ if entry.ProjectID.Valid {
+ info.ProjectID = &entry.ProjectID.Int64
}
-
- // 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
+ if entry.Description.Valid {
+ info.Description = &entry.Description.String
}
-
- // Use entry-specific billable rate if set
- if activeEntry.BillableRate.Valid {
- entryRate := float64(activeEntry.BillableRate.Int64) / 100.0
- info.BillableRate = &entryRate
+ if entry.BillableRate.Valid {
+ rate := float64(entry.BillableRate.Int64) / 100
+ info.BillableRate = &rate
}
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))
-
+func RenderTopBar(m AppModel) string {
+ left := fmt.Sprintf("👊 Punchcard ♦️ - %s", m.selectedBox.String())
+
+ today := m.timeStats.TodayTotal
+ week := m.timeStats.WeekTotal
+
+ if m.timerBox.timerInfo.IsActive {
+ activeTime := m.timerBox.currentTime.Sub(m.timerBox.timerInfo.StartTime)
+ today += activeTime
+ week += activeTime
+ }
+
+ right := fmt.Sprintf("Today: %s | Week: %s",
+ FormatDuration(today),
+ FormatDuration(week))
+
// 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
-
+ contentWidth := m.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)
+
+ return topBarInactiveStyle.Width(m.width).Render(content)
}
// RenderBottomBar renders the bottom bar with key bindings
-func RenderBottomBar(bindings []KeyBinding, width int) string {
+func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string {
var content string
for i, binding := range bindings {
+ if binding.Hide {
+ continue
+ }
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)
+ content += fmt.Sprintf("%s %s", formattedKey, binding.Description(m))
}
-
- 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)
+ content = bottomBarStyle.Align(lipgloss.Left).Render(content)
if err != nil {
- return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get timer info: %w", err)
+ content = lipgloss.JoinHorizontal(
+ lipgloss.Bottom,
+ content,
+ bottomBarStyle.Bold(true).Foreground(lipgloss.Color("196")).Align(lipgloss.Right).Render(err.Error()),
+ )
}
-
- // Get time stats
- stats, err := GetTimeStats(ctx, q)
+
+ return bottomBarStyle.Width(m.width).Render(content)
+}
+
+// GetAppData fetches all data needed for the TUI
+func getAppData(
+ ctx context.Context,
+ q *queries.Queries,
+) (
+ info TimerInfo,
+ stats TimeStats,
+ clients []queries.Client,
+ projectsIdx map[int64][]queries.Project,
+ entries []queries.TimeEntry,
+ err error,
+) {
+ info, err = getTimerInfo(ctx, q)
if err != nil {
- return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get time stats: %w", err)
+ return
}
-
- // Get clients
- clients, err := q.ListAllClients(ctx)
+
+ clients, err = q.ListAllClients(ctx)
if err != nil {
- return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get clients: %w", err)
+ return
}
-
- // Get projects
+ slices.SortFunc(clients, func(a, b queries.Client) int {
+ if a.Name <= b.Name {
+ return -1
+ }
+ return 1
+ })
+
projects, err := q.ListAllProjects(ctx)
if err != nil {
- return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get projects: %w", err)
+ return
}
-
- // Get recent entries
- entries, err := q.GetRecentTimeEntries(ctx, 20)
+ slices.SortFunc(projects, func(a, b queries.ListAllProjectsRow) int {
+ if a.Name <= b.Name {
+ return -1
+ }
+ return 1
+ })
+ projectsIdx = make(map[int64][]queries.Project)
+ for i := range projects {
+ projectsIdx[projects[i].ClientID] = append(
+ projectsIdx[projects[i].ClientID],
+ queries.Project{
+ ID: projects[i].ID,
+ Name: projects[i].Name,
+ ClientID: projects[i].ClientID,
+ BillableRate: projects[i].BillableRate,
+ CreatedAt: projects[i].CreatedAt,
+ },
+ )
+ }
+
+ entries, err = q.GetRecentTimeEntries(ctx, time.Now().Add(-time.Hour*24*14))
if err != nil {
- return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get recent entries: %w", err)
+ return
}
-
- return timerInfo, stats, clients, projects, entries, nil
+
+ now := time.Now()
+ todayY, todayM, todayD := now.Date()
+ lastMon := mostRecentMonday(now)
+ inDay := true
+ for i := range entries {
+ e := entries[i]
+
+ if info.IsActive && e.ID == info.EntryID {
+ // skip the active timer
+ continue
+ }
+
+ if inDay {
+ y, m, d := e.StartTime.Date()
+ if y != todayY || m != todayM || d != todayD {
+ inDay = false
+ }
+ }
+
+ dur := e.EndTime.Time.Sub(e.StartTime)
+ if inDay {
+ stats.TodayTotal += dur
+ stats.WeekTotal += dur
+ continue
+ }
+
+ mon := mostRecentMonday(e.StartTime)
+ if mon != lastMon {
+ break
+ }
+ stats.WeekTotal += dur
+ }
+
+ return
}
+func mostRecentMonday(from time.Time) time.Time {
+ d := dateOnly(from)
+ dayOffset := time.Duration(d.Weekday()-1) % 7
+ return d.Add(-time.Hour * 24 * dayOffset)
+}
diff --git a/internal/tui/timer.go b/internal/tui/timer.go
deleted file mode 100644
index 827951d..0000000
--- a/internal/tui/timer.go
+++ /dev/null
@@ -1,150 +0,0 @@
-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
index 17781ee..408c3b5 100644
--- a/internal/tui/timer_box.go
+++ b/internal/tui/timer_box.go
@@ -2,56 +2,84 @@ package tui
import (
"fmt"
+ "time"
- tea "github.com/charmbracelet/bubbletea"
+ "punchcard/internal/queries"
)
+// TimerInfo holds information about the current or most recent timer state
+type TimerInfo struct {
+ IsActive bool
+ EntryID int64
+ StartTime time.Time
+ Duration time.Duration
+ ClientID int64
+ ClientName string
+ ProjectID *int64
+ ProjectName string
+ Description *string
+ BillableRate *float64
+}
+
+func (ti *TimerInfo) setNames(clients []queries.Client, projects map[int64][]queries.Project) {
+ for _, cl := range clients {
+ if cl.ID == ti.ClientID {
+ ti.ClientName = cl.Name
+ break
+ }
+ }
+
+ if ti.ProjectID == nil {
+ return
+ }
+ for _, group := range projects {
+ for _, proj := range group {
+ if proj.ID == *ti.ProjectID {
+ ti.ProjectName = proj.Name
+ return
+ }
+ }
+ }
+}
+
+// Box models for the three main components
+type TimerBoxModel struct {
+ timerInfo TimerInfo
+ currentTime time.Time
+}
+
// 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()
+ 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),
- )
+
+ return style.Width(width).Height(height).Render(content)
}
// renderActiveTimer renders the active timer display
func (m TimerBoxModel) renderActiveTimer() string {
- var content string
-
+ content := "⏱ Active Timer\n\n"
+
// Timer duration
- timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration))
+ timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.currentTime.Sub(m.timerInfo.StartTime)))
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)
@@ -60,42 +88,43 @@ func (m TimerBoxModel) renderActiveTimer() string {
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 != "" {
+ if m.timerInfo.Description != nil {
content += "\n"
- descLine := fmt.Sprintf("Description: %s", 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"
}
-
+
return content
}
// renderInactiveTimer renders the inactive timer display
func (m TimerBoxModel) renderInactiveTimer() string {
- var content string
-
+ content := "⚪ Last Timer (Inactive)\n\n"
+
content += "No active timer\n\n"
content += "Ready to start tracking time.\n"
- content += "Use 'i' to punch in or select\n"
+ content += "Use 'p' 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
+func (m TimerBoxModel) activeTime() time.Duration {
+ if !m.timerInfo.IsActive {
+ return 0
+ }
+ return m.currentTime.Sub(m.timerInfo.StartTime)
+}
diff --git a/internal/tui/types.go b/internal/tui/types.go
deleted file mode 100644
index 2fcf55c..0000000
--- a/internal/tui/types.go
+++ /dev/null
@@ -1,165 +0,0 @@
-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
-}