diff --git a/filter.go b/filter.go new file mode 100644 index 0000000000000000000000000000000000000000..3e37bfd185fcf719122b5eaefff279df99493dea --- /dev/null +++ b/filter.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask + +import "time" + +// TaskFilterOptions configures task list filtering. +type TaskFilterOptions struct { + AreaID *string + GoalID *string + Status *TaskStatus + IncludeCompleted bool // If false, excludes completed tasks older than Today + Today time.Time // Reference date for "old completed" check +} + +// FilterTasks returns tasks matching the given filter options. +// If opts is nil, returns all tasks unchanged. +func FilterTasks(tasks []Task, opts *TaskFilterOptions) []Task { + if opts == nil { + return tasks + } + + filtered := make([]Task, 0, len(tasks)) + + for _, task := range tasks { + if matchesFilter(task, opts) { + filtered = append(filtered, task) + } + } + + return filtered +} + +func matchesFilter(task Task, opts *TaskFilterOptions) bool { + if !matchesAreaFilter(task, opts.AreaID) { + return false + } + + if !matchesGoalFilter(task, opts.GoalID) { + return false + } + + if !matchesStatusFilter(task, opts.Status) { + return false + } + + if !opts.IncludeCompleted && opts.Status == nil && IsOldCompleted(task, opts.Today) { + return false + } + + return true +} + +func matchesAreaFilter(task Task, areaID *string) bool { + if areaID == nil { + return true + } + + return task.AreaID != nil && *task.AreaID == *areaID +} + +func matchesGoalFilter(task Task, goalID *string) bool { + if goalID == nil { + return true + } + + return task.GoalID != nil && *task.GoalID == *goalID +} + +func matchesStatusFilter(task Task, status *TaskStatus) bool { + if status == nil { + return true + } + + return task.Status != nil && *task.Status == *status +} + +// IsOldCompleted returns true if the task was completed before the given date. +// Returns false for non-completed tasks or tasks with no CompletedAt timestamp. +func IsOldCompleted(task Task, today time.Time) bool { + if task.Status == nil || *task.Status != StatusCompleted { + return false + } + + return task.CompletedAt == nil || task.CompletedAt.Before(today) +} diff --git a/filter_test.go b/filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..791e8f0a09ae08d3354b16fdce2e025509e7a3b1 --- /dev/null +++ b/filter_test.go @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "testing" + "time" + + lunatask "git.secluded.site/go-lunatask" +) + +func TestFilterTasks(t *testing.T) { + t.Parallel() + + today := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + yesterday := today.Add(-24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + + areaA := "area-a" + areaB := "area-b" + goalX := "goal-x" + goalY := "goal-y" + + tasks := []lunatask.Task{ + {ID: "1", AreaID: &areaA, GoalID: &goalX, Status: ptr(lunatask.StatusNext)}, + {ID: "2", AreaID: &areaA, GoalID: &goalY, Status: ptr(lunatask.StatusLater)}, + {ID: "3", AreaID: &areaB, Status: ptr(lunatask.StatusCompleted), CompletedAt: &yesterday}, + {ID: "4", AreaID: &areaB, Status: ptr(lunatask.StatusCompleted), CompletedAt: &tomorrow}, + {ID: "5", Status: ptr(lunatask.StatusInProgress)}, + } + + completed := ptr(lunatask.StatusCompleted) + + tests := []struct { + name string + opts *lunatask.TaskFilterOptions + wantIDs []string + }{ + {"nil_opts_returns_all", nil, []string{"1", "2", "3", "4", "5"}}, + {"empty_opts_excludes_old_completed", &lunatask.TaskFilterOptions{Today: today}, []string{"1", "2", "4", "5"}}, + {"include_completed", &lunatask.TaskFilterOptions{IncludeCompleted: true}, []string{"1", "2", "3", "4", "5"}}, + {"filter_by_area", &lunatask.TaskFilterOptions{AreaID: &areaA, IncludeCompleted: true}, []string{"1", "2"}}, + {"filter_by_goal", &lunatask.TaskFilterOptions{GoalID: &goalX, IncludeCompleted: true}, []string{"1"}}, + {"filter_by_status", &lunatask.TaskFilterOptions{Status: completed, IncludeCompleted: true}, []string{"3", "4"}}, + {"filter_by_status_includes_old", &lunatask.TaskFilterOptions{Status: completed, Today: today}, []string{"3", "4"}}, + {"combined_area_and_status", &lunatask.TaskFilterOptions{AreaID: &areaB, Status: completed}, []string{"3", "4"}}, + {"no_matches", &lunatask.TaskFilterOptions{AreaID: ptr("nonexistent"), IncludeCompleted: true}, []string{}}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + assertFilterResult(t, tasks, testCase.opts, testCase.wantIDs) + }) + } +} + +func TestIsOldCompleted(t *testing.T) { + t.Parallel() + + today := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + yesterday := today.Add(-24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + completed := ptr(lunatask.StatusCompleted) + + tests := []struct { + name string + task lunatask.Task + today time.Time + expect bool + }{ + {"not_completed", lunatask.Task{Status: ptr(lunatask.StatusNext)}, today, false}, + {"nil_status", lunatask.Task{}, today, false}, + {"completed_yesterday", lunatask.Task{Status: completed, CompletedAt: &yesterday}, today, true}, + {"completed_tomorrow", lunatask.Task{Status: completed, CompletedAt: &tomorrow}, today, false}, + {"completed_nil_timestamp", lunatask.Task{Status: completed}, today, true}, + {"completed_exactly_today", lunatask.Task{Status: completed, CompletedAt: &today}, today, false}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got := lunatask.IsOldCompleted(testCase.task, testCase.today) + if got != testCase.expect { + t.Errorf("IsOldCompleted() = %v, want %v", got, testCase.expect) + } + }) + } +} + +func TestFilterTasks_NilTaskFields(t *testing.T) { + t.Parallel() + + areaA := "area-a" + goalX := "goal-x" + + tasks := []lunatask.Task{ + {ID: "1"}, + {ID: "2", AreaID: &areaA}, + {ID: "3", GoalID: &goalX}, + } + + tests := []struct { + name string + opts *lunatask.TaskFilterOptions + wantIDs []string + }{ + { + "area_filter_skips_nil_area", + &lunatask.TaskFilterOptions{AreaID: &areaA, IncludeCompleted: true}, + []string{"2"}, + }, + { + "goal_filter_skips_nil_goal", + &lunatask.TaskFilterOptions{GoalID: &goalX, IncludeCompleted: true}, + []string{"3"}, + }, + { + "status_filter_skips_nil_status", + &lunatask.TaskFilterOptions{Status: ptr(lunatask.StatusNext), IncludeCompleted: true}, + []string{}, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + assertFilterResult(t, tasks, testCase.opts, testCase.wantIDs) + }) + } +} + +func assertFilterResult(t *testing.T, tasks []lunatask.Task, opts *lunatask.TaskFilterOptions, wantIDs []string) { + t.Helper() + + got := lunatask.FilterTasks(tasks, opts) + gotIDs := make([]string, len(got)) + + for i, task := range got { + gotIDs[i] = task.ID + } + + if len(gotIDs) != len(wantIDs) { + t.Errorf("FilterTasks() got %v, want %v", gotIDs, wantIDs) + + return + } + + for i, id := range gotIDs { + if id != wantIDs[i] { + t.Errorf("FilterTasks() got %v, want %v", gotIDs, wantIDs) + + return + } + } +}