From b6dd10038000cf68e14f7a10a3b5fa44fb95152e Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Wed, 2 Sep 2026 16:17:39 -0300 Subject: [PATCH] Enhancement: Task filters for date state, start date and excluded assignees The tasks endpoint takes three filters nothing modelled: taskFilter, startDate and the new excludeResponsiblePartyIds. DateFilter carries taskFilter as a TaskDateFilter vocabulary, with each constant documenting what it returns. StartAfter is startDate, an inclusive bound on the task's own start date with no milestone fallback. The endpoint's companion endDate is deliberately not modelled: alone it bounds the due date, which DueBefore already covers, and sent beside startDate it stops the endpoint reading either one as a bound at all, switching to a due-date window it rejects with 400 past a maximum width. So there is no start-date upper bound to pair StartAfter with. ExcludeAssigneeUserIDs drops a task any listed user is assigned to, whatever its other assignees are, and matches direct assignment only. --- projects/task.go | 121 ++++++++++++++++++++++++-- projects/task_test.go | 198 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 6 deletions(-) diff --git a/projects/task.go b/projects/task.go index d29c35d..a6676ac 100644 --- a/projects/task.go +++ b/projects/task.go @@ -1011,6 +1011,79 @@ const ( TaskOrderByParentTask TaskOrderBy = "parenttask" ) +// TaskDateFilter selects tasks by where their dates fall relative to today, in +// the calling user's own timezone. It is the `taskFilter` query parameter, and +// its values are the words the task UI puts in front of users. +type TaskDateFilter string + +// Supported task date filters. The endpoint applies TaskDateFilterAnytime when +// none is sent, and rejects an unknown value with 400. +const ( + // TaskDateFilterAnytime applies no date restriction. It is the endpoint's + // default. + TaskDateFilterAnytime TaskDateFilter = "anytime" + + // TaskDateFilterOverdue returns tasks due before today that are not + // completed. A task with no due date is matched on its milestone's. Completed + // tasks never match, even with IncludeCompletedTasks set. + TaskDateFilterOverdue TaskDateFilter = "overdue" + + // TaskDateFilterToday returns tasks due today, and does not add the overdue + // ones. + TaskDateFilterToday TaskDateFilter = "today" + + // TaskDateFilterTomorrow returns tasks due tomorrow. + TaskDateFilterTomorrow TaskDateFilter = "tomorrow" + + // TaskDateFilterYesterday returns tasks due yesterday. + TaskDateFilterYesterday TaskDateFilter = "yesterday" + + // TaskDateFilterThisWeek returns tasks due in the calendar week containing + // today, including the days of it that have already passed. The week starts + // on the calling user's start-of-week setting. + TaskDateFilterThisWeek TaskDateFilter = "thisweek" + + // TaskDateFilterUpcoming returns tasks due today or later. + TaskDateFilterUpcoming TaskDateFilter = "upcoming" + + // TaskDateFilterStarted returns tasks whose start date has arrived and whose + // due date has not passed: start date on or before today, and either no due + // date at all or one falling today or later. A task that started and is + // already overdue does not match. + TaskDateFilterStarted TaskDateFilter = "started" + + // TaskDateFilterWithin7 returns tasks due between today and 7 days from + // today, both included. + TaskDateFilterWithin7 TaskDateFilter = "within7" + + // TaskDateFilterWithin14 returns tasks due between today and 14 days from + // today, both included. + TaskDateFilterWithin14 TaskDateFilter = "within14" + + // TaskDateFilterWithin30 returns tasks due between today and 30 days from + // today, both included. + TaskDateFilterWithin30 TaskDateFilter = "within30" + + // TaskDateFilterWithin365 returns tasks due between today and 365 days from + // today, both included. + TaskDateFilterWithin365 TaskDateFilter = "within365" + + // TaskDateFilterNoDate returns tasks with no start date, no due date and no + // milestone. + TaskDateFilterNoDate TaskDateFilter = "nodate" + + // TaskDateFilterNoDueDate returns tasks with no due date and no milestone to + // borrow one from. + TaskDateFilterNoDueDate TaskDateFilter = "noduedate" + + // TaskDateFilterNoStartDate returns tasks with no start date, whatever their + // due date. + TaskDateFilterNoStartDate TaskDateFilter = "nostartdate" + + // TaskDateFilterHasDate returns tasks carrying a start date or a due date. + TaskDateFilterHasDate TaskDateFilter = "hasdate" +) + // TaskListRequestFilters contains the filters for loading multiple tasks. type TaskListRequestFilters struct { TaskRequestFilters @@ -1022,6 +1095,16 @@ type TaskListRequestFilters struct { // AssigneeUserIDs is an optional list of User IDs to filter tasks by assigned user. AssigneeUserIDs []int64 + // ExcludeAssigneeUserIDs is an optional list of user IDs whose tasks are left + // out of the results. A task is dropped when any one of the listed users is + // assigned to it, even when it also carries assignees nobody excluded. A user + // reached only through a team, company or job-role assignment on the task is + // not matched, the same way AssigneeUserIDs does not match one. + // + // It composes with every other filter, AssigneeUserIDs included: the + // exclusion is applied on top of whatever the rest matched. + ExcludeAssigneeUserIDs []int64 + // CreatedAfter is an optional filter to retrieve tasks created at or after a // specific date and time. The boundary is inclusive. CreatedAfter *time.Time @@ -1056,15 +1139,34 @@ type TaskListRequestFilters struct { // says. CompletedBefore *time.Time - // IncludeCompletedTasks indicates whether to include completed tasks in the + // IncludeCompleted indicates whether to include completed tasks in the // results. When nil or false, completed tasks are excluded (the API default). // Set to true to include them. - IncludeCompletedTasks *bool + IncludeCompleted *bool - // IncludeTasksFromCompletedTasklists indicates whether to include tasks that + // IncludeCompletedTasklists indicates whether to include tasks that // belong to completed tasklists. When nil or false, those tasks are excluded // (the API default). Set to true to include them. - IncludeTasksFromCompletedTasklists *bool + IncludeCompletedTasklists *bool + + // DateFilter selects tasks by where their dates fall relative to today, in + // the calling user's own timezone. See the TaskDateFilter constants for what + // each value returns. Left unset it sends nothing and the endpoint applies + // TaskDateFilterAnytime, which restricts nothing. + DateFilter TaskDateFilter + + // StartAfter is an optional filter to retrieve tasks whose own start date + // falls on or after this date, sent as the endpoint's `startDate`. The + // boundary is inclusive, and a task with no start date of its own never + // matches — there is no milestone fallback. + // + // The endpoint has no upper bound to pair it with. Its companion `endDate` + // parameter is deliberately not modelled here: on its own it reads the due + // date rather than a start or end date, which DueBefore already covers, and + // sending it alongside `startDate` stops the endpoint reading either one as + // a bound at all — it switches to a window on the due date, and answers 400 + // for a window longer than the maximum it allows. + StartAfter *twapi.Date // DueAfter is an optional filter to retrieve tasks due after a specific date. // @@ -1101,6 +1203,9 @@ type TaskListRequestFilters struct { // unassigned, have no due date, or are missing estimated time. OnlyUnplanned *bool + // OnlyCompleted is an optional flag to only return tasks that are completed. + OnlyCompleted *bool + // OrderBy is the field to sort the results by. Use the TaskOrderBy // constants. The endpoint defaults to duedate. OrderBy TaskOrderBy @@ -1138,6 +1243,7 @@ func (t TaskListRequestFilters) apply(req *http.Request) { query := req.URL.Query() querySetString(query, "searchTerm", t.SearchTerm) querySetInt64s(query, "responsiblePartyIds", t.AssigneeUserIDs) + querySetInt64s(query, "excludeResponsiblePartyIds", t.ExcludeAssigneeUserIDs) querySetTimestamp(query, "createdAfter", t.CreatedAfter) querySetTimestamp(query, "createdBefore", t.CreatedBefore) querySetInt64s(query, "createdByUserIds", t.CreatedByUserIDs) @@ -1145,14 +1251,17 @@ func (t TaskListRequestFilters) apply(req *http.Request) { querySetTimestamp(query, "updatedBefore", t.UpdatedBefore) querySetTimestamp(query, "completedAfter", t.CompletedAfter) querySetTimestamp(query, "completedBefore", t.CompletedBefore) - querySetBool(query, "includeCompletedTasks", t.IncludeCompletedTasks) - querySetBool(query, "showCompletedLists", t.IncludeTasksFromCompletedTasklists) + querySetBool(query, "includeCompletedTasks", t.IncludeCompleted) + querySetBool(query, "showCompletedLists", t.IncludeCompletedTasklists) + querySetString(query, "taskFilter", t.DateFilter) + querySetDate(query, "startDate", t.StartAfter) querySetDate(query, "dueAfter", t.DueAfter) querySetDate(query, "dueBefore", t.DueBefore) querySetInt64s(query, "tagIds", t.TagIDs) querySetBool(query, "matchAllTags", t.MatchAllTags) querySetBool(query, "onlyUnassignedTasks", t.OnlyUnassigned) querySetBool(query, "onlyUnplanned", t.OnlyUnplanned) + querySetBool(query, "completedOnly", t.OnlyCompleted) querySetString(query, "orderBy", t.OrderBy) querySetString(query, "orderMode", t.OrderMode) querySetInt64(query, "orderByCustomFieldId", t.OrderByCustomFieldID) diff --git a/projects/task_test.go b/projects/task_test.go index cb25f27..43b93b0 100644 --- a/projects/task_test.go +++ b/projects/task_test.go @@ -789,3 +789,201 @@ func TestTaskWorkflowsRequestGeneration(t *testing.T) { }) } } + +// TestTaskListFiltersApplied pins the whole query string the task list builds +// when every filter is populated, against the parameter names the endpoint +// documents: +// +// https://apidocs.teamwork.com/docs/teamwork/v3/tasks/get-projects-api-v3-tasks-json +// +// Comparing the complete map rather than a subset is deliberate. An +// unrecognised query key is silently ignored by the API, so a misspelled +// parameter looks exactly like a working one from the caller's side — only an +// exact comparison catches it, and only an exact comparison catches a filter +// that stopped reaching the wire. +// +// StartAfter is deliberately absent, so that TestTaskListStartAfterAlone can +// pin it as the only date bound reaching the wire. +func TestTaskListFiltersApplied(t *testing.T) { + createdAfter := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + createdBefore := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) + updatedAfter := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + updatedBefore := time.Date(2026, 4, 5, 6, 7, 8, 0, time.UTC) + completedAfter := time.Date(2026, 5, 6, 7, 8, 9, 0, time.UTC) + completedBefore := time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC) + dueAfter := twapi.Date(time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC)) + dueBefore := twapi.Date(time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)) + + req := projects.TaskListRequest{ + Filters: projects.TaskListRequestFilters{ + TaskRequestFilters: projects.TaskRequestFilters{ + IncludeRelatedTasks: true, + IncludeCompletedPredecessors: true, + IncludeTasksWithoutDueDates: new(false), + Include: []projects.TaskRequestSideload{ + projects.TaskRequestSideloadCustomFields, + }, + }, + + SearchTerm: "acme", + + AssigneeUserIDs: []int64{777, 888}, + ExcludeAssigneeUserIDs: []int64{999}, + + CreatedAfter: &createdAfter, + CreatedBefore: &createdBefore, + CreatedByUserIDs: []int64{12345}, + UpdatedAfter: &updatedAfter, + UpdatedBefore: &updatedBefore, + CompletedAfter: &completedAfter, + CompletedBefore: &completedBefore, + + IncludeCompleted: new(true), + IncludeCompletedTasklists: new(true), + + DateFilter: projects.TaskDateFilterStarted, + DueAfter: &dueAfter, + DueBefore: &dueBefore, + + TagIDs: []int64{111, 222}, + MatchAllTags: new(true), + + OnlyUnassigned: new(false), + OnlyUnplanned: new(true), + + OrderBy: projects.TaskOrderByCustomField, + OrderMode: twapi.OrderModeDescending, + OrderByCustomFieldID: 42, + + Page: 2, + PageSize: 25, + CountMode: twapi.ListCountModeExact, + + Fields: projects.TaskListFields{ + Tasks: []projects.TaskField{projects.TaskFieldName}, + }, + }, + } + + want := map[string]string{ + "includeRelatedTasks": "true", + "includeCompletedPredecessors": "true", + "includeTasksWithoutDueDates": "false", + "include": "customfields", + + "searchTerm": "acme", + + "responsiblePartyIds": "777,888", + "excludeResponsiblePartyIds": "999", + + "createdAfter": "2026-01-02T03:04:05Z", + "createdBefore": "2026-02-03T04:05:06Z", + "createdByUserIds": "12345", + "updatedAfter": "2026-03-04T05:06:07Z", + "updatedBefore": "2026-04-05T06:07:08Z", + "completedAfter": "2026-05-06T07:08:09Z", + "completedBefore": "2026-06-07T08:09:10Z", + + "includeCompletedTasks": "true", + "showCompletedLists": "true", + + "taskFilter": "started", + "dueAfter": "2026-07-08", + "dueBefore": "2026-08-09", + + "tagIds": "111,222", + "matchAllTags": "true", + + "onlyUnassignedTasks": "false", + "onlyUnplanned": "true", + + "orderBy": "customfield", + "orderMode": "desc", + "orderByCustomFieldId": "42", + + "page": "2", + "pageSize": "25", + "skipCounts": "false", + + "fields[tasks]": "name", + } + + query := listQuery(t, req) + + for key, expected := range want { + if got := query.Get(key); got != expected { + t.Errorf("expected %s=%q but got %q", key, expected, got) + } + } + for key := range query { + if _, ok := want[key]; !ok { + t.Errorf("unexpected query parameter %s=%q", key, query.Get(key)) + } + } +} + +// TestTaskListFiltersUnset checks the zero-value filters send nothing, so the +// endpoint applies its own defaults — TaskDateFilterAnytime among them — +// rather than receiving a wall of false. +func TestTaskListFiltersUnset(t *testing.T) { + query := listQuery(t, projects.TaskListRequest{}) + if len(query) != 0 { + t.Errorf("expected no query parameters but got %v", query) + } +} + +// TestTaskListStartAfterAlone pins the start-date bound on the query string, +// and that nothing sends the endpoint's companion `endDate`: with both set the +// endpoint stops reading `startDate` as a start-date bound at all. +func TestTaskListStartAfterAlone(t *testing.T) { + startAfter := twapi.Date(time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)) + + query := listQuery(t, projects.TaskListRequest{ + Filters: projects.TaskListRequestFilters{ + StartAfter: &startAfter, + }, + }) + + if got := query.Get("startDate"); got != "2026-03-04" { + t.Errorf("expected startDate=%q but got %q", "2026-03-04", got) + } + if got := query.Get("endDate"); got != "" { + t.Errorf("expected no endDate but got %q", got) + } +} + +// TestTaskListDateFilterValuesReachTheWire drives every published date filter +// through the query string. The endpoint rejects an unknown value with 400, and +// answers a value it does understand with an ordinary task list, so a constant +// carrying a typo is only visible here. +func TestTaskListDateFilterValuesReachTheWire(t *testing.T) { + for _, dateFilter := range []projects.TaskDateFilter{ + projects.TaskDateFilterAnytime, + projects.TaskDateFilterOverdue, + projects.TaskDateFilterToday, + projects.TaskDateFilterTomorrow, + projects.TaskDateFilterYesterday, + projects.TaskDateFilterThisWeek, + projects.TaskDateFilterUpcoming, + projects.TaskDateFilterStarted, + projects.TaskDateFilterWithin7, + projects.TaskDateFilterWithin14, + projects.TaskDateFilterWithin30, + projects.TaskDateFilterWithin365, + projects.TaskDateFilterNoDate, + projects.TaskDateFilterNoDueDate, + projects.TaskDateFilterNoStartDate, + projects.TaskDateFilterHasDate, + } { + t.Run(string(dateFilter), func(t *testing.T) { + query := listQuery(t, projects.TaskListRequest{ + Filters: projects.TaskListRequestFilters{ + DateFilter: dateFilter, + }, + }) + if got := query.Get("taskFilter"); got != string(dateFilter) { + t.Errorf("expected taskFilter=%q but got %q", dateFilter, got) + } + }) + } +}