Skip to content
Merged
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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
36 changes: 26 additions & 10 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down
69 changes: 67 additions & 2 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 })
Expand All @@ -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)
}
}
Expand Down
20 changes: 18 additions & 2 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 24 additions & 1 deletion internal/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)
}
}