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
56 changes: 41 additions & 15 deletions cmd/sava/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
11 changes: 8 additions & 3 deletions docs/memo-log-redesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の仕様

### トリガー: 「今日のログへの最初の書き込み」
Expand Down Expand Up @@ -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 再評価
Expand Down
7 changes: 3 additions & 4 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion internal/command/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
27 changes: 20 additions & 7 deletions internal/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions internal/log/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
28 changes: 28 additions & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions internal/model/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 20 additions & 24 deletions internal/view/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "・"
}
}

Expand Down
Loading
Loading