diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index e7bb639..36c10c4 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -21,6 +21,44 @@ func execute(t *testing.T, args ...string) (string, error) { return buf.String(), err } +// openTaskHashes extracts the hash of every open (unchecked) task +// line in a `sava list` timeline, e.g. "- 09:30 [ ] fix bug (7ba24aef)". +// It matches the "[ ]" marker only at its fixed position right after +// the bullet and timestamp (not anywhere in the line) and reads the +// hash from the LAST parenthesized group, so item content that +// happens to contain "[ ]" or literal parentheses doesn't produce a +// false match. +func openTaskHashes(list string) []string { + const markerOffset = len("- 00:00 ") // bullet + space + "HH:MM" + space + var hashes []string + for _, line := range strings.Split(list, "\n") { + if len(line) <= markerOffset || !strings.HasPrefix(line[markerOffset:], "[ ]") { + continue + } + openParen, closeParen := strings.LastIndex(line, "("), strings.LastIndex(line, ")") + if openParen == -1 || closeParen == -1 || closeParen < openParen { + continue + } + hashes = append(hashes, line[openParen+1:closeParen]) + } + return hashes +} + +// TestOpenTaskHashesIgnoresContentThatLooksLikeAMarker guards against +// two false-match bugs found in review: a memo whose content contains +// the literal substring "[ ]" must not be mistaken for an open task, +// and a task whose content contains parentheses before the trailing +// (hash) must still yield the real hash, not the content's own text. +func TestOpenTaskHashesIgnoresContentThatLooksLikeAMarker(t *testing.T) { + list := "- 09:12 ・ use [ ] for checkboxes (aaaa1111)\n" + + "- 09:30 [ ] call (urgent) client (bbbb2222)\n" + + got := openTaskHashes(list) + if len(got) != 1 || got[0] != "bbbb2222" { + t.Errorf("openTaskHashes = %+v, want exactly [bbbb2222]", got) + } +} + func mustExecute(t *testing.T, args ...string) string { t.Helper() @@ -48,7 +86,7 @@ func TestAddListFlow(t *testing.T) { mustExecute(t, "add", "shrimp memo") out := mustExecute(t, "list") - for _, want := range []string{"Task ->", "buy cabbage", "Memo ->", "shrimp memo"} { + for _, want := range []string{"[ ] buy cabbage", "・ shrimp memo"} { if !strings.Contains(out, want) { t.Errorf("list output should contain %q:\n%s", want, out) } @@ -91,13 +129,7 @@ func TestEndMultipleHashes(t *testing.T) { mustExecute(t, "todo", "second task") list := mustExecute(t, "list") - var hashes []string - for _, line := range strings.Split(list, "\n") { - if strings.HasPrefix(line, "- [ ]") { - fields := strings.Fields(line) - hashes = append(hashes, fields[len(fields)-1]) - } - } + hashes := openTaskHashes(list) if len(hashes) != 2 { t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list) } @@ -158,13 +190,7 @@ func TestDiffFlow(t *testing.T) { mustExecute(t, "todo", "second task") list := mustExecute(t, "list") - var hashes []string - for _, line := range strings.Split(list, "\n") { - if strings.HasPrefix(line, "- [ ]") { - fields := strings.Fields(line) - hashes = append(hashes, fields[len(fields)-1]) - } - } + hashes := openTaskHashes(list) if len(hashes) != 2 { t.Fatalf("hashes = %+v, want 2:\n%s", hashes, list) } diff --git a/docs/memo-log-redesign.md b/docs/memo-log-redesign.md index c3d5774..d328a6c 100644 --- a/docs/memo-log-redesign.md +++ b/docs/memo-log-redesign.md @@ -92,12 +92,17 @@ graph TD; ``` $ sava list -- 09:12 ・ standup メモ +- 09:12 ・ standup メモ (90ab7a28) - 09:30 [ ] fix bug (7ba24aef) #cli -- 10:02 ・ shrimp 元気 +- 10:02 ・ shrimp 元気 (3c115016) - 11:15 [x] review PR (1ed29de4) ``` +メモ行にも hash(と、あれば tag)を表示する(del/tag の操作対象になるため省かない)。 +既存のセクション表示(`Task ->`/`Memo ->`)から、発生時刻順の単一リストに変わる点が +このフォーマットの本質: 各行は `- HH:MM <マーカー> content (hash)#tags`、マーカーは +メモが `・`、TODO が `[ ]`/`[x]`/`[>]`。 + ## 自動 carry の仕様 ### トリガー: 「今日のログへの最初の書き込み」 @@ -240,7 +245,7 @@ Added!! * [x] Phase A: `add` メモ化 / `todo` コマンド新設(作成・`-s`・`-t`)/ `start`・`end`(独立コマンドのまま、複数 hash 対応で #10 を吸収)/ 追加・削除時の結果出力(#25) -* [ ] Phase B: `list` タイムライン化 +* [x] Phase B: `list` タイムライン化 * [ ] Phase C: 自動 carry(トリガー・対象日探索・新 hash コピー・ `carriedFrom`・`logfile.Update` の凍結ガード撤去と呼び出し側での 凍結チェック)→ #8 解決、#12 再評価 diff --git a/internal/command/command.go b/internal/command/command.go index eb4b3a0..7bc4ff9 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -443,12 +443,11 @@ func listOneDay(w io.Writer, dir string, opts ListOptions) error { return nil } - tasks, memos := log.Split(file.Body) + items := log.Timeline(file.Body) if len(opts.Tags) > 0 { - tasks = log.FilterByTags(tasks, opts.Tags, opts.Or) - memos = log.FilterByTags(memos, opts.Tags, opts.Or) + items = log.FilterByTags(items, opts.Tags, opts.Or) } - view.ItemList(w, tasks, memos) + view.Timeline(w, items) return nil } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 16e4baa..7de3a11 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -553,7 +553,7 @@ func TestListToday(t *testing.T) { t.Fatal(err) } - for _, want := range []string{"Today's logs are...", "Task ->", "buy cabbage", "Memo ->", "shrimp memo"} { + for _, want := range []string{"Today's logs are...", "[ ] buy cabbage", "・ shrimp memo"} { if !strings.Contains(out.String(), want) { t.Errorf("list output should contain %q:\n%s", want, out.String()) } diff --git a/internal/log/log.go b/internal/log/log.go index 839c6a1..17b3aa3 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -252,17 +252,30 @@ func Split(l model.Log) (tasks, memos []model.Item) { } } - // createdAt is a fixed-width UTC ISO string, so lexicographic - // order equals chronological order. - byCreatedAt := func(items []model.Item) func(i, j int) bool { - return func(i, j int) bool { return items[i].CreatedAt < items[j].CreatedAt } - } - sort.Slice(tasks, byCreatedAt(tasks)) - sort.Slice(memos, byCreatedAt(memos)) + sort.SliceStable(tasks, byCreatedAt(tasks)) + sort.SliceStable(memos, byCreatedAt(memos)) return tasks, memos } +// Timeline returns every item (tasks and memos together) sorted by +// creation time, for a single chronological list. +func Timeline(l model.Log) []model.Item { + items := make([]model.Item, len(l.Items)) + copy(items, l.Items) + sort.SliceStable(items, byCreatedAt(items)) + return items +} + +// byCreatedAt orders items by creation time, oldest first. createdAt +// is a fixed-width UTC ISO string, so lexicographic order equals +// chronological order. Ties (items created within the same +// millisecond) are broken by insertion order via a stable sort, so +// output order is deterministic even when timestamps collide. +func byCreatedAt(items []model.Item) func(i, j int) bool { + return func(i, j int) bool { return items[i].CreatedAt < items[j].CreatedAt } +} + // CountUnfinished returns the number of open tasks. func CountUnfinished(tasks []model.Item) int { count := 0 diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 3c29ae9..97ccf28 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -295,3 +295,42 @@ func TestSplit(t *testing.T) { t.Errorf("CountUnfinished = %d, want 1", got) } } + +func TestTimeline(t *testing.T) { + items := Timeline(newTestLog()) + + if len(items) != 3 { + t.Fatalf("items = %d, want 3", len(items)) + } + // Sorted by createdAt ascending, tasks and memos interleaved: + // task-done (01:00), task-open (02:00), memo-1 (03:00). + got := []string{items[0].Hash, items[1].Hash, items[2].Hash} + want := []string{"task-done", "task-open", "memo-1"} + for i := range want { + if got[i] != want[i] { + t.Errorf("items = %+v, want order %+v", got, want) + break + } + } +} + +// TestTimelineTiesKeepInsertionOrder guards against a non-deterministic +// tie-break: items sharing the exact same CreatedAt (possible within +// the same millisecond) must keep their original relative order. +func TestTimelineTiesKeepInsertionOrder(t *testing.T) { + l := model.Log{Items: []model.Item{ + {Hash: "first", CreatedAt: "2026-07-05T02:00:00.000Z"}, + {Hash: "second", CreatedAt: "2026-07-05T02:00:00.000Z"}, + {Hash: "third", CreatedAt: "2026-07-05T02:00:00.000Z"}, + }} + + items := Timeline(l) + got := []string{items[0].Hash, items[1].Hash, items[2].Hash} + want := []string{"first", "second", "third"} + for i := range want { + if got[i] != want[i] { + t.Errorf("items = %+v, want insertion order %+v", got, want) + break + } + } +} diff --git a/internal/model/model.go b/internal/model/model.go index b543682..ee99a1d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -74,6 +74,34 @@ func (i Item) IsStarted() bool { return i.StartedAt != nil } +// Status describes an item's lifecycle stage: a memo, or a task that is +// open, started, or closed. +type Status int + +// The Status values, in precedence order from lowest to highest. +const ( + StatusMemo Status = iota + StatusOpen + StatusStarted + StatusClosed +) + +// Status reports the item's lifecycle stage, giving Closed precedence +// over Started so a task that was started and then finished still +// reports as closed rather than started. +func (i Item) Status() Status { + switch { + case !i.IsTask(): + return StatusMemo + case i.IsClosed(): + return StatusClosed + case i.IsStarted(): + return StatusStarted + default: + return StatusOpen + } +} + // HasTag reports whether the item carries the tag. func (i Item) HasTag(tag string) bool { for _, t := range i.Tags { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index e36a8e8..98a6e1c 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -87,6 +87,31 @@ func TestNewItems(t *testing.T) { } } +// TestStatus pins the precedence a caller can rely on without +// re-deriving it from IsTask/IsClosed/IsStarted: Closed always wins +// over Started, even for a task that was both started and finished. +func TestStatus(t *testing.T) { + closed, open := true, false + now := NowISO() + + cases := []struct { + name string + item Item + want Status + }{ + {"memo", Item{}, StatusMemo}, + {"open task", Item{Closed: &open}, StatusOpen}, + {"started task", Item{Closed: &open, StartedAt: &now}, StatusStarted}, + {"closed task", Item{Closed: &closed}, StatusClosed}, + {"closed and started task", Item{Closed: &closed, StartedAt: &now}, StatusClosed}, + } + for _, c := range cases { + if got := c.item.Status(); got != c.want { + t.Errorf("%s: Status() = %v, want %v", c.name, got, c.want) + } + } +} + func TestNowISOFormat(t *testing.T) { pattern := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`) if now := NowISO(); !pattern.MatchString(now) { diff --git a/internal/view/view.go b/internal/view/view.go index 805f3ac..20e8259 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -17,36 +17,32 @@ func Header(w io.Writer, title string) { fmt.Fprintf(w, "\n %s\n\n", title) } -// ItemList prints tasks and memos grouped with headers. -func ItemList(w io.Writer, tasks, memos []model.Item) { - if len(tasks) == 0 && len(memos) == 0 { +// Timeline prints items (tasks and memos mixed) as a single list in +// the order given, one line per item: a marker ("・" for a memo, +// "[ ]"/"[x]"/"[>]" for a task), the creation time, the content, and +// the hash/tags for reference. +func Timeline(w io.Writer, items []model.Item) { + if len(items) == 0 { fmt.Fprintln(w, "There is no body...") return } - if len(tasks) > 0 { - fmt.Fprintln(w, "Task ->") - for _, item := range tasks { - checkbox := "[ ]" - switch { - case item.IsClosed(): - checkbox = "[x]" - case item.IsStarted(): - checkbox = "[>]" - } - fmt.Fprintf(w, "%s %s %s (%s) %s%s\n", bullet, checkbox, item.Content, formatTime(item.CreatedAt), item.Hash, formatTags(item.Tags)) - } - } - - if len(tasks) > 0 && len(memos) > 0 { - fmt.Fprintln(w) + for _, item := range items { + fmt.Fprintf(w, "%s %s %s %s (%s)%s\n", bullet, formatTime(item.CreatedAt), marker(item.Status()), item.Content, item.Hash, formatTags(item.Tags)) } +} - if len(memos) > 0 { - fmt.Fprintln(w, "Memo ->") - for _, item := range memos { - fmt.Fprintf(w, "%s %s (%s) %s%s\n", bullet, item.Content, formatTime(item.CreatedAt), item.Hash, formatTags(item.Tags)) - } +// marker renders a lifecycle status as its display marker. +func marker(status model.Status) string { + switch status { + case model.StatusClosed: + return "[x]" + case model.StatusStarted: + return "[>]" + case model.StatusOpen: + return "[ ]" + default: + return "・" } } diff --git a/internal/view/view_test.go b/internal/view/view_test.go index f432916..b08e5e0 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -8,44 +8,47 @@ import ( "github.com/rn404/nippo-cli/internal/model" ) -func TestItemList(t *testing.T) { +func TestTimeline(t *testing.T) { closed := true open := false startedAt := "2026-07-05T09:00:00.000Z" - tasks := []model.Item{ + items := []model.Item{ {Hash: "aaaa1111", Content: "buy cabbage", CreatedAt: "2026-07-05T08:43:04.971Z", Closed: &closed}, {Hash: "bbbb2222", Content: "feed the shrimp", CreatedAt: "2026-07-05T08:43:05.026Z", Closed: &open}, {Hash: "dddd4444", Content: "slice cabbage", CreatedAt: "2026-07-05T08:43:05.050Z", StartedAt: &startedAt, Closed: &open}, - } - memos := []model.Item{ {Hash: "cccc3333", Content: "shrimp looks happy today", CreatedAt: "2026-07-05T08:43:05.073Z", Tags: []string{"shrimp", "pet"}}, } var buf strings.Builder - ItemList(&buf, tasks, memos) + Timeline(&buf, items) out := buf.String() for _, want := range []string{ - "Task ->", - "- [x] buy cabbage (", - ") aaaa1111", - "- [ ] feed the shrimp (", - "- [>] slice cabbage (", - "Memo ->", - "- shrimp looks happy today (", - ") cccc3333 #shrimp #pet", + "- ", + "[x] buy cabbage (aaaa1111)", + "[ ] feed the shrimp (bbbb2222)", + "[>] slice cabbage (dddd4444)", + "・ shrimp looks happy today (cccc3333) #shrimp #pet", } { if !strings.Contains(out, want) { t.Errorf("output should contain %q:\n%s", want, out) } } + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("lines = %d, want 4 (one per item):\n%s", len(lines), out) + } + if !strings.Contains(lines[3], "cccc3333") { + t.Errorf("last line should be the latest item (shrimp memo): %q", lines[3]) + } } -func TestItemListEmpty(t *testing.T) { +func TestTimelineEmpty(t *testing.T) { var buf strings.Builder - ItemList(&buf, nil, nil) + Timeline(&buf, nil) if !strings.Contains(buf.String(), "There is no body...") { - t.Errorf("empty list output = %q", buf.String()) + t.Errorf("empty timeline output = %q", buf.String()) } }