From 84c1b8a4da20088c20d39427cd3aba2d0c8e5fad Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 21 Dec 2025 16:36:54 -0700 Subject: [PATCH] feat(tasks): add Eisenhower semantic builder API Replaces raw int eisenhower values with typed Eisenhower constants and adds intuitive builder methods (.Important(), .Urgent(), .NotImportant(), .NotUrgent()) that compute the correct quadrant at create/update time. Consumers can now use: client.NewTask("x").Important().Urgent().Create(ctx) // DoNow client.NewTask("x").Important().Create(ctx) // DoLater Reading tasks also benefits from helper methods: task.Eisenhower.IsImportant() task.Eisenhower.IsUrgent() Assisted-by: Claude Opus 4.5 via Crush --- eisenhower_test.go | 341 +++++++++++++++++++++++++++++++++++++++++++++ tasks.go | 122 ++++++++++++++-- types.go | 54 +++++++ 3 files changed, 503 insertions(+), 14 deletions(-) create mode 100644 eisenhower_test.go diff --git a/eisenhower_test.go b/eisenhower_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bf8f7816905a14a4b1d5bd1d9af0110b63c32849 --- /dev/null +++ b/eisenhower_test.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask_test + +import ( + "testing" + + lunatask "git.secluded.site/go-lunatask" +) + +// --- Eisenhower Type Constants --- + +func TestEisenhower_Constants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Eisenhower + want int + }{ + {"uncategorized", lunatask.EisenhowerUncategorized, 0}, + {"do_now", lunatask.EisenhowerDoNow, 1}, + {"delegate", lunatask.EisenhowerDelegate, 2}, + {"do_later", lunatask.EisenhowerDoLater, 3}, + {"eliminate", lunatask.EisenhowerEliminate, 4}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if int(tc.value) != tc.want { + t.Errorf("Eisenhower constant = %d, want %d", tc.value, tc.want) + } + }) + } +} + +// --- Eisenhower Helper Methods --- + +func TestEisenhower_IsUrgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Eisenhower + want bool + }{ + {"uncategorized", lunatask.EisenhowerUncategorized, false}, + {"do_now", lunatask.EisenhowerDoNow, true}, // urgent + important + {"delegate", lunatask.EisenhowerDelegate, true}, // urgent only + {"do_later", lunatask.EisenhowerDoLater, false}, // important only + {"eliminate", lunatask.EisenhowerEliminate, false}, // neither + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.value.IsUrgent(); got != tc.want { + t.Errorf("Eisenhower(%d).IsUrgent() = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +func TestEisenhower_IsImportant(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value lunatask.Eisenhower + want bool + }{ + {"uncategorized", lunatask.EisenhowerUncategorized, false}, + {"do_now", lunatask.EisenhowerDoNow, true}, // urgent + important + {"delegate", lunatask.EisenhowerDelegate, false}, // urgent only + {"do_later", lunatask.EisenhowerDoLater, true}, // important only + {"eliminate", lunatask.EisenhowerEliminate, false}, // neither + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.value.IsImportant(); got != tc.want { + t.Errorf("Eisenhower(%d).IsImportant() = %v, want %v", tc.value, got, tc.want) + } + }) + } +} + +// --- Eisenhower Helper Function --- + +func TestNewEisenhower(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + important bool + urgent bool + want lunatask.Eisenhower + }{ + {"both", true, true, lunatask.EisenhowerDoNow}, + {"urgent_only", false, true, lunatask.EisenhowerDelegate}, + {"important_only", true, false, lunatask.EisenhowerDoLater}, + {"neither", false, false, lunatask.EisenhowerEliminate}, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got := lunatask.NewEisenhower(testCase.important, testCase.urgent) + if got != testCase.want { + t.Errorf("NewEisenhower(%v, %v) = %d, want %d", testCase.important, testCase.urgent, got, testCase.want) + } + }) + } +} + +// --- TaskBuilder Semantic Methods --- + +func TestTaskBuilder_Important(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("Important task"). + Important(). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Important only = 3 (DoLater) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 3) +} + +func TestTaskBuilder_Urgent(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("Urgent task"). + Urgent(). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Urgent only = 2 (Delegate) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 2) +} + +func TestTaskBuilder_ImportantAndUrgent(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("Do now task"). + Important(). + Urgent(). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Both = 1 (DoNow) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 1) +} + +func TestTaskBuilder_UrgentAndImportant_ReverseOrder(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("Do now task"). + Urgent(). + Important(). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Both = 1 (DoNow), order shouldn't matter + assertBodyFieldFloat(t, capture.Body, "eisenhower", 1) +} + +func TestTaskBuilder_NeitherImportantNorUrgent(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + // Explicitly set neither - should result in Eliminate (4) + _, err := client.NewTask("Eliminate task"). + NotImportant(). + NotUrgent(). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Neither = 4 (Eliminate) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 4) +} + +// --- TaskUpdateBuilder Semantic Methods --- + +func TestTaskUpdateBuilder_Important(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + Important(). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Important only = 3 (DoLater) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 3) +} + +func TestTaskUpdateBuilder_Urgent(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + Urgent(). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Urgent only = 2 (Delegate) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 2) +} + +func TestTaskUpdateBuilder_ImportantAndUrgent(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + Important(). + Urgent(). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Both = 1 (DoNow) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 1) +} + +func TestTaskUpdateBuilder_NeitherImportantNorUrgent(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + NotImportant(). + NotUrgent(). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + // Neither = 4 (Eliminate) + assertBodyFieldFloat(t, capture.Body, "eisenhower", 4) +} + +// --- WithEisenhower still works with typed constant --- + +func TestTaskBuilder_WithEisenhowerTyped(t *testing.T) { + t.Parallel() + + server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTask("Typed eisenhower"). + WithEisenhower(lunatask.EisenhowerDoNow). + Create(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyFieldFloat(t, capture.Body, "eisenhower", 1) +} + +func TestTaskUpdateBuilder_WithEisenhowerTyped(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewTaskUpdate(taskID). + WithEisenhower(lunatask.EisenhowerDelegate). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyFieldFloat(t, capture.Body, "eisenhower", 2) +} diff --git a/tasks.go b/tasks.go index ca36ae625e3e53f302c943c194063249a0ac9675..a3f3ed382f8cdeb578b786dde1391d1e0a5ece34 100644 --- a/tasks.go +++ b/tasks.go @@ -23,7 +23,7 @@ type Task struct { Priority *int `json:"priority"` Progress *int `json:"progress"` Motivation *Motivation `json:"motivation"` - Eisenhower *int `json:"eisenhower"` + Eisenhower *Eisenhower `json:"eisenhower"` Sources []Source `json:"sources"` ScheduledOn *Date `json:"scheduled_on"` CompletedAt *time.Time `json:"completed_at"` @@ -41,7 +41,7 @@ type createTaskRequest struct { Motivation *Motivation `json:"motivation,omitempty"` Estimate *int `json:"estimate,omitempty"` Priority *int `json:"priority,omitempty"` - Eisenhower *int `json:"eisenhower,omitempty"` + Eisenhower *Eisenhower `json:"eisenhower,omitempty"` ScheduledOn *Date `json:"scheduled_on,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` Source *string `json:"source,omitempty"` @@ -58,7 +58,7 @@ type updateTaskRequest struct { Motivation *Motivation `json:"motivation,omitempty"` Estimate *int `json:"estimate,omitempty"` Priority *int `json:"priority,omitempty"` - Eisenhower *int `json:"eisenhower,omitempty"` + Eisenhower *Eisenhower `json:"eisenhower,omitempty"` ScheduledOn *Date `json:"scheduled_on,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` } @@ -113,8 +113,10 @@ func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) { // WithEstimate(30). // Create(ctx) type TaskBuilder struct { - client *Client - req createTaskRequest + client *Client + req createTaskRequest + important *bool + urgent *bool } // NewTask starts building a task with the given name. @@ -173,14 +175,52 @@ func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder { return b } -// WithEisenhower sets the Eisenhower matrix quadrant: -// 0=uncategorized, 1=urgent+important, 2=urgent, 3=important, 4=neither. -func (b *TaskBuilder) WithEisenhower(eisenhower int) *TaskBuilder { +// WithEisenhower sets the Eisenhower matrix quadrant directly. +// Prefer [TaskBuilder.Important] and [TaskBuilder.Urgent] for a more readable API. +func (b *TaskBuilder) WithEisenhower(eisenhower Eisenhower) *TaskBuilder { b.req.Eisenhower = &eisenhower return b } +// Important marks the task as important. Produces [EisenhowerDoLater] alone, +// or [EisenhowerDoNow] when combined with [TaskBuilder.Urgent]. +func (b *TaskBuilder) Important() *TaskBuilder { + t := true + b.important = &t + + return b +} + +// NotImportant marks the task as not important. Use with [TaskBuilder.NotUrgent] +// to explicitly set [EisenhowerEliminate], or with [TaskBuilder.Urgent] for [EisenhowerDelegate]. +// Calling neither Important nor NotImportant leaves Eisenhower unset. +func (b *TaskBuilder) NotImportant() *TaskBuilder { + f := false + b.important = &f + + return b +} + +// Urgent marks the task as urgent. Produces [EisenhowerDelegate] alone, +// or [EisenhowerDoNow] when combined with [TaskBuilder.Important]. +func (b *TaskBuilder) Urgent() *TaskBuilder { + t := true + b.urgent = &t + + return b +} + +// NotUrgent marks the task as not urgent. Use with [TaskBuilder.NotImportant] +// to explicitly set [EisenhowerEliminate], or with [TaskBuilder.Important] for [EisenhowerDoLater]. +// Calling neither Urgent nor NotUrgent leaves Eisenhower unset. +func (b *TaskBuilder) NotUrgent() *TaskBuilder { + f := false + b.urgent = &f + + return b +} + // ScheduledOn sets when the task should appear on your schedule. func (b *TaskBuilder) ScheduledOn(date Date) *TaskBuilder { b.req.ScheduledOn = &date @@ -207,6 +247,13 @@ func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder { // Create sends the task to Lunatask. Returns (nil, nil) if a not-completed // task already exists in the same area with matching source/source_id. func (b *TaskBuilder) Create(ctx context.Context) (*Task, error) { + if b.important != nil || b.urgent != nil { + important := b.important != nil && *b.important + urgent := b.urgent != nil && *b.urgent + e := NewEisenhower(important, urgent) + b.req.Eisenhower = &e + } + return create(ctx, b.client, "/tasks", b.req, func(r taskResponse) Task { return r.Task }) } @@ -218,9 +265,11 @@ func (b *TaskBuilder) Create(ctx context.Context) (*Task, error) { // CompletedAt(time.Now()). // Update(ctx) type TaskUpdateBuilder struct { - client *Client - taskID string - req updateTaskRequest + client *Client + taskID string + req updateTaskRequest + important *bool + urgent *bool } // NewTaskUpdate starts building a task update for the given task ID. @@ -286,14 +335,52 @@ func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder { return b } -// WithEisenhower sets the Eisenhower matrix quadrant: -// 0=uncategorized, 1=urgent+important, 2=urgent, 3=important, 4=neither. -func (b *TaskUpdateBuilder) WithEisenhower(eisenhower int) *TaskUpdateBuilder { +// WithEisenhower sets the Eisenhower matrix quadrant directly. +// Prefer [TaskUpdateBuilder.Important] and [TaskUpdateBuilder.Urgent] for a more readable API. +func (b *TaskUpdateBuilder) WithEisenhower(eisenhower Eisenhower) *TaskUpdateBuilder { b.req.Eisenhower = &eisenhower return b } +// Important marks the task as important. Produces [EisenhowerDoLater] alone, +// or [EisenhowerDoNow] when combined with [TaskUpdateBuilder.Urgent]. +func (b *TaskUpdateBuilder) Important() *TaskUpdateBuilder { + t := true + b.important = &t + + return b +} + +// NotImportant marks the task as not important. Use with [TaskUpdateBuilder.NotUrgent] +// to explicitly set [EisenhowerEliminate], or with [TaskUpdateBuilder.Urgent] for [EisenhowerDelegate]. +// Calling neither Important nor NotImportant leaves Eisenhower unset. +func (b *TaskUpdateBuilder) NotImportant() *TaskUpdateBuilder { + f := false + b.important = &f + + return b +} + +// Urgent marks the task as urgent. Produces [EisenhowerDelegate] alone, +// or [EisenhowerDoNow] when combined with [TaskUpdateBuilder.Important]. +func (b *TaskUpdateBuilder) Urgent() *TaskUpdateBuilder { + t := true + b.urgent = &t + + return b +} + +// NotUrgent marks the task as not urgent. Use with [TaskUpdateBuilder.NotImportant] +// to explicitly set [EisenhowerEliminate], or with [TaskUpdateBuilder.Important] for [EisenhowerDoLater]. +// Calling neither Urgent nor NotUrgent leaves Eisenhower unset. +func (b *TaskUpdateBuilder) NotUrgent() *TaskUpdateBuilder { + f := false + b.urgent = &f + + return b +} + // ScheduledOn sets when the task should appear on your schedule. func (b *TaskUpdateBuilder) ScheduledOn(date Date) *TaskUpdateBuilder { b.req.ScheduledOn = &date @@ -310,5 +397,12 @@ func (b *TaskUpdateBuilder) CompletedAt(t time.Time) *TaskUpdateBuilder { // Update sends the changes to Lunatask. func (b *TaskUpdateBuilder) Update(ctx context.Context) (*Task, error) { + if b.important != nil || b.urgent != nil { + important := b.important != nil && *b.important + urgent := b.urgent != nil && *b.urgent + e := NewEisenhower(important, urgent) + b.req.Eisenhower = &e + } + return update(ctx, b.client, "/tasks", b.taskID, "task", b.req, func(r taskResponse) Task { return r.Task }) } diff --git a/types.go b/types.go index 6668e607209de0e65439630e6df85d7a3290843b..8a509a703ca2f801f9ac6cdb6b4896b499d13d73 100644 --- a/types.go +++ b/types.go @@ -127,3 +127,57 @@ const ( MotivationShould Motivation = "should" MotivationWant Motivation = "want" ) + +// Eisenhower represents a quadrant in the Eisenhower priority matrix, which +// categorizes tasks by whether they are urgent (time-sensitive) and/or +// important (valuable toward your goals). +// +// The four quadrants guide what action to take: +// +// | Urgent | Not Urgent +// ------------------|---------------|------------- +// Important | Do Now (1) | Do Later (3) +// Not Important | Delegate (2) | Eliminate (4) +// +// Use [NewEisenhower] to compute the quadrant from boolean flags, or the +// builder methods [TaskBuilder.Important] and [TaskBuilder.Urgent] for a +// fluent API. +type Eisenhower int + +// Eisenhower matrix quadrants. See [Eisenhower] for the full matrix. +const ( + EisenhowerUncategorized Eisenhower = 0 // not yet categorized + EisenhowerDoNow Eisenhower = 1 // urgent + important + EisenhowerDelegate Eisenhower = 2 // urgent, not important + EisenhowerDoLater Eisenhower = 3 // important, not urgent + EisenhowerEliminate Eisenhower = 4 // neither urgent nor important +) + +// NewEisenhower returns the quadrant for the given flags: +// +// NewEisenhower(true, true) // DoNow +// NewEisenhower(true, false) // DoLater +// NewEisenhower(false, true) // Delegate +// NewEisenhower(false, false) // Eliminate +func NewEisenhower(important, urgent bool) Eisenhower { + switch { + case important && urgent: + return EisenhowerDoNow + case urgent: + return EisenhowerDelegate + case important: + return EisenhowerDoLater + default: + return EisenhowerEliminate + } +} + +// IsUrgent reports whether e is in an urgent quadrant ([EisenhowerDoNow] or [EisenhowerDelegate]). +func (e Eisenhower) IsUrgent() bool { + return e == EisenhowerDoNow || e == EisenhowerDelegate +} + +// IsImportant reports whether e is in an important quadrant ([EisenhowerDoNow] or [EisenhowerDoLater]). +func (e Eisenhower) IsImportant() bool { + return e == EisenhowerDoNow || e == EisenhowerDoLater +}