feat: add date & recency filters

This commit is contained in:
2026-02-10 09:51:08 +02:00
parent d6124a374a
commit 254ec53876
3 changed files with 402 additions and 13 deletions

View File

@@ -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.

View File

@@ -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.")

View File

@@ -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 = ""
}