From b930fcc93463d9f89ccae51fca1372ba23cea75a Mon Sep 17 00:00:00 2001 From: okaryo Date: Sun, 26 Apr 2026 17:36:58 +0900 Subject: [PATCH] feat: support 'yesterday' and 'today' as date arguments for log commands --- README.md | 14 ++++++- cmd/root.go | 36 ++++++++++++----- cmd/root_test.go | 69 +++++++++++++++++++++++++++++++- internal/service/service.go | 20 ++++++++- internal/service/service_test.go | 25 +++++++++++- 5 files changed, 147 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 50a4b50..8d8917f 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ dlog "task progress update" dlog amend "corrected task progress update" dlog dlog log +dlog log yesterday dlog log --date 2026-04-12 dlog md +dlog md yesterday dlog md --date 2026-04-12 ``` @@ -25,9 +27,9 @@ dlog md --date 2026-04-12 - `dlog "text"` appends a log entry for today with the current local timestamp. - `dlog amend "text"` replaces today's most recent log entry while keeping its original timestamp. - `dlog` and `dlog log` show today's logs in reverse chronological order. -- `dlog log --date YYYY-MM-DD` shows logs for the specified date. +- `dlog log [YYYY-MM-DD|today|yesterday]` and `dlog log --date [YYYY-MM-DD|today|yesterday]` show logs for the specified date. - `dlog md` prints today's logs in Markdown order from oldest to newest. -- `dlog md --date YYYY-MM-DD` prints logs for the specified date in Markdown order. +- `dlog md [YYYY-MM-DD|today|yesterday]` and `dlog md --date [YYYY-MM-DD|today|yesterday]` print logs for the specified date in Markdown order. - Displayed times use the timezone recorded in each log entry, not the viewer's current local timezone. ## Examples @@ -93,6 +95,14 @@ $ dlog md --date 2026-04-11 - 18:45 previous day task ``` +Output yesterday's logs as Markdown: + +```bash +$ dlog md yesterday +# 2026-04-11 +- 18:45 previous day task +``` + ## Storage - Logs are stored under `~/.dlog`. diff --git a/cmd/root.go b/cmd/root.go index da42aa4..dda0a82 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,19 +48,27 @@ func newLogCmd(svc *service.Service, out io.Writer) *cobra.Command { var date string cmd := &cobra.Command{ - Use: "log", + Use: "log [date]", Short: "Show logs for today or a specified date", - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var ( dayLog *model.DayLog err error ) - if date == "" { + selectedDate := date + if selectedDate != "" && len(args) == 1 { + return fmt.Errorf("use either --date or a date argument, not both") + } + if selectedDate == "" && len(args) == 1 { + selectedDate = args[0] + } + + if selectedDate == "" { dayLog, err = svc.GetTodayLog() } else { - dayLog, err = svc.GetLogByDate(date) + dayLog, err = svc.GetLogByDate(selectedDate) } if err != nil { return err @@ -76,7 +84,7 @@ func newLogCmd(svc *service.Service, out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&date, "date", "d", "", "Show logs for the specified date (YYYY-MM-DD)") + cmd.Flags().StringVarP(&date, "date", "d", "", "Show logs for the specified date (YYYY-MM-DD, today, or yesterday)") return cmd } @@ -85,19 +93,27 @@ func newMarkdownCmd(svc *service.Service, out io.Writer) *cobra.Command { var date string cmd := &cobra.Command{ - Use: "md", + Use: "md [date]", Short: "Output logs as Markdown for today or a specified date", - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var ( dayLog *model.DayLog err error ) - if date == "" { + selectedDate := date + if selectedDate != "" && len(args) == 1 { + return fmt.Errorf("use either --date or a date argument, not both") + } + if selectedDate == "" && len(args) == 1 { + selectedDate = args[0] + } + + if selectedDate == "" { dayLog, err = svc.GetTodayLog() } else { - dayLog, err = svc.GetLogByDate(date) + dayLog, err = svc.GetLogByDate(selectedDate) } if err != nil { return err @@ -113,7 +129,7 @@ func newMarkdownCmd(svc *service.Service, out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&date, "date", "d", "", "Output logs for the specified date (YYYY-MM-DD)") + cmd.Flags().StringVarP(&date, "date", "d", "", "Output logs for the specified date (YYYY-MM-DD, today, or yesterday)") return cmd } diff --git a/cmd/root_test.go b/cmd/root_test.go index c7179bf..a0a86f7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -102,6 +102,28 @@ func TestLogSubcommandShowsSpecifiedDate(t *testing.T) { } } +func TestLogSubcommandShowsYesterday(t *testing.T) { + current := time.Date(2026, 4, 11, 18, 45, 0, 0, time.FixedZone("JST", 9*60*60)) + root, stdout, _, _ := newTestRootCmd(t, func() time.Time { return current }) + + root.SetArgs([]string{"previous day task"}) + if err := root.Execute(); err != nil { + t.Fatalf("seed previous day log: %v", err) + } + + current = time.Date(2026, 4, 12, 9, 0, 0, 0, time.FixedZone("JST", 9*60*60)) + stdout.Reset() + root.SetArgs([]string{"log", "yesterday"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute log yesterday: %v", err) + } + + want := "2026-04-11\n\n18:45 previous day task\n" + if stdout.String() != want { + t.Fatalf("unexpected output:\nwant:\n%s\ngot:\n%s", want, stdout.String()) + } +} + func TestLogSubcommandRejectsInvalidDate(t *testing.T) { now := time.Date(2026, 4, 12, 9, 0, 0, 0, time.FixedZone("JST", 9*60*60)) root, _, _, _ := newTestRootCmd(t, func() time.Time { return now }) @@ -112,7 +134,22 @@ func TestLogSubcommandRejectsInvalidDate(t *testing.T) { t.Fatalf("expected error") } - if !strings.Contains(err.Error(), "date must be in YYYY-MM-DD format") { + if !strings.Contains(err.Error(), "date must be YYYY-MM-DD, today, or yesterday") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLogSubcommandRejectsDateFlagAndArgument(t *testing.T) { + now := time.Date(2026, 4, 12, 9, 0, 0, 0, time.FixedZone("JST", 9*60*60)) + root, _, _, _ := newTestRootCmd(t, func() time.Time { return now }) + + root.SetArgs([]string{"log", "-d", "2026-04-12", "yesterday"}) + err := root.Execute() + if err == nil { + t.Fatalf("expected error") + } + + if !strings.Contains(err.Error(), "use either --date or a date argument, not both") { t.Fatalf("unexpected error: %v", err) } } @@ -159,6 +196,34 @@ func TestMarkdownSubcommandShowsSpecifiedDate(t *testing.T) { } } +func TestMarkdownSubcommandShowsYesterday(t *testing.T) { + current := time.Date(2026, 4, 11, 10, 3, 0, 0, time.FixedZone("JST", 9*60*60)) + root, stdout, _, _ := newTestRootCmd(t, func() time.Time { return current }) + + root.SetArgs([]string{"first task"}) + if err := root.Execute(); err != nil { + t.Fatalf("seed first log: %v", err) + } + + current = time.Date(2026, 4, 11, 11, 10, 0, 0, time.FixedZone("JST", 9*60*60)) + root.SetArgs([]string{"second task"}) + if err := root.Execute(); err != nil { + t.Fatalf("seed second log: %v", err) + } + + current = time.Date(2026, 4, 12, 9, 0, 0, 0, time.FixedZone("JST", 9*60*60)) + stdout.Reset() + root.SetArgs([]string{"md", "yesterday"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute md yesterday: %v", err) + } + + want := "# 2026-04-11\n- 10:03 first task\n- 11:10 second task\n" + if stdout.String() != want { + t.Fatalf("unexpected output:\nwant:\n%s\ngot:\n%s", want, stdout.String()) + } +} + func TestMarkdownSubcommandRejectsInvalidDate(t *testing.T) { now := time.Date(2026, 4, 12, 9, 0, 0, 0, time.FixedZone("JST", 9*60*60)) root, _, _, _ := newTestRootCmd(t, func() time.Time { return now }) @@ -169,7 +234,7 @@ func TestMarkdownSubcommandRejectsInvalidDate(t *testing.T) { t.Fatalf("expected error") } - if !strings.Contains(err.Error(), "date must be in YYYY-MM-DD format") { + if !strings.Contains(err.Error(), "date must be YYYY-MM-DD, today, or yesterday") { t.Fatalf("unexpected error: %v", err) } } diff --git a/internal/service/service.go b/internal/service/service.go index c5b7282..ac27cd0 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -86,14 +86,30 @@ func (s *Service) GetTodayLog() (*model.DayLog, error) { } func (s *Service) GetLogByDate(date string) (*model.DayLog, error) { - parsedDate, err := time.ParseInLocation(dayLayout, date, time.Local) + parsedDate, err := s.parseDate(date) if err != nil { - return nil, fmt.Errorf("date must be in YYYY-MM-DD format: %q", date) + return nil, err } return s.getLogByTime(parsedDate) } +func (s *Service) parseDate(date string) (time.Time, error) { + switch strings.ToLower(strings.TrimSpace(date)) { + case "today": + return s.now(), nil + case "yesterday": + return s.now().AddDate(0, 0, -1), nil + } + + parsedDate, err := time.ParseInLocation(dayLayout, date, time.Local) + if err != nil { + return time.Time{}, fmt.Errorf("date must be YYYY-MM-DD, today, or yesterday: %q", date) + } + + return parsedDate, nil +} + func (s *Service) getLogByTime(target time.Time) (*model.DayLog, error) { dayLog, err := s.store.LoadDay(target) if err != nil { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 6e7d5a3..2866258 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -141,6 +141,29 @@ func TestGetLogByDateReturnsSpecifiedDayLog(t *testing.T) { } } +func TestGetLogByDateReturnsRelativeDayLog(t *testing.T) { + now := time.Date(2026, 4, 11, 18, 45, 0, 0, time.FixedZone("JST", 9*60*60)) + store := storage.NewStore(t.TempDir()) + svc := NewWithNow(store, func() time.Time { return now }) + + if err := svc.AddTodayLog("previous day task"); err != nil { + t.Fatalf("add log: %v", err) + } + + now = time.Date(2026, 4, 12, 10, 3, 21, 0, time.FixedZone("JST", 9*60*60)) + dayLog, err := svc.GetLogByDate("yesterday") + if err != nil { + t.Fatalf("get log by relative date: %v", err) + } + + if dayLog.Date != "2026-04-11" { + t.Fatalf("unexpected date: %s", dayLog.Date) + } + if len(dayLog.Logs) != 1 || dayLog.Logs[0].Text != "previous day task" { + t.Fatalf("unexpected logs: %+v", dayLog.Logs) + } +} + func TestGetLogByDateRejectsInvalidFormat(t *testing.T) { now := time.Date(2026, 4, 12, 10, 3, 21, 0, time.FixedZone("JST", 9*60*60)) store := storage.NewStore(t.TempDir()) @@ -151,7 +174,7 @@ func TestGetLogByDateRejectsInvalidFormat(t *testing.T) { t.Fatalf("expected error") } - if !strings.Contains(err.Error(), "date must be in YYYY-MM-DD format") { + if !strings.Contains(err.Error(), "date must be YYYY-MM-DD, today, or yesterday") { t.Fatalf("unexpected error: %v", err) } }