feat(tasks): add Eisenhower semantic builder API

Amolith created

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

Change summary

eisenhower_test.go | 341 ++++++++++++++++++++++++++++++++++++++++++++++++
tasks.go           | 122 +++++++++++++++-
types.go           |  54 +++++++
3 files changed, 503 insertions(+), 14 deletions(-)

Detailed changes

eisenhower_test.go 🔗

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

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 })
 }

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
+}