From 3aa2a284dea33410d8fdb174f9384d7339252662 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 14 Jan 2026 13:39:37 +0100 Subject: [PATCH] Implement Task operations Add tasks.go with TaskService for full task CRUD: - client.Tasks(checklistID) returns TaskService - List(ctx) - get all tasks in checklist - Get(ctx, taskID) - get single task with parent hierarchy - Create(ctx, builder) - create task using TaskBuilder - Update(ctx, taskID, req) - update task properties - Delete(ctx, taskID) - permanently delete task - Close(ctx, taskID) - mark task as completed - Reopen(ctx, taskID) - reopen closed/invalidated task - Invalidate(ctx, taskID) - mark task as invalidated (strikethrough) TaskBuilder fluent interface for task creation: - NewTask(content) - create builder - WithParent(id) - set parent for subtask - WithPosition(pos) - set position in list - WithDueDate(due) - set due date using DueDate type - WithPriority(level) - set priority (0=normal, 1=highest, 2=high) - WithTags(tags...) - set tags Automatic DueDate parsing from DueDateRaw (ISO 8601 format). Closes checkvist-api-rl9 --- .beads/issues.jsonl | 2 +- tasks.go | 220 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c9f46e6..a132e20 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -20,7 +20,7 @@ {"id":"checkvist-api-lpn","title":"Implement authentication with auto token renewal","description":"Add to client.go:\n- Authenticate(ctx context.Context) error - explicit login\n- refreshToken(ctx context.Context) error - token renewal\n- ensureAuthenticated(ctx context.Context) error - auto-auth before requests\n- CurrentUser(ctx context.Context) (*User, error) - get logged in user\n- Token management: store token and expiry, auto-refresh before expiry\n- Thread-safe token access using mutex\n- Support for optional 2FA token\nAPI endpoints:\n- POST /auth/login.json?version=2 (login)\n- POST /auth/refresh_token.json?version=2 (refresh)\n- GET /auth/curr_user.json (current user)\nToken sent via X-Client-Token header","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.358878117+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:26:32.668016856+01:00","closed_at":"2026-01-14T13:26:32.668016856+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-lpn","depends_on_id":"checkvist-api-ymg","type":"blocks","created_at":"2026-01-14T12:32:47.656124681+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-mnh","title":"Implement error types and sentinel errors","description":"Create errors.go with:\n- APIError struct (StatusCode, Message, RequestID, Err) with Error() and Unwrap() methods\n- Sentinel errors: ErrUnauthorized, ErrNotFound, ErrRateLimited, ErrBadRequest, ErrServerError\n- Helper function to create APIError from HTTP response","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:07.619359293+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:23:43.296883265+01:00","closed_at":"2026-01-14T13:23:43.296883265+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-mnh","depends_on_id":"checkvist-api-5wr","type":"blocks","created_at":"2026-01-14T12:32:46.754134799+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-nrk","title":"Create README with quickstart","description":"Create README.md (in English) with:\n- Project description\n- Installation: go get code.beautifulmachines.dev/jakoubek/checkvist-api\n- Quick start example (init client, list checklists, create task)\n- API overview (Checklists, Tasks, Notes, Filters)\n- Builder pattern examples\n- Error handling examples\n- Configuration options\n- Link to GoDoc\n- License (MIT)","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:38.724338606+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:38.724338606+01:00","dependencies":[{"issue_id":"checkvist-api-nrk","depends_on_id":"checkvist-api-c2k","type":"blocks","created_at":"2026-01-14T12:33:15.785698203+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-nrk","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:16.125115134+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"checkvist-api-rl9","title":"Implement Task operations","description":"Create tasks.go with TaskService:\n- client.Tasks(checklistID) returns TaskService\n- List(ctx) ([]Task, error) - GET /checklists/{id}/tasks.json\n- Get(ctx, taskID) (*Task, error) - GET /checklists/{id}/tasks/{task_id}.json (includes parent hierarchy)\n- Create(ctx, builder *TaskBuilder) (*Task, error) - POST /checklists/{id}/tasks.json\n- Update(ctx, taskID, opts) (*Task, error) - PUT /checklists/{id}/tasks/{task_id}.json\n- Delete(ctx, taskID) error - DELETE /checklists/{id}/tasks/{task_id}.json\n- Close(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/close.json\n- Reopen(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/reopen.json\n- Invalidate(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/invalidate.json\nParse DueDate from DueDateRaw when retrieving tasks.","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:53.90629838+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:53.90629838+01:00","dependencies":[{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:32:55.247292478+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:32:55.536931433+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"checkvist-api-rl9","title":"Implement Task operations","description":"Create tasks.go with TaskService:\n- client.Tasks(checklistID) returns TaskService\n- List(ctx) ([]Task, error) - GET /checklists/{id}/tasks.json\n- Get(ctx, taskID) (*Task, error) - GET /checklists/{id}/tasks/{task_id}.json (includes parent hierarchy)\n- Create(ctx, builder *TaskBuilder) (*Task, error) - POST /checklists/{id}/tasks.json\n- Update(ctx, taskID, opts) (*Task, error) - PUT /checklists/{id}/tasks/{task_id}.json\n- Delete(ctx, taskID) error - DELETE /checklists/{id}/tasks/{task_id}.json\n- Close(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/close.json\n- Reopen(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/reopen.json\n- Invalidate(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/invalidate.json\nParse DueDate from DueDateRaw when retrieving tasks.","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:53.90629838+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:39:29.443727155+01:00","closed_at":"2026-01-14T13:39:29.443727155+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:32:55.247292478+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:32:55.536931433+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-tjk","title":"Implement TaskBuilder fluent interface","description":"Enhance task creation with builder pattern:\n- NewTask(content string) *TaskBuilder\n- WithTags(tags ...string) *TaskBuilder\n- WithDueDate(due DueDate) *TaskBuilder\n- WithPriority(p int) *TaskBuilder\n- WithParent(parentID int64) *TaskBuilder\n- WithPosition(pos int) *TaskBuilder\n- Build() returns parameters for API call\nTaskBuilder should be chainable and return itself for fluent usage:\n checkvist.NewTask(\"Content\").WithTags(\"tag1\").WithDueDate(checkvist.DueTomorrow).WithPriority(1)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:55.929907579+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:55.929907579+01:00","dependencies":[{"issue_id":"checkvist-api-tjk","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:02.20339206+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-v2f","title":"Write unit tests for Tasks","description":"Create tasks_test.go with tests:\n- TestTasks_List\n- TestTasks_Get\n- TestTasks_Get_WithParentHierarchy\n- TestTasks_Create\n- TestTasks_Create_WithBuilder\n- TestTasks_Update\n- TestTasks_Delete\n- TestTasks_Close\n- TestTasks_Reopen\n- TestTasks_Invalidate\n- TestDueDate_Parsing\nUse table-driven tests. Create testdata/tasks/ fixtures.","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:37.538754679+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:37.538754679+01:00","dependencies":[{"issue_id":"checkvist-api-v2f","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:13.81085058+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-ymg","title":"Implement Client struct with functional options","description":"Create client.go with:\n- Client struct (baseURL, username, remoteKey, token, tokenExp, httpClient, retryConf, logger, mu sync.RWMutex)\n- NewClient(username, remoteKey string, opts ...Option) *Client constructor\n- Create options.go with Option type and functional options:\n - WithHTTPClient(*http.Client)\n - WithTimeout(time.Duration)\n - WithRetryConfig(RetryConfig)\n - WithLogger(*slog.Logger)\n - WithBaseURL(string) for testing\n- RetryConfig struct (MaxRetries, BaseDelay, MaxDelay, Jitter)\n- Default values: 3 retries, 1s base delay, 30s max delay, jitter enabled","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.021154076+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:24:55.101093793+01:00","closed_at":"2026-01-14T13:24:55.101093793+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-ymg","depends_on_id":"checkvist-api-e9p","type":"blocks","created_at":"2026-01-14T12:32:47.077541448+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-ymg","depends_on_id":"checkvist-api-mnh","type":"blocks","created_at":"2026-01-14T12:32:47.358639632+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/tasks.go b/tasks.go index 358392a..55a5a9a 100644 --- a/tasks.go +++ b/tasks.go @@ -1,3 +1,223 @@ package checkvist +import ( + "context" + "fmt" + "time" +) + // tasks.go contains the TaskService for CRUD operations on tasks within a checklist. + +// TaskService provides operations on tasks within a specific checklist. +type TaskService struct { + client *Client + checklistID int +} + +// Tasks returns a TaskService for performing task operations on the specified checklist. +func (c *Client) Tasks(checklistID int) *TaskService { + return &TaskService{ + client: c, + checklistID: checklistID, + } +} + +// List returns all tasks in the checklist. +func (s *TaskService) List(ctx context.Context) ([]Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks.json", s.checklistID) + + var tasks []Task + if err := s.client.doGet(ctx, path, &tasks); err != nil { + return nil, err + } + + // Parse due dates + for i := range tasks { + parseDueDate(&tasks[i]) + } + + return tasks, nil +} + +// Get returns a single task by ID, including its parent hierarchy. +func (s *TaskService) Get(ctx context.Context, taskID int) (*Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks/%d.json", s.checklistID, taskID) + + var task Task + if err := s.client.doGet(ctx, path, &task); err != nil { + return nil, err + } + + parseDueDate(&task) + return &task, nil +} + +// CreateTaskRequest represents the request body for creating a task. +type CreateTaskRequest struct { + Content string `json:"content"` + ParentID int `json:"parent_id,omitempty"` + Position int `json:"position,omitempty"` + Due string `json:"due,omitempty"` + Priority int `json:"priority,omitempty"` + Tags string `json:"tags,omitempty"` +} + +// TaskBuilder provides a fluent interface for building task creation requests. +type TaskBuilder struct { + content string + parentID int + position int + due string + priority int + tags []string +} + +// NewTask creates a new TaskBuilder with the given content. +func NewTask(content string) *TaskBuilder { + return &TaskBuilder{content: content} +} + +// WithParent sets the parent task ID for creating a subtask. +func (b *TaskBuilder) WithParent(parentID int) *TaskBuilder { + b.parentID = parentID + return b +} + +// WithPosition sets the position of the task within its siblings. +func (b *TaskBuilder) WithPosition(position int) *TaskBuilder { + b.position = position + return b +} + +// WithDueDate sets the due date using a DueDate value. +func (b *TaskBuilder) WithDueDate(due DueDate) *TaskBuilder { + b.due = due.String() + return b +} + +// WithPriority sets the priority level (1 = highest, 2 = high, 0 = normal). +func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder { + b.priority = priority + return b +} + +// WithTags sets the tags for the task. +func (b *TaskBuilder) WithTags(tags ...string) *TaskBuilder { + b.tags = tags + return b +} + +// build converts the TaskBuilder to a CreateTaskRequest. +func (b *TaskBuilder) build() CreateTaskRequest { + req := CreateTaskRequest{ + Content: b.content, + ParentID: b.parentID, + Position: b.position, + Due: b.due, + Priority: b.priority, + } + if len(b.tags) > 0 { + for i, tag := range b.tags { + if i > 0 { + req.Tags += ", " + } + req.Tags += tag + } + } + return req +} + +// Create creates a new task using a TaskBuilder. +func (s *TaskService) Create(ctx context.Context, builder *TaskBuilder) (*Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks.json", s.checklistID) + body := builder.build() + + var task Task + if err := s.client.doPost(ctx, path, body, &task); err != nil { + return nil, err + } + + parseDueDate(&task) + return &task, nil +} + +// UpdateTaskRequest represents the request body for updating a task. +type UpdateTaskRequest struct { + Content *string `json:"content,omitempty"` + ParentID *int `json:"parent_id,omitempty"` + Position *int `json:"position,omitempty"` + Due *string `json:"due,omitempty"` + Priority *int `json:"priority,omitempty"` + Tags *string `json:"tags,omitempty"` +} + +// Update updates an existing task. +func (s *TaskService) Update(ctx context.Context, taskID int, req UpdateTaskRequest) (*Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks/%d.json", s.checklistID, taskID) + + var task Task + if err := s.client.doPut(ctx, path, req, &task); err != nil { + return nil, err + } + + parseDueDate(&task) + return &task, nil +} + +// Delete permanently deletes a task. +func (s *TaskService) Delete(ctx context.Context, taskID int) error { + path := fmt.Sprintf("/checklists/%d/tasks/%d.json", s.checklistID, taskID) + return s.client.doDelete(ctx, path) +} + +// Close marks a task as completed. +func (s *TaskService) Close(ctx context.Context, taskID int) (*Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks/%d/close.json", s.checklistID, taskID) + + var task Task + if err := s.client.doPost(ctx, path, nil, &task); err != nil { + return nil, err + } + + parseDueDate(&task) + return &task, nil +} + +// Reopen reopens a closed or invalidated task. +func (s *TaskService) Reopen(ctx context.Context, taskID int) (*Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks/%d/reopen.json", s.checklistID, taskID) + + var task Task + if err := s.client.doPost(ctx, path, nil, &task); err != nil { + return nil, err + } + + parseDueDate(&task) + return &task, nil +} + +// Invalidate marks a task as invalidated (strikethrough). +func (s *TaskService) Invalidate(ctx context.Context, taskID int) (*Task, error) { + path := fmt.Sprintf("/checklists/%d/tasks/%d/invalidate.json", s.checklistID, taskID) + + var task Task + if err := s.client.doPost(ctx, path, nil, &task); err != nil { + return nil, err + } + + parseDueDate(&task) + return &task, nil +} + +// parseDueDate attempts to parse the DueDateRaw string into a time.Time. +// It supports ISO 8601 date format (YYYY-MM-DD). +func parseDueDate(task *Task) { + if task.DueDateRaw == "" { + return + } + + // Try to parse as ISO date + if t, err := time.Parse("2006-01-02", task.DueDateRaw); err == nil { + task.DueDate = &t + } +}