From 4527645280f99aa230eaa9a01e6b06179dec3c8c Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 24 Dec 2025 08:54:57 -0700 Subject: [PATCH] feat(workflow): add Workflow type and helpers 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 --- workflow.go | 129 +++++++++++++++++++ workflow_test.go | 326 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 workflow.go create mode 100644 workflow_test.go diff --git a/workflow.go b/workflow.go new file mode 100644 index 0000000000000000000000000000000000000000..a657e6742241f07d85d39c7d768350a16566ca43 --- /dev/null +++ b/workflow.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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, + } +} diff --git a/workflow_test.go b/workflow_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5e5400f849b028e4177ca679dfb41a616cca59fe --- /dev/null +++ b/workflow_test.go @@ -0,0 +1,326 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}