refactor(lunatask): idiomatic client API

Amolith created

- doRequest returns status code; 204 is valid success
- Add generic doJSON[T] to reduce boilerplate
- Ping now returns *PingResponse with message
- Add Date type for date-only fields (YYYY-MM-DD)
- Task uses time.Time/Date instead of strings
- Add WithHTTPClient/WithBaseURL functional options
- Make Client fields private for encapsulation

Assisted-by: Claude Sonnet 4 via Crush

Change summary

go.mod             |   9 --
go.sum             |  20 ----
lunatask/client.go | 119 ++++++++++++++++++++-----
lunatask/habits.go | 104 +++-------------------
lunatask/tasks.go  | 215 +++++++++++++----------------------------------
lunatask/types.go  |  80 +++++++++++++++++
tools/tasks.go     |  19 ++--
7 files changed, 258 insertions(+), 308 deletions(-)

Detailed changes

go.mod 🔗

@@ -8,22 +8,13 @@ go 1.24.2
 
 require (
 	github.com/BurntSushi/toml v1.5.0
-	github.com/go-playground/validator/v10 v10.26.0
 	github.com/ijt/go-anytime v1.9.2
 	github.com/mark3labs/mcp-go v0.23.1
 )
 
 require (
-	github.com/gabriel-vasile/mimetype v1.4.9 // indirect
-	github.com/go-playground/locales v0.14.1 // indirect
-	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect
-	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/spf13/cast v1.7.1 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
-	golang.org/x/crypto v0.37.0 // indirect
-	golang.org/x/net v0.39.0 // indirect
-	golang.org/x/sys v0.32.0 // indirect
-	golang.org/x/text v0.24.0 // indirect
 )

go.sum 🔗

@@ -4,16 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
-github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
-github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
-github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
-github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
-github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
-github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
-github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -26,8 +16,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
-github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/mark3labs/mcp-go v0.23.1 h1:RzTzZ5kJ+HxwnutKA4rll8N/pKV6Wh5dhCmiJUu5S9I=
 github.com/mark3labs/mcp-go v0.23.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -42,13 +30,5 @@ github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0
 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
-golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
-golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
-golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
-golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
-golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
-golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
-golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

lunatask/client.go 🔗

@@ -5,7 +5,9 @@
 package lunatask
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -76,52 +78,119 @@ func newAPIError(statusCode int, body string) *APIError {
 	return &APIError{StatusCode: statusCode, Body: body, Err: err}
 }
 
-// Client handles communication with the Lunatask API
+// DefaultBaseURL is the default Lunatask API base URL.
+const DefaultBaseURL = "https://api.lunatask.app/v1"
+
+// Client handles communication with the Lunatask API.
 type Client struct {
-	AccessToken string
-	BaseURL     string
-	HTTPClient  *http.Client
+	accessToken string
+	baseURL     string
+	httpClient  *http.Client
+}
+
+// Option configures a Client.
+type Option func(*Client)
+
+// WithHTTPClient sets a custom HTTP client.
+func WithHTTPClient(client *http.Client) Option {
+	return func(c *Client) {
+		c.httpClient = client
+	}
+}
+
+// WithBaseURL sets a custom base URL (useful for testing).
+func WithBaseURL(url string) Option {
+	return func(c *Client) {
+		c.baseURL = url
+	}
 }
 
-// NewClient creates a new Lunatask API client
-func NewClient(accessToken string) *Client {
-	return &Client{
-		AccessToken: accessToken,
-		BaseURL:     "https://api.lunatask.app/v1",
-		HTTPClient:  &http.Client{},
+// NewClient creates a new Lunatask API client.
+func NewClient(accessToken string, opts ...Option) *Client {
+	c := &Client{
+		accessToken: accessToken,
+		baseURL:     DefaultBaseURL,
+		httpClient:  &http.Client{},
+	}
+	for _, opt := range opts {
+		opt(c)
 	}
+	return c
 }
 
-// doRequest performs an HTTP request and handles common response processing
-func (c *Client) doRequest(req *http.Request) ([]byte, error) {
-	req.Header.Set("Authorization", "bearer "+c.AccessToken)
+// doRequest performs an HTTP request and handles common response processing.
+// Returns the response body and status code. 204 No Content returns nil body.
+func (c *Client) doRequest(req *http.Request) ([]byte, int, error) {
+	req.Header.Set("Authorization", "bearer "+c.accessToken)
 
-	resp, err := c.HTTPClient.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
+		return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err)
 	}
 	defer func() { _ = resp.Body.Close() }()
 
 	body, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
+		return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err)
 	}
 
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		return nil, newAPIError(resp.StatusCode, string(body))
+		return nil, resp.StatusCode, newAPIError(resp.StatusCode, string(body))
+	}
+
+	// 204 No Content is a valid success with no body
+	if resp.StatusCode == http.StatusNoContent {
+		return nil, resp.StatusCode, nil
 	}
 
-	return body, nil
+	return body, resp.StatusCode, nil
 }
 
-// Ping verifies the access token is valid by calling the /ping endpoint.
-// Returns nil on success, or an error (typically ErrUnauthorized) on failure.
-func (c *Client) Ping(ctx context.Context) error {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/ping", nil)
+// doJSON performs an HTTP request with optional JSON body and unmarshals the response.
+// Returns (nil, true, nil) for 204 No Content responses.
+func doJSON[T any](c *Client, ctx context.Context, method, path string, body any) (*T, bool, error) {
+	var reqBody io.Reader
+	if body != nil {
+		data, err := json.Marshal(body)
+		if err != nil {
+			return nil, false, fmt.Errorf("failed to marshal request body: %w", err)
+		}
+		reqBody = bytes.NewReader(data)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
 	if err != nil {
-		return fmt.Errorf("failed to create HTTP request: %w", err)
+		return nil, false, fmt.Errorf("failed to create HTTP request: %w", err)
+	}
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+
+	respBody, statusCode, err := c.doRequest(req)
+	if err != nil {
+		return nil, false, err
 	}
 
-	_, err = c.doRequest(req)
-	return err
+	if statusCode == http.StatusNoContent {
+		return nil, true, nil
+	}
+
+	var result T
+	if err := json.Unmarshal(respBody, &result); err != nil {
+		return nil, false, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &result, false, nil
+}
+
+// PingResponse represents the response from the /ping endpoint.
+type PingResponse struct {
+	Message string `json:"message"`
+}
+
+// Ping verifies the access token is valid by calling the /ping endpoint.
+// Returns the ping response on success, or an error (typically ErrUnauthorized) on failure.
+func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
+	resp, _, err := doJSON[PingResponse](c, ctx, http.MethodGet, "/ping", nil)
+	return resp, err
 }

lunatask/habits.go 🔗

@@ -5,115 +5,43 @@
 package lunatask
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
-	"io"
 	"net/http"
-	"strings"
-
-	"github.com/go-playground/validator/v10"
+	"time"
 )
 
-// TrackHabitActivityRequest represents the request to track a habit activity
+// TrackHabitActivityRequest represents the request to track a habit activity.
 type TrackHabitActivityRequest struct {
-	PerformedOn string `json:"performed_on" validate:"required,datetime=2006-01-02T15:04:05Z07:00"`
+	// PerformedOn is the ISO-8601 date when the activity was performed (e.g., "2024-08-26").
+	PerformedOn string `json:"performed_on"`
 }
 
-// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity
+// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity.
 type TrackHabitActivityResponse struct {
 	Status  string `json:"status"`
 	Message string `json:"message,omitempty"`
 }
 
-// ValidateTrackHabitActivity validates the track habit activity request
-func ValidateTrackHabitActivity(request *TrackHabitActivityRequest) error {
-	validate := validator.New()
-	if err := validate.Struct(request); err != nil {
-		var invalidValidationError *validator.InvalidValidationError
-		if errors.As(err, &invalidValidationError) {
-			return fmt.Errorf("invalid validation error: %w", err)
-		}
-
-		var validationErrs validator.ValidationErrors
-		if errors.As(err, &validationErrs) {
-			var msgBuilder strings.Builder
-			msgBuilder.WriteString("habit activity validation failed:")
-			for _, e := range validationErrs {
-				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
-			}
-			return errors.New(msgBuilder.String())
-		}
-		return fmt.Errorf("validation error: %w", err)
-	}
-	return nil
-}
-
-// TrackHabitActivity tracks an activity for a habit in Lunatask
+// TrackHabitActivity tracks an activity for a habit in Lunatask.
+// The PerformedOn field must be an ISO-8601 date (e.g., "2024-08-26").
 func (c *Client) TrackHabitActivity(ctx context.Context, habitID string, request *TrackHabitActivityRequest) (*TrackHabitActivityResponse, error) {
 	if habitID == "" {
-		return nil, errors.New("habit ID cannot be empty")
+		return nil, fmt.Errorf("%w: habit ID cannot be empty", ErrBadRequest)
 	}
-
-	// Validate the request
-	if err := ValidateTrackHabitActivity(request); err != nil {
-		return nil, err
+	if request.PerformedOn == "" {
+		return nil, fmt.Errorf("%w: performed_on is required", ErrBadRequest)
 	}
 
-	// Marshal the request to JSON
-	payloadBytes, err := json.Marshal(request)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal payload: %w", err)
+	// Validate date format (YYYY-MM-DD)
+	if _, err := time.Parse("2006-01-02", request.PerformedOn); err != nil {
+		return nil, fmt.Errorf("%w: performed_on must be a valid ISO-8601 date (YYYY-MM-DD)", ErrBadRequest)
 	}
 
-	// Create the request
-	req, err := http.NewRequestWithContext(
-		ctx,
-		"POST",
-		fmt.Sprintf("%s/habits/%s/track", c.BaseURL, habitID),
-		bytes.NewBuffer(payloadBytes),
-	)
+	resp, _, err := doJSON[TrackHabitActivityResponse](c, ctx, http.MethodPost, "/habits/"+habitID+"/track", request)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
-	}
-
-	// Set headers
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", "bearer "+c.AccessToken)
-
-	// Send the request
-	resp, err := c.HTTPClient.Do(req)
-	if err != nil {
-		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
-	}
-	defer func() {
-		if resp.Body != nil {
-			if err := resp.Body.Close(); err != nil {
-				// We're in a defer, so we can only log the error
-				fmt.Printf("Error closing response body: %v\n", err)
-			}
-		}
-	}()
-
-	// Handle error status codes
-	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		respBody, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
-	}
-
-	// Read and parse the response
-	respBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
-	}
-
-	var response TrackHabitActivityResponse
-	err = json.Unmarshal(respBody, &response)
-	if err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
+		return nil, err
 	}
 
-	return &response, nil
+	return resp, nil
 }

lunatask/tasks.go 🔗

@@ -5,82 +5,74 @@
 package lunatask
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
-	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
+	"time"
 )
 
-// Source represents a task source like GitHub or other integrations
-type Source struct {
-	Source   string `json:"source"`
-	SourceID string `json:"source_id"`
-}
-
 // Task represents a task returned from the Lunatask API
 type Task struct {
-	ID             string   `json:"id"`
-	AreaID         *string  `json:"area_id"`
-	GoalID         *string  `json:"goal_id"`
-	Name           *string  `json:"name"`
-	Note           *string  `json:"note"`
-	Status         *string  `json:"status"`
-	PreviousStatus *string  `json:"previous_status"`
-	Estimate       *int     `json:"estimate"`
-	Priority       *int     `json:"priority"`
-	Progress       *int     `json:"progress"`
-	Motivation     *string  `json:"motivation"`
-	Eisenhower     *int     `json:"eisenhower"`
-	Sources        []Source `json:"sources"`
-	ScheduledOn    *string  `json:"scheduled_on"`
-	CompletedAt    *string  `json:"completed_at"`
-	CreatedAt      string   `json:"created_at"`
-	UpdatedAt      string   `json:"updated_at"`
+	ID             string     `json:"id"`
+	AreaID         *string    `json:"area_id"`
+	GoalID         *string    `json:"goal_id"`
+	Name           *string    `json:"name"`
+	Note           *string    `json:"note"`
+	Status         *string    `json:"status"`
+	PreviousStatus *string    `json:"previous_status"`
+	Estimate       *int       `json:"estimate"`
+	Priority       *int       `json:"priority"`
+	Progress       *int       `json:"progress"`
+	Motivation     *string    `json:"motivation"`
+	Eisenhower     *int       `json:"eisenhower"`
+	Sources        []Source   `json:"sources"`
+	ScheduledOn    *Date      `json:"scheduled_on"`
+	CompletedAt    *time.Time `json:"completed_at"`
+	CreatedAt      time.Time  `json:"created_at"`
+	UpdatedAt      time.Time  `json:"updated_at"`
 }
 
 // CreateTaskRequest represents the request to create a task in Lunatask
 type CreateTaskRequest struct {
-	Name        string  `json:"name"`
-	AreaID      *string `json:"area_id,omitempty"`
-	GoalID      *string `json:"goal_id,omitempty"`
-	Note        *string `json:"note,omitempty"`
-	Status      *string `json:"status,omitempty"`
-	Motivation  *string `json:"motivation,omitempty"`
-	Estimate    *int    `json:"estimate,omitempty"`
-	Priority    *int    `json:"priority,omitempty"`
-	Eisenhower  *int    `json:"eisenhower,omitempty"`
-	ScheduledOn *string `json:"scheduled_on,omitempty"`
-	CompletedAt *string `json:"completed_at,omitempty"`
-	Source      *string `json:"source,omitempty"`
-	SourceID    *string `json:"source_id,omitempty"`
+	Name        string     `json:"name"`
+	AreaID      *string    `json:"area_id,omitempty"`
+	GoalID      *string    `json:"goal_id,omitempty"`
+	Note        *string    `json:"note,omitempty"`
+	Status      *string    `json:"status,omitempty"`
+	Motivation  *string    `json:"motivation,omitempty"`
+	Estimate    *int       `json:"estimate,omitempty"`
+	Priority    *int       `json:"priority,omitempty"`
+	Eisenhower  *int       `json:"eisenhower,omitempty"`
+	ScheduledOn *Date      `json:"scheduled_on,omitempty"`
+	CompletedAt *time.Time `json:"completed_at,omitempty"`
+	Source      *string    `json:"source,omitempty"`
+	SourceID    *string    `json:"source_id,omitempty"`
 }
 
 // UpdateTaskRequest represents the request to update a task in Lunatask.
 // All fields are optional; only provided fields will be updated.
 type UpdateTaskRequest struct {
-	Name        *string `json:"name,omitempty"`
-	AreaID      *string `json:"area_id,omitempty"`
-	GoalID      *string `json:"goal_id,omitempty"`
-	Note        *string `json:"note,omitempty"`
-	Status      *string `json:"status,omitempty"`
-	Motivation  *string `json:"motivation,omitempty"`
-	Estimate    *int    `json:"estimate,omitempty"`
-	Priority    *int    `json:"priority,omitempty"`
-	Eisenhower  *int    `json:"eisenhower,omitempty"`
-	ScheduledOn *string `json:"scheduled_on,omitempty"`
-	CompletedAt *string `json:"completed_at,omitempty"`
+	Name        *string    `json:"name,omitempty"`
+	AreaID      *string    `json:"area_id,omitempty"`
+	GoalID      *string    `json:"goal_id,omitempty"`
+	Note        *string    `json:"note,omitempty"`
+	Status      *string    `json:"status,omitempty"`
+	Motivation  *string    `json:"motivation,omitempty"`
+	Estimate    *int       `json:"estimate,omitempty"`
+	Priority    *int       `json:"priority,omitempty"`
+	Eisenhower  *int       `json:"eisenhower,omitempty"`
+	ScheduledOn *Date      `json:"scheduled_on,omitempty"`
+	CompletedAt *time.Time `json:"completed_at,omitempty"`
 }
 
-// TaskResponse represents a single task response from the API
-type TaskResponse struct {
+// taskResponse represents a single task response from the API
+type taskResponse struct {
 	Task Task `json:"task"`
 }
 
-// TasksResponse represents a list of tasks response from the API
-type TasksResponse struct {
+// tasksResponse represents a list of tasks response from the API
+type tasksResponse struct {
 	Tasks []Task `json:"tasks"`
 }
 
@@ -92,7 +84,7 @@ type ListTasksOptions struct {
 
 // ListTasks retrieves all tasks, optionally filtered by source and/or source_id
 func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, error) {
-	u := fmt.Sprintf("%s/tasks", c.BaseURL)
+	path := "/tasks"
 
 	if opts != nil {
 		params := url.Values{}
@@ -103,26 +95,16 @@ func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task,
 			params.Set("source_id", *opts.SourceID)
 		}
 		if len(params) > 0 {
-			u = fmt.Sprintf("%s?%s", u, params.Encode())
+			path = fmt.Sprintf("%s?%s", path, params.Encode())
 		}
 	}
 
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
-	}
-
-	body, err := c.doRequest(req)
+	resp, _, err := doJSON[tasksResponse](c, ctx, http.MethodGet, path, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	var response TasksResponse
-	if err := json.Unmarshal(body, &response); err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
-	return response.Tasks, nil
+	return resp.Tasks, nil
 }
 
 // GetTask retrieves a specific task by ID
@@ -131,27 +113,12 @@ func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) {
 		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
 	}
 
-	req, err := http.NewRequestWithContext(
-		ctx,
-		http.MethodGet,
-		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
-		nil,
-	)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
-	}
-
-	body, err := c.doRequest(req)
+	resp, _, err := doJSON[taskResponse](c, ctx, http.MethodGet, "/tasks/"+taskID, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	var response TaskResponse
-	if err := json.Unmarshal(body, &response); err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
-	return &response.Task, nil
+	return &resp.Task, nil
 }
 
 // CreateTask creates a new task in Lunatask.
@@ -161,43 +128,15 @@ func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Task
 		return nil, fmt.Errorf("%w: name is required", ErrBadRequest)
 	}
 
-	payloadBytes, err := json.Marshal(task)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal payload: %w", err)
-	}
-
-	req, err := http.NewRequestWithContext(
-		ctx,
-		http.MethodPost,
-		fmt.Sprintf("%s/tasks", c.BaseURL),
-		bytes.NewBuffer(payloadBytes),
-	)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
-	}
-	req.Header.Set("Content-Type", "application/json")
-
-	body, err := c.doRequest(req)
+	resp, noContent, err := doJSON[taskResponse](c, ctx, http.MethodPost, "/tasks", task)
 	if err != nil {
-		// Check for 204 No Content (task already exists)
-		var apiErr *APIError
-		if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNoContent {
-			return nil, nil
-		}
 		return nil, err
 	}
-
-	// Handle empty body (204 case that slipped through)
-	if len(body) == 0 {
+	if noContent {
 		return nil, nil
 	}
 
-	var response TaskResponse
-	if err := json.Unmarshal(body, &response); err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
-	return &response.Task, nil
+	return &resp.Task, nil
 }
 
 // UpdateTask updates an existing task in Lunatask
@@ -206,33 +145,12 @@ func (c *Client) UpdateTask(ctx context.Context, taskID string, task *UpdateTask
 		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
 	}
 
-	payloadBytes, err := json.Marshal(task)
-	if err != nil {
-		return nil, fmt.Errorf("failed to marshal payload: %w", err)
-	}
-
-	req, err := http.NewRequestWithContext(
-		ctx,
-		http.MethodPut,
-		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
-		bytes.NewBuffer(payloadBytes),
-	)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
-	}
-	req.Header.Set("Content-Type", "application/json")
-
-	body, err := c.doRequest(req)
+	resp, _, err := doJSON[taskResponse](c, ctx, http.MethodPut, "/tasks/"+taskID, task)
 	if err != nil {
 		return nil, err
 	}
 
-	var response TaskResponse
-	if err := json.Unmarshal(body, &response); err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
-	return &response.Task, nil
+	return &resp.Task, nil
 }
 
 // DeleteTask deletes a task in Lunatask
@@ -241,25 +159,10 @@ func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) {
 		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
 	}
 
-	req, err := http.NewRequestWithContext(
-		ctx,
-		http.MethodDelete,
-		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
-		nil,
-	)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
-	}
-
-	body, err := c.doRequest(req)
+	resp, _, err := doJSON[taskResponse](c, ctx, http.MethodDelete, "/tasks/"+taskID, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	var response TaskResponse
-	if err := json.Unmarshal(body, &response); err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
-	}
-
-	return &response.Task, nil
+	return &resp.Task, nil
 }

lunatask/types.go 🔗

@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package lunatask
+
+import (
+	"encoding/json"
+	"time"
+)
+
+// Source represents an external source integration (e.g., GitHub, Todoist).
+// Used across multiple entity types for tracking where items originated.
+type Source struct {
+	Source   string `json:"source"`
+	SourceID string `json:"source_id"`
+}
+
+// Date represents a date-only value (YYYY-MM-DD) with proper JSON marshaling.
+// Used for fields like scheduled_on that don't include time components.
+type Date struct {
+	time.Time
+}
+
+const dateFormat = "2006-01-02"
+
+// MarshalJSON implements json.Marshaler for Date.
+func (d Date) MarshalJSON() ([]byte, error) {
+	if d.IsZero() {
+		return []byte("null"), nil
+	}
+	return json.Marshal(d.Format(dateFormat))
+}
+
+// UnmarshalJSON implements json.Unmarshaler for Date.
+func (d *Date) UnmarshalJSON(data []byte) error {
+	if string(data) == "null" {
+		return nil
+	}
+
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+
+	t, err := time.Parse(dateFormat, s)
+	if err != nil {
+		return err
+	}
+
+	d.Time = t
+	return nil
+}
+
+// String returns the date in YYYY-MM-DD format.
+func (d Date) String() string {
+	if d.IsZero() {
+		return ""
+	}
+	return d.Format(dateFormat)
+}
+
+// NewDate creates a Date from a time.Time, discarding time components.
+func NewDate(t time.Time) Date {
+	return Date{time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)}
+}
+
+// ParseDate parses a date string in YYYY-MM-DD format.
+func ParseDate(s string) (Date, error) {
+	t, err := time.Parse(dateFormat, s)
+	if err != nil {
+		return Date{}, err
+	}
+	return Date{t}, nil
+}
+
+// Today returns the current date.
+func Today() Date {
+	return NewDate(time.Now())
+}

tools/tasks.go 🔗

@@ -8,7 +8,6 @@ import (
 	"context"
 	"fmt"
 	"strings"
-	"time"
 
 	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 	"github.com/mark3labs/mcp-go/mcp"
@@ -155,10 +154,11 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
 			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
 		}
 		if scheduledOnStr != "" {
-			if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
-				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_timestamp tool first.", scheduledOnStr))
+			date, err := lunatask.ParseDate(scheduledOnStr)
+			if err != nil {
+				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
 			}
-			task.ScheduledOn = &scheduledOnStr
+			task.ScheduledOn = &date
 		}
 	}
 
@@ -368,13 +368,12 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
 		if !ok && scheduledOnArg != nil {
 			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
 		}
-		if ok {
-			if scheduledOnStr != "" {
-				if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
-					return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", scheduledOnStr))
-				}
+		if ok && scheduledOnStr != "" {
+			date, err := lunatask.ParseDate(scheduledOnStr)
+			if err != nil {
+				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
 			}
-			updatePayload.ScheduledOn = &scheduledOnStr
+			updatePayload.ScheduledOn = &date
 		}
 	}