feat: specify date for add command

This commit is contained in:
2026-02-10 10:31:46 +02:00
parent 9a9cc6d14d
commit 4bd4773a66
3 changed files with 154 additions and 2 deletions

View File

@@ -177,6 +177,12 @@ cospend add "Hotel" 150.00 -p vacation -m "credit card" -o "2 nights"
# Add an expense in a different currency
cospend add "Souvenirs" 30.00 -p vacation -C usd
# Add an expense with a specific date
cospend add "Lunch" 15.00 -p myproject -d 2026-03-15
cospend add "Lunch" 15.00 -p myproject -d 03-15 # assumes current year
cospend add "Lunch" 15.00 -p myproject -d -1d # yesterday
cospend add "Lunch" 15.00 -p myproject -d +2d # 2 days from now
```
#### Add Command Flags
@@ -190,6 +196,7 @@ cospend add "Souvenirs" 30.00 -p vacation -C usd
| `-C` | `--convert` | Currency to convert to (by ID, name, or code like `usd`) |
| `-m` | `--method` | Payment method by ID or case-insensitive name |
| `-o` | `--comment` | Additional details about the bill |
| `-d` | `--date` | Date of expense (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
| `-h` | `--help` | Display help information |
---

View File

@@ -20,6 +20,7 @@ var (
convertTo string
paymentMethod string
comment string
addDate string
)
// NewAddCommand creates the add command
@@ -42,6 +43,7 @@ Examples:
cmd.Flags().StringVarP(&convertTo, "convert", "C", "", "Currency to convert to")
cmd.Flags().StringVarP(&paymentMethod, "method", "m", "", "Payment method by ID or name")
cmd.Flags().StringVarP(&comment, "comment", "o", "", "Additional details about the bill")
cmd.Flags().StringVarP(&addDate, "date", "d", "", "Date of expense (YYYY-MM-DD, MM-DD, or relative like -1d, +2w)")
return cmd
}
@@ -118,13 +120,23 @@ func runAdd(cmd *cobra.Command, args []string) error {
}
}
// Resolve date
billDate := time.Now().Format("2006-01-02")
if addDate != "" {
parsed, err := parseDate(addDate)
if err != nil {
return err
}
billDate = parsed
}
// Build bill
bill := api.Bill{
What: expenseName,
Amount: amount,
PayerID: payerID,
OwedTo: owedIDs,
Date: time.Now().Format("2006-01-02"),
Date: billDate,
}
// Resolve optional category
@@ -215,5 +227,45 @@ func runAdd(cmd *cobra.Command, args []string) error {
if bill.Comment != "" {
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
}
if addDate != "" {
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
}
return nil
}
func parseDate(s string) (string, error) {
s = strings.TrimSpace(s)
// Try relative date: -1d, +2d, -1w, +2w, -1m, +2m
if len(s) >= 2 && (s[0] == '+' || s[0] == '-') {
unit := s[len(s)-1]
valueStr := s[1 : len(s)-1]
value, err := strconv.Atoi(valueStr)
if err == nil {
if s[0] == '-' {
value = -value
}
now := time.Now()
switch unit {
case 'd':
return now.AddDate(0, 0, value).Format("2006-01-02"), nil
case 'w':
return now.AddDate(0, 0, value*7).Format("2006-01-02"), nil
case 'm':
return now.AddDate(0, value, 0).Format("2006-01-02"), nil
}
}
}
// Try full date YYYY-MM-DD
if _, err := time.Parse("2006-01-02", s); err == nil {
return s, nil
}
// Try short date MM-DD (assume current year)
if t, err := time.Parse("01-02", s); err == nil {
return fmt.Sprintf("%d-%s", time.Now().Year(), t.Format("01-02")), nil
}
return "", fmt.Errorf("invalid date: %s (expected YYYY-MM-DD, MM-DD, or relative like -1d, +2w)", s)
}

View File

@@ -3,9 +3,11 @@ package cmd
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/chenasraf/cospend-cli/internal/api"
)
@@ -41,6 +43,7 @@ func resetFlags() {
convertTo = ""
paymentMethod = ""
comment = ""
addDate = ""
infoCached = false
}
@@ -72,7 +75,7 @@ func TestNewAddCommand(t *testing.T) {
}
// Check flags exist (project is now a persistent flag on root)
flags := []string{"category", "by", "for", "convert", "method", "comment"}
flags := []string{"category", "by", "for", "convert", "method", "comment", "date"}
for _, flag := range flags {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("Missing flag: %s", flag)
@@ -87,6 +90,7 @@ func TestNewAddCommand(t *testing.T) {
"C": "convert",
"m": "method",
"o": "comment",
"d": "date",
}
for short, long := range shortFlags {
flag := cmd.Flags().ShorthandLookup(short)
@@ -484,3 +488,92 @@ func TestAddCommandAPIError(t *testing.T) {
t.Error("Expected error from API")
}
}
func TestParseDate(t *testing.T) {
tests := []struct {
name string
input string
wantDate string
wantErr bool
}{
{"full date", "2026-03-15", "2026-03-15", false},
{"short date", "03-15", fmt.Sprintf("%d-03-15", time.Now().Year()), false},
{"with spaces", " 2026-01-01 ", "2026-01-01", false},
{"relative -1d", "-1d", time.Now().AddDate(0, 0, -1).Format("2006-01-02"), false},
{"relative +2d", "+2d", time.Now().AddDate(0, 0, 2).Format("2006-01-02"), false},
{"relative -1w", "-1w", time.Now().AddDate(0, 0, -7).Format("2006-01-02"), false},
{"relative +2w", "+2w", time.Now().AddDate(0, 0, 14).Format("2006-01-02"), false},
{"relative -1m", "-1m", time.Now().AddDate(0, -1, 0).Format("2006-01-02"), false},
{"relative +3m", "+3m", time.Now().AddDate(0, 3, 0).Format("2006-01-02"), false},
{"invalid", "not-a-date", "", true},
{"invalid short", "13-40", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseDate(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseDate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.wantDate {
t.Errorf("parseDate() = %v, want %v", got, tt.wantDate)
}
})
}
}
func TestAddCommandWithDate(t *testing.T) {
project := api.Project{
ID: "test-project",
Name: "Test Project",
Members: []api.Member{
{ID: 1, Name: "testuser", UserID: "testuser"},
},
}
var receivedBill map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
return
}
if r.URL.Path == "/ocs/v2.php/cloud/user" {
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
return
}
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" {
_ = r.ParseForm()
receivedBill = make(map[string]string)
for k, v := range r.Form {
if len(v) > 0 {
receivedBill[k] = v[0]
}
}
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]int{"id": 1}))
return
}
}))
defer server.Close()
cleanup := setupTestEnv(t, server.URL)
defer cleanup()
ProjectID = "test-project"
cmd := NewAddCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"Groceries", "25.50", "-d", "2026-06-15"})
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if receivedBill["date"] != "2026-06-15" {
t.Errorf("Wrong date: got %s, want 2026-06-15", receivedBill["date"])
}
if !bytes.Contains(stdout.Bytes(), []byte("Date: 2026-06-15")) {
t.Errorf("Output should show date, got:\n%s", stdout.String())
}
}