Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ sava list -t <tag>,<tag> --or

# List items of a specific day / all log files / summaries
sava list <yyyy-MM-dd>
sava list yesterday
sava list -a
sava list -s
sava list -a -s
Expand Down
2 changes: 1 addition & 1 deletion cmd/sava/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions cmd/sava/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
12 changes: 7 additions & 5 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand All @@ -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...")
Expand Down
30 changes: 30 additions & 0 deletions internal/command/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
17 changes: 17 additions & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
)

Expand Down Expand Up @@ -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
}
}
21 changes: 21 additions & 0 deletions internal/model/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"regexp"
"strings"
"testing"
"time"
)

// TestFormatSampleRoundTrip pins the storage format: the sample under
Expand Down Expand Up @@ -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 {
Expand Down
Loading