diff --git a/cmd/sava/root_test.go b/cmd/sava/root_test.go index 36c10c4..f9b1ebf 100644 --- a/cmd/sava/root_test.go +++ b/cmd/sava/root_test.go @@ -3,7 +3,9 @@ package main import ( "strings" "testing" + "time" + "github.com/rn404/nippo-cli/internal/logfile" "github.com/rn404/nippo-cli/internal/model" ) @@ -231,6 +233,43 @@ func TestClearAllWithYes(t *testing.T) { } } +// TestCarryFlow exercises Phase C end-to-end through the CLI: an +// unfinished TODO left over from a previous day should reappear in +// today's list under a new hash, with that source day now frozen, +// the moment the first command of a new day is run. +func TestCarryFlow(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) + } + open := false + file.Body.Items = []model.Item{ + {Hash: "open1111", Content: "unfinished todo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z", Closed: &open}, + } + if err := logfile.Update(dir, yesterday, file.Body); err != nil { + t.Fatal(err) + } + + out := mustExecute(t, "add", "today's memo") + if !strings.Contains(out, "Carried 1 items from "+yesterday+" (that day is now frozen).") { + t.Errorf("carry notice missing from CLI output:\n%s", out) + } + + list := mustExecute(t, "list") + if !strings.Contains(list, "[ ] unfinished todo") { + t.Errorf("carried todo should appear in today's list:\n%s", list) + } + + stat := mustExecute(t, "list", yesterday, "-s") + if !strings.Contains(stat, yesterday+"*") { + t.Errorf("yesterday should show as frozen in stats:\n%s", stat) + } +} + func TestInvalidDateFails(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/docs/memo-log-redesign.md b/docs/memo-log-redesign.md index d328a6c..cfcd582 100644 --- a/docs/memo-log-redesign.md +++ b/docs/memo-log-redesign.md @@ -246,7 +246,7 @@ Added!! `start`・`end`(独立コマンドのまま、複数 hash 対応で #10 を吸収)/ 追加・削除時の結果出力(#25) * [x] Phase B: `list` タイムライン化 -* [ ] Phase C: 自動 carry(トリガー・対象日探索・新 hash コピー・ +* [x] Phase C: 自動 carry(トリガー・対象日探索・新 hash コピー・ `carriedFrom`・`logfile.Update` の凍結ガード撤去と呼び出し側での 凍結チェック)→ #8 解決、#12 再評価 diff --git a/internal/command/command.go b/internal/command/command.go index 7bc4ff9..bbd0d55 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -34,7 +34,7 @@ type AddOptions struct { // Add appends a memo to today's log. func Add(w io.Writer, dir, content string, opts AddOptions) error { - file, err := logfile.Get(dir, "") + file, err := ensureToday(w, dir) if err != nil { return err } @@ -71,6 +71,80 @@ func persistItem(w io.Writer, dir string, file *logfile.LogFile, item model.Item return nil } +// ensureToday returns today's log file, running the automatic carry +// engine first if today has not been written to yet. Carry copies +// every unfinished TODO on the most recent existing log day before +// today forward into today's (about to be created) log with fresh +// hashes, then freezes that source day so it is never carried again. +// A day that is already frozen, or no prior day at all, leaves today +// as a plain empty log, same as before Phase C. Once today's file +// exists, every later call within the same day just returns it as-is: +// the file's existence is itself the one-carry-per-day guarantee, so +// this never re-runs carry. +func ensureToday(w io.Writer, dir string) (*logfile.LogFile, error) { + if today, err := logfile.Stat(dir, ""); err == nil { + return today, nil + } else if !errors.Is(err, logfile.ErrNotFound) { + return nil, err + } + + body := model.NewLog() + carried, sourceDate, err := carryFromPreviousDay(dir) + if err != nil { + return nil, err + } + if len(carried) > 0 { + body.Items = carried + view.Carried(w, len(carried), sourceDate) + } + + if err := logfile.Update(dir, "", body); err != nil { + return nil, err + } + return logfile.Get(dir, "") +} + +// carryFromPreviousDay finds the most recent existing log day strictly +// before today, copies its unfinished TODOs forward, and freezes that +// day so it becomes permanently read-only. It is a no-op (nil items, +// empty sourceDate) when no such day exists, or that day is already +// frozen (already carried from). +func carryFromPreviousDay(dir string) (carried []model.Item, sourceDate string, err error) { + refs, err := logfile.List(dir) + if err != nil { + return nil, "", err + } + + today := model.Today() + var source *logfile.Ref + for i := range refs { + if refs[i].Name >= today { + break // refs is sorted ascending, so nothing further back stays before today. + } + source = &refs[i] + } + if source == nil { + return nil, "", nil + } + + file, err := logfile.Stat(dir, source.Name) + if err != nil { + return nil, "", err + } + if file.Body.Freezed { + return nil, "", nil + } + + carried = log.CarryForward(file.Body.Items, source.Name) + + file.Body.Freezed = true + if err := logfile.Update(dir, source.Name, file.Body); err != nil { + return nil, "", err + } + + return carried, source.Name, nil +} + // TodoOptions controls the todo command behavior. type TodoOptions struct { Start bool // mark the task as started right away @@ -79,7 +153,7 @@ type TodoOptions struct { // Todo appends a TODO item (task) to today's log. func Todo(w io.Writer, dir, content string, opts TodoOptions) error { - file, err := logfile.Get(dir, "") + file, err := ensureToday(w, dir) if err != nil { return err } @@ -105,7 +179,7 @@ func Todo(w io.Writer, dir, content string, opts TodoOptions) error { // Tag adds tags to (or removes them from, when remove is true) the // item matching hash in today's log, then refreshes the index. func Tag(w io.Writer, dir, hash string, tags []string, remove bool) error { - file, err := logfile.Get(dir, "") + file, err := ensureToday(w, dir) if err != nil { return err } @@ -223,7 +297,7 @@ func elapsedBetween(a, b model.Item) (time.Duration, error) { // Start marks the task matching hash in today's log as started. func Start(w io.Writer, dir, hash string) error { - file, err := logfile.Get(dir, "") + file, err := ensureToday(w, dir) if err != nil { return err } @@ -247,7 +321,7 @@ func Start(w io.Writer, dir, hash string) error { // nothing about two concurrent sava processes racing on the same // file — there is no file locking anywhere in this codebase). func End(w io.Writer, dir string, hashes []string) error { - file, err := logfile.Get(dir, "") + file, err := ensureToday(w, dir) if err != nil { return err } @@ -323,12 +397,19 @@ func Del(w io.Writer, dir, ref string, deep bool) error { } } -// deleteOn removes the item matching hash from the log for date. +// deleteOn removes the item matching hash from the log for date. date +// may be any past day, not just today (see Del), so unlike the other +// write commands this must check Freezed itself: logfile.Update no +// longer guards against writing to a frozen day, and a carried-from +// day being permanently read-only is the whole point of freezing it. func deleteOn(w io.Writer, dir, date, hash string) error { file, err := logfile.Stat(dir, date) if err != nil { return err } + if file.Body.Freezed { + return fmt.Errorf("%s: %w", date, logfile.ErrFreezed) + } item, err := log.Delete(&file.Body, hash) if err != nil { diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 7de3a11..f212116 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -364,7 +364,10 @@ func TestListWithTagFilter(t *testing.T) { // breakIndexRebuild writes a corrupt sibling log file so that // index.Rebuild (which scans every daily log) fails, independently of -// today's file. +// today's file. It also seeds a valid, empty, more recent day so that +// automatic carry (which also scans past log files, to find the most +// recent one before today) lands on that instead of the broken file: +// this helper's job is to break the index, not carry. func breakIndexRebuild(t *testing.T, dir string) { t.Helper() if err := os.MkdirAll(dir, 0o700); err != nil { @@ -373,6 +376,9 @@ func breakIndexRebuild(t *testing.T, dir string) { if err := os.WriteFile(filepath.Join(dir, "2000-01-01.json"), []byte("not json"), 0o600); err != nil { t.Fatal(err) } + if _, err := logfile.Get(dir, "2000-01-02"); err != nil { + t.Fatal(err) + } } // writeDay stores items as the log of day, bypassing Add so tests can @@ -704,3 +710,213 @@ func TestClearAll(t *testing.T) { func time2date(year, month, day int) string { return fmt.Sprintf("%04d-%02d-%02d", year, month, day) } + +// TestCarryOnFirstWriteOfDay proves the core Phase C behavior: the +// first write command of a new day copies yesterday's unfinished +// TODOs forward with fresh hashes, freezes yesterday, and prints a +// notice ahead of the command's own output. Closed tasks and memos +// are not carried, and the source item itself is left untouched. +func TestCarryOnFirstWriteOfDay(t *testing.T) { + dir := t.TempDir() + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + closed := true + open := false + writeDay(t, dir, yesterday, []model.Item{ + {Hash: "open1111", Content: "unfinished todo", CreatedAt: "2026-01-01T02:00:00.000Z", UpdatedAt: "2026-01-01T02:00:00.000Z", Closed: &open, Tags: []string{"cli"}}, + {Hash: "done1111", Content: "finished todo", CreatedAt: "2026-01-01T01:00:00.000Z", UpdatedAt: "2026-01-01T01:00:00.000Z", Closed: &closed}, + {Hash: "memo1111", Content: "a memo", CreatedAt: "2026-01-01T03:00:00.000Z", UpdatedAt: "2026-01-01T03:00:00.000Z"}, + }) + + var out strings.Builder + if err := Add(&out, dir, "today's memo", AddOptions{}); err != nil { + t.Fatal(err) + } + + notice := "Carried 1 items from " + yesterday + " (that day is now frozen)." + if !strings.Contains(out.String(), notice) { + t.Errorf("carry notice = %q, want to contain %q", out.String(), notice) + } + if strings.Index(out.String(), "Carried") > strings.Index(out.String(), "Added!!") { + t.Errorf("carry notice should print before the command's own output: %q", out.String()) + } + + items := todayItems(t, dir) + if len(items) != 2 { + t.Fatalf("today's items = %+v, want 2 (the carried todo and the new memo)", items) + } + carried := items[0] + if carried.Content != "unfinished todo" { + t.Errorf("carried item content = %q, want %q", carried.Content, "unfinished todo") + } + if carried.Hash == "open1111" { + t.Errorf("carried item should get a fresh hash") + } + if carried.CarriedFrom == nil || *carried.CarriedFrom != yesterday+":open1111" { + t.Errorf("CarriedFrom = %v, want %q", carried.CarriedFrom, yesterday+":open1111") + } + if carried.IsStarted() || carried.IsClosed() { + t.Errorf("carried item should start open and untouched today: %+v", carried) + } + if len(carried.Tags) != 1 || carried.Tags[0] != "cli" { + t.Errorf("tags should carry over: %+v", carried.Tags) + } + + source, err := logfile.Stat(dir, yesterday) + if err != nil { + t.Fatal(err) + } + if !source.Body.Freezed { + t.Errorf("source day should be frozen after carry") + } + if len(source.Body.Items) != 3 { + t.Fatalf("source items should be untouched: %+v", source.Body.Items) + } + for _, item := range source.Body.Items { + if item.Hash == "open1111" && (item.IsClosed() || item.CarriedFrom != nil) { + t.Errorf("the source item itself must not be rewritten by carry: %+v", item) + } + } +} + +// TestCarryFreezesEvenWithNothingToCarry proves that a source day with +// no unfinished TODOs (a memo only, here) is still frozen once it is +// passed over as "the most recent day before today": carry marks the +// day as visited regardless of whether there was anything to copy, +// and prints no notice for a zero-item carry. +func TestCarryFreezesEvenWithNothingToCarry(t *testing.T) { + dir := t.TempDir() + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + writeDay(t, dir, yesterday, []model.Item{ + {Hash: "memo1111", Content: "just a memo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z"}, + }) + + var out strings.Builder + if err := Add(&out, dir, "today's memo", AddOptions{}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Carried") { + t.Errorf("no carry notice expected when nothing was carried: %q", out.String()) + } + if items := todayItems(t, dir); len(items) != 1 { + t.Fatalf("today's items = %+v, want only the new memo", items) + } + + source, err := logfile.Stat(dir, yesterday) + if err != nil { + t.Fatal(err) + } + if !source.Body.Freezed { + t.Errorf("source day should still be frozen even with nothing to carry") + } +} + +// TestCarrySkipsAlreadyFrozenDay proves a day already frozen (already +// carried from once) is left alone: no second carry, no notice, and +// today starts as a plain log with nothing extra in it. +func TestCarrySkipsAlreadyFrozenDay(t *testing.T) { + dir := t.TempDir() + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + open := false + writeDay(t, dir, yesterday, []model.Item{ + {Hash: "open1111", Content: "unfinished todo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z", Closed: &open}, + }) + frozen, err := logfile.Stat(dir, yesterday) + if err != nil { + t.Fatal(err) + } + frozen.Body.Freezed = true + if err := logfile.Update(dir, yesterday, frozen.Body); err != nil { + t.Fatal(err) + } + + var out strings.Builder + if err := Add(&out, dir, "today's memo", AddOptions{}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Carried") { + t.Errorf("no carry should run against an already-frozen day: %q", out.String()) + } + if items := todayItems(t, dir); len(items) != 1 { + t.Errorf("today should only have the new memo, not a carried copy: %+v", items) + } +} + +// TestCarryFindsMostRecentDayAcrossAGap proves the source day is +// "whichever day actually has a log file, most recently, before +// today" rather than literally "yesterday": a multi-day gap (e.g. a +// weekend sava was never touched) is skipped without special-casing. +func TestCarryFindsMostRecentDayAcrossAGap(t *testing.T) { + dir := t.TempDir() + fiveDaysAgo := time.Now().AddDate(0, 0, -5).Format("2006-01-02") + open := false + writeDay(t, dir, fiveDaysAgo, []model.Item{ + {Hash: "open1111", Content: "unfinished todo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z", Closed: &open}, + }) + + var out strings.Builder + if err := Add(&out, dir, "today's memo", AddOptions{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Carried 1 items from "+fiveDaysAgo) { + t.Errorf("carry should reach across the gap to the most recent existing day: %q", out.String()) + } +} + +// TestCarryRunsOnlyOncePerDay proves the one-carry-per-day guarantee: +// once today's file exists, a second write command the same day must +// not re-carry or re-freeze. +func TestCarryRunsOnlyOncePerDay(t *testing.T) { + dir := t.TempDir() + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + open := false + writeDay(t, dir, yesterday, []model.Item{ + {Hash: "open1111", Content: "unfinished todo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z", Closed: &open}, + }) + + if err := Add(io.Discard, dir, "first write", AddOptions{}); err != nil { + t.Fatal(err) + } + + var out strings.Builder + if err := Add(&out, dir, "second write", AddOptions{}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Carried") { + t.Errorf("carry should not run again on the same day: %q", out.String()) + } + + if items := todayItems(t, dir); len(items) != 3 { + t.Fatalf("items = %+v, want 3 (1 carried + 2 memos, no duplicate carry)", items) + } +} + +// TestDelFailsOnFrozenDay proves del's own explicit freeze check: +// since logfile.Update no longer guards frozen writes internally, del +// must reject deleting from a day that carry has already frozen. +func TestDelFailsOnFrozenDay(t *testing.T) { + dir := t.TempDir() + day := time.Now().AddDate(0, 0, -5).Format("2006-01-02") + writeDay(t, dir, day, []model.Item{ + {Hash: "aaaa1111", Content: "frozen memo", CreatedAt: "2026-01-01T00:00:00.000Z", UpdatedAt: "2026-01-01T00:00:00.000Z"}, + }) + file, err := logfile.Stat(dir, day) + if err != nil { + t.Fatal(err) + } + file.Body.Freezed = true + if err := logfile.Update(dir, day, file.Body); err != nil { + t.Fatal(err) + } + + if err := Del(io.Discard, dir, day+":aaaa1111", false); !errors.Is(err, logfile.ErrFreezed) { + t.Errorf("err = %v, want to wrap logfile.ErrFreezed", err) + } + + reloaded, err := logfile.Stat(dir, day) + if err != nil { + t.Fatal(err) + } + if len(reloaded.Body.Items) != 1 { + t.Errorf("frozen day's item should survive the rejected delete: %+v", reloaded.Body.Items) + } +} diff --git a/internal/log/log.go b/internal/log/log.go index 17b3aa3..f064648 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -11,8 +11,6 @@ import ( ) var ( - // ErrFreezed is returned when modifying items of a frozen log. - ErrFreezed = errors.New("this log file is freezed, no updates") // ErrNotTask is returned when finishing an item that is a memo. ErrNotTask = errors.New("target item is not a task") // ErrAlreadyFinished is returned when finishing a closed task. @@ -27,10 +25,6 @@ var ( // The item is given a fresh hash even if it happens to collide with an // existing item's, so hashes stay unique within the log. func Add(l *model.Log, content string, isTask bool) (model.Item, error) { - if l.Freezed { - return model.Item{}, ErrFreezed - } - var item model.Item if isTask { item = model.NewTaskItem(content) @@ -61,6 +55,33 @@ func HashExists(items []model.Item, hash string) bool { return indexOf(items, hash) != -1 } +// CarryForward returns fresh copies of every unfinished task in items, +// ready to seed a new day's log: each copy gets its own fresh hash +// (unique among the copies), fresh CreatedAt/UpdatedAt, and content and +// tags carried over unchanged. StartedAt and Closed are not carried +// over, so a copy starts today as untouched, open work. CarriedFrom on +// each copy points back to "sourceDate:". Memos and +// already-closed tasks are left where they are, not copied. +func CarryForward(items []model.Item, sourceDate string) []model.Item { + var out []model.Item + for _, item := range items { + if !item.IsTask() || item.IsClosed() { + continue + } + + clone := model.NewTaskItem(item.Content) + clone.Hash = uniqueID(out, model.NewID) + if len(item.Tags) > 0 { + clone.Tags = append([]string(nil), item.Tags...) + } + from := sourceDate + ":" + item.Hash + clone.CarriedFrom = &from + + out = append(out, clone) + } + return out +} + // Delete removes the item matching hash from the log and returns it. // Only the first match is removed, so behavior stays well-defined even // if two items were ever created with colliding hashes. diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 97ccf28..ac2c241 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -45,11 +45,43 @@ func TestAdd(t *testing.T) { } } -func TestAddToFreezedLog(t *testing.T) { - l := newTestLog() - l.Freezed = true - if _, err := Add(&l, "content", true); !errors.Is(err, ErrFreezed) { - t.Errorf("err = %v, want ErrFreezed", err) +func TestCarryForward(t *testing.T) { + tagged := newTestLog() + tagged.Items[0].Tags = []string{"cli"} // task-open + + carried := CarryForward(tagged.Items, "2026-07-05") + + if len(carried) != 1 { + t.Fatalf("carried = %+v, want exactly 1 (the open task; memo and done task excluded)", carried) + } + + item := carried[0] + if item.Content != "open task" { + t.Errorf("Content = %q, want %q", item.Content, "open task") + } + if item.Hash == "task-open" { + t.Errorf("carried copy should get a fresh hash, not reuse the source hash") + } + if item.CarriedFrom == nil || *item.CarriedFrom != "2026-07-05:task-open" { + t.Errorf("CarriedFrom = %v, want %q", item.CarriedFrom, "2026-07-05:task-open") + } + if item.IsStarted() { + t.Errorf("carried copy should not inherit StartedAt: %+v", item) + } + if item.IsClosed() { + t.Errorf("carried copy should start open, not closed: %+v", item) + } + if len(item.Tags) != 1 || item.Tags[0] != "cli" { + t.Errorf("Tags = %+v, want carried over unchanged", item.Tags) + } + if item.CreatedAt == "2026-07-05T02:00:00.000Z" { + t.Errorf("carried copy should get a fresh CreatedAt, not the source's") + } +} + +func TestCarryForwardEmpty(t *testing.T) { + if got := CarryForward(nil, "2026-07-05"); got != nil { + t.Errorf("CarryForward(nil, ...) = %+v, want nil", got) } } diff --git a/internal/logfile/logfile.go b/internal/logfile/logfile.go index ea6d455..f8303a0 100644 --- a/internal/logfile/logfile.go +++ b/internal/logfile/logfile.go @@ -20,7 +20,11 @@ const ( ) var ( - // ErrFreezed is returned when attempting to update a frozen log file. + // ErrFreezed marks a frozen-log error. Update does not check + // Body.Freezed itself (a frozen body must still be writable, since + // that is how carry persists the freeze in the first place); + // callers that must not write to an already-frozen day (e.g. del) + // check LogFile.Body.Freezed themselves and wrap this error. ErrFreezed = errors.New("this log file is freezed, no updates") // ErrNotFound is returned by Stat when the day has no log file. ErrNotFound = errors.New("log file not found") @@ -114,12 +118,10 @@ func Get(dir, day string) (*LogFile, error) { return &LogFile{Path: pathFor(dir, name), Name: name, Body: body}, nil } -// Update writes body to the log file for day. Frozen logs are rejected. +// Update writes body to the log file for day, including a Freezed +// body: whether writing to an already-frozen day is allowed is a +// caller-side policy decision (see ErrFreezed), not this function's. func Update(dir, day string, body model.Log) error { - if body.Freezed { - return ErrFreezed - } - name, err := resolveName(day) if err != nil { return err diff --git a/internal/logfile/logfile_test.go b/internal/logfile/logfile_test.go index c100cb2..f46711d 100644 --- a/internal/logfile/logfile_test.go +++ b/internal/logfile/logfile_test.go @@ -105,11 +105,23 @@ func TestFilePermissions(t *testing.T) { } } -func TestUpdateFreezedLog(t *testing.T) { +// TestUpdateCanPersistFreezedLog guards against reintroducing a guard +// that rejects writing a Freezed body: carry has no other way to +// persist the freeze it just performed, so Update must accept it. +func TestUpdateCanPersistFreezedLog(t *testing.T) { + dir := t.TempDir() body := model.NewLog() body.Freezed = true - if err := Update(t.TempDir(), "2026-07-05", body); !errors.Is(err, ErrFreezed) { - t.Errorf("err = %v, want ErrFreezed", err) + if err := Update(dir, "2026-07-05", body); err != nil { + t.Fatalf("Update should be able to persist a freezed body: %v", err) + } + + reloaded, err := Stat(dir, "2026-07-05") + if err != nil { + t.Fatal(err) + } + if !reloaded.Body.Freezed { + t.Errorf("reloaded body should still be freezed: %+v", reloaded.Body) } } diff --git a/internal/model/model.go b/internal/model/model.go index ee99a1d..d1979c3 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -24,13 +24,14 @@ const idBytes = 4 // task (non-nil) from a memo (nil). StartedAt is set when work on a // task begins. Tags hold free-form labels on any item kind. type Item struct { - Hash string `json:"hash"` - Content string `json:"content"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - StartedAt *string `json:"startedAt,omitempty"` - Closed *bool `json:"closed,omitempty"` - Tags []string `json:"tags,omitempty"` + Hash string `json:"hash"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + StartedAt *string `json:"startedAt,omitempty"` + Closed *bool `json:"closed,omitempty"` + Tags []string `json:"tags,omitempty"` + CarriedFrom *string `json:"carriedFrom,omitempty"` // ":" of the copy this one was carried from } // NewTaskItem creates an open task with a fresh ID and timestamps. diff --git a/internal/view/view.go b/internal/view/view.go index 20e8259..b44afb8 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -46,6 +46,13 @@ func marker(status model.Status) string { } } +// Carried prints the automatic-carry notice, ahead of whatever output +// the write command that triggered it goes on to print, so a carry +// never happens invisibly. +func Carried(w io.Writer, count int, sourceDate string) { + fmt.Fprintf(w, "Carried %d items from %s (that day is now frozen).\n", count, sourceDate) +} + // Added prints the newly created item confirmation, including its // hash so it can be used right away without a separate list call. func Added(w io.Writer, item model.Item) { diff --git a/internal/view/view_test.go b/internal/view/view_test.go index b08e5e0..e7b8a6e 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -70,6 +70,15 @@ func TestFileStat(t *testing.T) { } } +func TestCarried(t *testing.T) { + var buf strings.Builder + Carried(&buf, 2, "2026-07-24") + out := buf.String() + if !strings.Contains(out, "Carried 2 items from 2026-07-24 (that day is now frozen).") { + t.Errorf("Carried output = %q", out) + } +} + func TestAdded(t *testing.T) { var buf strings.Builder Added(&buf, model.Item{Hash: "1ed29de4", Content: "review PR #123", CreatedAt: "2026-07-05T08:43:04.971Z", Tags: []string{"cli"}})