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