diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/database/queries.sql | 5 | ||||
-rw-r--r-- | internal/queries/queries.sql.go | 89 | ||||
-rw-r--r-- | internal/reports/daterange.go | 37 | ||||
-rw-r--r-- | internal/reports/daterange_test.go | 45 | ||||
-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 |
11 files changed, 333 insertions, 52 deletions
diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 6d1108f..1ab9f44 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -294,9 +294,12 @@ select from time_entry te where date(te.start_time, 'localtime') = date('now', 'localtime'); --- name: GetRecentTimeEntries :many +-- name: GetFilteredTimeEntries :many select * from time_entry where start_time >= @start_time + and (@end_time is null or start_time <= @end_time) + and (@client_id is null or client_id = @client_id) + and (@project_id is null or project_id = @project_id) order by start_time desc; -- name: UpdateActiveTimerDescription :exec diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go index 0a7d134..0408942 100644 --- a/internal/queries/queries.sql.go +++ b/internal/queries/queries.sql.go @@ -377,6 +377,58 @@ func (q *Queries) GetContractor(ctx context.Context) (Contractor, error) { return i, err } +const getFilteredTimeEntries = `-- name: GetFilteredTimeEntries :many +select id, start_time, end_time, description, client_id, project_id, billable_rate from time_entry +where start_time >= ?1 + and (?2 is null or start_time <= ?2) + and (?3 is null or client_id = ?3) + and (?4 is null or project_id = ?4) +order by start_time desc +` + +type GetFilteredTimeEntriesParams struct { + StartTime time.Time + EndTime interface{} + ClientID interface{} + ProjectID interface{} +} + +func (q *Queries) GetFilteredTimeEntries(ctx context.Context, arg GetFilteredTimeEntriesParams) ([]TimeEntry, error) { + rows, err := q.db.QueryContext(ctx, getFilteredTimeEntries, + arg.StartTime, + arg.EndTime, + arg.ClientID, + arg.ProjectID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TimeEntry + for rows.Next() { + var i TimeEntry + if err := rows.Scan( + &i.ID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.ClientID, + &i.ProjectID, + &i.BillableRate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getHighestInvoiceNumber = `-- name: GetHighestInvoiceNumber :one select cast(coalesce(max(number), 0) as integer) as max_number from invoice @@ -681,43 +733,6 @@ func (q *Queries) GetProjectByNameAndClient(ctx context.Context, arg GetProjectB return i, err } -const getRecentTimeEntries = `-- name: GetRecentTimeEntries :many -select id, start_time, end_time, description, client_id, project_id, billable_rate from time_entry -where start_time >= ?1 -order by start_time desc -` - -func (q *Queries) GetRecentTimeEntries(ctx context.Context, startTime time.Time) ([]TimeEntry, error) { - rows, err := q.db.QueryContext(ctx, getRecentTimeEntries, startTime) - if err != nil { - return nil, err - } - defer rows.Close() - var items []TimeEntry - for rows.Next() { - var i TimeEntry - if err := rows.Scan( - &i.ID, - &i.StartTime, - &i.EndTime, - &i.Description, - &i.ClientID, - &i.ProjectID, - &i.BillableRate, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - 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 diff --git a/internal/reports/daterange.go b/internal/reports/daterange.go index adeb48b..040023e 100644 --- a/internal/reports/daterange.go +++ b/internal/reports/daterange.go @@ -67,16 +67,22 @@ func ParseDateRange(dateStr string) (DateRange, error) { return dateRange, nil } + // Check for "since YYYY-MM-DD" format + if strings.HasPrefix(lowerDateStr, "since ") { + return parseSinceDateRange(dateStr) + } + // Check for custom date range format: "YYYY-MM-DD to YYYY-MM-DD" - if strings.Contains(dateStr, " to ") { + if strings.Contains(lowerDateStr, " to ") { return parseCustomDateRange(dateStr) } - return DateRange{}, fmt.Errorf("unsupported date range: %s (supported: 'this week', 'this month', 'last week', 'last month', month names like 'february', 'month year' like 'july 2023', or 'YYYY-MM-DD to YYYY-MM-DD')", dateStr) + return DateRange{}, fmt.Errorf("unsupported date range: %s (supported: 'this week', 'this month', 'last week', 'last month', month names like 'february', 'month year' like 'july 2023', 'since YYYY-MM-DD', or 'YYYY-MM-DD to YYYY-MM-DD')", dateStr) } func parseCustomDateRange(dateStr string) (DateRange, error) { - parts := strings.Split(dateStr, " to ") + lowerDateStr := strings.ToLower(dateStr) + parts := strings.Split(lowerDateStr, " to ") if len(parts) != 2 { return DateRange{}, fmt.Errorf("invalid date range format: expected 'YYYY-MM-DD to YYYY-MM-DD'") } @@ -248,3 +254,28 @@ func getMonthRange(year int, month time.Month) DateRange { } } +func parseSinceDateRange(dateStr string) (DateRange, error) { + // Remove "since " prefix (case-insensitive) + lowerDateStr := strings.ToLower(dateStr) + if !strings.HasPrefix(lowerDateStr, "since ") { + return DateRange{}, fmt.Errorf("invalid since format: expected 'since YYYY-MM-DD'") + } + + dateOnly := strings.TrimSpace(dateStr[6:]) // Remove "since " prefix + + // Parse start date + startDate, err := time.Parse("2006-01-02", dateOnly) + if err != nil { + return DateRange{}, fmt.Errorf("invalid start date '%s' in since format: expected YYYY-MM-DD format", dateOnly) + } + + // Convert to UTC and set start time to beginning of day (00:00:00) + startUTC := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.UTC) + + // No end date for "since" format - return zero time for End to indicate open-ended + return DateRange{ + Start: startUTC, + End: time.Time{}, // Zero time indicates no end date + }, nil +} + diff --git a/internal/reports/daterange_test.go b/internal/reports/daterange_test.go index 69b0e4a..97678b6 100644 --- a/internal/reports/daterange_test.go +++ b/internal/reports/daterange_test.go @@ -192,6 +192,46 @@ func TestParseDateRange(t *testing.T) { input: "july abcd", wantErr: true, }, + { + name: "since format", + input: "since 2024-07-01", + wantErr: false, + validate: func(t *testing.T, dr DateRange) { + // Should start on July 1, 2024 with no end date + expectedStart := time.Date(2024, time.July, 1, 0, 0, 0, 0, time.UTC) + if !dr.Start.Equal(expectedStart) { + t.Errorf("Start = %v, want %v", dr.Start, expectedStart) + } + if !dr.End.IsZero() { + t.Errorf("End = %v, want zero time (no end date)", dr.End) + } + }, + }, + { + name: "since format case insensitive", + input: "SINCE 2024-12-25", + wantErr: false, + validate: func(t *testing.T, dr DateRange) { + // Should start on December 25, 2024 with no end date + expectedStart := time.Date(2024, time.December, 25, 0, 0, 0, 0, time.UTC) + if !dr.Start.Equal(expectedStart) { + t.Errorf("Start = %v, want %v", dr.Start, expectedStart) + } + if !dr.End.IsZero() { + t.Errorf("End = %v, want zero time (no end date)", dr.End) + } + }, + }, + { + name: "since format invalid date", + input: "since 2024-13-01", + wantErr: true, + }, + { + name: "since format missing date", + input: "since", + wantErr: true, + }, } for _, tt := range tests { @@ -223,6 +263,11 @@ func TestParseDateRange(t *testing.T) { return dateRange, nil } + // Check for "since YYYY-MM-DD" format + if strings.HasPrefix(lowerDateStr, "since ") { + return parseSinceDateRange(dateStr) + } + // Check for custom date range format: "YYYY-MM-DD to YYYY-MM-DD" if strings.Contains(dateStr, " to ") { return parseCustomDateRange(dateStr) 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 } |