feat(filter): add FilterTasks and IsOldCompleted

Amolith created

Add TaskFilterOptions struct for configuring task list filtering by
area, goal, status, and completed date. FilterTasks applies these
filters to a task slice. IsOldCompleted is exported for custom filtering
needs.

Consolidates duplicate filtering logic from lune's CLI and MCP.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

filter.go      |  88 ++++++++++++++++++++++++++++
filter_test.go | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 247 insertions(+)

Detailed changes

filter.go 🔗

@@ -0,0 +1,88 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+}

filter_test.go 🔗

@@ -0,0 +1,159 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+		}
+	}
+}