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