summaryrefslogtreecommitdiff
path: root/internal/tui/app.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/app.go')
-rw-r--r--internal/tui/app.go599
1 files changed, 155 insertions, 444 deletions
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,
}
}