diff --git a/README.md b/README.md index 3f5b402..c88a5a5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ add and list expenses directly from your terminal without opening the web interf - **Add**, **list**, and **delete** expenses in Cospend projects via the **REST API** - **List projects** you have access to -- **Filter** expenses by payer, owed members, amount, name, category, or payment method +- **Filter** expenses by payer, owed members, amount, name, category, payment method, or date - Resolve categories, payment methods, and members by **name or ID** - **Case-insensitive** matching for all lookups - **Currency code support** (e.g., `usd`, `eur`, `gbp`) with automatic symbol resolution @@ -220,23 +220,40 @@ cospend list -p myproject --amount "<=100" # Filter by name (case-insensitive, contains) cospend list -p myproject -n dinner +# Filter by date (supports =, >, <, >=, <=) +cospend list -p myproject --date ">=2026-01-01" +cospend list -p myproject --date "<=01-15" # short MM-DD format (assumes current year) + +# Filter by current month or week +cospend list -p myproject --this-month +cospend list -p myproject --this-week + +# Filter recent bills (d=days, w=weeks, m=months) +cospend list -p myproject --recent 7d +cospend list -p myproject --recent 2w +cospend list -p myproject --recent 1m + # Combine multiple filters cospend list -p myproject -b alice -c restaurant --amount ">=20" ``` #### List Command Flags -| Short | Long | Description | -| ----- | ------------ | ---------------------------------------------------- | -| `-p` | `--project` | Project ID (required) | -| `-b` | `--by` | Filter by paying member username | -| `-f` | `--for` | Filter by owed member username (repeatable) | -| `-a` | `--amount` | Filter by amount (e.g., `50`, `>30`, `<=100`, `=25`) | -| `-n` | `--name` | Filter by name (case-insensitive, contains) | -| `-c` | `--category` | Filter by category name or ID | -| `-m` | `--method` | Filter by payment method name or ID | -| `-l` | `--limit` | Limit number of results (0 = no limit) | -| `-h` | `--help` | Display help information | +| Short | Long | Description | +| ----- | -------------- | -------------------------------------------------------------- | +| `-p` | `--project` | Project ID (required) | +| `-b` | `--by` | Filter by paying member username | +| `-f` | `--for` | Filter by owed member username (repeatable) | +| `-a` | `--amount` | Filter by amount (e.g., `50`, `>30`, `<=100`, `=25`) | +| `-n` | `--name` | Filter by name (case-insensitive, contains) | +| `-c` | `--category` | Filter by category name or ID | +| `-m` | `--method` | Filter by payment method name or ID | +| `-l` | `--limit` | Limit number of results (0 = no limit) | +| | `--date` | Filter by date (e.g., `2026-01-15`, `>=2026-01-01`, `<=01-15`) | +| | `--this-month` | Filter bills from the current month | +| | `--this-week` | Filter bills from the current calendar week | +| | `--recent` | Filter recent bills (e.g., `7d`, `2w`, `1m`) | +| `-h` | `--help` | Display help information | The output includes the bill ID for each expense, which can be used with the delete command. diff --git a/cmd/list.go b/cmd/list.go index eb0a73f..891e98b 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -6,6 +6,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/chenasraf/cospend-cli/internal/api" "github.com/chenasraf/cospend-cli/internal/cache" @@ -22,6 +23,10 @@ var ( listPaymentMethod string listCategory string listLimit int + listDate string + listThisMonth bool + listThisWeek bool + listRecent string ) // amountFilter holds parsed amount filter criteria @@ -43,7 +48,13 @@ Examples: cospend list -p myproject -b alice cospend list -p myproject -c groceries cospend list -p myproject --amount ">50" - cospend list -p myproject --amount "<=100" -n dinner`, + cospend list -p myproject --amount "<=100" -n dinner + cospend list -p myproject --date ">=2026-01-01" + cospend list -p myproject --date "<=01-15" + cospend list -p myproject --this-month + cospend list -p myproject --this-week + cospend list -p myproject --recent 7d + cospend list -p myproject --recent 2w`, RunE: runList, } @@ -54,6 +65,10 @@ Examples: cmd.Flags().StringVarP(&listPaymentMethod, "method", "m", "", "Filter by payment method") cmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category") cmd.Flags().IntVarP(&listLimit, "limit", "l", 0, "Limit number of results (0 = no limit)") + cmd.Flags().StringVar(&listDate, "date", "", "Filter by date (e.g., 2026-01-15, >=2026-01-01, <=01-15)") + cmd.Flags().BoolVar(&listThisMonth, "this-month", false, "Filter bills from the current month") + cmd.Flags().BoolVar(&listThisWeek, "this-week", false, "Filter bills from the current calendar week") + cmd.Flags().StringVar(&listRecent, "recent", "", "Filter recent bills (e.g., 7d, 2w, 1m)") return cmd } @@ -215,6 +230,54 @@ func buildFilters(project *api.Project) ([]billFilter, error) { }) } + // Filter by date + if listDate != "" { + df, err := parseDateFilter(listDate) + if err != nil { + return nil, fmt.Errorf("parsing date filter: %w", err) + } + filters = append(filters, func(bill api.BillResponse) bool { + return matchDate(bill.Date, df) + }) + } + + // Filter by this month + if listThisMonth { + now := time.Now() + prefix := now.Format("2006-01") + filters = append(filters, func(bill api.BillResponse) bool { + return strings.HasPrefix(bill.Date, prefix) + }) + } + + // Filter by this week + if listThisWeek { + now := time.Now() + weekday := now.Weekday() + if weekday == time.Sunday { + weekday = 7 + } + startOfWeek := now.AddDate(0, 0, -int(weekday-time.Monday)) + endOfWeek := startOfWeek.AddDate(0, 0, 6) + startStr := startOfWeek.Format("2006-01-02") + endStr := endOfWeek.Format("2006-01-02") + filters = append(filters, func(bill api.BillResponse) bool { + return bill.Date >= startStr && bill.Date <= endStr + }) + } + + // Filter by recent duration + if listRecent != "" { + cutoff, err := parseRecent(listRecent) + if err != nil { + return nil, fmt.Errorf("parsing recent filter: %w", err) + } + cutoffStr := cutoff.Format("2006-01-02") + filters = append(filters, func(bill api.BillResponse) bool { + return bill.Date >= cutoffStr + }) + } + return filters, nil } @@ -279,6 +342,85 @@ func matchAmount(amount float64, af amountFilter) bool { } } +// dateFilter holds parsed date filter criteria +type dateFilter struct { + operator string + date string // YYYY-MM-DD format for string comparison +} + +func parseDateFilter(s string) (dateFilter, error) { + s = strings.TrimSpace(s) + + re := regexp.MustCompile(`^(>=|<=|>|<|=)?(.+)$`) + matches := re.FindStringSubmatch(s) + if matches == nil { + return dateFilter{}, fmt.Errorf("invalid date filter format: %s", s) + } + + operator := matches[1] + if operator == "" { + operator = "=" + } + + dateStr := strings.TrimSpace(matches[2]) + + // Try full date format YYYY-MM-DD + if _, err := time.Parse("2006-01-02", dateStr); err == nil { + return dateFilter{operator: operator, date: dateStr}, nil + } + + // Try short format MM-DD (assume current year) + if t, err := time.Parse("01-02", dateStr); err == nil { + dateStr = fmt.Sprintf("%d-%s", time.Now().Year(), t.Format("01-02")) + return dateFilter{operator: operator, date: dateStr}, nil + } + + return dateFilter{}, fmt.Errorf("invalid date format: %s (expected YYYY-MM-DD or MM-DD)", dateStr) +} + +func matchDate(billDate string, df dateFilter) bool { + switch df.operator { + case "=": + return billDate == df.date + case ">": + return billDate > df.date + case "<": + return billDate < df.date + case ">=": + return billDate >= df.date + case "<=": + return billDate <= df.date + default: + return false + } +} + +func parseRecent(s string) (time.Time, error) { + s = strings.TrimSpace(s) + if len(s) < 2 { + return time.Time{}, fmt.Errorf("invalid recent format: %s (expected e.g. 7d, 2w, 1m)", s) + } + + unit := s[len(s)-1] + valueStr := s[:len(s)-1] + value, err := strconv.Atoi(valueStr) + if err != nil { + return time.Time{}, fmt.Errorf("invalid recent value: %s", valueStr) + } + + now := time.Now() + switch unit { + case 'd': + return now.AddDate(0, 0, -value), nil + case 'w': + return now.AddDate(0, 0, -value*7), nil + case 'm': + return now.AddDate(0, -value, 0), nil + default: + return time.Time{}, fmt.Errorf("invalid recent unit: %c (expected d, w, or m)", unit) + } +} + func printBillsTable(cmd *cobra.Command, project *api.Project, bills []api.BillResponse, formatter *format.AmountFormatter) { if len(bills) == 0 { _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No bills found.") diff --git a/cmd/list_test.go b/cmd/list_test.go index 880a518..a08d65e 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -2,7 +2,9 @@ package cmd import ( "bytes" + "fmt" "testing" + "time" "github.com/chenasraf/cospend-cli/internal/api" "github.com/chenasraf/cospend-cli/internal/format" @@ -278,6 +280,229 @@ func TestBuildFiltersAmountFilter(t *testing.T) { resetListFlags() } +func TestParseDateFilter(t *testing.T) { + tests := []struct { + name string + input string + wantOp string + wantDate string + wantErr bool + }{ + {"full date", "2026-01-15", "=", "2026-01-15", false}, + {"full date with equals", "=2026-01-15", "=", "2026-01-15", false}, + {"full date gte", ">=2026-01-01", ">=", "2026-01-01", false}, + {"full date lte", "<=2026-12-31", "<=", "2026-12-31", false}, + {"full date gt", ">2026-06-15", ">", "2026-06-15", false}, + {"full date lt", "<2026-03-01", "<", "2026-03-01", false}, + {"short date", "01-15", "=", fmt.Sprintf("%d-01-15", time.Now().Year()), false}, + {"short date gte", ">=01-01", ">=", fmt.Sprintf("%d-01-01", time.Now().Year()), false}, + {"short date lte", "<=12-31", "<=", fmt.Sprintf("%d-12-31", time.Now().Year()), false}, + {"with spaces", " >= 2026-01-01 ", ">=", "2026-01-01", false}, + {"invalid date", "not-a-date", "", "", true}, + {"invalid short", "13-40", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + df, err := parseDateFilter(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseDateFilter() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if df.operator != tt.wantOp { + t.Errorf("parseDateFilter() operator = %v, want %v", df.operator, tt.wantOp) + } + if df.date != tt.wantDate { + t.Errorf("parseDateFilter() date = %v, want %v", df.date, tt.wantDate) + } + } + }) + } +} + +func TestMatchDate(t *testing.T) { + tests := []struct { + name string + billDate string + filter dateFilter + want bool + }{ + {"equals match", "2026-01-15", dateFilter{"=", "2026-01-15"}, true}, + {"equals no match", "2026-01-15", dateFilter{"=", "2026-01-16"}, false}, + {"gte match exact", "2026-01-15", dateFilter{">=", "2026-01-15"}, true}, + {"gte match after", "2026-01-16", dateFilter{">=", "2026-01-15"}, true}, + {"gte no match", "2026-01-14", dateFilter{">=", "2026-01-15"}, false}, + {"lte match exact", "2026-01-15", dateFilter{"<=", "2026-01-15"}, true}, + {"lte match before", "2026-01-14", dateFilter{"<=", "2026-01-15"}, true}, + {"lte no match", "2026-01-16", dateFilter{"<=", "2026-01-15"}, false}, + {"gt match", "2026-01-16", dateFilter{">", "2026-01-15"}, true}, + {"gt no match exact", "2026-01-15", dateFilter{">", "2026-01-15"}, false}, + {"lt match", "2026-01-14", dateFilter{"<", "2026-01-15"}, true}, + {"lt no match exact", "2026-01-15", dateFilter{"<", "2026-01-15"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchDate(tt.billDate, tt.filter); got != tt.want { + t.Errorf("matchDate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseRecent(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + input string + wantDay string + wantErr bool + }{ + {"7 days", "7d", now.AddDate(0, 0, -7).Format("2006-01-02"), false}, + {"2 weeks", "2w", now.AddDate(0, 0, -14).Format("2006-01-02"), false}, + {"1 month", "1m", now.AddDate(0, -1, 0).Format("2006-01-02"), false}, + {"3 months", "3m", now.AddDate(0, -3, 0).Format("2006-01-02"), false}, + {"invalid unit", "7x", "", true}, + {"invalid value", "abcd", "", true}, + {"too short", "d", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseRecent(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseRecent() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + gotDay := got.Format("2006-01-02") + if gotDay != tt.wantDay { + t.Errorf("parseRecent() = %v, want %v", gotDay, tt.wantDay) + } + } + }) + } +} + +func TestBuildFiltersDateFilter(t *testing.T) { + resetListFlags() + defer resetListFlags() + + project := &api.Project{} + listDate = ">=2026-01-15" + + filters, err := buildFilters(project) + if err != nil { + t.Fatalf("buildFilters() error = %v", err) + } + + if len(filters) != 1 { + t.Fatalf("buildFilters() returned %d filters, want 1", len(filters)) + } + + bill1 := api.BillResponse{Date: "2026-01-15"} + bill2 := api.BillResponse{Date: "2026-02-01"} + bill3 := api.BillResponse{Date: "2026-01-14"} + + if !filters[0](bill1) { + t.Error("Filter should match date 2026-01-15") + } + if !filters[0](bill2) { + t.Error("Filter should match date 2026-02-01") + } + if filters[0](bill3) { + t.Error("Filter should not match date 2026-01-14") + } +} + +func TestBuildFiltersThisMonth(t *testing.T) { + resetListFlags() + defer resetListFlags() + + project := &api.Project{} + listThisMonth = true + + filters, err := buildFilters(project) + if err != nil { + t.Fatalf("buildFilters() error = %v", err) + } + + if len(filters) != 1 { + t.Fatalf("buildFilters() returned %d filters, want 1", len(filters)) + } + + now := time.Now() + thisMonth := now.Format("2006-01-02") + lastMonth := now.AddDate(0, -1, 0).Format("2006-01-02") + + if !filters[0](api.BillResponse{Date: thisMonth}) { + t.Errorf("Filter should match date in current month: %s", thisMonth) + } + if filters[0](api.BillResponse{Date: lastMonth}) { + t.Errorf("Filter should not match date in previous month: %s", lastMonth) + } +} + +func TestBuildFiltersThisWeek(t *testing.T) { + resetListFlags() + defer resetListFlags() + + project := &api.Project{} + listThisWeek = true + + filters, err := buildFilters(project) + if err != nil { + t.Fatalf("buildFilters() error = %v", err) + } + + if len(filters) != 1 { + t.Fatalf("buildFilters() returned %d filters, want 1", len(filters)) + } + + today := time.Now().Format("2006-01-02") + twoWeeksAgo := time.Now().AddDate(0, 0, -14).Format("2006-01-02") + + if !filters[0](api.BillResponse{Date: today}) { + t.Errorf("Filter should match today: %s", today) + } + if filters[0](api.BillResponse{Date: twoWeeksAgo}) { + t.Errorf("Filter should not match two weeks ago: %s", twoWeeksAgo) + } +} + +func TestBuildFiltersRecent(t *testing.T) { + resetListFlags() + defer resetListFlags() + + project := &api.Project{} + listRecent = "7d" + + filters, err := buildFilters(project) + if err != nil { + t.Fatalf("buildFilters() error = %v", err) + } + + if len(filters) != 1 { + t.Fatalf("buildFilters() returned %d filters, want 1", len(filters)) + } + + today := time.Now().Format("2006-01-02") + threeDaysAgo := time.Now().AddDate(0, 0, -3).Format("2006-01-02") + tenDaysAgo := time.Now().AddDate(0, 0, -10).Format("2006-01-02") + + if !filters[0](api.BillResponse{Date: today}) { + t.Error("Filter should match today") + } + if !filters[0](api.BillResponse{Date: threeDaysAgo}) { + t.Error("Filter should match 3 days ago") + } + if filters[0](api.BillResponse{Date: tenDaysAgo}) { + t.Error("Filter should not match 10 days ago") + } +} + func resetListFlags() { ProjectID = "" listPaidBy = "" @@ -286,4 +511,9 @@ func resetListFlags() { listName = "" listPaymentMethod = "" listCategory = "" + listLimit = 0 + listDate = "" + listThisMonth = false + listThisWeek = false + listRecent = "" }