feat(stats): add TaskCounter for task counts

Amolith created

Provides reusable helpers to count uncompleted tasks by area or goal ID
with lazy loading of task list.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

internal/stats/stats.go | 90 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)

Detailed changes

internal/stats/stats.go 🔗

@@ -0,0 +1,90 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package stats provides helpers for computing task statistics.
+package stats
+
+import (
+	"context"
+	"fmt"
+
+	"git.secluded.site/go-lunatask"
+)
+
+// TaskCounter provides methods to count tasks by various criteria.
+type TaskCounter struct {
+	client *lunatask.Client
+	tasks  []lunatask.Task
+}
+
+// NewTaskCounter creates a counter that fetches tasks on first use.
+func NewTaskCounter(client *lunatask.Client) *TaskCounter {
+	return &TaskCounter{client: client}
+}
+
+// UncompletedInArea counts uncompleted tasks in the specified area.
+func (tc *TaskCounter) UncompletedInArea(ctx context.Context, areaID string) (int, error) {
+	if err := tc.ensureTasks(ctx); err != nil {
+		return 0, err
+	}
+
+	count := 0
+
+	for _, task := range tc.tasks {
+		if !isUncompleted(task) {
+			continue
+		}
+
+		if task.AreaID != nil && *task.AreaID == areaID {
+			count++
+		}
+	}
+
+	return count, nil
+}
+
+// UncompletedInGoal counts uncompleted tasks in the specified goal.
+func (tc *TaskCounter) UncompletedInGoal(ctx context.Context, goalID string) (int, error) {
+	if err := tc.ensureTasks(ctx); err != nil {
+		return 0, err
+	}
+
+	count := 0
+
+	for _, task := range tc.tasks {
+		if !isUncompleted(task) {
+			continue
+		}
+
+		if task.GoalID != nil && *task.GoalID == goalID {
+			count++
+		}
+	}
+
+	return count, nil
+}
+
+// ensureTasks fetches tasks if not already loaded.
+func (tc *TaskCounter) ensureTasks(ctx context.Context) error {
+	if tc.tasks != nil {
+		return nil
+	}
+
+	tasks, err := tc.client.ListTasks(ctx, nil)
+	if err != nil {
+		return fmt.Errorf("listing tasks: %w", err)
+	}
+
+	tc.tasks = tasks
+
+	return nil
+}
+
+func isUncompleted(task lunatask.Task) bool {
+	if task.Status == nil {
+		return true
+	}
+
+	return *task.Status != lunatask.StatusCompleted
+}