Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 115 additions & 6 deletions projects/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1138,21 +1243,25 @@ 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)
querySetTimestamp(query, "updatedAfter", t.UpdatedAfter)
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)
Expand Down
198 changes: 198 additions & 0 deletions projects/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}