diff options
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/app.go | 63 | ||||
-rw-r--r-- | internal/tui/commands.go | 31 | ||||
-rw-r--r-- | internal/tui/form.go | 164 | ||||
-rw-r--r-- | internal/tui/history_box.go | 28 | ||||
-rw-r--r-- | internal/tui/keys.go | 190 | ||||
-rw-r--r-- | internal/tui/modal.go | 173 | ||||
-rw-r--r-- | internal/tui/timer_box.go | 2 |
7 files changed, 534 insertions, 117 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go index 28f04dc..e325116 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "time" "punchcard/internal/queries" @@ -121,7 +122,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height case tea.KeyMsg: - cmds = append(cmds, HandleKeyPress(msg, m)) + cmds = append(cmds, HandleKeyPress(msg, &m)) case TickMsg: m.timerBox.currentTime = time.Time(msg) @@ -165,11 +166,67 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case searchActivated: m.modalBox.activate(ModalTypeSearch) + case modalClosed: + m.modalBox.deactivate() + + case openTimeEntryEditor: + if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { + m.openEntryEditor() + } + + case openModalUnchanged: + m.modalBox.Active = true + + case openDeleteConfirmation: + if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { + m.modalBox.activate(ModalTypeDeleteConfirmation) + m.modalBox.editedID = m.historyBox.selectedEntry().ID + } + + case recheckBounds: + switch m.selectedBox { + case HistoryBox: + m.historyBox.recheckBounds() + } + } return m, tea.Batch(cmds...) } +func (m *AppModel) openEntryEditor() { + m.modalBox.activate(ModalTypeEntry) + m.modalBox.editedID = m.historyBox.selectedEntry().ID + f := m.modalBox.form + f.fields[0].Focus() + + entry := m.historyBox.selectedEntry() + f.fields[0].SetValue(entry.StartTime.Format(time.DateTime)) + if entry.EndTime.Valid { + f.fields[1].SetValue(entry.EndTime.Time.Format(time.DateTime)) + } + for _, client := range m.projectsBox.clients { + if client.ID == entry.ClientID { + f.fields[2].SetValue(client.Name) + break + } + } + if entry.ProjectID.Valid { + for _, project := range m.projectsBox.projects[entry.ClientID] { + if project.ID == entry.ProjectID.Int64 { + f.fields[3].SetValue(project.Name) + break + } + } + } + if entry.Description.Valid { + f.fields[4].SetValue(entry.Description.String) + } + if entry.BillableRate.Valid { + f.fields[5].SetValue(fmt.Sprintf("%.2f", float64(entry.BillableRate.Int64)/100)) + } +} + // View renders the app func (m AppModel) View() string { if m.width == 0 || m.height == 0 { @@ -209,10 +266,10 @@ func (m AppModel) View() string { leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, projectsBox) mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) - keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel) + 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) + return m.modalBox.RenderCenteredOver(topBar+"\n"+mainContent+"\n"+bottomBar, m) } // dataUpdatedMsg is sent when data is updated from the database diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4a19551..be5c8dc 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -9,11 +9,16 @@ import ( ) type ( - navigationMsg struct{ Forward bool } - selectionMsg struct{ Forward bool } - drillDownMsg struct{} - drillUpMsg struct{} - searchActivated struct{} + navigationMsg struct{ Forward bool } + selectionMsg struct{ Forward bool } + drillDownMsg struct{} + drillUpMsg struct{} + searchActivated struct{} + modalClosed struct{} + openTimeEntryEditor struct{} + openModalUnchanged struct{} + openDeleteConfirmation struct{} + recheckBounds struct{} ) func navigate(forward bool) tea.Cmd { @@ -68,3 +73,19 @@ func changeSelection(forward bool) tea.Cmd { func activateSearch() tea.Cmd { return func() tea.Msg { return searchActivated{} } } + +func closeModal() tea.Cmd { + return func() tea.Msg { return modalClosed{} } +} + +func editCurrentEntry() tea.Cmd { + return func() tea.Msg { return openTimeEntryEditor{} } +} + +func reOpenModal() tea.Cmd { + return func() tea.Msg { return openModalUnchanged{} } +} + +func confirmDeleteEntry() tea.Cmd { + return func() tea.Msg { return openDeleteConfirmation{} } +} diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..4b12762 --- /dev/null +++ b/internal/tui/form.go @@ -0,0 +1,164 @@ +package tui + +import ( + "errors" + "fmt" + "strconv" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type FormField struct { + textinput.Model + label string +} + +func (ff FormField) Update(msg tea.Msg) (FormField, tea.Cmd) { + field, cmd := ff.Model.Update(msg) + ff.Model = field + return ff, cmd +} + +func newTimestampField(label string) FormField { + f := FormField{ + Model: textinput.New(), + label: label, + } + f.Validate = func(s string) error { + if _, err := time.Parse(time.DateTime, s); err != nil { + return fmt.Errorf("timestamps must be written like \"%s\"", time.DateTime) + } + return nil + } + return f +} + +func newOptionalTimestampField(label string) FormField { + f := FormField{ + Model: textinput.New(), + label: label, + } + f.Validate = func(s string) error { + if s == "" { + return nil + } + if _, err := time.Parse(time.DateTime, s); err != nil { + return fmt.Errorf("timestamps must be written like \"%s\"", time.DateTime) + } + return nil + } + return f +} + +func newOptionalFloatField(label string) FormField { + f := FormField{ + Model: textinput.New(), + label: label, + } + f.Validate = func(s string) error { + if s == "" { + return nil + } + _, err := strconv.ParseFloat(s, 64) + if err != nil { + return errors.New("numerical values only") + } + return nil + } + return f +} + +type Form struct { + fields []FormField + selIdx int + + SelectedStyle *lipgloss.Style + UnselectedStyle *lipgloss.Style +} + +func NewForm(fields []FormField) Form { + return Form{fields: fields} +} + +func (ff Form) Error() error { + for _, field := range ff.fields { + if field.Err != nil { + return field.Err + } + } + return nil +} + +func NewEntryEditorForm() Form { + form := NewForm([]FormField{ + newTimestampField("Start time"), + newOptionalTimestampField("End time"), + {Model: textinput.New(), label: "Client"}, + {Model: textinput.New(), label: "Project"}, + {Model: textinput.New(), label: "Description"}, + 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() { + case "tab": + ff.fields[ff.selIdx].Blur() + ff.selIdx = (ff.selIdx + 1) % len(ff.fields) + return ff, ff.fields[ff.selIdx].Focus() + case "shift+tab": + ff.fields[ff.selIdx].Blur() + ff.selIdx-- + if ff.selIdx < 0 { + ff.selIdx += len(ff.fields) + } + return ff, ff.fields[ff.selIdx].Focus() + } + } + + field, cmd := ff.fields[ff.selIdx].Update(msg) + ff.fields[ff.selIdx] = field + return ff, cmd +} + +func (ff Form) View() string { + content := "" + for i, field := range ff.fields { + if i > 0 { + content += "\n\n" + } + content += field.label + ":\n" + + style := ff.UnselectedStyle + if i == ff.selIdx { + style = ff.SelectedStyle + } + if style != nil { + content += style.Render(field.View()) + } else { + content += field.View() + } + + if field.Err != nil { + content += "\n" + errorStyle.Render(field.Err.Error()) + } + } + return content +} + +var ( + modalFocusedInputStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("238")) + modalBlurredInputStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("238")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) +) diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index 847b4fc..99fa4ac 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -262,7 +262,7 @@ func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel) string { for i, item := range m.summaryItems { if date == nil || !date.Equal(item.Date) { date = &item.Date - content += fmt.Sprintf("\n\n%s\n", dateStyle.Render(date.Format("Mon 2006/01/02"))) + content += fmt.Sprintf("\n\n%s\n", dateStyle.Render(date.Format("Mon 01/02"))) } style := summaryItemStyle @@ -409,6 +409,13 @@ func (m *HistoryBoxModel) changeDetailsSelection(forward bool) { } } +func (m HistoryBoxModel) selectedEntry() queries.TimeEntry { + if m.viewLevel != HistoryLevelDetails { + panic("fetching selected entry in history summary level") + } + return m.selectedEntries()[m.detailSelection] +} + func (m HistoryBoxModel) selection() (string, string, string, *float64) { item := m.summaryItems[m.summarySelection] @@ -444,3 +451,22 @@ func (m *HistoryBoxModel) drillDown() { func (m *HistoryBoxModel) drillUp() { m.viewLevel = HistoryLevelSummary } + +func (m *HistoryBoxModel) recheckBounds() { + for m.summarySelection >= len(m.summaryItems) { + m.summarySelection-- + } + if m.summarySelection < 0 { + m.summarySelection = 0 + } + + if m.viewLevel == HistoryLevelDetails { + ents := m.selectedEntries() + for m.detailSelection >= len(ents) { + m.detailSelection-- + } + if m.detailSelection < 0 { + m.detailSelection = 0 + } + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 46b664c..e1ac587 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -14,22 +14,21 @@ const ( ScopeProjectsBox ScopeHistoryBoxSummaries ScopeHistoryBoxDetails + ScopeModal ) // KeyBinding represents the available key bindings for a view type KeyBinding struct { Key string Description func(AppModel) string - Scope KeyBindingScope - Result func(AppModel) tea.Cmd + Result func(*AppModel) tea.Cmd Hide bool } type ( - createClientMsg struct{} - createProjectMsg struct{} - deleteHistoryEntry struct{} - editHistoryEntry struct{} + createClientMsg struct{} + createProjectMsg struct{} + editHistoryEntry struct{} ) func msgAsCmd(msg tea.Msg) tea.Cmd { @@ -41,14 +40,12 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map "ctrl+n": KeyBinding{ Key: "Ctrl+n", Description: func(AppModel) string { return "Next Pane" }, - Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return navigate(true) }, + Result: func(*AppModel) tea.Cmd { return navigate(true) }, }, "ctrl+p": KeyBinding{ Key: "Ctrl+p", Description: func(AppModel) string { return "Prev Pane" }, - Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return navigate(false) }, + Result: func(*AppModel) tea.Cmd { return navigate(false) }, }, "p": KeyBinding{ Key: "p", @@ -58,44 +55,33 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map } return "Punch In" }, - Scope: ScopeGlobal, - Result: func(am AppModel) tea.Cmd { + Result: func(am *AppModel) tea.Cmd { if am.timerBox.timerInfo.IsActive { - return punchOut(am) + return punchOut(*am) } - return punchIn(am) + return punchIn(*am) }, }, - "/": KeyBinding{ - Key: "/", - Description: func(am AppModel) string { return "Search" }, - Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return activateSearch() }, - }, "r": KeyBinding{ Key: "r", Description: func(am AppModel) string { return "Refresh" }, - Scope: ScopeGlobal, - Result: func(am AppModel) tea.Cmd { return am.refreshCmd }, + Result: func(am *AppModel) tea.Cmd { return am.refreshCmd }, }, "q": KeyBinding{ Key: "q", Description: func(am AppModel) string { return "Quit" }, - Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return tea.Quit }, + Result: func(*AppModel) tea.Cmd { return tea.Quit }, }, "ctrl+c": KeyBinding{ Key: "Ctrl+c", Description: func(am AppModel) string { return "Quit" }, - Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return tea.Quit }, + Result: func(*AppModel) tea.Cmd { return tea.Quit }, Hide: true, }, "ctrl+d": KeyBinding{ Key: "Ctrl+d", Description: func(am AppModel) string { return "Quit" }, - Scope: ScopeGlobal, - Result: func(AppModel) tea.Cmd { return tea.Quit }, + Result: func(*AppModel) tea.Cmd { return tea.Quit }, Hide: true, }, }, @@ -108,12 +94,11 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map } return "Punch In" }, - Scope: ScopeTimerBox, - Result: func(am AppModel) tea.Cmd { + Result: func(am *AppModel) tea.Cmd { if am.timerBox.timerInfo.IsActive { - return punchOut(am) + return punchOut(*am) } - return punchIn(am) + return punchIn(*am) }, }, }, @@ -121,147 +106,150 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map "j": KeyBinding{ Key: "j", Description: func(AppModel) string { return "Down" }, - Scope: ScopeProjectsBox, - Result: func(AppModel) tea.Cmd { return changeSelection(true) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(true) }, }, "k": KeyBinding{ Key: "k", Description: func(AppModel) string { return "Up" }, - Scope: ScopeProjectsBox, - Result: func(AppModel) tea.Cmd { return changeSelection(false) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(false) }, }, "down": KeyBinding{ Key: "down", Description: func(AppModel) string { return "Down" }, - Scope: ScopeProjectsBox, - Result: func(AppModel) tea.Cmd { return changeSelection(true) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(true) }, Hide: true, }, "up": KeyBinding{ Key: "up", Description: func(AppModel) string { return "Up" }, - Scope: ScopeProjectsBox, - Result: func(AppModel) tea.Cmd { return changeSelection(false) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(false) }, Hide: true, }, "enter": KeyBinding{ Key: "Enter", Description: func(AppModel) string { return "Punch In on Selection" }, - Scope: ScopeProjectsBox, - Result: func(am AppModel) tea.Cmd { return punchInOnSelection(am) }, + Result: func(am *AppModel) tea.Cmd { return punchInOnSelection(*am) }, }, "n": KeyBinding{ Key: "n", Description: func(AppModel) string { return "New Project" }, - Scope: ScopeProjectsBox, - Result: func(AppModel) tea.Cmd { return msgAsCmd(createProjectMsg{}) }, + Result: func(*AppModel) tea.Cmd { return msgAsCmd(createProjectMsg{}) }, }, "N": KeyBinding{ Key: "N", Description: func(AppModel) string { return "New Client" }, - Scope: ScopeProjectsBox, - Result: func(AppModel) tea.Cmd { return msgAsCmd(createClientMsg{}) }, + Result: func(*AppModel) tea.Cmd { return msgAsCmd(createClientMsg{}) }, }, }, ScopeHistoryBoxSummaries: { "j": KeyBinding{ Key: "j", Description: func(AppModel) string { return "Down" }, - Scope: ScopeHistoryBoxSummaries, - Result: func(AppModel) tea.Cmd { return changeSelection(true) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(true) }, }, "k": KeyBinding{ Key: "k", Description: func(AppModel) string { return "Up" }, - Scope: ScopeHistoryBoxSummaries, - Result: func(AppModel) tea.Cmd { return changeSelection(false) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(false) }, }, "down": KeyBinding{ Key: "down", Description: func(AppModel) string { return "Down" }, - Scope: ScopeHistoryBoxSummaries, - Result: func(AppModel) tea.Cmd { return changeSelection(true) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(true) }, Hide: true, }, "up": KeyBinding{ Key: "up", Description: func(AppModel) string { return "Up" }, - Scope: ScopeHistoryBoxSummaries, - Result: func(AppModel) tea.Cmd { return changeSelection(false) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(false) }, Hide: true, }, "enter": KeyBinding{ Key: "Enter", Description: func(AppModel) string { return "Select" }, - Scope: ScopeHistoryBoxSummaries, - Result: func(AppModel) tea.Cmd { return selectHistorySummary() }, + Result: func(*AppModel) tea.Cmd { return selectHistorySummary() }, }, }, ScopeHistoryBoxDetails: { "j": KeyBinding{ Key: "j", Description: func(AppModel) string { return "Down" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return changeSelection(true) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(true) }, }, "k": KeyBinding{ Key: "k", Description: func(AppModel) string { return "Up" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return changeSelection(false) }, + Result: func(*AppModel) tea.Cmd { return changeSelection(false) }, }, "down": KeyBinding{ Key: "Down", Description: func(AppModel) string { return "Down" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return changeSelection(true) }, - Hide: true, + Result: func(*AppModel) tea.Cmd { return changeSelection(true) }, + Hide: true, }, "up": KeyBinding{ Key: "Up", Description: func(AppModel) string { return "Up" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return changeSelection(false) }, - Hide: true, + Result: func(*AppModel) tea.Cmd { return changeSelection(false) }, + Hide: true, }, "e": KeyBinding{ Key: "e", Description: func(AppModel) string { return "Edit" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return msgAsCmd(editHistoryEntry{}) }, + Result: func(am *AppModel) tea.Cmd { return editCurrentEntry() }, }, "d": KeyBinding{ Key: "d", Description: func(AppModel) string { return "Delete" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return msgAsCmd(deleteHistoryEntry{}) }, + Result: func(*AppModel) tea.Cmd { return confirmDeleteEntry() }, }, "enter": KeyBinding{ Key: "Enter", Description: func(AppModel) string { return "Resume" }, - Scope: ScopeHistoryBoxDetails, - Result: func(am AppModel) tea.Cmd { return punchInOnSelection(am) }, + Result: func(am *AppModel) tea.Cmd { return punchInOnSelection(*am) }, }, "b": KeyBinding{ Key: "b", Description: func(AppModel) string { return "Back" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return backToHistorySummary() }, + Result: func(*AppModel) tea.Cmd { return backToHistorySummary() }, }, "esc": KeyBinding{ Key: "Esc", Description: func(AppModel) string { return "Back" }, - Scope: ScopeHistoryBoxDetails, - Result: func(AppModel) tea.Cmd { return backToHistorySummary() }, + Result: func(*AppModel) tea.Cmd { return backToHistorySummary() }, Hide: true, }, }, + ScopeModal: { + "enter": KeyBinding{ + Key: "Enter", + Description: func(AppModel) string { return "Submit" }, + Result: func(am *AppModel) tea.Cmd { + return tea.Sequence( + closeModal(), + am.modalBox.SubmitForm(*am), + ) + }, + }, + "esc": KeyBinding{ + Key: "Esc", + Description: func(AppModel) string { return "Close" }, + Result: func(*AppModel) tea.Cmd { return closeModal() }, + }, + }, } -// KeyHandler processes key messages and returns the appropriate action -func HandleKeyPress(msg tea.KeyMsg, data AppModel) tea.Cmd { +// HandleKeyPress processes key messages and returns the appropriate action +func HandleKeyPress(msg tea.KeyMsg, data *AppModel) tea.Cmd { key := msg.String() + if data.modalBox.Active { + if binding, ok := Bindings[ScopeModal][key]; ok { + return binding.Result(data) + } + return data.modalBox.HandleKeyPress(msg) + } + if binding, ok := Bindings[ScopeGlobal][key]; ok { return binding.Result(data) } @@ -280,36 +268,42 @@ func HandleKeyPress(msg tea.KeyMsg, data AppModel) tea.Cmd { local = Bindings[ScopeHistoryBoxDetails] } } - if binding, ok := local[key]; ok { return binding.Result(data) } return nil } -func activeBindings(box BoxType, level HistoryViewLevel) []KeyBinding { +func activeBindings(box BoxType, level HistoryViewLevel, modal ModalBoxModel) []KeyBinding { out := make([]KeyBinding, 0, len(Bindings[ScopeGlobal])) - for _, binding := range Bindings[ScopeGlobal] { - out = append(out, binding) - } - var scope KeyBindingScope - switch box { - case TimerBox: - scope = ScopeTimerBox - case ProjectsBox: - scope = ScopeProjectsBox - case HistoryBox: - switch level { - case HistoryLevelSummary: - scope = ScopeHistoryBoxSummaries - case HistoryLevelDetails: - scope = ScopeHistoryBoxDetails + if modal.Active { + for _, binding := range Bindings[ScopeModal] { + out = append(out, binding) + } + } else { + for _, binding := range Bindings[ScopeGlobal] { + out = append(out, binding) } - } - for _, binding := range Bindings[scope] { - out = append(out, binding) + var scope KeyBindingScope + switch box { + case TimerBox: + scope = ScopeTimerBox + case ProjectsBox: + scope = ScopeProjectsBox + case HistoryBox: + switch level { + case HistoryLevelSummary: + scope = ScopeHistoryBoxSummaries + case HistoryLevelDetails: + scope = ScopeHistoryBoxDetails + } + } + + for _, binding := range Bindings[scope] { + out = append(out, binding) + } } slices.SortFunc(out, func(a, b KeyBinding) int { diff --git a/internal/tui/modal.go b/internal/tui/modal.go index 3a57880..88b2861 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -1,6 +1,18 @@ package tui -import "github.com/charmbracelet/lipgloss/v2" +import ( + "context" + "database/sql" + "fmt" + "strconv" + "time" + + "punchcard/internal/actions" + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss/v2" +) type ModalType int @@ -12,10 +24,23 @@ const ( ModalTypeEntry ) +func (mt ModalType) newForm() Form { + return NewEntryEditorForm() +} + type ModalBoxModel struct { Active bool Type ModalType + + form Form + editedID int64 +} + +func (m *ModalBoxModel) HandleKeyPress(msg tea.KeyMsg) tea.Cmd { + form, cmd := m.form.Update(msg) + m.form = form + return cmd } func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) string { @@ -24,18 +49,16 @@ func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) stri } modalContent := m.Render() - base := lipgloss.NewLayer(mainContent) - overlayWidth := 60 - overlayHeight := 5 + overlayStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(2, 4) + + overlay := lipgloss.NewLayer(overlayStyle.Render(modalContent)) + overlayWidth := overlay.GetWidth() + overlayHeight := overlay.GetHeight() overlayLeft := (app.width - overlayWidth) / 2 overlayTop := (app.height - overlayHeight) / 2 - overlayStyle := lipgloss.NewStyle().Height(overlayHeight).Width(overlayWidth).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")) - - overlay := lipgloss.NewLayer(overlayStyle.Render(modalContent)).Width(overlayWidth).Height(overlayHeight) - canvas := lipgloss.NewCanvas( base.Z(0), overlay.X(overlayLeft).Y(overlayTop).Z(1), @@ -47,13 +70,145 @@ func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) stri func (m ModalBoxModel) Render() string { switch m.Type { case ModalTypeSearch: - return "SEARCH BOX" + return modalTitleStyle.Render("SEARCH BOX") + case ModalTypeEntry: + return m.RenderEntryEditor() + case ModalTypeDeleteConfirmation: + return m.RenderDeleteConfirmation() 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) 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) +} + func (m *ModalBoxModel) activate(t ModalType) { m.Active = true m.Type = t + m.form = t.newForm() +} + +func (m *ModalBoxModel) deactivate() { + m.Active = false +} + +var modalTitleStyle = lipgloss.NewStyle(). + Bold(true) + +func (m ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { + switch m.Type { + case ModalTypeDeleteConfirmation: + err := am.queries.RemoveTimeEntry(context.Background(), m.editedID) + if err != nil { + return reOpenModal() + } + return tea.Sequence(am.refreshCmd, func() tea.Msg { return recheckBounds{} }) + + case ModalTypeEntry: + if err := m.form.Error(); err != nil { + return reOpenModal() + } + + // Extract and validate form data + params, hasErrors := m.validateAndParseForm(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) + return reOpenModal() + } + + // Success - close modal and refresh data + return func() tea.Msg { return am.refreshCmd() } + } + + return nil +} + +func (m *ModalBoxModel) validateAndParseForm(am AppModel) (queries.EditTimeEntryParams, bool) { + var params queries.EditTimeEntryParams + var hasErrors bool + + // Set the entry ID + params.EntryID = m.editedID + + entry, _ := am.queries.GetTimeEntryById(context.Background(), params.EntryID) + + startTimeStr := m.form.fields[0].Value() + startTime, err := time.Parse(time.DateTime, startTimeStr) + if err != nil { + m.form.fields[0].Err = fmt.Errorf("invalid start time format") + hasErrors = true + } else { + params.StartTime = startTime + } + + endTimeStr := m.form.fields[1].Value() + if endTimeStr != "" { + endTime, err := time.Parse(time.DateTime, endTimeStr) + if err != nil { + m.form.fields[1].Err = fmt.Errorf("invalid end time format") + hasErrors = true + } else { + params.EndTime = sql.NullTime{Time: endTime, Valid: true} + } + } else if entry.EndTime.Valid { + m.form.fields[1].Err = fmt.Errorf("can not re-open an entry") + hasErrors = true + } + + clientStr := m.form.fields[2].Value() + if clientStr == "" { + m.form.fields[2].Err = fmt.Errorf("client is required") + hasErrors = true + } else { + client, err := actions.New(am.queries).FindClient(context.Background(), clientStr) + if err != nil { + m.form.fields[2].Err = fmt.Errorf("client not found: %s", clientStr) + hasErrors = true + } else { + params.ClientID = client.ID + } + } + + projectStr := m.form.fields[3].Value() + if projectStr != "" { + project, err := actions.New(am.queries).FindProject(context.Background(), projectStr) + if err != nil { + m.form.fields[3].Err = fmt.Errorf("project not found: %s", projectStr) + hasErrors = true + } else { + params.ProjectID = sql.NullInt64{Int64: project.ID, Valid: true} + } + } + + descriptionStr := m.form.fields[4].Value() + if descriptionStr != "" { + params.Description = sql.NullString{String: descriptionStr, Valid: true} + } + + rateStr := m.form.fields[5].Value() + if rateStr != "" { + rate, err := strconv.ParseFloat(rateStr, 64) + if err != nil { + m.form.fields[5].Err = fmt.Errorf("invalid hourly rate") + hasErrors = true + } else { + params.HourlyRate = sql.NullInt64{Int64: int64(rate * 100), Valid: true} + } + } + + return params, hasErrors } diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go index 93e05bc..2f8ebbe 100644 --- a/internal/tui/timer_box.go +++ b/internal/tui/timer_box.go @@ -125,7 +125,7 @@ func (m TimerBoxModel) renderInactiveTimer() string { content += inactiveTimerStyle.Render(fmt.Sprintf("Client: %s", m.timerInfo.ClientName)) + "\n" } - content += inactiveTimerStyle.Render(fmt.Sprintf("Started: %s", m.timerInfo.StartTime.Local().Format("3:04 PM"))) + "\n" + content += inactiveTimerStyle.Render(fmt.Sprintf("Started: %s", m.timerInfo.StartTime.Local().Format("2006/01/02 3:04 PM"))) + "\n" if m.timerInfo.Description != nil { content += "\n" + inactiveTimerStyle.Render(fmt.Sprintf("Description: %s", *m.timerInfo.Description)) + "\n" |