summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/app.go538
-rw-r--r--internal/tui/clients_projects_box.go247
-rw-r--r--internal/tui/history_box.go516
-rw-r--r--internal/tui/keys.go229
-rw-r--r--internal/tui/shared.go225
-rw-r--r--internal/tui/timer.go150
-rw-r--r--internal/tui/timer_box.go101
-rw-r--r--internal/tui/types.go165
8 files changed, 2171 insertions, 0 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go
new file mode 100644
index 0000000..98bad4f
--- /dev/null
+++ b/internal/tui/app.go
@@ -0,0 +1,538 @@
+package tui
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ "punchcard/internal/queries"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "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())
+
+ 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{}
+ }))
+
+ case updateDataCmd:
+ cmds = append(cmds, m.updateDataCmd())
+ }
+
+ 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()
+ }
+
+ return nil
+}
+
+// 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
+ }
+}
+
+// Projects pane action handlers
+func (m *AppModel) handleProjectsNext() {
+ m.clientsProjectsModel = m.clientsProjectsModel.NextSelection()
+}
+
+func (m *AppModel) handleProjectsPrev() {
+ m.clientsProjectsModel = m.clientsProjectsModel.PrevSelection()
+}
+
+func (m *AppModel) handleProjectsEnter() tea.Cmd {
+ // TODO: Punch in to selected client/project
+ return nil
+}
+
+func (m *AppModel) handleProjectsNewProject() tea.Cmd {
+ // TODO: Open new project modal
+ return nil
+}
+
+func (m *AppModel) handleProjectsNewClient() tea.Cmd {
+ // TODO: Open new client modal
+ return nil
+}
+
+// History pane action handlers
+func (m *AppModel) handleHistoryNext() {
+ m.historyBoxModel = m.historyBoxModel.NextSelection()
+}
+
+func (m *AppModel) handleHistoryPrev() {
+ m.historyBoxModel = m.historyBoxModel.PrevSelection()
+}
+
+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
+ }
+}
+
+func (m *AppModel) handleHistoryEdit() tea.Cmd {
+ // TODO: Open edit modal for selected entry
+ return nil
+}
+
+func (m *AppModel) handleHistoryDelete() tea.Cmd {
+ // TODO: Delete selected entry
+ return nil
+}
+
+func (m *AppModel) handleHistoryResume() tea.Cmd {
+ // TODO: Resume selected entry (punch in with same details)
+ return nil
+}
+
+func (m *AppModel) handleHistoryBack() {
+ // Switch back to summary view
+ m.historyBoxModel = m.historyBoxModel.GoBack()
+}
+
+// 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),
+ }
+}
+
+// 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 "left":
+ if m.textInputModel.cursorPos > 0 {
+ m.textInputModel.cursorPos--
+ }
+ case "right":
+ if m.textInputModel.cursorPos < len(m.textInputModel.value) {
+ m.textInputModel.cursorPos++
+ }
+ 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++
+ }
+ }
+
+ 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{}
+ }
+}
+
+// View renders the app
+func (m AppModel) View() string {
+ if m.width == 0 || m.height == 0 {
+ return "Loading..."
+ }
+
+ // Calculate dimensions
+ topBarHeight := 1
+ bottomBarHeight := 1
+ contentHeight := m.height - topBarHeight - bottomBarHeight
+
+ vertBoxOverhead := 6 // 2 border, 4 padding
+ horizBoxOverhead := 4 // 2 border, 2 padding
+
+ // Timer box is in 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
+
+ // History box takes the right side
+ historyBoxWidth := (m.width - (m.width / 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)
+
+ // 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)
+
+ // 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)
+
+ // 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)
+}
+
+
+// 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
+}
+
+// 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
+ entries []queries.TimeEntry
+}
+
+// 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{},
+ }
+ }
+
+ return dataUpdatedMsg{
+ timerInfo: timerInfo,
+ stats: stats,
+ clients: clients,
+ projects: projects,
+ entries: entries,
+ }
+ }
+}
+
+// Run starts the TUI application
+func Run(ctx context.Context, q *queries.Queries) error {
+ app := NewApp(ctx, q)
+ p := tea.NewProgram(app, tea.WithAltScreen())
+ _, err := p.Run()
+ return err
+}
diff --git a/internal/tui/clients_projects_box.go b/internal/tui/clients_projects_box.go
new file mode 100644
index 0000000..c52e964
--- /dev/null
+++ b/internal/tui/clients_projects_box.go
@@ -0,0 +1,247 @@
+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/history_box.go b/internal/tui/history_box.go
new file mode 100644
index 0000000..813eb17
--- /dev/null
+++ b/internal/tui/history_box.go
@@ -0,0 +1,516 @@
+package tui
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ "punchcard/internal/queries"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+// NewHistoryBoxModel creates a new history box model
+func NewHistoryBoxModel() HistoryBoxModel {
+ return HistoryBoxModel{
+ viewLevel: HistoryLevelSummary,
+ selectedIndex: 0,
+ }
+}
+
+// Update handles messages for the history box
+func (m HistoryBoxModel) Update(msg tea.Msg) (HistoryBoxModel, tea.Cmd) {
+ return m, nil
+}
+
+// View renders the history box
+func (m HistoryBoxModel) View(width, height int, isSelected bool) 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"
+ } 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"
+ content = m.renderSummaryView()
+ }
+ }
+
+ // Apply box styling
+ style := unselectedBoxStyle
+ if isSelected {
+ style = selectedBoxStyle
+ }
+
+ return style.Width(width).Height(height).Render(
+ fmt.Sprintf("%s\n\n%s", title, content),
+ )
+}
+
+// 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."
+ }
+
+ // 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
+ }
+ }
+ }
+
+ 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()
+ }
+ }
+
+ content += itemStyle.Render(line) + "\n"
+ }
+
+ return content
+}
+
+// renderDetailsView renders the details view (level 2) showing individual entries
+func (m HistoryBoxModel) renderDetailsView() string {
+ var content string
+
+ if len(m.detailsEntries) == 0 {
+ return "No entries found for this selection."
+ }
+
+ for i, entry := range m.detailsEntries {
+ // Calculate duration
+ 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)
+ }
+ }
+
+ // Format time range
+ startTime := entry.StartTime.Local().Format("3:04 PM")
+ var timeRange string
+ if entry.EndTime.Valid {
+ endTime := entry.EndTime.Time.Local().Format("3:04 PM")
+ timeRange = fmt.Sprintf("%s - %s", startTime, endTime)
+ } 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"))
+ } else {
+ // Non-selected active entry
+ entryStyle = activeTimerStyle
+ }
+ }
+
+ 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"))
+ }
+ content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) + "\n"
+ }
+
+ // Add spacing between entries
+ if i < len(m.detailsEntries)-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 title
+}
+
+// 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
+}
+
+// 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
+ }
+ }
+
+ 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
+ }
+ }
+
+ 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
+ }
+ }
+
+ 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]
+ }
+ } else {
+ if m.selectedIndex >= 0 && m.selectedIndex < len(m.entries) {
+ return &m.entries[m.selectedIndex]
+ }
+ }
+ 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
+}
+
+// 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
+}
+
+// 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
+ }
+ }
+ 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)
+ }
+ }
+
+ 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
+}
+
+// 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
+}
+
+// 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
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
new file mode 100644
index 0000000..7f23407
--- /dev/null
+++ b/internal/tui/keys.go
@@ -0,0 +1,229 @@
+package tui
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// KeyAction represents the action to take for a key press
+type KeyAction 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
+)
+
+// 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
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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)
+ }
+ 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
+ }
+ 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
+ }
+ return ActionNone
+}
+
+// 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"})
+ }
+
+ // Add search and refresh bindings
+ bindings = append(bindings, []KeyBinding{
+ {"/", "Search"},
+ {"r", "Refresh"},
+ }...)
+
+ // Context-specific bindings
+ switch selectedBox {
+ case TimerBox:
+ bindings = append(bindings, getTimerKeyBindings(hasActiveTimer)...)
+ case ClientsProjectsBox:
+ bindings = append(bindings, getProjectsKeyBindings()...)
+ 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"},
+ }
+ }
+ 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"},
+ }
+}
+
+// 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"},
+ }
+ }
+ return []KeyBinding{}
+}
diff --git a/internal/tui/shared.go b/internal/tui/shared.go
new file mode 100644
index 0000000..77b282d
--- /dev/null
+++ b/internal/tui/shared.go
@@ -0,0 +1,225 @@
+package tui
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "punchcard/internal/queries"
+
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+var (
+ // Styles for the TUI
+ topBarInactiveStyle = lipgloss.NewStyle().
+ 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)
+
+ // Box styles
+ selectedBoxStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("62")).
+ Padding(1, 2)
+
+ unselectedBoxStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("238")).
+ Padding(1, 2)
+
+ activeTimerStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("196")).
+ Bold(true)
+
+ inactiveTimerStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("246"))
+)
+
+// FormatDuration formats a duration in a human-readable way
+func FormatDuration(d time.Duration) string {
+ d = d.Round(time.Second)
+ hours := int(d.Hours())
+ minutes := int(d.Minutes()) % 60
+ seconds := int(d.Seconds()) % 60
+
+ if hours > 0 {
+ return fmt.Sprintf("%dh %02dm", hours, minutes)
+ }
+ if minutes > 0 {
+ return fmt.Sprintf("%dm %02ds", minutes, seconds)
+ }
+ 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
+
+ // Get today's total
+ todaySeconds, err := q.GetTodaySummary(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
+ }
+
+ // Get week's total
+ weekSummary, err := q.GetWeekSummaryByProject(ctx)
+ if err != nil {
+ return stats, fmt.Errorf("failed to get week summary: %w", err)
+ }
+
+ var weekTotal time.Duration
+ for _, row := range weekSummary {
+ weekTotal += time.Duration(row.TotalSeconds) * time.Second
+ }
+ stats.WeekTotal = weekTotal
+
+ return stats, nil
+}
+
+// GetTimerInfo retrieves current timer information
+func GetTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) {
+ var info TimerInfo
+
+ activeEntry, err := q.GetActiveTimeEntry(ctx)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return info, fmt.Errorf("failed to get active timer: %w", err)
+ }
+
+ if errors.Is(err, sql.ErrNoRows) {
+ // No active timer
+ 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
+ }
+ }
+
+ // 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
+ }
+
+ // Use entry-specific billable rate if set
+ if activeEntry.BillableRate.Valid {
+ entryRate := float64(activeEntry.BillableRate.Int64) / 100.0
+ info.BillableRate = &entryRate
+ }
+
+ 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))
+
+ // 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
+
+ // 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)
+}
+
+// RenderBottomBar renders the bottom bar with key bindings
+func RenderBottomBar(bindings []KeyBinding, width int) string {
+ var content string
+ for i, binding := range bindings {
+ 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)
+ }
+
+ 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)
+ if err != nil {
+ return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get timer info: %w", err)
+ }
+
+ // Get time stats
+ stats, err := GetTimeStats(ctx, q)
+ if err != nil {
+ return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get time stats: %w", err)
+ }
+
+ // Get clients
+ clients, err := q.ListAllClients(ctx)
+ if err != nil {
+ return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get clients: %w", err)
+ }
+
+ // Get projects
+ projects, err := q.ListAllProjects(ctx)
+ if err != nil {
+ return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get projects: %w", err)
+ }
+
+ // Get recent entries
+ entries, err := q.GetRecentTimeEntries(ctx, 20)
+ if err != nil {
+ return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get recent entries: %w", err)
+ }
+
+ return timerInfo, stats, clients, projects, entries, nil
+}
+
diff --git a/internal/tui/timer.go b/internal/tui/timer.go
new file mode 100644
index 0000000..827951d
--- /dev/null
+++ b/internal/tui/timer.go
@@ -0,0 +1,150 @@
+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
new file mode 100644
index 0000000..17781ee
--- /dev/null
+++ b/internal/tui/timer_box.go
@@ -0,0 +1,101 @@
+package tui
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// 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()
+ }
+
+ // 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),
+ )
+}
+
+// renderActiveTimer renders the active timer display
+func (m TimerBoxModel) renderActiveTimer() string {
+ var content string
+
+ // Timer duration
+ timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration))
+ 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)
+ content += projectLine + "\n"
+ } else {
+ 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 != "" {
+ content += "\n"
+ 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 += "No active timer\n\n"
+ content += "Ready to start tracking time.\n"
+ content += "Use 'i' 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
diff --git a/internal/tui/types.go b/internal/tui/types.go
new file mode 100644
index 0000000..2fcf55c
--- /dev/null
+++ b/internal/tui/types.go
@@ -0,0 +1,165 @@
+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
+}