diff --git a/internal/automation/automation.go b/internal/automation/automation.go new file mode 100644 index 0000000..aab300c --- /dev/null +++ b/internal/automation/automation.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2025 Daniel Morris +// SPDX-License-Identifier: MIT + +package automation + +import ( + "fmt" + "time" + + "github.com/unfunco/t/internal/list" + "github.com/unfunco/t/internal/model" + "github.com/unfunco/t/internal/storage" +) + +const day = 24 * time.Hour + +// Sync loads all default lists, applies scheduled automations, persists any +// changes, and returns the resulting lists keyed by their ID. +func Sync(store storage.Storage, now time.Time) (map[list.ID]*model.TodoList, error) { + defs := list.Default() + lists := make(map[list.ID]*model.TodoList, len(defs)) + + for _, def := range defs { + l, err := store.LoadList(def) + if err != nil { + return nil, fmt.Errorf("load %s list: %w", def.Name, err) + } + + lists[def.ID] = l + } + + todayStart := startOfDay(now) + changed := ensureDueDates(lists[list.TodayID], list.TodayID) + + if ensureDueDates(lists[list.TomorrowID], list.TomorrowID) { + changed = true + } + + if moveTomorrowTodos(lists[list.TomorrowID], lists[list.TodayID], todayStart) { + changed = true + } + + if changed { + for _, def := range defs { + l := lists[def.ID] + if l == nil { + continue + } + if err := store.SaveList(def, l); err != nil { + return nil, fmt.Errorf("save %s list: %w", def.Name, err) + } + } + } + + return lists, nil +} + +func ensureDueDates(todoList *model.TodoList, id list.ID) bool { + if todoList == nil { + return false + } + + changed := false + for i := range todoList.Todos { + todo := &todoList.Todos[i] + + switch id { + case list.TodayID: + if applyDueDate(todo, list.DefaultDueDate(list.TodayID, todo.CreatedAt)) { + changed = true + } + case list.TomorrowID: + if applyDueDate(todo, list.DefaultDueDate(list.TomorrowID, todo.CreatedAt)) { + changed = true + } + default: + if todo.DueDate != nil { + normalized := startOfDay(*todo.DueDate) + if !todo.DueDate.Equal(normalized) { + todo.SetDueDate(&normalized) + changed = true + } + } + } + } + + return changed +} + +func moveTomorrowTodos(tomorrowList, todayList *model.TodoList, todayStart time.Time) bool { + if tomorrowList == nil || todayList == nil { + return false + } + + var remaining []model.Todo + changed := false + + for _, todo := range tomorrowList.Todos { + if todo.Completed { + remaining = append(remaining, todo) + continue + } + + if todo.DueDate == nil { + remaining = append(remaining, todo) + continue + } + + due := startOfDay(*todo.DueDate) + if due.After(todayStart) { + remaining = append(remaining, todo) + continue + } + + todayList.Todos = append(todayList.Todos, todo) + changed = true + } + + if len(remaining) != len(tomorrowList.Todos) { + tomorrowList.Todos = remaining + } + + return changed +} + +func applyDueDate(todo *model.Todo, due *time.Time) bool { + if due == nil { + if todo.DueDate == nil { + return false + } + todo.SetDueDate(nil) + return true + } + + normalizedTarget := startOfDay(*due) + + if todo.DueDate == nil { + todo.SetDueDate(&normalizedTarget) + return true + } + + current := startOfDay(*todo.DueDate) + if current.Equal(normalizedTarget) { + return false + } + + todo.SetDueDate(&normalizedTarget) + return true +} + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} diff --git a/internal/automation/automation_test.go b/internal/automation/automation_test.go new file mode 100644 index 0000000..1e35492 --- /dev/null +++ b/internal/automation/automation_test.go @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2025 Daniel Morris +// SPDX-License-Identifier: MIT + +package automation + +import ( + "testing" + "time" + + "github.com/unfunco/t/internal/list" + "github.com/unfunco/t/internal/model" +) + +func TestSyncMovesDueTomorrowTodos(t *testing.T) { + now := time.Date(2025, time.January, 2, 9, 0, 0, 0, time.UTC) + yesterday := now.Add(-day) + + due := list.DefaultDueDate(list.TomorrowID, yesterday) + if due == nil { + t.Fatalf("expected tomorrow list to have a due date") + } + + store := newMemoryStorage(map[list.ID]*model.TodoList{ + list.TodayID: { + Name: list.Today().Name, + }, + list.TomorrowID: { + Name: list.Tomorrow().Name, + Todos: []model.Todo{ + { + ID: "1", + Title: "Move me", + CreatedAt: yesterday, + DueDate: due, + }, + }, + }, + }) + + lists, err := Sync(store, now) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + if got := len(lists[list.TomorrowID].Todos); got != 0 { + t.Fatalf("expected tomorrow list to be empty, got %d todos", got) + } + + if got := len(lists[list.TodayID].Todos); got != 1 { + t.Fatalf("expected today list to have 1 todo, got %d", got) + } + + todo := lists[list.TodayID].Todos[0] + wantDue := list.DefaultDueDate(list.TodayID, now) + if todo.DueDate == nil || wantDue == nil || !todo.DueDate.Equal(*wantDue) { + t.Fatalf("expected due date %v, got %v", wantDue, todo.DueDate) + } +} + +func TestSyncLeavesFutureTomorrowTodos(t *testing.T) { + now := time.Date(2025, time.January, 1, 9, 0, 0, 0, time.UTC) + + due := list.DefaultDueDate(list.TomorrowID, now) + store := newMemoryStorage(map[list.ID]*model.TodoList{ + list.TomorrowID: { + Name: list.Tomorrow().Name, + Todos: []model.Todo{ + { + ID: "1", + Title: "Too early", + CreatedAt: now, + DueDate: due, + }, + }, + }, + }) + + lists, err := Sync(store, now) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + if got := len(lists[list.TomorrowID].Todos); got != 1 { + t.Fatalf("expected tomorrow list to keep todo, got %d", got) + } +} + +func TestSyncBackfillsMissingDueDates(t *testing.T) { + now := time.Date(2025, time.January, 3, 9, 0, 0, 0, time.UTC) + created := now.Add(-2 * day) + + store := newMemoryStorage(map[list.ID]*model.TodoList{ + list.TodayID: { + Name: list.Today().Name, + Todos: []model.Todo{ + { + ID: "a", + Title: "Missing due date", + CreatedAt: created, + }, + }, + }, + }) + + lists, err := Sync(store, now) + if err != nil { + t.Fatalf("Sync returned error: %v", err) + } + + todos := lists[list.TodayID].Todos + if len(todos) != 1 { + t.Fatalf("expected 1 todo after sync, got %d", len(todos)) + } + + want := list.DefaultDueDate(list.TodayID, created) + if want == nil { + t.Fatalf("expected today list to produce a due date") + } + + if todos[0].DueDate == nil || !todos[0].DueDate.Equal(*want) { + t.Fatalf("expected due date %v, got %v", want, todos[0].DueDate) + } +} + +type memoryStorage struct { + lists map[list.ID]*model.TodoList +} + +func newMemoryStorage(initial map[list.ID]*model.TodoList) *memoryStorage { + lists := make(map[list.ID]*model.TodoList, len(initial)) + for id, todoList := range initial { + lists[id] = todoList + } + + return &memoryStorage{lists: lists} +} + +func (m *memoryStorage) LoadList(def list.Definition) (*model.TodoList, error) { + if l, ok := m.lists[def.ID]; ok { + return l, nil + } + + l := &model.TodoList{Name: def.Name} + m.lists[def.ID] = l + + return l, nil +} + +func (m *memoryStorage) SaveList(def list.Definition, todoList *model.TodoList) error { + m.lists[def.ID] = todoList + return nil +} diff --git a/internal/cmd/t.go b/internal/cmd/t.go index 19ba1a0..c4a881c 100644 --- a/internal/cmd/t.go +++ b/internal/cmd/t.go @@ -9,11 +9,13 @@ import ( "io" "os" "strings" + "time" "unicode/utf8" "github.com/MakeNowJust/heredoc/v2" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" + "github.com/unfunco/t/internal/automation" "github.com/unfunco/t/internal/list" "github.com/unfunco/t/internal/model" "github.com/unfunco/t/internal/storage" @@ -80,22 +82,16 @@ func NewTCommand(in io.Reader, out, errOut io.Writer) *cobra.Command { return fmt.Errorf("failed to initialise storage: %w", err) } - todayList, err := store.LoadList(list.Today()) + lists, err := automation.Sync(store, time.Now()) if err != nil { - return fmt.Errorf("failed to load %s list: %w", list.Today().Name, err) + return fmt.Errorf("failed to prepare lists: %w", err) } - tomorrowList, err := store.LoadList(list.Tomorrow()) - if err != nil { - return fmt.Errorf("failed to load %s list: %w", list.Tomorrow().Name, err) - } - - todoList, err := store.LoadList(list.Todos()) - if err != nil { - return fmt.Errorf("failed to load %s list: %w", list.Todos().Name, err) - } - - m := tui.New(todayList, tomorrowList, todoList) + m := tui.New( + lists[list.TodayID], + lists[list.TomorrowID], + lists[list.TodosID], + ) p := tea.NewProgram(&m) tuiModel, err := p.Run() @@ -122,7 +118,9 @@ func NewTCommand(in io.Reader, out, errOut io.Writer) *cobra.Command { return fmt.Errorf("failed to initialise storage: %w", err) } - todo := model.NewTodo(title, "") + if _, err := automation.Sync(store, time.Now()); err != nil { + return fmt.Errorf("failed to prepare lists: %w", err) + } var def list.Definition switch { @@ -134,6 +132,8 @@ func NewTCommand(in io.Reader, out, errOut io.Writer) *cobra.Command { def = list.Todos() } + todo := model.NewTodo(title, "", list.DefaultDueDate(def.ID, time.Now())) + if err := appendToList(store, def, &todo); err != nil { return err } diff --git a/internal/list/list.go b/internal/list/list.go index 704789f..fb536a8 100644 --- a/internal/list/list.go +++ b/internal/list/list.go @@ -3,6 +3,8 @@ package list +import "time" + // ID identifies a todo list. type ID string @@ -22,6 +24,9 @@ type Definition struct { Filename string } +// day is the number of hours in a full calendar day. +const day = 24 * time.Hour + var definitions = map[ID]Definition{ TodayID: { ID: TodayID, @@ -67,3 +72,22 @@ func Tomorrow() Definition { func Todos() Definition { return definitions[TodosID] } + +// DefaultDueDate returns the default due date for items added to the provided +// list ID. Lists that do not have a due date return nil. +func DefaultDueDate(id ID, now time.Time) *time.Time { + switch id { + case TodayID: + t := startOfDay(now) + return &t + case TomorrowID: + t := startOfDay(now).Add(day) + return &t + default: + return nil + } +} + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} diff --git a/internal/model/todo.go b/internal/model/todo.go index 24a20e1..bea507e 100644 --- a/internal/model/todo.go +++ b/internal/model/todo.go @@ -13,6 +13,7 @@ type Todo struct { Completed bool `json:"completed"` CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at"` + DueDate *time.Time `json:"due_date"` } // TodoList represents a collection of todos with a name. @@ -21,14 +22,17 @@ type TodoList struct { Todos []Todo } -// NewTodo creates a new todo item with the given title and description. -func NewTodo(title, description string) Todo { +// NewTodo creates a new todo item with the given title, description, and +// optional due date. +func NewTodo(title, description string, dueDate *time.Time) Todo { + now := time.Now() return Todo{ - ID: time.Now().Format("20060102150405.000000"), + ID: now.Format("20060102150405.000000"), Title: title, Description: description, Completed: false, - CreatedAt: time.Now(), + CreatedAt: now, + DueDate: cloneTimePtr(dueDate), } } @@ -42,3 +46,33 @@ func (t *Todo) ToggleCompleted() { t.CompletedAt = nil } } + +// SetDueDate updates the due date for the todo. +func (t *Todo) SetDueDate(dueDate *time.Time) { + t.DueDate = cloneTimePtr(dueDate) +} + +// IsOverdue reports whether the todo is overdue relative to the provided time. +func (t *Todo) IsOverdue(reference time.Time) bool { + if t.Completed || t.DueDate == nil { + return false + } + + due := startOfDay(*t.DueDate) + ref := startOfDay(reference) + + return due.Before(ref) +} + +func cloneTimePtr(in *time.Time) *time.Time { + if in == nil { + return nil + } + + clone := *in + return &clone +} + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 0ab1e14..f0cb57a 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -139,7 +139,7 @@ func normalizeHex(input string) (string, error) { } for _, r := range s { - if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { return "", fmt.Errorf("invalid hex digit %q", r) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f07f1e4..2751b73 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -6,6 +6,7 @@ package tui import ( "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" @@ -337,6 +338,7 @@ func (m *Model) renderList() string { return "No todos yet." } + now := time.Now() var items []string for i, todo := range l.Todos { var checkbox string @@ -380,6 +382,11 @@ func (m *Model) renderList() string { titleStyle.Render(todo.Title), ) + if todo.IsOverdue(now) { + overdueLabel := m.theme.WorryStyle().Render("! Overdue") + item += " " + overdueLabel + } + if todo.Description != "" { item += "\n " + descStyle.Render(todo.Description) } @@ -569,6 +576,7 @@ func (m *Model) submitForm() { todo.Description = description if m.formTargetList != m.activeTab { + todo.SetDueDate(m.dueDateForTab(m.formTargetList)) currentList.Todos = append(currentList.Todos[:m.editingIndex], currentList.Todos[m.editingIndex+1:]...) targetList := m.getListByTab(m.formTargetList) @@ -584,7 +592,7 @@ func (m *Model) submitForm() { } } } else { - newTodo := model.NewTodo(title, description) + newTodo := model.NewTodo(title, description, m.dueDateForTab(m.formTargetList)) targetList := m.getListByTab(m.formTargetList) if targetList != nil { targetList.Todos = append(targetList.Todos, newTodo) @@ -648,6 +656,20 @@ func (m *Model) getListByTab(tab Tab) *model.TodoList { } } +func (m *Model) dueDateForTab(tab Tab) *time.Time { + now := time.Now() + switch tab { + case TabToday: + return list.DefaultDueDate(list.TodayID, now) + case TabTomorrow: + return list.DefaultDueDate(list.TomorrowID, now) + case TabTodo: + return list.DefaultDueDate(list.TodosID, now) + default: + return nil + } +} + // renderForm renders the add or edit todo form. func (m *Model) renderForm() string { var b strings.Builder