feat(workflow): add Workflow type and helpers

Amolith created

Supports all six Lunatask workflows: priority_list, now_later, kanban,
plan_your_days, must_should_want, and eisenhower.

Includes methods for determining which task fields are relevant per
workflow (ValidStatuses, UsesMotivation, UsesEisenhower, UsesScheduling,
UsesPriority) and human-readable descriptions for MCP/LLM consumption.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

workflow.go      | 129 +++++++++++++++++++
workflow_test.go | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 455 insertions(+)

Detailed changes

workflow.go 🔗

@@ -0,0 +1,129 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package lunatask
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+)
+
+// Workflow represents an area's task management workflow.
+type Workflow string
+
+// Valid workflow values.
+const (
+	WorkflowPriorityList   Workflow = "priority_list"
+	WorkflowNowLater       Workflow = "now_later"
+	WorkflowKanban         Workflow = "kanban"
+	WorkflowPlanYourDays   Workflow = "plan_your_days"
+	WorkflowMustShouldWant Workflow = "must_should_want"
+	WorkflowEisenhower     Workflow = "eisenhower"
+)
+
+// Errors returned by Workflow operations.
+var (
+	// ErrInvalidWorkflow is returned when parsing an unknown workflow string.
+	ErrInvalidWorkflow = errors.New("invalid workflow")
+)
+
+// String returns the workflow value as a string.
+func (w Workflow) String() string {
+	return string(w)
+}
+
+// ValidStatuses returns the task statuses that apply to this workflow.
+func (w Workflow) ValidStatuses() []TaskStatus {
+	switch w {
+	case WorkflowPriorityList:
+		return nil
+	case WorkflowNowLater:
+		return []TaskStatus{StatusLater, StatusInProgress, StatusCompleted}
+	case WorkflowKanban:
+		return []TaskStatus{StatusLater, StatusNext, StatusInProgress, StatusWaiting, StatusCompleted}
+	case WorkflowPlanYourDays:
+		return []TaskStatus{StatusCompleted}
+	case WorkflowMustShouldWant:
+		return []TaskStatus{StatusCompleted}
+	case WorkflowEisenhower:
+		return []TaskStatus{StatusCompleted}
+	default:
+		return nil
+	}
+}
+
+// UsesMotivation reports whether this workflow uses the motivation field (must/should/want).
+func (w Workflow) UsesMotivation() bool {
+	return w == WorkflowMustShouldWant
+}
+
+// UsesEisenhower reports whether this workflow uses the important/urgent fields.
+func (w Workflow) UsesEisenhower() bool {
+	return w == WorkflowEisenhower
+}
+
+// UsesScheduling reports whether this workflow is date-oriented (scheduled_on).
+func (w Workflow) UsesScheduling() bool {
+	return w == WorkflowPlanYourDays
+}
+
+// UsesPriority reports whether this workflow uses only priority (no status columns).
+func (w Workflow) UsesPriority() bool {
+	return w == WorkflowPriorityList
+}
+
+// Description returns a human-readable description of the workflow for LLMs.
+func (w Workflow) Description() string {
+	switch w {
+	case WorkflowPriorityList:
+		return "Priority-only workflow. Tasks are ranked by priority (lowest to highest) without status columns."
+	case WorkflowNowLater:
+		return "Simple two-column workflow. Tasks are either 'later' or 'in-progress', then marked 'completed'."
+	case WorkflowKanban:
+		return "Full kanban board with columns: later, next, in-progress, waiting, completed."
+	case WorkflowPlanYourDays:
+		return "Date-oriented workflow. Tasks are scheduled for specific days using the scheduled_on field."
+	case WorkflowMustShouldWant:
+		return "Motivation-based workflow. Tasks are categorized as 'must', 'should', or 'want' to clarify why they matter."
+	case WorkflowEisenhower:
+		return "Eisenhower matrix workflow. Tasks are categorized by 'important' and 'urgent' flags into four quadrants."
+	default:
+		return fmt.Sprintf("Unknown workflow: %s", w)
+	}
+}
+
+// ParseWorkflow parses a string to a Workflow value (case-insensitive).
+// Accepts both snake_case (priority_list) and kebab-case (priority-list).
+func ParseWorkflow(str string) (Workflow, error) {
+	normalized := strings.ToLower(strings.ReplaceAll(str, "-", "_"))
+	switch normalized {
+	case "priority_list":
+		return WorkflowPriorityList, nil
+	case "now_later":
+		return WorkflowNowLater, nil
+	case "kanban":
+		return WorkflowKanban, nil
+	case "plan_your_days":
+		return WorkflowPlanYourDays, nil
+	case "must_should_want":
+		return WorkflowMustShouldWant, nil
+	case "eisenhower":
+		return WorkflowEisenhower, nil
+	default:
+		return "", fmt.Errorf("%w: %q", ErrInvalidWorkflow, str)
+	}
+}
+
+// Workflows returns all valid workflow values.
+func Workflows() []Workflow {
+	return []Workflow{
+		WorkflowPriorityList,
+		WorkflowNowLater,
+		WorkflowKanban,
+		WorkflowPlanYourDays,
+		WorkflowMustShouldWant,
+		WorkflowEisenhower,
+	}
+}

workflow_test.go 🔗

@@ -0,0 +1,326 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package lunatask_test
+
+import (
+	"slices"
+	"testing"
+
+	lunatask "git.secluded.site/go-lunatask"
+)
+
+func TestParseWorkflow(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		want    lunatask.Workflow
+		wantErr bool
+	}{
+		{"priority_list_snake", "priority_list", lunatask.WorkflowPriorityList, false},
+		{"priority_list_kebab", "priority-list", lunatask.WorkflowPriorityList, false},
+		{"priority_list_upper", "PRIORITY_LIST", lunatask.WorkflowPriorityList, false},
+		{"priority_list_mixed", "Priority-List", lunatask.WorkflowPriorityList, false},
+		{"now_later_snake", "now_later", lunatask.WorkflowNowLater, false},
+		{"now_later_kebab", "now-later", lunatask.WorkflowNowLater, false},
+		{"now_later_upper", "NOW_LATER", lunatask.WorkflowNowLater, false},
+		{"kanban_lower", "kanban", lunatask.WorkflowKanban, false},
+		{"kanban_upper", "KANBAN", lunatask.WorkflowKanban, false},
+		{"plan_your_days_snake", "plan_your_days", lunatask.WorkflowPlanYourDays, false},
+		{"plan_your_days_kebab", "plan-your-days", lunatask.WorkflowPlanYourDays, false},
+		{"must_should_want_snake", "must_should_want", lunatask.WorkflowMustShouldWant, false},
+		{"must_should_want_kebab", "must-should-want", lunatask.WorkflowMustShouldWant, false},
+		{"eisenhower_lower", "eisenhower", lunatask.WorkflowEisenhower, false},
+		{"eisenhower_upper", "EISENHOWER", lunatask.WorkflowEisenhower, false},
+		{"invalid", "invalid", "", true},
+		{"empty", "", "", true},
+		{"numeric", "1", "", true},
+		{"typo", "kanbann", "", true},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := lunatask.ParseWorkflow(testCase.input)
+			if (err != nil) != testCase.wantErr {
+				t.Errorf("ParseWorkflow(%q) error = %v, wantErr %v", testCase.input, err, testCase.wantErr)
+
+				return
+			}
+
+			if !testCase.wantErr && got != testCase.want {
+				t.Errorf("ParseWorkflow(%q) = %q, want %q", testCase.input, got, testCase.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_String(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		value lunatask.Workflow
+		want  string
+	}{
+		{"priority_list", lunatask.WorkflowPriorityList, "priority_list"},
+		{"now_later", lunatask.WorkflowNowLater, "now_later"},
+		{"kanban", lunatask.WorkflowKanban, "kanban"},
+		{"plan_your_days", lunatask.WorkflowPlanYourDays, "plan_your_days"},
+		{"must_should_want", lunatask.WorkflowMustShouldWant, "must_should_want"},
+		{"eisenhower", lunatask.WorkflowEisenhower, "eisenhower"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.value.String(); got != tc.want {
+				t.Errorf("Workflow.String() = %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_ValidStatuses(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		workflow lunatask.Workflow
+		want     []lunatask.TaskStatus
+	}{
+		{
+			"priority_list",
+			lunatask.WorkflowPriorityList,
+			nil,
+		},
+		{
+			"now_later",
+			lunatask.WorkflowNowLater,
+			[]lunatask.TaskStatus{lunatask.StatusLater, lunatask.StatusInProgress, lunatask.StatusCompleted},
+		},
+		{
+			"kanban",
+			lunatask.WorkflowKanban,
+			[]lunatask.TaskStatus{
+				lunatask.StatusLater, lunatask.StatusNext, lunatask.StatusInProgress,
+				lunatask.StatusWaiting, lunatask.StatusCompleted,
+			},
+		},
+		{
+			"plan_your_days",
+			lunatask.WorkflowPlanYourDays,
+			[]lunatask.TaskStatus{lunatask.StatusCompleted},
+		},
+		{
+			"must_should_want",
+			lunatask.WorkflowMustShouldWant,
+			[]lunatask.TaskStatus{lunatask.StatusCompleted},
+		},
+		{
+			"eisenhower",
+			lunatask.WorkflowEisenhower,
+			[]lunatask.TaskStatus{lunatask.StatusCompleted},
+		},
+		{
+			"unknown",
+			lunatask.Workflow("unknown"),
+			nil,
+		},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := testCase.workflow.ValidStatuses()
+			if !slices.Equal(got, testCase.want) {
+				t.Errorf("Workflow.ValidStatuses() = %v, want %v", got, testCase.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_UsesMotivation(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		workflow lunatask.Workflow
+		want     bool
+	}{
+		{"priority_list", lunatask.WorkflowPriorityList, false},
+		{"now_later", lunatask.WorkflowNowLater, false},
+		{"kanban", lunatask.WorkflowKanban, false},
+		{"plan_your_days", lunatask.WorkflowPlanYourDays, false},
+		{"must_should_want", lunatask.WorkflowMustShouldWant, true},
+		{"eisenhower", lunatask.WorkflowEisenhower, false},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.workflow.UsesMotivation(); got != tc.want {
+				t.Errorf("Workflow.UsesMotivation() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_UsesEisenhower(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		workflow lunatask.Workflow
+		want     bool
+	}{
+		{"priority_list", lunatask.WorkflowPriorityList, false},
+		{"now_later", lunatask.WorkflowNowLater, false},
+		{"kanban", lunatask.WorkflowKanban, false},
+		{"plan_your_days", lunatask.WorkflowPlanYourDays, false},
+		{"must_should_want", lunatask.WorkflowMustShouldWant, false},
+		{"eisenhower", lunatask.WorkflowEisenhower, true},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.workflow.UsesEisenhower(); got != tc.want {
+				t.Errorf("Workflow.UsesEisenhower() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_UsesScheduling(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		workflow lunatask.Workflow
+		want     bool
+	}{
+		{"priority_list", lunatask.WorkflowPriorityList, false},
+		{"now_later", lunatask.WorkflowNowLater, false},
+		{"kanban", lunatask.WorkflowKanban, false},
+		{"plan_your_days", lunatask.WorkflowPlanYourDays, true},
+		{"must_should_want", lunatask.WorkflowMustShouldWant, false},
+		{"eisenhower", lunatask.WorkflowEisenhower, false},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.workflow.UsesScheduling(); got != tc.want {
+				t.Errorf("Workflow.UsesScheduling() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_UsesPriority(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		workflow lunatask.Workflow
+		want     bool
+	}{
+		{"priority_list", lunatask.WorkflowPriorityList, true},
+		{"now_later", lunatask.WorkflowNowLater, false},
+		{"kanban", lunatask.WorkflowKanban, false},
+		{"plan_your_days", lunatask.WorkflowPlanYourDays, false},
+		{"must_should_want", lunatask.WorkflowMustShouldWant, false},
+		{"eisenhower", lunatask.WorkflowEisenhower, false},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.workflow.UsesPriority(); got != tc.want {
+				t.Errorf("Workflow.UsesPriority() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWorkflow_Description(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		workflow lunatask.Workflow
+		contains string
+	}{
+		{"priority_list", lunatask.WorkflowPriorityList, "priority"},
+		{"now_later", lunatask.WorkflowNowLater, "in-progress"},
+		{"kanban", lunatask.WorkflowKanban, "kanban"},
+		{"plan_your_days", lunatask.WorkflowPlanYourDays, "scheduled_on"},
+		{"must_should_want", lunatask.WorkflowMustShouldWant, "must"},
+		{"eisenhower", lunatask.WorkflowEisenhower, "Eisenhower"},
+		{"unknown", lunatask.Workflow("unknown"), "Unknown"},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := testCase.workflow.Description()
+			if got == "" {
+				t.Error("Workflow.Description() returned empty string")
+			}
+
+			if !contains(got, testCase.contains) {
+				t.Errorf("Workflow.Description() = %q, want to contain %q", got, testCase.contains)
+			}
+		})
+	}
+}
+
+func TestWorkflows(t *testing.T) {
+	t.Parallel()
+
+	workflows := lunatask.Workflows()
+
+	if len(workflows) != 6 {
+		t.Errorf("Workflows() returned %d workflows, want 6", len(workflows))
+	}
+
+	expected := []lunatask.Workflow{
+		lunatask.WorkflowPriorityList,
+		lunatask.WorkflowNowLater,
+		lunatask.WorkflowKanban,
+		lunatask.WorkflowPlanYourDays,
+		lunatask.WorkflowMustShouldWant,
+		lunatask.WorkflowEisenhower,
+	}
+
+	if !slices.Equal(workflows, expected) {
+		t.Errorf("Workflows() = %v, want %v", workflows, expected)
+	}
+}
+
+func contains(s, substr string) bool {
+	return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+		(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
+}
+
+func findSubstring(s, substr string) bool {
+	for i := 0; i <= len(s)-len(substr); i++ {
+		if s[i:i+len(substr)] == substr {
+			return true
+		}
+	}
+
+	return false
+}