summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-13 13:04:05 -0600
committerT <t@tjp.lol>2025-08-13 13:42:43 -0600
commit389b72e55b04ccfc02b04eb81cb8f7bb7a5c8b59 (patch)
treebe3015b2c7db90cddfc85d3e77ddc76213485494 /internal/tui
parent29c6581e08d0fe98433eff218de7701b51a6861c (diff)
history filtering
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/app.go65
-rw-r--r--internal/tui/commands.go6
-rw-r--r--internal/tui/form.go31
-rw-r--r--internal/tui/history_box.go21
-rw-r--r--internal/tui/keys.go5
-rw-r--r--internal/tui/modal.go57
-rw-r--r--internal/tui/shared.go24
7 files changed, 198 insertions, 11 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go
index 6765cd1..34310bd 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -192,6 +192,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openCreateProjectModal:
m.modalBox.activateCreateProjectModal(m)
+ case openHistoryFilterModal:
+ m.openHistoryFilterModal()
+
+ case updateHistoryFilter:
+ m.historyBox.filter = HistoryFilter(msg)
+ cmds = append(cmds, m.refreshCmd)
+
}
return m, tea.Batch(cmds...)
@@ -199,33 +206,73 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *AppModel) openEntryEditor() {
m.modalBox.activate(ModalTypeEntry, m.historyBox.selectedEntry().ID)
- f := m.modalBox.form
- f.fields[0].Focus()
+ m.modalBox.form.fields[0].Focus()
entry := m.historyBox.selectedEntry()
- f.fields[0].SetValue(entry.StartTime.Local().Format(time.DateTime))
+ m.modalBox.form.fields[0].SetValue(entry.StartTime.Local().Format(time.DateTime))
if entry.EndTime.Valid {
- f.fields[1].SetValue(entry.EndTime.Time.Local().Format(time.DateTime))
+ m.modalBox.form.fields[1].SetValue(entry.EndTime.Time.Local().Format(time.DateTime))
}
for _, client := range m.projectsBox.clients {
if client.ID == entry.ClientID {
- f.fields[2].SetValue(client.Name)
+ m.modalBox.form.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)
+ m.modalBox.form.fields[3].SetValue(project.Name)
break
}
}
}
if entry.Description.Valid {
- f.fields[4].SetValue(entry.Description.String)
+ m.modalBox.form.fields[4].SetValue(entry.Description.String)
}
if entry.BillableRate.Valid {
- f.fields[5].SetValue(fmt.Sprintf("%.2f", float64(entry.BillableRate.Int64)/100))
+ m.modalBox.form.fields[5].SetValue(fmt.Sprintf("%.2f", float64(entry.BillableRate.Int64)/100))
+ }
+}
+
+func (m *AppModel) openHistoryFilterModal() {
+ m.modalBox.activate(ModalTypeHistoryFilter, 0)
+ m.modalBox.form.fields[0].Focus()
+
+ // Pre-populate form with current filter values
+ filter := m.historyBox.filter
+
+ // Set date range based on current filter
+ var dateRangeStr string
+ if filter.EndDate == nil {
+ // Use "since <date>" format for open-ended ranges
+ dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format("2006-01-02"))
+ } else {
+ // Use "YYYY-MM-DD to YYYY-MM-DD" format for bounded ranges
+ dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format("2006-01-02"), filter.EndDate.Format("2006-01-02"))
+ }
+ m.modalBox.form.fields[0].SetValue(dateRangeStr)
+
+ // Set client filter if present
+ if filter.ClientID != nil {
+ for _, client := range m.projectsBox.clients {
+ if client.ID == *filter.ClientID {
+ m.modalBox.form.fields[1].SetValue(client.Name)
+ break
+ }
+ }
+ }
+
+ // Set project filter if present
+ if filter.ProjectID != nil {
+ for _, clientProjects := range m.projectsBox.projects {
+ for _, project := range clientProjects {
+ if project.ID == *filter.ProjectID {
+ m.modalBox.form.fields[2].SetValue(project.Name)
+ break
+ }
+ }
+ }
}
}
@@ -286,7 +333,7 @@ type dataUpdatedMsg struct {
// refreshCmd is a command to update all app data
func (m AppModel) refreshCmd() tea.Msg {
- timerInfo, stats, clients, projects, entries, err := getAppData(m.ctx, m.queries)
+ timerInfo, stats, clients, projects, entries, err := getAppData(m.ctx, m.queries, m.historyBox.filter)
if err != nil {
msg := dataUpdatedMsg{}
msg.err = err
diff --git a/internal/tui/commands.go b/internal/tui/commands.go
index ad3255f..040df4b 100644
--- a/internal/tui/commands.go
+++ b/internal/tui/commands.go
@@ -20,6 +20,8 @@ type (
recheckBounds struct{}
openCreateClientModal struct{}
openCreateProjectModal struct{}
+ openHistoryFilterModal struct{}
+ updateHistoryFilter HistoryFilter
)
func navigate(forward bool) tea.Cmd {
@@ -98,3 +100,7 @@ func createClientModal() tea.Cmd {
func createProjectModal() tea.Cmd {
return func() tea.Msg { return openCreateProjectModal{} }
}
+
+func createHistoryFilterModal() tea.Cmd {
+ return func() tea.Msg { return openHistoryFilterModal{} }
+}
diff --git a/internal/tui/form.go b/internal/tui/form.go
index 71d5299..7dae012 100644
--- a/internal/tui/form.go
+++ b/internal/tui/form.go
@@ -6,6 +6,8 @@ import (
"strconv"
"time"
+ "punchcard/internal/reports"
+
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -71,6 +73,24 @@ func newOptionalFloatField(label string) FormField {
return f
}
+func newDateRangeField(label string) FormField {
+ f := FormField{
+ Model: textinput.New(),
+ label: label,
+ }
+ f.Validate = func(s string) error {
+ if s == "" {
+ return errors.New("date range is required")
+ }
+ _, err := reports.ParseDateRange(s)
+ if err != nil {
+ return fmt.Errorf("invalid date range: %v", err)
+ }
+ return nil
+ }
+ return f
+}
+
type Form struct {
fields []FormField
selIdx int
@@ -129,6 +149,17 @@ func NewProjectForm() Form {
return form
}
+func NewHistoryFilterForm() Form {
+ form := NewForm([]FormField{
+ newDateRangeField("Date Range"),
+ {Model: textinput.New(), label: "Client (optional)"},
+ {Model: textinput.New(), label: "Project (optional)"},
+ })
+ 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() {
diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go
index d2f71f9..c5c045e 100644
--- a/internal/tui/history_box.go
+++ b/internal/tui/history_box.go
@@ -26,8 +26,17 @@ type HistorySummaryKey struct {
ProjectID int64
}
+// HistoryFilter represents the filtering criteria for the history view
+type HistoryFilter struct {
+ StartDate time.Time // Required - start of date range to display
+ EndDate *time.Time // Optional - end of date range to display (nil means no end date)
+ ClientID *int64 // Optional - filter to specific client
+ ProjectID *int64 // Optional - filter to specific project
+}
+
type HistoryBoxModel struct {
viewLevel HistoryViewLevel
+ filter HistoryFilter
summaryItems []HistorySummaryItem
summarySelection int
@@ -60,7 +69,17 @@ func (item HistorySummaryItem) key() HistorySummaryKey {
// NewHistoryBoxModel creates a new history box model
func NewHistoryBoxModel() HistoryBoxModel {
- return HistoryBoxModel{}
+ now := time.Now()
+ startOfPreviousMonth := time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, time.UTC)
+
+ return HistoryBoxModel{
+ filter: HistoryFilter{
+ StartDate: startOfPreviousMonth,
+ EndDate: nil,
+ ClientID: nil,
+ ProjectID: nil,
+ },
+ }
}
func buildIndex[T any, K comparable](items []T, keyf func(T) K) map[K][]T {
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 71dc251..c2f2271 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -159,6 +159,11 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map
Description: func(AppModel) string { return "Select" },
Result: func(*AppModel) tea.Cmd { return selectHistorySummary() },
},
+ "f": KeyBinding{
+ Key: "f",
+ Description: func(AppModel) string { return "Filter" },
+ Result: func(*AppModel) tea.Cmd { return createHistoryFilterModal() },
+ },
},
ScopeHistoryBoxDetails: {
"j": KeyBinding{
diff --git a/internal/tui/modal.go b/internal/tui/modal.go
index 9a72336..8277077 100644
--- a/internal/tui/modal.go
+++ b/internal/tui/modal.go
@@ -9,6 +9,7 @@ import (
"punchcard/internal/actions"
"punchcard/internal/queries"
+ "punchcard/internal/reports"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss/v2"
@@ -21,6 +22,7 @@ const (
ModalTypeProject
ModalTypeDeleteConfirmation
ModalTypeEntry
+ ModalTypeHistoryFilter
)
func (mt ModalType) newForm() Form {
@@ -31,6 +33,8 @@ func (mt ModalType) newForm() Form {
return NewClientForm()
case ModalTypeProject:
return NewProjectForm()
+ case ModalTypeHistoryFilter:
+ return NewHistoryFilterForm()
}
return Form{}
@@ -85,6 +89,8 @@ func (m ModalBoxModel) Render() string {
return m.RenderFormModal("👤 Client")
case ModalTypeProject:
return m.RenderFormModal("📂 Project")
+ case ModalTypeHistoryFilter:
+ return m.RenderFormModal("🔍 History Filter")
default: // REMOVE ME
return "DEFAULT CONTENT"
}
@@ -216,6 +222,57 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd {
msg := am.refreshCmd()
return func() tea.Msg { return msg }
+
+ case ModalTypeHistoryFilter:
+ if err := m.form.Error(); err != nil {
+ return reOpenModal()
+ }
+
+ // Parse date range
+ dateRangeStr := m.form.fields[0].Value()
+ dateRange, err := reports.ParseDateRange(dateRangeStr)
+ if err != nil {
+ m.form.fields[0].Err = fmt.Errorf("invalid date range: %v", err)
+ return reOpenModal()
+ }
+
+ // Create new filter
+ newFilter := HistoryFilter{
+ StartDate: dateRange.Start,
+ EndDate: nil,
+ ClientID: nil,
+ ProjectID: nil,
+ }
+
+ // Set end date if the parsed range has one
+ if !dateRange.End.IsZero() {
+ newFilter.EndDate = &dateRange.End
+ }
+
+ // Parse client filter if provided
+ clientStr := m.form.fields[1].Value()
+ if clientStr != "" {
+ client, err := actions.New(am.queries).FindClient(context.Background(), clientStr)
+ if err != nil {
+ m.form.fields[1].Err = fmt.Errorf("client not found: %s", clientStr)
+ return reOpenModal()
+ }
+ newFilter.ClientID = &client.ID
+ }
+
+ // Parse project filter if provided
+ projectStr := m.form.fields[2].Value()
+ if projectStr != "" {
+ project, err := actions.New(am.queries).FindProject(context.Background(), projectStr)
+ if err != nil {
+ m.form.fields[2].Err = fmt.Errorf("project not found: %s", projectStr)
+ return reOpenModal()
+ }
+ newFilter.ProjectID = &project.ID
+ }
+
+ // Return filter update message
+ return func() tea.Msg { return updateHistoryFilter(newFilter) }
}
return nil
diff --git a/internal/tui/shared.go b/internal/tui/shared.go
index ebaec6f..769d367 100644
--- a/internal/tui/shared.go
+++ b/internal/tui/shared.go
@@ -187,6 +187,7 @@ func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string {
func getAppData(
ctx context.Context,
q *queries.Queries,
+ filter HistoryFilter,
) (
info TimerInfo,
stats TimeStats,
@@ -235,7 +236,28 @@ func getAppData(
)
}
- entries, err = q.GetRecentTimeEntries(ctx, time.Now().Add(-time.Hour*24*14))
+ // Use filtered query with the provided filter
+ var endTimeParam interface{}
+ if filter.EndDate != nil {
+ endTimeParam = *filter.EndDate
+ }
+
+ var clientIDParam interface{}
+ if filter.ClientID != nil {
+ clientIDParam = *filter.ClientID
+ }
+
+ var projectIDParam interface{}
+ if filter.ProjectID != nil {
+ projectIDParam = *filter.ProjectID
+ }
+
+ entries, err = q.GetFilteredTimeEntries(ctx, queries.GetFilteredTimeEntriesParams{
+ StartTime: filter.StartDate,
+ EndTime: endTimeParam,
+ ClientID: clientIDParam,
+ ProjectID: projectIDParam,
+ })
if err != nil {
return
}