mirror of
https://github.com/chenasraf/cospend-cli.git
synced 2026-05-17 17:38:04 +00:00
feat: add date & recency filters
This commit is contained in:
41
README.md
41
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.
|
||||
|
||||
|
||||
144
cmd/list.go
144
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.")
|
||||
|
||||
230
cmd/list_test.go
230
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 = ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user