From 5533bef75c26217a31ec3d075999c195b5bc3208 Mon Sep 17 00:00:00 2001 From: rn404 <854196+rn404@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:37:48 +0900 Subject: [PATCH] feat(list): accept "yesterday" as a relative date argument sava list yesterday resolves to an actual yyyy-MM-dd via model.ResolveRelativeDate before hitting logfile.Stat, so the existing storage/view code is untouched. "today" is supported too for symmetry (list already defaults to today on no argument). Both are case-insensitive; anything else passes through unchanged to the existing strict date parser. --- README.md | 1 + cmd/sava/commands.go | 2 +- cmd/sava/root_test.go | 24 ++++++++++++++++++++++++ internal/command/command.go | 12 +++++++----- internal/command/command_test.go | 30 ++++++++++++++++++++++++++++++ internal/model/model.go | 17 +++++++++++++++++ internal/model/model_test.go | 21 +++++++++++++++++++++ 7 files changed, 101 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1104d50..4515786 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ sava list -t , --or # List items of a specific day / all log files / summaries sava list +sava list yesterday sava list -a sava list -s sava list -a -s diff --git a/cmd/sava/commands.go b/cmd/sava/commands.go index 64015c3..f2fc8f5 100644 --- a/cmd/sava/commands.go +++ b/cmd/sava/commands.go @@ -136,7 +136,7 @@ func splitDiffArgs(args []string) (string, string, error) { func newListCommand() *cobra.Command { opts := command.ListOptions{} cmd := &cobra.Command{ - Use: "list [date]", + Use: "list [date|yesterday]", Short: "list all logs.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index f9b1ebf..ff5bcab 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -270,6 +270,30 @@ func TestCarryFlow(t *testing.T) { } } +// TestListYesterdayKeyword proves "yesterday" works as a literal date +// argument on the actual CLI, not just at the command-layer. +func TestListYesterdayKeyword(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := logfile.Dir() + + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + file, err := logfile.Get(dir, yesterday) + if err != nil { + t.Fatal(err) + } + file.Body.Items = []model.Item{ + {Hash: "aaaa1111", Content: "yesterday's memo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z"}, + } + if err := logfile.Update(dir, yesterday, file.Body); err != nil { + t.Fatal(err) + } + + out := mustExecute(t, "list", "yesterday") + if !strings.Contains(out, "Log for "+yesterday+" are...") || !strings.Contains(out, "yesterday's memo") { + t.Errorf("list yesterday output:\n%s", out) + } +} + func TestInvalidDateFails(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/internal/command/command.go b/internal/command/command.go index bbd0d55..76ea9cb 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -495,16 +495,18 @@ func List(w io.Writer, r io.Reader, dir string, opts ListOptions) error { } func listOneDay(w io.Writer, dir string, opts ListOptions) error { - file, err := logfile.Stat(dir, opts.Date) + date := model.ResolveRelativeDate(opts.Date) + + file, err := logfile.Stat(dir, date) if err != nil && !errors.Is(err, logfile.ErrNotFound) { return err } if opts.Stat { - if opts.Date == "" { + if date == "" { view.Header(w, "Today's log stats are...") } else { - view.Header(w, fmt.Sprintf("Log stats for %s are...", opts.Date)) + view.Header(w, fmt.Sprintf("Log stats for %s are...", date)) } if file == nil { fmt.Fprintln(w, "There is no body...") @@ -514,10 +516,10 @@ func listOneDay(w io.Writer, dir string, opts ListOptions) error { return nil } - if opts.Date == "" { + if date == "" { view.Header(w, "Today's logs are...") } else { - view.Header(w, fmt.Sprintf("Log for %s are...", opts.Date)) + view.Header(w, fmt.Sprintf("Log for %s are...", date)) } if file == nil { fmt.Fprintln(w, "There is no body...") diff --git a/internal/command/command_test.go b/internal/command/command_test.go index f212116..d647daa 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -566,6 +566,36 @@ func TestListToday(t *testing.T) { } } +// TestListYesterday proves "yesterday" resolves to an actual date +// before reaching logfile.Stat, both for the plain timeline and for +// --stat, and that it's case-insensitive. +func TestListYesterday(t *testing.T) { + dir := t.TempDir() + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + writeDay(t, dir, yesterday, []model.Item{ + {Hash: "aaaa1111", Content: "yesterday's memo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z"}, + }) + + for _, keyword := range []string{"yesterday", "Yesterday", "YESTERDAY"} { + var out strings.Builder + if err := List(&out, strings.NewReader(""), dir, ListOptions{Date: keyword}); err != nil { + t.Fatal(err) + } + want := "Log for " + yesterday + " are..." + if !strings.Contains(out.String(), want) || !strings.Contains(out.String(), "yesterday's memo") { + t.Errorf("List with Date=%q output = %q, want to contain %q and the item", keyword, out.String(), want) + } + } + + var stat strings.Builder + if err := List(&stat, strings.NewReader(""), dir, ListOptions{Date: "yesterday", Stat: true}); err != nil { + t.Fatal(err) + } + if !strings.Contains(stat.String(), "Log stats for "+yesterday+" are...") { + t.Errorf("List --stat with Date=yesterday output = %q", stat.String()) + } +} + func TestListEmptyAndInvalidDate(t *testing.T) { dir := t.TempDir() diff --git a/internal/model/model.go b/internal/model/model.go index d1979c3..459cae9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strings" "time" ) @@ -166,3 +167,19 @@ func IsDateString(value string) bool { func Today() string { return time.Now().Format(DateLayout) } + +// ResolveRelativeDate translates a small set of relative date keywords +// ("today", "yesterday", case-insensitive) into a yyyy-MM-dd string. +// Any other value — including an already well-formed date, or an +// empty string, which callers elsewhere already treat as "today" — is +// returned unchanged. +func ResolveRelativeDate(value string) string { + switch strings.ToLower(value) { + case "today": + return Today() + case "yesterday": + return time.Now().AddDate(0, 0, -1).Format(DateLayout) + default: + return value + } +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 98a6e1c..adc5027 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "testing" + "time" ) // TestFormatSampleRoundTrip pins the storage format: the sample under @@ -135,6 +136,26 @@ func TestParseDateStrict(t *testing.T) { } } +func TestResolveRelativeDate(t *testing.T) { + yesterday := time.Now().AddDate(0, 0, -1).Format(DateLayout) + + cases := map[string]string{ + "today": Today(), + "Today": Today(), + "TODAY": Today(), + "yesterday": yesterday, + "Yesterday": yesterday, + "2026-07-05": "2026-07-05", // an already well-formed date passes through + "": "", // callers elsewhere treat "" as today + "tomorrow": "tomorrow", // unsupported keyword passes through unchanged + } + for input, want := range cases { + if got := ResolveRelativeDate(input); got != want { + t.Errorf("ResolveRelativeDate(%q) = %q, want %q", input, got, want) + } + } +} + func TestNewLogMarshalsEmptyItems(t *testing.T) { data, err := json.Marshal(NewLog()) if err != nil {