package tui import ( "context" "fmt" "time" "git.tjp.lol/punchcard/internal/queries" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss/v2" ) // BoxType represents the different boxes that can be selected type BoxType int const ( TimerBox BoxType = iota ProjectsBox HistoryBox ) func (b BoxType) String() string { switch b { case TimerBox: return "Timer" case ProjectsBox: return "Clients & Projects" case HistoryBox: return "History" default: return "Unknown" } } func (b BoxType) Next() BoxType { switch b { case TimerBox: return ProjectsBox case ProjectsBox: return HistoryBox case HistoryBox: return TimerBox } return 0 } func (b BoxType) Prev() BoxType { switch b { case TimerBox: return HistoryBox case HistoryBox: return ProjectsBox case ProjectsBox: return TimerBox } return 0 } // AppModel is the main model for the TUI application type AppModel struct { ctx context.Context queries *queries.Queries selectedBox BoxType timerBox TimerBoxModel projectsBox ClientsProjectsModel historyBox HistoryBoxModel modalBox ModalBoxModel contractor ContractorInfo width int height int timeStats TimeStats err error } // TimeStats holds time statistics for display (excluding the currently running timer, if any) type TimeStats struct { TodayTotal time.Duration WeekTotal time.Duration } type ContractorInfo struct { name string label string email string } // 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{}, } } // Init initializes the app func (m AppModel) Init() tea.Cmd { return tea.Batch( m.refreshCmd, doTick(), ) } // TickMsg is sent every second to update timers type TickMsg time.Time // 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) }) } // 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: cmds = append(cmds, HandleKeyPress(msg, &m)) case TickMsg: m.timerBox.currentTime = time.Time(msg) cmds = append(cmds, doTick()) case dataUpdatedMsg: m.contractor = msg.contractor 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 selectionMsg: switch m.selectedBox { case ProjectsBox: m.projectsBox.changeSelection(msg.Forward) case HistoryBox: m.historyBox.changeSelection(msg.Forward) } case selectionToEnd: switch m.selectedBox { case HistoryBox: m.historyBox.changeSelectionToEnd(msg.Top) } case drillDownMsg: if m.selectedBox == HistoryBox { m.historyBox.drillDown() } case drillUpMsg: if m.selectedBox == HistoryBox { m.historyBox.drillUp() } case modalClosed: m.modalBox.deactivate() case openTimeEntryEditor: if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { m.openEntryEditor() } case openContractorEditor: m.openContractorEditor() case openModalUnchanged: m.modalBox.Active = true case openDeleteConfirmation: if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { m.modalBox.activate(ModalTypeDeleteConfirmation, m.historyBox.selectedEntry().ID, m) } case recheckBounds: switch m.selectedBox { case HistoryBox: m.historyBox.recheckBounds() } case openCreateClientModal: m.modalBox.activate(ModalTypeClient, 0, m) m.modalBox.form.fields[0].Focus() case openCreateProjectModal: m.modalBox.activateCreateProjectModal(m) case openHistoryFilterModal: m.openHistoryFilterModal() case updateHistoryFilter: m.historyBox.filter = HistoryFilter(msg) cmds = append(cmds, m.refreshCmd) case openReportModal: if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelSummary { m.openReportModal() } } return m, tea.Batch(cmds...) } func (m *AppModel) openEntryEditor() { m.modalBox.activate(ModalTypeEntry, m.historyBox.selectedEntry().ID, *m) m.modalBox.form.fields[0].Focus() entry := m.historyBox.selectedEntry() m.modalBox.form.fields[0].SetValue(entry.StartTime.Local().Format(time.DateTime)) if entry.EndTime.Valid { m.modalBox.form.fields[1].SetValue(entry.EndTime.Time.Local().Format(time.DateTime)) } for _, client := range m.projectsBox.clients { if client.ID == entry.ClientID { m.modalBox.form.fields[2].SetValue(client.Name) break } } if entry.ProjectID.Valid { for _, project := range m.projectsBox.projects[entry.ClientID] { if project.ID == entry.ProjectID.Int64 { m.modalBox.form.fields[3].SetValue(project.Name) break } } } if entry.Description.Valid { m.modalBox.form.fields[4].SetValue(entry.Description.String) } if entry.BillableRate.Valid { m.modalBox.form.fields[5].SetValue(fmt.Sprintf("%.2f", float64(entry.BillableRate.Int64)/100)) } } func (m *AppModel) openContractorEditor() { m.modalBox.activate(ModalTypeContractor, 0, *m) m.modalBox.populateContractorFields(m.contractor) m.modalBox.form.fields[0].Focus() } func (m *AppModel) openHistoryFilterModal() { m.modalBox.activate(ModalTypeHistoryFilter, 0, *m) m.modalBox.form.fields[0].Focus() // Pre-populate form with current filter values filter := m.historyBox.filter // Set date range based on current filter var dateRangeStr string if filter.EndDate == nil { // Use "since " format for open-ended ranges dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format(time.DateOnly)) } else { // Use "YYYY-MM-DD to YYYY-MM-DD" format for bounded ranges dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format(time.DateOnly), filter.EndDate.Format(time.DateOnly)) } m.modalBox.form.fields[0].SetValue(dateRangeStr) // Set client filter if present if filter.ClientID != nil { for _, client := range m.projectsBox.clients { if client.ID == *filter.ClientID { m.modalBox.form.fields[1].SetValue(client.Name) break } } } // Set project filter if present if filter.ProjectID != nil { for _, clientProjects := range m.projectsBox.projects { for _, project := range clientProjects { if project.ID == *filter.ProjectID { m.modalBox.form.fields[2].SetValue(project.Name) break } } } } } func (m *AppModel) openReportModal() { m.modalBox.activate(ModalTypeGenerateReport, 0, *m) m.modalBox.form.fields[0].Focus() filter := m.historyBox.filter var dateRangeStr string if filter.EndDate == nil { dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format(time.DateOnly)) } else { dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format(time.DateOnly), filter.EndDate.Format(time.DateOnly)) } m.modalBox.form.fields[1].SetValue(dateRangeStr) if filter.ClientID != nil { for _, client := range m.projectsBox.clients { if client.ID == *filter.ClientID { m.modalBox.form.fields[2].SetValue(client.Name) break } } } if filter.ProjectID != nil { for _, clientProjects := range m.projectsBox.projects { for _, project := range clientProjects { if project.ID == *filter.ProjectID { m.modalBox.form.fields[3].SetValue(project.Name) break } } } } } // View renders the app func (m AppModel) View() string { if m.width == 0 || m.height == 0 { return "Loading..." } topBarHeight := 1 bottomBarHeight := 2 contentHeight := m.height - topBarHeight - bottomBarHeight // Timer box top-left timerBoxWidth := (m.width / 3) timerBoxHeight := (contentHeight / 2) if timerBoxWidth < 30 { timerBoxWidth = 30 } // Projects box bottom-left projectsBoxWidth := timerBoxWidth projectsBoxHeight := contentHeight - timerBoxHeight // History box right side full height historyBoxWidth := m.width - projectsBoxWidth historyBoxHeight := contentHeight activeDur := m.timerBox.activeTime() stats := m.timeStats stats.TodayTotal += activeDur stats.WeekTotal += activeDur topBar := RenderTopBar(m) 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) leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, projectsBox) mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel, m.modalBox) bottomBar := RenderBottomBar(m, keyBindings, m.err) return m.modalBox.RenderCenteredOver(topBar+"\n"+mainContent+"\n"+bottomBar, m) } // dataUpdatedMsg is sent when data is updated from the database type dataUpdatedMsg struct { contractor ContractorInfo timerInfo TimerInfo stats TimeStats clients []queries.Client projects map[int64][]queries.Project entries []queries.TimeEntry err error } // refreshCmd is a command to update all app data func (m AppModel) refreshCmd() tea.Msg { contractor, timerInfo, stats, clients, projects, entries, err := getAppData(m.ctx, m.queries, m.historyBox.filter) if err != nil { msg := dataUpdatedMsg{} msg.err = err return msg } return dataUpdatedMsg{ contractor: contractor, timerInfo: timerInfo, stats: stats, clients: clients, projects: projects, entries: entries, err: nil, } } // 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 }