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
139 changes: 139 additions & 0 deletions pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,145 @@ func (m *memoryPersistence) DeleteCollection(_ context.Context, name string) err
return nil
}

func TestMigrationCandidates_DefaultFilters(t *testing.T) {
now := time.Date(2024, time.May, 15, 9, 0, 0, 0, time.UTC)
task := &entry.Entry{
ID: "task-1",
Collection: "Inbox",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-24 * time.Hour)},
}
eventEntry := &entry.Entry{
ID: "event-1",
Collection: "Meetings",
Bullet: glyph.Event,
Created: entry.Timestamp{Time: now.Add(-2 * time.Hour)},
}
completed := &entry.Entry{
ID: "done-1",
Collection: "Inbox",
Bullet: glyph.Completed,
Created: entry.Timestamp{Time: now.Add(-time.Hour)},
}
note := &entry.Entry{
ID: "note-1",
Collection: "Inbox",
Bullet: glyph.Note,
Created: entry.Timestamp{Time: now},
}
futureRoot := &entry.Entry{
ID: "future-root",
Collection: "Future",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-10 * 24 * time.Hour)},
}
futureMonth := &entry.Entry{
ID: "future-month",
Collection: "Future/June 2024",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-3 * 24 * time.Hour)},
}
futureDay := &entry.Entry{
ID: "future-day",
Collection: "May 2024/May 20, 2024",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-2 * 24 * time.Hour)},
}
pastDay := &entry.Entry{
ID: "past-day",
Collection: "May 2024/May 10, 2024",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-6 * 24 * time.Hour)},
}
locked := &entry.Entry{
ID: "locked",
Collection: "Inbox",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-3 * time.Hour)},
Immutable: true,
}

store := newMemoryPersistence(task, eventEntry, completed, note, futureRoot, futureMonth, futureDay, pastDay, locked)
svc := &Service{Persistence: store}

results, err := svc.MigrationCandidates(context.Background(), time.Time{}, now)
if err != nil {
t.Fatalf("MigrationCandidates error: %v", err)
}
got := make(map[string]bool)
for _, cand := range results {
if cand.Entry != nil {
got[cand.Entry.ID] = true
}
}
assertIncluded := func(id string) {
if !got[id] {
t.Fatalf("expected %q to be included, but was missing (results=%v)", id, got)
}
}
assertExcluded := func(id string) {
if got[id] {
t.Fatalf("expected %q to be excluded, but found (results=%v)", id, got)
}
}

assertIncluded("task-1")
assertIncluded("event-1")
assertIncluded("future-root")
assertIncluded("past-day")

assertExcluded("done-1")
assertExcluded("note-1")
assertExcluded("future-month")
assertExcluded("future-day")
assertExcluded("locked")
}

func TestMigrationCandidates_WindowFilters(t *testing.T) {
now := time.Date(2024, time.April, 30, 12, 0, 0, 0, time.UTC)
recent := &entry.Entry{
ID: "recent",
Collection: "Inbox",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-2 * time.Hour)},
}
old := &entry.Entry{
ID: "old",
Collection: "Inbox",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-10 * 24 * time.Hour)},
}
futureRoot := &entry.Entry{
ID: "future-root",
Collection: "Future",
Bullet: glyph.Task,
Created: entry.Timestamp{Time: now.Add(-30 * 24 * time.Hour)},
}
store := newMemoryPersistence(recent, old, futureRoot)
svc := &Service{Persistence: store}

since := now.Add(-72 * time.Hour)
results, err := svc.MigrationCandidates(context.Background(), since, now)
if err != nil {
t.Fatalf("MigrationCandidates error: %v", err)
}
got := make(map[string]bool)
for _, cand := range results {
if cand.Entry != nil {
got[cand.Entry.ID] = true
}
}
if !got["recent"] {
t.Fatalf("expected \"recent\" to be included within window, got %v", got)
}
if got["old"] {
t.Fatalf("expected \"old\" to be excluded outside window, got %v", got)
}
if !got["future-root"] {
t.Fatalf("expected \"future-root\" to be included regardless of window, got %v", got)
}
}

func (m *memoryPersistence) SetCollectionType(name string, typ collection.Type) error {
m.mu.Lock()
defer m.mu.Unlock()
Expand Down
95 changes: 79 additions & 16 deletions pkg/app/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"tableflip.dev/bujo/pkg/collection"
"tableflip.dev/bujo/pkg/entry"
"tableflip.dev/bujo/pkg/glyph"
)
Expand All @@ -18,10 +19,12 @@ type MigrationCandidate struct {
LastTouched time.Time
}

// MigrationCandidates returns open tasks that were touched within the provided window.
// A task is considered "open" when it still carries the task bullet (i.e. not completed,
// moved, struck, or converted to another bullet). The LastTouched timestamp reflects the
// most recent history record (or creation time).
// MigrationCandidates returns open, completable entries that require review in a migration
// session. When the since window is zero, all open tasks/events outside the Future tree
// (and not scheduled on future daily collections) are returned alongside top-level Future
// entries. When a non-zero window is provided, candidates must have been touched within the
// window, with top-level Future entries always included. LastTouched reflects the most recent
// history record (or creation time).
func (s *Service) MigrationCandidates(ctx context.Context, since, until time.Time) ([]MigrationCandidate, error) {
if ctx != nil {
if err := ctx.Err(); err != nil {
Expand All @@ -39,17 +42,40 @@ func (s *Service) MigrationCandidates(ctx context.Context, since, until time.Tim
items := indexEntriesByID(all)

results := make([]MigrationCandidate, 0, len(all))
now := until
if now.IsZero() {
now = time.Now()
}
if until.IsZero() {
until = now
}
sinceZero := since.IsZero()

for _, e := range all {
if e == nil || e.ID == "" {
if !isCompletableCandidate(e) {
continue
}
if !isOpenTask(e) {
last := lastTouchedAt(e)
if !until.IsZero() && !last.IsZero() && last.After(until) {
continue
}
last := lastTouchedAt(e)
if last.Before(since) || last.After(until) {

collectionPath := strings.TrimSpace(e.Collection)
if collectionPath == "" {
continue
}
topLevelFuture := isTopLevelFuture(collectionPath)
if !topLevelFuture {
if isInFutureTree(collectionPath) {
continue
}
if isDailyCollectionAfter(now, collectionPath) {
continue
}
if !sinceZero && (last.IsZero() || last.Before(since)) {
continue
}
}
var parent *entry.Entry
if e.ParentID != "" {
parent = items[e.ParentID]
Expand All @@ -73,21 +99,19 @@ func (s *Service) MigrationCandidates(ctx context.Context, since, until time.Tim
return results, nil
}

func isOpenTask(e *entry.Entry) bool {
if e == nil {
func isCompletableCandidate(e *entry.Entry) bool {
if e == nil || e.ID == "" {
return false
}
if e.Immutable {
return false
}
if e.Bullet != glyph.Task {
return false
}
if strings.TrimSpace(e.Message) == "" && e.ParentID == "" {
// Allow blank tasks, but keep logic symmetrical in case we need special handling later.
switch e.Bullet {
case glyph.Task, glyph.Event:
return true
default:
return false
}
return true
}

func lastTouchedAt(e *entry.Entry) time.Time {
Expand All @@ -106,3 +130,42 @@ func lastTouchedAt(e *entry.Entry) time.Time {
}
return latest
}

func isTopLevelFuture(path string) bool {
return strings.EqualFold(strings.TrimSpace(path), "Future")
}

func isInFutureTree(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
if isTopLevelFuture(path) {
return false
}
return strings.HasPrefix(path, "Future/")
}

func isDailyCollectionAfter(now time.Time, path string) bool {
if path == "" {
return false
}
segments := strings.Split(path, "/")
if len(segments) == 0 {
return false
}
last := strings.TrimSpace(segments[len(segments)-1])
if !collection.IsDayName(last) {
return false
}
loc := now.Location()
day, err := time.ParseInLocation("January 2, 2006", last, loc)
if err != nil {
day, err = time.Parse("January 2, 2006", last)
if err != nil {
return false
}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
return day.After(today)
}
1 change: 1 addition & 0 deletions pkg/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func AddCommands(topLevel *cobra.Command) {
addComplete(topLevel)
addStrike(topLevel)
addReport(topLevel)
addMigration(topLevel)
addTrack(topLevel)
addLog(topLevel)
addCompletions(topLevel)
Expand Down
Loading
Loading