summaryrefslogtreecommitdiff
path: root/internal/tui/app.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-05 11:37:02 -0600
committerT <t@tjp.lol>2025-08-05 11:37:08 -0600
commit665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch)
treef34f9ec77891308c600c680683f60951599429c3 /internal/tui/app.go
parentdc895cec9d8a84af89ce2501db234dff33c757e2 (diff)
WIP TUI
Diffstat (limited to 'internal/tui/app.go')
-rw-r--r--internal/tui/app.go538
1 files changed, 538 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
+}