summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-07 13:11:24 -0600
committerT <t@tjp.lol>2025-08-07 23:14:00 -0600
commita7ee7f7280d593481501446008acc05e32abcd22 (patch)
treef056bd9c72934a9e04aa5af872e836bc43d3739f /internal
parent4843deb9cfa6d91282c5124ec025c636137e9e94 (diff)
entry edit and delete
Diffstat (limited to 'internal')
-rw-r--r--internal/actions/actions.go1
-rw-r--r--internal/actions/timer.go14
-rw-r--r--internal/database/queries.sql15
-rw-r--r--internal/queries/queries.sql.go55
-rw-r--r--internal/tui/app.go63
-rw-r--r--internal/tui/commands.go31
-rw-r--r--internal/tui/form.go164
-rw-r--r--internal/tui/history_box.go28
-rw-r--r--internal/tui/keys.go190
-rw-r--r--internal/tui/modal.go173
-rw-r--r--internal/tui/timer_box.go2
11 files changed, 618 insertions, 118 deletions
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"