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
153 changes: 153 additions & 0 deletions internal/automation/automation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: 2025 Daniel Morris <daniel@honestempire.com>
// 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
}

Copy link

Copilot AI Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When moving a todo from the tomorrow list to the today list, the due date should be updated to reflect the new list. Consider updating the todo's due date to today before appending it to the today list:

todo.SetDueDate(list.DefaultDueDate(list.TodayID, todayStart))
todayList.Todos = append(todayList.Todos, todo)
Suggested change
todo.SetDueDate(list.DefaultDueDate(list.TodayID, todayStart))

Copilot uses AI. Check for mistakes.
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())
}
152 changes: 152 additions & 0 deletions internal/automation/automation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2025 Daniel Morris <daniel@honestempire.com>
// 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
}
28 changes: 14 additions & 14 deletions internal/cmd/t.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
Loading