feat(lunatask): add People and Timeline APIs

Amolith created

People API: ListPeople, GetPerson, CreatePerson, DeletePerson
PersonTimelineNote API: CreatePersonTimelineNote

Assisted-by: Claude Sonnet 4 via Crush

Change summary

lunatask/people.go   | 111 ++++++++++++++++++++++++++++++++++++++++++++++
lunatask/timeline.go |  45 ++++++++++++++++++
2 files changed, 156 insertions(+)

Detailed changes

lunatask/people.go 🔗

@@ -3,3 +3,114 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
 package lunatask
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+// Person represents a person/relationship returned from the Lunatask API.
+// Note: first_name and last_name are E2EE and not returned by the API.
+type Person struct {
+	ID                   string    `json:"id"`
+	RelationshipStrength *string   `json:"relationship_strength"`
+	Sources              []Source  `json:"sources"`
+	CreatedAt            time.Time `json:"created_at"`
+	UpdatedAt            time.Time `json:"updated_at"`
+}
+
+// CreatePersonRequest represents the request to create a person in Lunatask.
+type CreatePersonRequest struct {
+	FirstName            *string `json:"first_name,omitempty"`
+	LastName             *string `json:"last_name,omitempty"`
+	RelationshipStrength *string `json:"relationship_strength,omitempty"`
+	Source               *string `json:"source,omitempty"`
+	SourceID             *string `json:"source_id,omitempty"`
+}
+
+// personResponse represents a single person response from the API.
+type personResponse struct {
+	Person Person `json:"person"`
+}
+
+// peopleResponse represents a list of people response from the API.
+type peopleResponse struct {
+	People []Person `json:"people"`
+}
+
+// ListPeopleOptions contains optional filters for listing people.
+type ListPeopleOptions struct {
+	Source   *string
+	SourceID *string
+}
+
+// ListPeople retrieves all people, optionally filtered by source and/or source_id.
+func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) {
+	path := "/people"
+
+	if opts != nil {
+		params := url.Values{}
+		if opts.Source != nil && *opts.Source != "" {
+			params.Set("source", *opts.Source)
+		}
+		if opts.SourceID != nil && *opts.SourceID != "" {
+			params.Set("source_id", *opts.SourceID)
+		}
+		if len(params) > 0 {
+			path = fmt.Sprintf("%s?%s", path, params.Encode())
+		}
+	}
+
+	resp, _, err := doJSON[peopleResponse](c, ctx, http.MethodGet, path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.People, nil
+}
+
+// GetPerson retrieves a specific person by ID.
+func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) {
+	if personID == "" {
+		return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest)
+	}
+
+	resp, _, err := doJSON[personResponse](c, ctx, http.MethodGet, "/people/"+personID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp.Person, nil
+}
+
+// CreatePerson creates a new person/relationship in Lunatask.
+// Returns nil, nil if a matching person already exists with the same
+// source/source_id (HTTP 204).
+func (c *Client) CreatePerson(ctx context.Context, person *CreatePersonRequest) (*Person, error) {
+	resp, noContent, err := doJSON[personResponse](c, ctx, http.MethodPost, "/people", person)
+	if err != nil {
+		return nil, err
+	}
+	if noContent {
+		return nil, nil
+	}
+
+	return &resp.Person, nil
+}
+
+// DeletePerson deletes a person in Lunatask.
+func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
+	if personID == "" {
+		return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest)
+	}
+
+	resp, _, err := doJSON[personResponse](c, ctx, http.MethodDelete, "/people/"+personID, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp.Person, nil
+}

lunatask/timeline.go 🔗

@@ -3,3 +3,48 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
 package lunatask
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+// PersonTimelineNote represents a note on a person's memory timeline.
+// Note: content is E2EE and not returned by the API.
+type PersonTimelineNote struct {
+	ID        string    `json:"id"`
+	DateOn    *Date     `json:"date_on"`
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// CreatePersonTimelineNoteRequest represents the request to create a timeline note.
+type CreatePersonTimelineNoteRequest struct {
+	// PersonID is the ID of the person to add the note to (required).
+	PersonID string `json:"person_id"`
+	// DateOn is the ISO-8601 date for the note (optional, defaults to today).
+	DateOn *Date `json:"date_on,omitempty"`
+	// Content is the Markdown content of the note (optional but impractical if empty).
+	Content *string `json:"content,omitempty"`
+}
+
+// personTimelineNoteResponse represents a single timeline note response from the API.
+type personTimelineNoteResponse struct {
+	PersonTimelineNote PersonTimelineNote `json:"person_timeline_note"`
+}
+
+// CreatePersonTimelineNote creates a new note on a person's memory timeline.
+func (c *Client) CreatePersonTimelineNote(ctx context.Context, note *CreatePersonTimelineNoteRequest) (*PersonTimelineNote, error) {
+	if note.PersonID == "" {
+		return nil, fmt.Errorf("%w: person_id is required", ErrBadRequest)
+	}
+
+	resp, _, err := doJSON[personTimelineNoteResponse](c, ctx, http.MethodPost, "/person_timeline_notes", note)
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp.PersonTimelineNote, nil
+}