diff options
author | T <t@tjp.lol> | 2025-08-13 13:04:05 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-13 13:42:43 -0600 |
commit | 389b72e55b04ccfc02b04eb81cb8f7bb7a5c8b59 (patch) | |
tree | be3015b2c7db90cddfc85d3e77ddc76213485494 /internal/tui | |
parent | 29c6581e08d0fe98433eff218de7701b51a6861c (diff) |
history filtering
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/app.go | 65 | ||||
-rw-r--r-- | internal/tui/commands.go | 6 | ||||
-rw-r--r-- | internal/tui/form.go | 31 | ||||
-rw-r--r-- | internal/tui/history_box.go | 21 | ||||
-rw-r--r-- | internal/tui/keys.go | 5 | ||||
-rw-r--r-- | internal/tui/modal.go | 57 | ||||
-rw-r--r-- | internal/tui/shared.go | 24 |
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 } |