From 54c791927b2851fb6739ed75897090c3c39ecca1 Mon Sep 17 00:00:00 2001 From: T Date: Fri, 8 Aug 2025 10:11:07 -0600 Subject: create forms for clients and projects --- internal/tui/app.go | 16 +++--- internal/tui/commands.go | 19 +++++-- internal/tui/form.go | 28 ++++++++++ internal/tui/history_box.go | 10 +++- internal/tui/keys.go | 14 +---- internal/tui/modal.go | 128 +++++++++++++++++++++++++++++++++++-------- internal/tui/projects_box.go | 14 ++++- internal/tui/timer_box.go | 9 ++- 8 files changed, 188 insertions(+), 50 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index e325116..0caf571 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -163,9 +163,6 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyBox.drillUp() } - case searchActivated: - m.modalBox.activate(ModalTypeSearch) - case modalClosed: m.modalBox.deactivate() @@ -179,8 +176,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case openDeleteConfirmation: if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { - m.modalBox.activate(ModalTypeDeleteConfirmation) - m.modalBox.editedID = m.historyBox.selectedEntry().ID + m.modalBox.activate(ModalTypeDeleteConfirmation, m.historyBox.selectedEntry().ID) } case recheckBounds: @@ -189,14 +185,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyBox.recheckBounds() } + case openCreateClientModal: + m.modalBox.activate(ModalTypeClient, 0) + m.modalBox.form.fields[0].Focus() + + case openCreateProjectModal: + m.modalBox.activateCreateProjectModal(m) + } return m, tea.Batch(cmds...) } func (m *AppModel) openEntryEditor() { - m.modalBox.activate(ModalTypeEntry) - m.modalBox.editedID = m.historyBox.selectedEntry().ID + m.modalBox.activate(ModalTypeEntry, m.historyBox.selectedEntry().ID) f := m.modalBox.form f.fields[0].Focus() diff --git a/internal/tui/commands.go b/internal/tui/commands.go index be5c8dc..ad3255f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -13,12 +13,13 @@ type ( selectionMsg struct{ Forward bool } drillDownMsg struct{} drillUpMsg struct{} - searchActivated struct{} modalClosed struct{} openTimeEntryEditor struct{} openModalUnchanged struct{} openDeleteConfirmation struct{} recheckBounds struct{} + openCreateClientModal struct{} + openCreateProjectModal struct{} ) func navigate(forward bool) tea.Cmd { @@ -52,6 +53,10 @@ func punchInOnSelection(m AppModel) tea.Cmd { clientID, projectID, description, entryRate = m.historyBox.selection() } + if clientID == "" { + return nil + } + _, _ = actions.New(m.queries).PunchIn(context.Background(), clientID, projectID, description, entryRate) // TODO: use the returned TimerSession instead of re-querying everything return m.refreshCmd() @@ -70,10 +75,6 @@ func changeSelection(forward bool) tea.Cmd { return func() tea.Msg { return selectionMsg{forward} } } -func activateSearch() tea.Cmd { - return func() tea.Msg { return searchActivated{} } -} - func closeModal() tea.Cmd { return func() tea.Msg { return modalClosed{} } } @@ -89,3 +90,11 @@ func reOpenModal() tea.Cmd { func confirmDeleteEntry() tea.Cmd { return func() tea.Msg { return openDeleteConfirmation{} } } + +func createClientModal() tea.Cmd { + return func() tea.Msg { return openCreateClientModal{} } +} + +func createProjectModal() tea.Cmd { + return func() tea.Msg { return openCreateProjectModal{} } +} diff --git a/internal/tui/form.go b/internal/tui/form.go index 4b12762..71d5299 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -74,6 +74,7 @@ func newOptionalFloatField(label string) FormField { type Form struct { fields []FormField selIdx int + err error SelectedStyle *lipgloss.Style UnselectedStyle *lipgloss.Style @@ -106,6 +107,28 @@ func NewEntryEditorForm() Form { return form } +func NewClientForm() Form { + form := NewForm([]FormField{ + {Model: textinput.New(), label: "Name"}, + {Model: textinput.New(), label: "Email"}, + newOptionalFloatField("Hourly Rate"), + }) + form.SelectedStyle = &modalFocusedInputStyle + form.UnselectedStyle = &modalBlurredInputStyle + return form +} + +func NewProjectForm() Form { + form := NewForm([]FormField{ + {Model: textinput.New(), label: "Name"}, + {Model: textinput.New(), label: "Client"}, + newOptionalFloatField("Hourly Rate"), + }) + form.SelectedStyle = &modalFocusedInputStyle + form.UnselectedStyle = &modalBlurredInputStyle + return form +} + func (ff Form) Update(msg tea.Msg) (Form, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { @@ -130,6 +153,11 @@ func (ff Form) Update(msg tea.Msg) (Form, tea.Cmd) { func (ff Form) View() string { content := "" + + if ff.err != nil { + content += errorStyle.Render(ff.err.Error()) + "\n\n" + } + for i, field := range ff.fields { if i > 0 { content += "\n\n" diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index 99fa4ac..00b39f3 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -159,7 +159,8 @@ func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBox var content string if len(m.entries) == 0 { - content = "📝 Recent History\n\nNo recent entries\n\nStart tracking time to\nsee your history here." + content = "📝 Recent History\n\n" + content += inactiveTimerStyle.Render("No recent entries\n\nStart tracking time to\nsee your history here.") } else { switch m.viewLevel { case HistoryLevelSummary: @@ -283,6 +284,9 @@ func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel) string { } func (m HistoryBoxModel) selectedEntries() []queries.TimeEntry { + if len(m.summaryItems) == 0 { + return nil + } summary := m.summaryItems[m.summarySelection] key := HistorySummaryKey{ Date: summary.Date.Local(), @@ -417,6 +421,10 @@ func (m HistoryBoxModel) selectedEntry() queries.TimeEntry { } func (m HistoryBoxModel) selection() (string, string, string, *float64) { + if len(m.summaryItems) == 0 { + return "", "", "", nil + } + item := m.summaryItems[m.summarySelection] clientID := strconv.FormatInt(item.ClientID, 10) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index e1ac587..71dc251 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -25,16 +25,6 @@ type KeyBinding struct { Hide bool } -type ( - createClientMsg struct{} - createProjectMsg struct{} - editHistoryEntry struct{} -) - -func msgAsCmd(msg tea.Msg) tea.Cmd { - return func() tea.Msg { return msg } -} - var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map[string]KeyBinding{ ScopeGlobal: { "ctrl+n": KeyBinding{ @@ -133,12 +123,12 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map "n": KeyBinding{ Key: "n", Description: func(AppModel) string { return "New Project" }, - Result: func(*AppModel) tea.Cmd { return msgAsCmd(createProjectMsg{}) }, + Result: func(*AppModel) tea.Cmd { return createProjectModal() }, }, "N": KeyBinding{ Key: "N", Description: func(AppModel) string { return "New Client" }, - Result: func(*AppModel) tea.Cmd { return msgAsCmd(createClientMsg{}) }, + Result: func(*AppModel) tea.Cmd { return createClientModal() }, }, }, ScopeHistoryBoxSummaries: { diff --git a/internal/tui/modal.go b/internal/tui/modal.go index 88b2861..167b659 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -17,15 +17,23 @@ import ( type ModalType int const ( - ModalTypeSearch ModalType = iota - ModalTypeClient + ModalTypeClient ModalType = iota ModalTypeProject ModalTypeDeleteConfirmation ModalTypeEntry ) func (mt ModalType) newForm() Form { - return NewEntryEditorForm() + switch mt { + case ModalTypeEntry: + return NewEntryEditorForm() + case ModalTypeClient: + return NewClientForm() + case ModalTypeProject: + return NewProjectForm() + } + + return Form{} } type ModalBoxModel struct { @@ -69,41 +77,64 @@ func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) stri func (m ModalBoxModel) Render() string { switch m.Type { - case ModalTypeSearch: - return modalTitleStyle.Render("SEARCH BOX") case ModalTypeEntry: - return m.RenderEntryEditor() + return m.RenderFormModal("⏰ Time Entry") case ModalTypeDeleteConfirmation: return m.RenderDeleteConfirmation() + case ModalTypeClient: + return m.RenderFormModal("👤 Client") + case ModalTypeProject: + return m.RenderFormModal("📂 Project") default: // REMOVE ME return "DEFAULT CONTENT" } } -func (m ModalBoxModel) RenderEntryEditor() string { - return fmt.Sprintf("%s\n\n%s", modalTitleStyle.Render("✏️ Edit Time Entry"), m.form.View()) +func (m ModalBoxModel) RenderFormModal(title string) string { + return fmt.Sprintf( + "%s\n\n%s\n\n%s Delete %s Cancel", + modalTitleStyle.Render(title), + m.form.View(), + boldStyle.Render("[Enter]"), + boldStyle.Render("[Esc]"), + ) } func (m ModalBoxModel) RenderDeleteConfirmation() string { - title := modalTitleStyle.Render("🗑️ Delete Time Entry") - content := "Are you sure you want to delete this time entry?\nThis action cannot be undone.\n\n[Enter] Delete [Esc] Cancel" - return fmt.Sprintf("%s\n\n%s", title, content) + return fmt.Sprintf( + "%s\n\nAre you sure you want to delete this time entry?\nThis action cannot be undone.\n\n%s Delete %s Cancel", + modalTitleStyle.Render("🗑️ Delete Time Entry"), + boldStyle.Render("[Enter]"), + boldStyle.Render("[Esc]"), + ) +} + +func (m *ModalBoxModel) activateCreateProjectModal(am AppModel) { + m.activate(ModalTypeProject, 0) + if am.selectedBox == ProjectsBox && len(am.projectsBox.clients) > 0 { + client := am.projectsBox.clients[am.projectsBox.selectedClient] + m.form.fields[1].SetValue(client.Name) + } + m.form.fields[0].Focus() } -func (m *ModalBoxModel) activate(t ModalType) { +func (m *ModalBoxModel) activate(t ModalType, editedID int64) { m.Active = true m.Type = t m.form = t.newForm() + m.editedID = editedID } func (m *ModalBoxModel) deactivate() { m.Active = false } -var modalTitleStyle = lipgloss.NewStyle(). - Bold(true) +var ( + boldStyle = lipgloss.NewStyle().Bold(true) + modalTitleStyle = boldStyle +) -func (m ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { +func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { switch m.Type { case ModalTypeDeleteConfirmation: err := am.queries.RemoveTimeEntry(context.Background(), m.editedID) @@ -117,27 +148,80 @@ func (m ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - // Extract and validate form data - params, hasErrors := m.validateAndParseForm(am) + params, hasErrors := m.validateAndParseEntryForm(am) if hasErrors { return reOpenModal() } - // Perform the edit err := am.queries.EditTimeEntry(context.Background(), params) if err != nil { - // TODO: Handle edit error (could set form error) + m.form.err = err + return reOpenModal() + } + + msg := am.refreshCmd() + return func() tea.Msg { return msg } + case ModalTypeClient: + if err := m.form.Error(); err != nil { + return reOpenModal() + } + + var rate *float64 + if value := m.form.fields[2].Value(); value != "" { + r, _ := strconv.ParseFloat(value, 64) + rate = &r + } + + if m.editedID != 0 { + panic("editing a client not yet implemented") + } + + if _, err := actions.New(am.queries).CreateClient( + context.Background(), + m.form.fields[0].Value(), + m.form.fields[1].Value(), + rate, + ); err != nil { + m.form.err = err + return reOpenModal() + } + + msg := am.refreshCmd() + return func() tea.Msg { return msg } + + case ModalTypeProject: + if err := m.form.Error(); err != nil { + return reOpenModal() + } + + var rate *float64 + if value := m.form.fields[2].Value(); value != "" { + r, _ := strconv.ParseFloat(value, 64) + rate = &r + } + + if m.editedID != 0 { + panic("editing a project not yet implemented") + } + + if _, err := actions.New(am.queries).CreateProject( + context.Background(), + m.form.fields[0].Value(), + m.form.fields[1].Value(), + rate, + ); err != nil { + m.form.err = err return reOpenModal() } - // Success - close modal and refresh data - return func() tea.Msg { return am.refreshCmd() } + msg := am.refreshCmd() + return func() tea.Msg { return msg } } return nil } -func (m *ModalBoxModel) validateAndParseForm(am AppModel) (queries.EditTimeEntryParams, bool) { +func (m *ModalBoxModel) validateAndParseEntryForm(am AppModel) (queries.EditTimeEntryParams, bool) { var params queries.EditTimeEntryParams var hasErrors bool diff --git a/internal/tui/projects_box.go b/internal/tui/projects_box.go index f40cbd0..9c62d4d 100644 --- a/internal/tui/projects_box.go +++ b/internal/tui/projects_box.go @@ -26,7 +26,7 @@ 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." + content = inactiveTimerStyle.Render("No clients found\n\nUse 'punch add client' to\nadd your first client.") } else { content = m.renderClientsAndProjects() } @@ -105,6 +105,10 @@ func (m *ClientsProjectsModel) changeSelection(forward bool) { } func (m *ClientsProjectsModel) changeSelectionForward() { + if len(m.clients) == 0 { + return + } + selectedClient := m.clients[m.selectedClient] projects := m.projects[selectedClient.ID] @@ -146,6 +150,10 @@ func (m *ClientsProjectsModel) changeSelectionForward() { } func (m *ClientsProjectsModel) changeSelectionBackward() { + if len(m.clients) == 0 { + return + } + selectedClient := m.clients[m.selectedClient] if m.selectedProject == nil { @@ -180,6 +188,10 @@ func (m *ClientsProjectsModel) changeSelectionBackward() { } func (m ClientsProjectsModel) selection() (string, string, string, *float64) { + if len(m.clients) == 0 { + return "", "", "", nil + } + client := m.clients[m.selectedClient] clientID := strconv.FormatInt(client.ID, 10) diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go index 2f8ebbe..09a42c7 100644 --- a/internal/tui/timer_box.go +++ b/internal/tui/timer_box.go @@ -76,7 +76,7 @@ func (m TimerBoxModel) View(width, height int, isSelected bool) string { // renderActiveTimer renders the active timer display func (m TimerBoxModel) renderActiveTimer() string { - content := titleStyle.Render("⏱ Active Timer") + "\n\n" + content := titleStyle.Render("⏰ Active Timer") + "\n\n" // Timer duration timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.currentTime.Sub(m.timerInfo.StartTime))) @@ -114,7 +114,12 @@ func (m TimerBoxModel) renderActiveTimer() string { // renderInactiveTimer renders the inactive timer display func (m TimerBoxModel) renderInactiveTimer() string { - content := titleStyle.Render("⚪ Last Timer (Inactive)") + "\n\n" + content := titleStyle.Render("⌛ Last Timer (Inactive)") + "\n\n" + + if m.timerInfo.EntryID == 0 { + content += inactiveTimerStyle.Render("No time entries yet.\nSelect a client or project and\npunch in to start tracking time.") + return content + } timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration)) content += inactiveTimerStyle.Render(timerLine) + "\n\n" -- cgit v1.2.3