From a7ee7f7280d593481501446008acc05e32abcd22 Mon Sep 17 00:00:00 2001 From: T Date: Thu, 7 Aug 2025 13:11:24 -0600 Subject: entry edit and delete --- internal/actions/actions.go | 1 + internal/actions/timer.go | 14 ++- internal/database/queries.sql | 15 ++++ internal/queries/queries.sql.go | 55 ++++++++++++ internal/tui/app.go | 63 ++++++++++++- internal/tui/commands.go | 31 +++++-- internal/tui/form.go | 164 ++++++++++++++++++++++++++++++++++ internal/tui/history_box.go | 28 +++++- internal/tui/keys.go | 190 +++++++++++++++++++--------------------- internal/tui/modal.go | 173 ++++++++++++++++++++++++++++++++++-- internal/tui/timer_box.go | 2 +- 11 files changed, 618 insertions(+), 118 deletions(-) create mode 100644 internal/tui/form.go (limited to 'internal') diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 727b474..d3e1460 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -12,6 +12,7 @@ type Actions interface { PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) PunchOut(ctx context.Context) (*TimerSession, error) + EditEntry(ctx context.Context, entry queries.TimeEntry) error // Client operations CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) diff --git a/internal/actions/timer.go b/internal/actions/timer.go index 5235a0a..9f29c51 100644 --- a/internal/actions/timer.go +++ b/internal/actions/timer.go @@ -58,7 +58,7 @@ func (a *actions) PunchIn(ctx context.Context, client, project, description stri // Verify project belongs to client if both specified if resolvedProject != nil && resolvedProject.ClientID != clientID { - return nil, fmt.Errorf("%w: project %q does not belong to client %q", + return nil, fmt.Errorf("%w: project %q does not belong to client %q", ErrProjectClientMismatch, project, client) } } else if resolvedProject != nil { @@ -262,6 +262,18 @@ func (a *actions) PunchOut(ctx context.Context) (*TimerSession, error) { }, nil } +func (a *actions) EditEntry(ctx context.Context, entry queries.TimeEntry) error { + return a.queries.EditTimeEntry(ctx, queries.EditTimeEntryParams{ + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + ClientID: entry.ClientID, + ProjectID: entry.ProjectID, + HourlyRate: entry.BillableRate, + EntryID: entry.ID, + }) +} + // Helper functions func (a *actions) createTimeEntry(ctx context.Context, clientID int64, projectID sql.NullInt64, description string, billableRate *float64) (*queries.TimeEntry, error) { diff --git a/internal/database/queries.sql b/internal/database/queries.sql index f46f426..8eda5f4 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -39,6 +39,10 @@ where end_time is null order by start_time desc limit 1; +-- name: GetTimeEntryById :one +select * from time_entry +where id = @entry_id; + -- name: StopTimeEntry :one update time_entry set end_time = datetime('now', 'utc') @@ -314,6 +318,17 @@ where id = ( limit 1 ); +-- name: EditTimeEntry :exec +update time_entry +set + start_time = @start_time, + end_time = @end_time, + description = @description, + client_id = @client_id, + project_id = @project_id, + billable_rate = @hourly_rate +where id = @entry_id; + -- name: RemoveTimeEntry :exec delete from time_entry where id = @entry_id; diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go index 1084cea..fb7f390 100644 --- a/internal/queries/queries.sql.go +++ b/internal/queries/queries.sql.go @@ -212,6 +212,41 @@ func (q *Queries) CreateTimeEntryWithTimes(ctx context.Context, arg CreateTimeEn return i, err } +const editTimeEntry = `-- name: EditTimeEntry :exec +update time_entry +set + start_time = ?1, + end_time = ?2, + description = ?3, + client_id = ?4, + project_id = ?5, + billable_rate = ?6 +where id = ?7 +` + +type EditTimeEntryParams struct { + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + ClientID int64 + ProjectID sql.NullInt64 + HourlyRate sql.NullInt64 + EntryID int64 +} + +func (q *Queries) EditTimeEntry(ctx context.Context, arg EditTimeEntryParams) error { + _, err := q.db.ExecContext(ctx, editTimeEntry, + arg.StartTime, + arg.EndTime, + arg.Description, + arg.ClientID, + arg.ProjectID, + arg.HourlyRate, + arg.EntryID, + ) + return err +} + const findClient = `-- name: FindClient :many select c1.id, c1.name, c1.email, c1.billable_rate, c1.created_at from client c1 where c1.id = cast(?1 as integer) union all @@ -691,6 +726,26 @@ func (q *Queries) GetRecentTimeEntries(ctx context.Context, startTime time.Time) return items, nil } +const getTimeEntryById = `-- name: GetTimeEntryById :one +select id, start_time, end_time, description, client_id, project_id, billable_rate from time_entry +where id = ?1 +` + +func (q *Queries) GetTimeEntryById(ctx context.Context, entryID int64) (TimeEntry, error) { + row := q.db.QueryRowContext(ctx, getTimeEntryById, entryID) + var i TimeEntry + err := row.Scan( + &i.ID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.ClientID, + &i.ProjectID, + &i.BillableRate, + ) + return i, err +} + const getTimesheetDataByClient = `-- name: GetTimesheetDataByClient :many select te.id as time_entry_id, 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" -- cgit v1.2.3