diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/actions/actions.go | 18 | ||||
-rw-r--r-- | internal/actions/clients.go | 6 | ||||
-rw-r--r-- | internal/actions/projects.go | 6 | ||||
-rw-r--r-- | internal/actions/timer.go | 10 | ||||
-rw-r--r-- | internal/database/queries.sql | 8 | ||||
-rw-r--r-- | internal/queries/queries.sql.go | 16 | ||||
-rw-r--r-- | internal/tui/app.go | 599 | ||||
-rw-r--r-- | internal/tui/clients_projects_box.go | 247 | ||||
-rw-r--r-- | internal/tui/commands.go | 65 | ||||
-rw-r--r-- | internal/tui/history_box.go | 683 | ||||
-rw-r--r-- | internal/tui/keys.go | 486 | ||||
-rw-r--r-- | internal/tui/projects_box.go | 194 | ||||
-rw-r--r-- | internal/tui/shared.go | 294 | ||||
-rw-r--r-- | internal/tui/timer.go | 150 | ||||
-rw-r--r-- | internal/tui/timer_box.go | 107 | ||||
-rw-r--r-- | internal/tui/types.go | 165 |
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 -} |